From adaabfcec4fe7f99a2fe3414010d5c8de8c34253 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 18 Aug 2026 03:35:04 -0500 Subject: [PATCH] feat: placeholder vector artwork, and calendar markers that work in greyscale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose vector paths in core/designsystem/art — no raster anywhere under src/, so everything stays crisp at any density and adds nothing to the APK. The four calendar markers differ in SHAPE, not only colour: a solid disc for a confirmed period, a dotted outline for a predicted one, a continuous ring for the fertile window, and a four-pointed mark for ovulation. §26 and §43 both require it and for the same reason — a calendar whose states differ only in colour carries no information for a colourblind user, none in greyscale, and none in the bug report somebody files about it. Predicted is deliberately not a lighter confirmed: opacity alone fails exactly when the screen is dim. Illustrations for welcome, empty state, learning and the privacy promise, plus the cycle progress mark for Today. The visual language is overlapping circular forms and nothing else — §42's forbidden list is a product decision, not squeamishness: this app gets opened in public and a glance over a shoulder should learn nothing. Placeholders here and never in docs/data/img, which docs/design/README.md now explains rather than leaving as an apparent inconsistency: §42 asks for these explicitly, a replaceable name keeps them replaceable, and a screen with no illustration cannot be evaluated while a project card with no icon just shows initials. closes #15 --- .../period/designsystem/art/CycleMarkers.kt | 101 ++++++++ .../period/designsystem/art/Illustrations.kt | 221 ++++++++++++++++++ docs/design/README.md | 21 ++ 3 files changed, 343 insertions(+) create mode 100644 core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt create mode 100644 core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt new file mode 100644 index 0000000..a1c488e --- /dev/null +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt @@ -0,0 +1,101 @@ +package dev.privacyllc.period.designsystem.art + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * The four calendar states, drawn so they differ in **shape**. + * + * PRODUCT_PLAN.md §26 and §43 both require it and for the same reason: a + * calendar whose states differ only in colour carries no information for a + * colourblind user, none in a greyscale screenshot, and none in the bug report + * somebody files about it. + * + * confirmed ● solid disc + * predicted ◌ dotted outline + * fertile ○ continuous ring + * ovulation ✦ small four-pointed mark + * + * Colour still carries meaning — it is just never the only thing that does. + * + * Each one is a named composable rather than a drawable resource because that is + * what "behind a replaceable name" means in Compose: swapping the drawing is a + * change in one function body, and every call site follows. + */ + +/** A period the user confirmed. Solid, because it is the only state that is certain. */ +@Composable +fun ConfirmedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { drawCircle(color = color, radius = this.size.minDimension / 2f) } +} + +/** + * A period the app predicts. + * + * Dotted, and never a lighter version of the confirmed marker: §26 is explicit + * that predicted and confirmed must never look identical, and a difference in + * opacity alone fails exactly when the screen is dim or the user is not looking + * closely. + */ +@Composable +fun PredictedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { + val stroke = this.size.minDimension * MarkerDefaults.StrokeFraction + drawCircle( + color = color, + radius = (this.size.minDimension - stroke) / 2f, + style = Stroke( + width = stroke, + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(this.size.minDimension / 9f, this.size.minDimension / 9f), + ), + ), + ) + } +} + +/** The estimated fertile window. A continuous ring — open, unlike the solid confirmed disc. */ +@Composable +fun FertileWindowMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { + val stroke = this.size.minDimension * MarkerDefaults.StrokeFraction + drawCircle(color = color, radius = (this.size.minDimension - stroke) / 2f, style = Stroke(width = stroke)) + } +} + +/** Estimated ovulation. Its own shape entirely, because it is a day rather than a span. */ +@Composable +fun OvulationMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { drawFourPointStar(color) } +} + +internal fun DrawScope.drawFourPointStar(color: Color) { + val r = size.minDimension / 2f + val c = Offset(size.width / 2f, size.height / 2f) + val waist = r * 0.34f + val path = Path().apply { + moveTo(c.x, c.y - r) + quadraticTo(c.x + waist, c.y - waist, c.x + r, c.y) + quadraticTo(c.x + waist, c.y + waist, c.x, c.y + r) + quadraticTo(c.x - waist, c.y + waist, c.x - r, c.y) + quadraticTo(c.x - waist, c.y - waist, c.x, c.y - r) + close() + } + drawPath(path, color) +} + +object MarkerDefaults { + 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. */ + const val StrokeFraction = 0.14f +} diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt new file mode 100644 index 0000000..07d238b --- /dev/null +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt @@ -0,0 +1,221 @@ +package dev.privacyllc.period.designsystem.art + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +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 kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * Placeholder illustrations, exactly as PRODUCT_PLAN.md §42 asks for them. + * + * > If custom artwork cannot be produced immediately, create polished + * > placeholder vector assets and keep them behind replaceable resource names. + * + * Every one is drawn, not drawn *on* — vector paths, no raster, so they stay + * crisp at any density and add nothing to the APK. + * + * ## What none of them may be + * + * §42's forbidden list, and it is a product decision rather than squeamishness: + * no blood drops, tampons, pads, uterus imagery, gender symbols or anatomical + * 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, + * abstractly. It is also the language of the launcher icon, which is what makes + * a placeholder set look deliberate rather than improvised. + * + * ## Replacing them + * + * These are the replaceable names. When real artwork arrives, the drawing inside + * each function changes and no call site does. + */ + +/** Screen 1 of onboarding. Overlapping rings — the cycle, before it means anything specific. */ +@Composable +fun WelcomeIllustration( + primary: Color, + accent: Color, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + Canvas( + modifier + .size(size) + .semantics { contentDescription = "Three overlapping circles" }, + ) { + val r = this.size.minDimension * 0.28f + val c = center + 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) + drawCircle(accent.copy(alpha = 0.75f), r, Offset(c.x + r * 0.55f, c.y - r * 0.2f), style = stroke) + drawCircle(primary, r, Offset(c.x, c.y + r * 0.45f), style = stroke) + } +} + +/** + * The empty state — no periods logged yet. + * + * A ring with a single gap, because the story is *the cycle has not started + * being recorded yet*, not *something is broken*. Empty states are one of the + * two screens most people meet first and the one most often left undesigned. + */ +@Composable +fun EmptyStateIllustration( + color: Color, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + 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. + * + * §42: points gradually converging into a pattern. Scattered dots that tighten + * toward a ring as they go round, which is the honest picture of what the engine + * is doing and reads as progress rather than as an error. + */ +@Composable +fun LearningIllustration( + color: Color, + accent: Color, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + Canvas( + modifier + .size(size) + .semantics { contentDescription = "Scattered dots settling into a circle" }, + ) { + val radius = this.size.minDimension * 0.32f + val dot = this.size.minDimension * 0.028f + 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, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + 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, + size: Dp = 56.dp, + modifier: Modifier = Modifier, +) { + 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 { + val Size: Dp = 160.dp +} diff --git a/docs/design/README.md b/docs/design/README.md index 0f496ff..4cbd28f 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -91,6 +91,27 @@ 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 your pattern", not a percentage. +## The artwork is placeholder, and says so here + +Every illustration and calendar marker in the app is a **Compose vector path** +in `core/designsystem/.../art/`, written as PRODUCT_PLAN.md §42 asks: polished +placeholders behind replaceable names, so real artwork changes one function body +and no call site. + +The visual language is deliberately narrow — **overlapping circular forms** and +nothing else. §42's forbidden list (blood drops, tampons, pads, uterus imagery, +gender symbols, anatomy) is a product decision rather than squeamishness: this +app gets opened in public, and somebody glancing over a shoulder should learn +nothing. That is a property of the pictures as much as of the notification text. + +**Note the deliberate inconsistency with `docs/data/img/`,** where a placeholder +is forbidden. The rule there is that an image which looks finished outlives the +issue that would have replaced it, because nobody files a ticket against +something that appears done. In-app assets escape that because §42 asks for them +explicitly and the replaceable-name mechanism is what keeps them replaceable — +and because a screen with no illustration cannot be evaluated at all, while a +project card with no icon simply shows initials. + ## Include the rejected version For any decision that was genuinely close, record what was not chosen and why.