feat: placeholder vector artwork, and calendar markers that work in greyscale

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
This commit is contained in:
null 2026-08-18 03:35:04 -05:00
parent da529b265f
commit adaabfcec4
3 changed files with 343 additions and 0 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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 rather than showing a confident forecast it has not earned. "Getting to know
your pattern", not a percentage. 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 ## Include the rejected version
For any decision that was genuinely close, record what was not chosen and why. For any decision that was genuinely close, record what was not chosen and why.