feat: ship the onboarding artwork in both themes

closes #29

All eight illustrations are in the app, every onboarding step has art for the
first time, and the theme swap is resource resolution rather than a branch:
drawable/ and drawable-night/, so Android picks the asset and the artwork follows
a device theme change with nothing of ours running.

NOT VECTORS, AND THE ISSUE WAS WRONG TO ASK FOR THEM

#29 called for a redraw as Compose vector paths taking colour from theme tokens.
That is right for artwork that does not exist yet and wrong for gradient
landscapes with glow and depth — there is no honest VectorDrawable of one, and
the attempt loses the drawing or produces path soup slower than the image. §42
objects to *unnecessary* raster, which this is not.

The cost was never measured before it was argued about: all sixteen files at the
height they are actually drawn at come to 130 KB. Release APK 1.90 -> 2.03 MB.

WHAT ONLY A DEVICE SHOWED

Each drawing is composed as a card with its own rounded corner painted in, so the
pixels outside that curve are the card's own backdrop — near-black in the dark
set. Rendered unclipped, every illustration showed four notches against the app
background. Illustration() now clips just inside the painted curve.

WHAT STAYED A VECTOR

The calendar markers and CycleProgressMark, because they are drawn from data — a
marker's shape is what a day is, and the progress arc is where the user stands in
their cycle. CycleProgressMark moves from Illustrations.kt to CycleMarkers.kt so
the file layout carries that boundary.

Sized by height rather than a square box: the set has no common aspect ratio, and
height is the dimension that decides whether the primary button stays on screen.
Steps 3 and 6 take 104dp because they carry three buttons and three option cards;
verified by driving all seven steps, not by reasoning about it.

OnboardingPreviews.kt now has one entry per illustration, because a missing
drawable-night asset is invisible in light mode.

Driven end to end on PeriodMinSdk26 in both themes, no crash. ./gradlew check,
schema-guard and doc-claims all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-18 20:25:59 -05:00
parent 807b8a4600
commit 5fc94536fe
25 changed files with 351 additions and 268 deletions

View File

@ -101,11 +101,7 @@ private fun LearningBanner(stage: LearningStage, cycleCount: Int) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (stage != LearningStage.PERSONALIZED) { if (stage != LearningStage.PERSONALIZED) {
LearningIllustration( LearningIllustration(size = 110.dp)
color = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 110.dp,
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)

View File

@ -1,22 +1,26 @@
package dev.privacyllc.period.feature.onboarding package dev.privacyllc.period.feature.onboarding
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.designsystem.art.EmptyStateIllustration
import dev.privacyllc.period.designsystem.art.LearningIllustration
import dev.privacyllc.period.designsystem.art.PrivacyIllustration
import dev.privacyllc.period.designsystem.art.WelcomeIllustration
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.designsystem.art.EmptyStateIllustration
import dev.privacyllc.period.designsystem.art.ForecastIllustration
import dev.privacyllc.period.designsystem.art.LastPeriodIllustration
import dev.privacyllc.period.designsystem.art.LearningIllustration
import dev.privacyllc.period.designsystem.art.NotificationPrivacyIllustration
import dev.privacyllc.period.designsystem.art.PeriodEndIllustration
import dev.privacyllc.period.designsystem.art.PrivacyIllustration
import dev.privacyllc.period.designsystem.art.WelcomeIllustration
/** /**
* Every illustration, in both themes. * Every illustration, in both themes.
@ -31,12 +35,19 @@ import androidx.compose.ui.unit.dp
* A preview pair is not a test and does not fail a build. What it does is put * A preview pair is not a test and does not fail a build. What it does is put
* both themes in front of whoever is editing the screen, which is the cheapest * both themes in front of whoever is editing the screen, which is the cheapest
* thing that would have caught it. * thing that would have caught it.
*
* These now carry a second job. The illustrations are drawables with a
* `drawable-night/` twin, so the theme swap happens in resource resolution
* rather than in any code here and a missing or mismatched night asset is
* invisible until somebody looks at the dark preview. There is one entry per
* illustration for exactly that reason: a set of eight where seven have a night
* variant looks completely fine in light mode.
*/ */
@Preview(name = "Illustrations · light", showBackground = true, heightDp = 620) @Preview(name = "Illustrations · light", showBackground = true, heightDp = 1100)
@Preview( @Preview(
name = "Illustrations · dark", name = "Illustrations · dark",
showBackground = true, showBackground = true,
heightDp = 620, heightDp = 1100,
uiMode = Configuration.UI_MODE_NIGHT_YES, uiMode = Configuration.UI_MODE_NIGHT_YES,
) )
@Composable @Composable
@ -47,26 +58,26 @@ private fun IllustrationPreviews() {
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text("Welcome", style = MaterialTheme.typography.labelLarge) Label("1 · Welcome")
WelcomeIllustration( WelcomeIllustration(size = 110.dp)
primary = MaterialTheme.colorScheme.primary, Label("2 · Last period start")
accent = MaterialTheme.colorScheme.tertiary, LastPeriodIllustration(size = 110.dp)
size = 120.dp, Label("3 · Period end")
) PeriodEndIllustration(size = 110.dp)
Text("Learning", style = MaterialTheme.typography.labelLarge) Label("4 · Learning, and Insights empty")
LearningIllustration( LearningIllustration(size = 110.dp)
color = MaterialTheme.colorScheme.primary, Label("5 · Privacy promise")
accent = MaterialTheme.colorScheme.tertiary, PrivacyIllustration(size = 110.dp)
size = 110.dp, Label("6 · Reminder privacy")
) NotificationPrivacyIllustration(size = 110.dp)
Text("Privacy", style = MaterialTheme.typography.labelLarge) Label("7 · First forecast")
PrivacyIllustration( ForecastIllustration(size = 110.dp)
color = MaterialTheme.colorScheme.primary, Label("Today empty state")
accent = MaterialTheme.colorScheme.tertiary, EmptyStateIllustration(size = 110.dp)
size = 110.dp,
)
Text("Empty state", style = MaterialTheme.typography.labelLarge)
EmptyStateIllustration(color = MaterialTheme.colorScheme.outline, size = 110.dp)
} }
} }
} }
@Composable
private fun Label(text: String) =
Text(text, style = MaterialTheme.typography.labelLarge)

View File

@ -36,7 +36,11 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.privacyllc.period.core.datastore.NotificationPrivacy import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.designsystem.art.ForecastIllustration
import dev.privacyllc.period.designsystem.art.LastPeriodIllustration
import dev.privacyllc.period.designsystem.art.LearningIllustration import dev.privacyllc.period.designsystem.art.LearningIllustration
import dev.privacyllc.period.designsystem.art.NotificationPrivacyIllustration
import dev.privacyllc.period.designsystem.art.PeriodEndIllustration
import dev.privacyllc.period.designsystem.art.PrivacyIllustration import dev.privacyllc.period.designsystem.art.PrivacyIllustration
import dev.privacyllc.period.designsystem.art.WelcomeIllustration import dev.privacyllc.period.designsystem.art.WelcomeIllustration
import java.time.Instant import java.time.Instant
@ -120,10 +124,7 @@ private fun Heading(title: String, body: String? = null) {
@Composable @Composable
private fun Welcome(onNext: () -> Unit) { private fun Welcome(onNext: () -> Unit) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
WelcomeIllustration( WelcomeIllustration()
primary = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
)
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Heading("Know what's coming.", "Track your period and get predictions that learn your cycle.") Heading("Know what's coming.", "Track your period and get predictions that learn your cycle.")
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Get Started") } Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Get Started") }
@ -132,7 +133,9 @@ private fun Welcome(onNext: () -> Unit) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) { private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(24.dp))
LastPeriodIllustration(size = 128.dp)
Spacer(Modifier.height(24.dp))
Heading("When did your last period start?") Heading("When did your last period start?")
DateField( DateField(
@ -152,7 +155,11 @@ private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel)
@Composable @Composable
private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) { private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(16.dp))
// Smaller than the other steps: this one carries three buttons under the
// date field, and the primary must stay reachable without scrolling.
PeriodEndIllustration(size = 104.dp)
Spacer(Modifier.height(16.dp))
Heading("When did it end?") Heading("When did it end?")
var picked by remember { mutableStateOf<LocalDate?>(null) } var picked by remember { mutableStateOf<LocalDate?>(null) }
@ -181,11 +188,7 @@ private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel)
@Composable @Composable
private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewModel) { private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
LearningIllustration( LearningIllustration(size = 120.dp)
color = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 120.dp,
)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Heading( Heading(
"Remember any earlier periods?", "Remember any earlier periods?",
@ -220,11 +223,7 @@ private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewM
@Composable @Composable
private fun PrivacyPromise(onNext: () -> Unit) { private fun PrivacyPromise(onNext: () -> Unit) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
PrivacyIllustration( PrivacyIllustration(size = 140.dp)
color = MaterialTheme.colorScheme.primary,
accent = MaterialTheme.colorScheme.tertiary,
size = 140.dp,
)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
// §4 requires this promise here, in Settings, and on the public privacy // §4 requires this promise here, in Settings, and on the public privacy
// page. The wording is deliberate: we never SELL your data. It does not // page. The wording is deliberate: we never SELL your data. It does not
@ -242,7 +241,10 @@ private fun PrivacyPromise(onNext: () -> Unit) {
@Composable @Composable
private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: OnboardingViewModel) { private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(16.dp))
// Also small: three option cards and a button follow it.
NotificationPrivacyIllustration(size = 104.dp)
Spacer(Modifier.height(16.dp))
Heading("How should reminders appear?") Heading("How should reminders appear?")
Column(Modifier.selectableGroup().fillMaxWidth()) { Column(Modifier.selectableGroup().fillMaxWidth()) {
@ -298,7 +300,9 @@ private fun PrivacyOption(title: String, example: String, selected: Boolean, onS
@Composable @Composable
private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) { private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(24.dp))
ForecastIllustration(size = 120.dp)
Spacer(Modifier.height(24.dp))
Heading("Your first forecast") Heading("Your first forecast")
val forecast = state.forecast val forecast = state.forecast

View File

@ -312,7 +312,7 @@ private fun PrimaryAction(text: String, onClick: () -> Unit) {
@Composable @Composable
private fun EmptyState() { private fun EmptyState() {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
EmptyStateIllustration(color = MaterialTheme.colorScheme.outline, size = 140.dp) EmptyStateIllustration(size = 140.dp)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text("No periods logged yet", style = MaterialTheme.typography.headlineSmall) Text("No periods logged yet", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
@ -12,6 +13,9 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/** /**
* The four calendar states, drawn so they differ in **shape**. * The four calendar states, drawn so they differ in **shape**.
@ -110,6 +114,43 @@ internal fun DrawScope.drawFourPointStar(color: Color) {
drawPath(path, color) drawPath(path, color)
} }
/**
* The forecast mark beside the hero number on Today.
*
* A ring with one offset dot the same idea as the launcher icon, so the app's
* own mark appears inside it rather than only on the home screen.
*
* This lives with the markers rather than with the illustrations because it is
* **drawn from data**: the arc is the user's actual position in their cycle, so
* it has to be a vector no matter how good the artwork elsewhere gets.
*/
@Composable
fun CycleProgressMark(
color: Color,
accent: Color,
/** 0f at the start of the cycle, 1f at the predicted start of the next. */
progress: Float,
modifier: Modifier = Modifier,
size: Dp = 56.dp,
) {
Canvas(modifier.size(size)) {
val r = this.size.minDimension * 0.38f
val stroke = this.size.minDimension * 0.07f
drawCircle(color.copy(alpha = 0.25f), r, center, style = Stroke(width = stroke))
drawArc(
color = color,
startAngle = -90f,
sweepAngle = 360f * progress.coerceIn(0f, 1f),
useCenter = false,
topLeft = Offset(center.x - r, center.y - r),
size = Size(r * 2, r * 2),
style = Stroke(width = stroke),
)
val angle = (-PI / 2 + 2 * PI * progress.coerceIn(0f, 1f)).toFloat()
drawCircle(accent, stroke * 0.85f, Offset(center.x + r * cos(angle), center.y + r * sin(angle)))
}
}
object MarkerDefaults { object MarkerDefaults {
val Size: Dp = 32.dp val Size: Dp = 32.dp
/** Thick enough to read at 32dp on a dense screen, thin enough to stay a ring rather than a disc. */ /** Thick enough to read at 32dp on a dense screen, thin enough to stay a ring rather than a disc. */

View File

@ -1,221 +1,174 @@
package dev.privacyllc.period.designsystem.art package dev.privacyllc.period.designsystem.art
import androidx.compose.foundation.Canvas import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.size import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.PI import dev.privacyllc.period.designsystem.R
import kotlin.math.cos
import kotlin.math.sin
/** /**
* Placeholder illustrations, exactly as PRODUCT_PLAN.md §42 asks for them. * The onboarding and empty-state illustrations.
* *
* > If custom artwork cannot be produced immediately, create polished * ## Why these are raster, when PRODUCT_PLAN.md §42 asks for vectors
* > placeholder vector assets and keep them behind replaceable resource names.
* *
* Every one is drawn, not drawn *on* vector paths, no raster, so they stay * §42's preference list ends *"avoid **unnecessary** raster imagery"*, and it
* crisp at any density and add nothing to the APK. * offers polished placeholder vectors as the fallback for artwork that does not
* exist yet which is what used to be here. The artwork now exists: eight
* illustrations supplied by the project owner, in a matched light and dark pair.
* *
* ## What none of them may be * They are gradient landscapes with soft glow and depth. There is no honest
* `VectorDrawable` of one; converting them would either lose the drawing or
* produce a path soup larger and slower than the image. So the raster is
* necessary rather than unnecessary, and the whole set costs 216 KB.
* *
* §42's forbidden list, and it is a product decision rather than squeamishness: * [CycleProgressMark] stays a vector, because it is the one mark that is
* no blood drops, tampons, pads, uterus imagery, gender symbols or anatomical * *drawn from data* its arc is the user's actual position in their cycle.
* graphics. This app is opened in public. Someone glancing over a shoulder
* should learn nothing, and that is a property of the pictures as much as of the
* notification text.
* *
* So the whole visual language here is **overlapping circular forms** a cycle, * ## How the theme swap works
* abstractly. It is also the language of the launcher icon, which is what makes
* a placeholder set look deliberate rather than improvised.
* *
* ## Replacing them * There is no `isSystemInDarkTheme()` branch here on purpose. Each drawable has
* a `drawable/` and a `drawable-night/` copy, and Android resolves it. That
* means the dark art also follows a device-level theme change with no
* recomposition of ours, and a screenshot test of either theme picks the right
* one for free.
* *
* These are the replaceable names. When real artwork arrives, the drawing inside * ## Sources
* each function changes and no call site does. *
* `docs/design/dist/` holds the delivered set at full resolution, with a README
* recording the decisions behind them no baked text, no step numbers, and a
* forecast illustration that deliberately names no fields. Re-read it before
* regenerating any of these.
*/ */
/** Screen 1 of onboarding. Overlapping rings — the cycle, before it means anything specific. */ /** Step 1. Overlapping rings over a dawn landscape — the cycle, before it means anything specific. */
@Composable @Composable
fun WelcomeIllustration( fun WelcomeIllustration(
primary: Color,
accent: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size, size: Dp = IllustrationDefaults.Size,
) { ) = Illustration(R.drawable.art_welcome, "Overlapping circles above a sunrise", modifier, size)
Canvas(
modifier /** Step 2. A cycle ring with one marked day — the first day being recorded. */
.size(size) @Composable
.semantics { contentDescription = "Three overlapping circles" }, fun LastPeriodIllustration(
) { modifier: Modifier = Modifier,
val r = this.size.minDimension * 0.28f size: Dp = IllustrationDefaults.Size,
val c = center ) = Illustration(R.drawable.art_last_period, "A cycle ring with one marked day", modifier, size)
val stroke = Stroke(width = this.size.minDimension * 0.035f)
drawCircle(accent.copy(alpha = 0.55f), r, Offset(c.x - r * 0.55f, c.y - r * 0.2f), style = stroke) /** Step 3. The same ring with a span between two marked days — a period with a start and an end. */
drawCircle(accent.copy(alpha = 0.75f), r, Offset(c.x + r * 0.55f, c.y - r * 0.2f), style = stroke) @Composable
drawCircle(primary, r, Offset(c.x, c.y + r * 0.45f), style = stroke) fun PeriodEndIllustration(
} modifier: Modifier = Modifier,
} size: Dp = IllustrationDefaults.Size,
) = Illustration(R.drawable.art_period_end, "A cycle ring with a marked span between two days", modifier, size)
/**
* Step 4, and the Insights empty state.
*
* Earlier cycles fading behind the current one §42's "points gradually
* converging into a pattern", which is the honest picture of what the engine
* does with history. Used in two places, which is why it carries no step number.
*/
@Composable
fun LearningIllustration(
modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size,
) = Illustration(R.drawable.art_learning, "Earlier cycle rings fading behind the current one", modifier, size)
/**
* Step 5, the privacy promise.
*
* A shield with the cycle ring inside it. Putting the cycle *inside* the shield
* is the whole sentence the screen is making, and enclosure reads as protection
* without reaching for a padlock which would make a calm product look like a
* security one.
*/
@Composable
fun PrivacyIllustration(
modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size,
) = Illustration(R.drawable.art_privacy, "A shield containing a cycle ring", modifier, size)
/** Step 6. A notification with a shield on it — what a reminder shows, and what it withholds. */
@Composable
fun NotificationPrivacyIllustration(
modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size,
) = Illustration(R.drawable.art_notification_privacy, "A notification card marked with a shield", modifier, size)
/**
* Step 7, the first forecast.
*
* A calendar and a cycle arc running from logged days into dashed predicted
* ones. It deliberately names no fields: a drawing that said "fertile window"
* or "confidence: low" would contradict the live forecast beside it, which on a
* first run declines to estimate fertility at all.
*/
@Composable
fun ForecastIllustration(
modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size,
) = Illustration(R.drawable.art_forecast, "A calendar with a cycle arc continuing into predicted days", modifier, size)
/** /**
* The empty state no periods logged yet. * The empty state no periods logged yet.
* *
* A ring with a single gap, because the story is *the cycle has not started * The ring is empty because nothing has been recorded, not because something
* being recorded yet*, not *something is broken*. Empty states are one of the * failed. It is the same ring the other illustrations fill with cycle days,
* two screens most people meet first and the one most often left undesigned. * which is what makes this read as *waiting* rather than *broken*.
*/ */
@Composable @Composable
fun EmptyStateIllustration( fun EmptyStateIllustration(
color: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size, size: Dp = IllustrationDefaults.Size,
) { ) = Illustration(R.drawable.art_empty_state, "An empty cycle ring above a sunrise", modifier, size)
Canvas(
modifier
.size(size)
.semantics { contentDescription = "An empty circle" },
) {
val d = this.size.minDimension * 0.72f
val stroke = this.size.minDimension * 0.035f
drawArc(
color = color,
startAngle = -60f,
sweepAngle = 300f,
useCenter = false,
topLeft = Offset(center.x - d / 2f, center.y - d / 2f),
size = Size(d, d),
style = Stroke(
width = stroke,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(d / 22f, d / 14f)),
),
)
}
}
/** /**
* The learning state a cycle or two recorded, not yet a pattern. * One drawing, sized by height and clipped to a rounded corner.
* *
* §42: points gradually converging into a pattern. Scattered dots that tighten * **Height rather than a square box**: the set does not share one aspect ratio,
* toward a ring as they go round, which is the honest picture of what the engine * and forcing a square would letterbox some and crop others. Each illustration
* is doing and reads as progress rather than as an error. * keeps its own proportions and every screen agrees on how tall the artwork is,
* which is the dimension that decides whether the button below it stays on
* screen.
*
* **Clipped because the corners are painted in.** Each drawing is composed as a
* card with its own rounded corner, so the pixels outside that curve are the
* card's own backdrop near-black in the dark set, near-white in the light one.
* Drawn unclipped they appear as four notches against the app's background,
* which is obvious the moment you look at a device and invisible in code review.
* Clipping slightly inside the painted curve removes them, and what is left
* reads as a deliberate rounded card rather than a pasted rectangle.
*/ */
@Composable @Composable
fun LearningIllustration( private fun Illustration(
color: Color, @DrawableRes id: Int,
accent: Color, description: String,
modifier: Modifier = Modifier, modifier: Modifier,
size: Dp = IllustrationDefaults.Size, size: Dp,
) { ) {
Canvas( Image(
modifier painter = painterResource(id),
.size(size) contentDescription = description,
.semantics { contentDescription = "Scattered dots settling into a circle" }, modifier = modifier
) { .height(size)
val radius = this.size.minDimension * 0.32f .clip(RoundedCornerShape(IllustrationDefaults.CornerRadius)),
val dot = this.size.minDimension * 0.028f contentScale = ContentScale.FillHeight,
val count = 12 )
repeat(count) { i ->
val progress = i / (count - 1f)
val angle = (progress * 1.6f * PI - PI / 2).toFloat()
// Early points sit off the ring and late ones settle onto it.
val scatter = (1f - progress) * radius * 0.42f
val wobble = if (i % 2 == 0) scatter else -scatter
val r = radius + wobble
drawCircle(
color = if (progress > 0.66f) color else accent.copy(alpha = 0.35f + progress * 0.5f),
radius = dot * (0.7f + progress * 0.6f),
center = Offset(center.x + r * cos(angle), center.y + r * sin(angle)),
)
}
}
}
/**
* The privacy promise screen.
*
* A shield with a circular cycle mark inside it §42 asks for a minimal
* shield or lock motif, and putting the cycle inside the shield is the whole
* sentence the screen is making.
*/
@Composable
fun PrivacyIllustration(
color: Color,
accent: Color,
modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size,
) {
Canvas(
modifier
.size(size)
.semantics { contentDescription = "A shield containing a circle" },
) {
val w = this.size.minDimension * 0.52f
val h = this.size.minDimension * 0.62f
val c = center
val shield = Path().apply {
moveTo(c.x, c.y - h / 2f)
lineTo(c.x + w / 2f, c.y - h / 2f + h * 0.16f)
cubicTo(
c.x + w / 2f, c.y + h * 0.16f,
c.x + w * 0.28f, c.y + h * 0.42f,
c.x, c.y + h / 2f,
)
cubicTo(
c.x - w * 0.28f, c.y + h * 0.42f,
c.x - w / 2f, c.y + h * 0.16f,
c.x - w / 2f, c.y - h / 2f + h * 0.16f,
)
close()
}
drawPath(shield, color, style = Stroke(width = this.size.minDimension * 0.035f))
drawCircle(accent, radius = w * 0.20f, center = Offset(c.x, c.y - h * 0.02f), style = Stroke(width = this.size.minDimension * 0.03f))
drawCircle(accent, radius = w * 0.05f, center = Offset(c.x, c.y - h * 0.02f - w * 0.20f))
}
}
/**
* The forecast mark used beside the hero number on Today.
*
* A ring with one offset dot the same idea as the launcher icon, so the app's
* own mark appears inside it rather than only on the home screen.
*/
@Composable
fun CycleProgressMark(
color: Color,
accent: Color,
/** 0f at the start of the cycle, 1f at the predicted start of the next. */
progress: Float,
modifier: Modifier = Modifier,
size: Dp = 56.dp,
) {
Canvas(modifier.size(size)) {
val r = this.size.minDimension * 0.38f
val stroke = this.size.minDimension * 0.07f
drawCircle(color.copy(alpha = 0.25f), r, center, style = Stroke(width = stroke))
drawArc(
color = color,
startAngle = -90f,
sweepAngle = 360f * progress.coerceIn(0f, 1f),
useCenter = false,
topLeft = Offset(center.x - r, center.y - r),
size = Size(r * 2, r * 2),
style = Stroke(width = stroke),
)
val angle = (-PI / 2 + 2 * PI * progress.coerceIn(0f, 1f)).toFloat()
drawCircle(accent, stroke * 0.85f, Offset(center.x + r * cos(angle), center.y + r * sin(angle)))
}
} }
object IllustrationDefaults { object IllustrationDefaults {
val Size: Dp = 160.dp val Size: Dp = 160.dp
/**
* Slightly wider than the curve painted into the artwork, so the clip lands
* inside it and no corner of the source card's backdrop survives.
*/
val CornerRadius: Dp = 18.dp
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -135,19 +135,40 @@ Designed here on purpose, because they are the two most people meet first:
rather than showing a confident forecast it has not earned. "Getting to know rather than showing a confident forecast it has not earned. "Getting to know
your pattern", not a percentage. your pattern", not a percentage.
## The in-app artwork is placeholder, and says so here ## The artwork is real now, and the replaceable names are why that was cheap
Every illustration and calendar marker in the app is a **Compose vector path** Every illustration in the app is the owner's own drawing, in a matched light and
in [`core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/`](../../core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/), written as PRODUCT_PLAN.md §42 asks: polished dark pair, in
placeholders behind replaceable names, so real artwork changes one function body [`core/designsystem/src/main/res/`](../../core/designsystem/src/main/res/). The
and no call site. sources and the decisions behind them are in [`dist/`](dist/).
Real brand artwork exists — the owner's sources in [`brand/`](brand/) and the **That swap changed one function body per illustration and no screen's layout**,
three exported marks in [`../data/img/`](../data/img/) — and none of it is in the because PRODUCT_PLAN.md §42's advice was followed literally when there was
app yet. The launcher icon keeps its simplified vector because the supplied nothing to draw with: polished placeholders behind replaceable names. The names
emblem has content close to its edges and an adaptive icon masks about a quarter did not change when the drawings did. That is the whole argument for the rule,
of the canvas away, so dropping it in unmodified would crop the shield. Fitting and it is worth keeping for the artwork still to come.
it to the safe zone is Batch 08's final-artwork work.
**These are raster, and §42 still holds.** Its preference list ends "avoid
*unnecessary* raster imagery", and it offers placeholder vectors as the fallback
for artwork that does not exist. The artwork exists, and it is gradient
landscapes with glow and depth — there is no honest `VectorDrawable` of one. The
whole set costs 130 KB of the release APK.
**The calendar markers stay vectors**, and so does `CycleProgressMark`. They are
drawn *from data* — a marker's shape is what a day is, and the progress arc is
where the user actually stands in their cycle. No amount of good artwork makes
those images.
**The theme swap is resource resolution, not a branch.** Each drawable has a
`drawable-night/` twin, so Android picks it and the artwork follows a device
theme change with no code of ours involved. The cost is that a missing night
asset is invisible in light mode, which is why the preview pair in
`OnboardingPreviews.kt` has one entry per illustration.
Still placeholder: **the launcher icon**, which keeps its simplified vector
because the supplied emblem has content close to its edges and an adaptive icon
masks about a quarter of the canvas away, so dropping it in unmodified would crop
the shield. Fitting it to the safe zone is issue #30.
### What the artwork has to do ### What the artwork has to do
@ -164,14 +185,12 @@ vocabulary — `PrivacyIllustration` encloses a ring in a shield, and enclosure
reads as protection without reaching for a padlock, which would read as a reads as protection without reaching for a padlock, which would read as a
security product rather than a calm one. security product rather than a calm one.
The onboarding half of that brief now has a decided direction: seven concept The onboarding brief has been answered: eight illustrations, both themes, now
cards, one per step, in [`dist/`](dist/). They are references and not shipping shipping in the app. [`dist/`](dist/) holds the sources and the reasoning — no
assets — raster, with their titles painted into the pixels, light mode only, and text painted into the pixels, no step numbers, and a forecast illustration that
card 7 shows a fertile window the first-forecast screen deliberately withholds. names no fields so it cannot contradict the live one beside it.
[`dist/README.md`](dist/README.md) says why each of those disqualifies them from
`res/drawable`, so that nobody mistakes a decided direction for a finished asset.
The remaining art is tracked as issues #29 to #32, not listed here. The art still outstanding is tracked as issues #30 to #32, not listed here.
The visual language is deliberately narrow — **overlapping circular forms** and The visual language is deliberately narrow — **overlapping circular forms** and
nothing else. §42's forbidden list (blood drops, tampons, pads, uterus imagery, nothing else. §42's forbidden list (blood drops, tampons, pads, uterus imagery,

View File

@ -62,26 +62,28 @@ to reintroduce by regenerating carelessly:
- **The dark set is a real dark composition**, not the light one dimmed — the - **The dark set is a real dark composition**, not the light one dimmed — the
midnight-plum ground `BRAND_GUIDE.md` §22 specifies. midnight-plum ground `BRAND_GUIDE.md` §22 specifies.
## Still to do before anything ships ## These now ship
Tracked as **#29**, which stays open until the app renders these: **#29 is closed.** All eight are in the app, in both themes, as WebP drawables in
[`../../../core/designsystem/src/main/res/`](../../../core/designsystem/src/main/res/)
`drawable/` for light and `drawable-night/` for dark, so Android resolves the
theme rather than any code of ours. The files here stay as the full-resolution
sources; the shipped copies are downscaled to the height they are actually drawn
at.
1. Redraw as Compose vector paths behind the existing replaceable names, taking They are **not** redrawn as vectors, which is what #29 originally called for.
colour from theme tokens rather than shipping two raster sets. That was the right plan for artwork that did not exist and the wrong one for
2. Add the light/dark preview pair per step, per [`../README.md`](../README.md). gradient landscapes: there is no honest `VectorDrawable` of one, §42 objects only
3. Reconcile step copy — `PRODUCT_PLAN.md` owns the screen wording. to *unnecessary* raster, and the whole set costs 130 KB of the release APK.
`CycleMarkers.kt` keeps its vectors, because a marker's shape carries meaning and
the progress arc is drawn from the user's own cycle.
**One inconsistency to settle when these are redrawn:** the fourteen onboarding **The painted-in corners needed clipping.** Each drawing is composed as a card
illustrations are contained cards with a rounded frame, and the two with its own rounded corner, so the pixels outside that curve are the card's own
`EmptyStateIllustration` files are full-bleed with no frame. Both scale backdrop — near-black in the dark set, near-white in the light one. Drawn
acceptably at the 110160 dp these are actually drawn at — checked by rendering unclipped they showed as four notches against the app background: obvious on a
them at size rather than assumed — but the frame should be consistent across the device, invisible in code review. `Illustration` in `Illustrations.kt` clips just
set, or deliberately absent from all of it. inside the painted curve, and what is left reads as a deliberate rounded card.
A second, smaller one: `TodayScreen.kt` currently calls
`EmptyStateIllustration(color = ..., size = ...)` with a single colour. These are
full-colour drawings, so the vector version will not take a colour parameter and
that call site changes with it.
## Format ## Format

View File

@ -32,6 +32,63 @@ written and stay true. It is exempt from review for the same reason a receipt is
## Entries ## Entries
### 2026-08-18 — The onboarding artwork ships, and a plan I had argued for turned out to be wrong
All eight illustrations are in the app in both themes, every onboarding step has
art for the first time, and **#29 closes**. The interesting part is that the plan
I pushed hardest for is not the one that shipped.
**I argued for vectors and was wrong.** The issue said redraw the set as Compose
vector paths taking colour from theme tokens. That is the right answer for
artwork that does not exist yet — it is exactly what §42 prescribes and what the
placeholders did. It is the wrong answer for gradient landscapes with glow and
depth: there is no honest `VectorDrawable` of one, and the attempt would either
lose the drawing or produce path soup slower than the image. §42 objects to
*unnecessary* raster, and I had been reading it as objecting to raster.
The number settled it. All sixteen files at the height they are actually drawn
at cost **130 KB**: the release APK went 1.90 MB to 2.03 MB. I had been arguing
against a cost I never measured.
**The theme swap is resource resolution, not a branch.** `drawable/` and
`drawable-night/`, so Android picks the asset and the artwork follows a device
theme change with nothing of ours running. It also means a missing night asset is
invisible in light mode, which is why `OnboardingPreviews.kt` now has one entry
per illustration rather than a representative few.
**What only a device showed.** Each drawing is composed as a card with its own
rounded corner painted in, so the pixels outside that curve are the card's own
backdrop — near-black in the dark set. Rendered unclipped, every illustration had
four visible notches against the app background. Nothing in the build says so,
the previews are small, and it was obvious within a second of looking at a phone.
`Illustration` now clips just inside the painted curve.
**Two vectors stayed vectors**, and the line is worth keeping: the calendar
markers and `CycleProgressMark` are drawn *from data* — a marker's shape is what
a day is, and the progress arc is where the user actually stands in their cycle.
Good artwork does not make those into images. `CycleProgressMark` moved from
`Illustrations.kt` to `CycleMarkers.kt` to put that boundary in the file layout.
**Sizing is by height, not a square box.** The set does not share one aspect
ratio and a square would letterbox some and crop others. Height is also the
dimension that decides whether the primary button stays on screen, so steps 3 and
6 — three buttons and three option cards respectively — take 104 dp where the
others take 120128 dp. Checked by driving all seven steps rather than by
reasoning about it.
Driven end to end on `PeriodMinSdk26` in both themes, no crash.
- **Closed:** #29.
- **Next action:** Batch 06 — Privacy and Security. File its issues first; the
milestone is still empty. Biometric and PIN lock, export, irreversible delete,
the privacy promise in Settings, and making §45's logging rules true in a
release build.
- **Blockers:** #9, the Command Center webhook, still needs a person. The QA gaps
are unchanged: no real lock screen has been looked at, TalkBack has never been
run, and text has never been scaled — the last one now matters more, because
every onboarding step has an illustration above its heading and nobody has seen
that layout at the largest font scale.
### 2026-08-18 — The app ran at `minSdk` for the first time ### 2026-08-18 — The app ran at `minSdk` for the first time
A second emulator exists — `PeriodMinSdk26`, API 26, Pixel 6 — and the app was A second emulator exists — `PeriodMinSdk26`, API 26, Pixel 6 — and the app was