Privacy-Period-Tracker/docs/design/README.md

422 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design
```
Status: Current
Owner: _null
Last reviewed: 2026-08-20
Governs: docs/design/**, core/designsystem/**, app/src/main/kotlin/**,
app/src/main/res/values/** — the design tokens, every user-facing
screen, and the product's tone and interface copy
Review trigger: Any new user-facing screen or state; any change to the colour or
type tokens; any change to notification copy or to a privacy or
fertility disclaimer
```
## Where the detail is
The screen-by-screen specification and the actual words are
[`../planning/PRODUCT_PLAN.md`](../planning/PRODUCT_PLAN.md), and they are not
repeated here — a second copy of interface copy is how two versions of a
disclaimer come to exist.
| Subject | Section it owns |
| --- | --- |
| Onboarding, seven screens, with copy | §19, §56 |
| Navigation — four tabs | §20 |
| Today screen and its six dynamic states | §21, §22 |
| Period logging, period end, spotting | §23, §24, §25 |
| Calendar states and markers | §26 |
| Insights | §27 |
| Notification modes, types and flow | §28, §29, §30 |
| Incognito launcher | §32 |
| Look and feel, visual direction, typography, motion | §37, §38, §40, §41 |
| Colour | §39 — **superseded by [`BRAND_GUIDE.md`](BRAND_GUIDE.md)**; see [Colour](#colour) below |
| Artwork | §42 |
| Accessibility | §43 |
This document holds what governs those: the tone, and the rules that decide an
argument the specification did not anticipate.
## Tone
Calm, private, intelligent, adult. The app is a good utility with warmth — it is
not clinical, not childish, not gamified, and not stereotypically feminine as an
identity. It never celebrates a period and never alarms about one.
One paragraph, and it settles most small arguments: **the app speaks like
someone competent who is not making a fuss.** "Your period is late!" is out.
"Not yet?" with an updated forecast is in.
## Four rules that decide the arguments
**1. The number is the hero.** The forecast dominates the Today screen —
[§38](../planning/PRODUCT_PLAN.md). Anything competing with it for attention is
wrong, including anything of ours.
**2. Nothing asserts certainty the model does not have.** A window and a
confidence label, never a bare exact date presented as fact. "Estimated
ovulation", never "you are ovulating today". Every **screen** that shows a
fertility estimate carries the not-contraception line, which is where
[§18](../planning/PRODUCT_PLAN.md) scopes it — "near fertility features and in
About". Notification copy is the deliberate exception: it says "Estimated
fertile window" and stops, because a disclaimer is a long sentence about
fertility on a lock screen.
**3. State is never colour alone.** Confirmed period is a solid fill, predicted
is dotted or outlined, fertile window is a ring, ovulation is its own small
marker — distinguishable in greyscale, because that is also what makes them
distinguishable to a colourblind user and to a screenshot in a bug report.
Predicted and confirmed days must never look identical.
A period *projected* months ahead is a fifth state, and it is the one case where
the family resemblance is the message: the same dashed ring as a prediction,
drawn thinner and finer, because it is still a period mark making a weaker claim.
A different shape would say "different kind of thing"; a lighter opacity of the
same ring would say nothing at all on a dim screen. The weaker claim also has to
be said in words — the month carries its assumption sentence and the confidence
for that distance — since no marker can carry "if nothing changes" on its own.
**4. Ads never touch a health action.** No banner in onboarding, in the
period-start confirmation, in the period-end confirmation, or between steps of a
health workflow — and never an interstitial after logging
([§33](../planning/PRODUCT_PLAN.md)).
**The banner space is reserved before there is an ad to put in it.** §48 wants no
layout jump when one loads and a graceful gap when one fails, and both are
properties of the space existing whether or not it is filled. Retrofitting the
reservation in Batch 07 means shipping the jump first and finding it in a QA
round. It is empty and invisible now — deliberately not a "your ad here" box,
which would be a placeholder for the thing a user pays to remove.
## Colour
The palette is [`BRAND_GUIDE.md`](BRAND_GUIDE.md), supplied by the project owner
on 2026-08-18. It **supersedes** the sketch in
[`../planning/PRODUCT_PLAN.md` §39](../planning/PRODUCT_PLAN.md) where the two
differ, and one difference is worth stating rather than leaving to be discovered:
> **§39 asks for a muted teal or sage fertile window. The brand guide is plum
> and rose throughout, and gives fertility its own pink (`#E891AE`).**
The guide wins — it is newer, it is the owner's, and a teal accent inside this
identity would look borrowed from another app. What §39 was protecting is kept
by other means, and this is the part that matters: **the calendar's states
differ in shape, not only in colour.** A solid disc, a dotted ring, a continuous
ring and a star stay tellable apart in greyscale and to a colourblind user, which
is what §43 actually requires. Colour is the second signal here, never the only
one.
The two rules from §39 that survive unchanged, because they are product
decisions rather than colour preferences:
- **Period state** is a sophisticated rose or plum — never graphic blood-red.
- **Nothing reads as "safe".** The fertile window is pink rather than green for
the same reason the labels say "Lower likelihood": this app never gives
permission.
Everything goes through Material 3 colour roles and centralized tokens in
`core/designsystem`. **No hard-coded colours in a Composable** — a colour
literal outside the token file is a review failure.
## Both themes, every time
Dark mode was broken for the whole of Batch 01 and nothing said so. `PeriodTheme`
was not wrapping its content in a `Surface`, so any `Text` without an explicit
colour inherited Material's default — black — and the app's own background never
painted at all. **In light mode that looked correct by accident**, because dark
text on cream is what was wanted anyway; in dark mode the onboarding headings
were near-black on charcoal.
No test caught it and no test easily would. It was found by opening the app.
Two rules follow. Every screen gets a **preview pair, light and dark** — cheap,
and it puts the failure in front of whoever is editing. And the `Surface` lives
in the theme rather than in each screen, so a screen without a `Scaffold` cannot
forget it.
## A row for an unbuilt feature is absent, not disabled
Settings is built as `PRODUCT_PLAN.md` §36 structures it, and it shows only the
sections that have something behind them. Privacy & Security arrives with the
issues that fill it; Appearance and Premium with their batches.
The alternative — every §36 row present and greyed out — is worse here than in
most apps. A disabled *Export My Data* is a promise with no delivery date, and a
disabled *Delete My Data* tells a user that the control over their own health
data exists and that they may not have it. In a product whose entire argument is
that the data is theirs, that is a bad first thing to say.
Absent is honest, and the tracker is where the plan lives — which is the same
rule [`../data/README.md`](../data/README.md) applies to placeholder images, for
the same reason: something that looks finished outlives the issue that would
have replaced it.
## The lock screen is the one semi-public surface
### When the copy and the platform disagree, the copy moves
The reminders screen promised delivery "a few minutes either side of this time".
It could not be made true. A periodic work request is *deferred* by the system,
never brought forward, and the flex window that would allow "before" also places
the first run nearly a full period out — so asking for it would have skipped the
reminder on the day the user set it, to keep a sentence.
The sentence changed instead: *Android may deliver a reminder a few minutes after
this time, never before.* Slightly less comfortable and exactly what happens,
which is the trade this document asks for everywhere else.
### A switch that is on should mean something is happening
Reminders can be switched on here and still never arrive: the notification
permission can be denied, notifications can be off for the whole app, or the
channel can be blocked. The screen used to show none of that. The permission
dialog's result was discarded, the toggle stayed on, and a revocation afterwards
was never noticed at all — so the honest reading of that screen was a promise the
app could not keep.
It says so now, in one row above the toggles, with a button to the system
setting that would fix it. And it is re-read when the screen resumes, so somebody
who leaves to switch notifications back on is believed the moment she returns
rather than told she is still blocked.
**A denial does not switch the toggle back off.** That is the tempting fix and it
is wrong: she said she wants the reminder, and rewriting her answer means a later
grant changes nothing and she has to find the toggle again to discover that.
The preference records what she asked for; the row records what the system is
doing about it.
### Back is a promise, and the top bar is where it is kept
**Every screen that is not a tab has a top bar with a back arrow, and it is one
component — `SettingsSubpage` — not three copies.** Before it, a grep for
`TopAppBar`, `navigationIcon` and `popBackStack` across the whole app returned
nothing: no screen had a visible way back, and App lock had a headline styled
like a bar without being one, so the affordance a user reached for was a label.
**Back returns to the screen it came from, never to the app root.** A screen with
internal steps makes back step-aware, and the arrow and the system gesture do the
same thing at the same step: from a step, back to the previous step; from the
first step, out of the screen. Inside a step the gesture is intercepted; at the
first step it is deliberately *not*, so it falls through and pops the destination
exactly as the arrow does.
**Re-selecting a tab returns it to its root**, and the Settings tab always lands
on the §36 tree. That is not a nicety: settings children are one-shot tasks, so
leaving Settings pops its stack without saving it. Saving it is what produced the
bug this section exists for — the tab restored the child you were trying to
leave, and kept doing it for the rest of the process.
Three versions were tried and rejected. Making Settings a nested graph *alone*
fixes which tab looks selected and nothing else; the tab tap still saved and
restored the child. Dropping `restoreState` alone leaves the stack saved on the
way out, so nav-scoped ViewModels are retained per exit and the tab still reads
as unselected on children. And having the arrow dispatch the system back gesture
is one code path, but it makes the arrow mean "whatever happens to be registered"
rather than "go back".
The title lives in the bar and carries the heading semantics; the body does not
repeat it. Two headings saying the same thing is one for a screen reader to wade
through and one more thing to keep in step.
### A step that asks a question waits for the answer
Both confirmations in App lock — "turn the lock off" and "change PIN" — used to
move on in the same breath as asking. The check of the current PIN is
asynchronous, and the screen reassigned its step outside the result, so the
"that is not the PIN" message arrived after the screen that would have shown it
had already gone. In the change case it was worse than confusing: any four
digits reached "Choose a PIN", and the replacement was written. The lock stopped
protecting the thing it exists to protect, which is somebody holding the phone
while it is unlocked.
A step now advances from inside the verified callback, and the ViewModel refuses
a replacement that no successful check authorised. Two guards for one rule, on
purpose: a screen is the kind of file that gets rewritten by somebody who has not
read the one behind it, and this is not a rule to leave in a composable's hands.
The same shape applies anywhere a screen has steps: the arrow, the system
gesture and the on-screen Cancel all mean the same thing at the same step, and
none of them may skip a question that has not been answered.
### A button says what it does, and it does what it says
A notification button is chosen twice: once as words on a lock screen, and once
as a write into the health record. Those two halves used to be produced in
different files — `NotificationCopy` returned a list of labels and the worker
attached the first to "started" and the second to "not yet", by position. That
holds while every reminder asks the same question. It stopped holding the moment
one did not: *"Is your period over?"* offers **Ended** and **Still going**, and
by position "Ended" recorded a brand-new period starting today, in the middle of
the period it was asking about.
So the label and the action are now one thing, produced together by
`NotificationCopy.buttons` and carried together to the tap. Nothing downstream
pairs them up again, because nothing downstream is allowed to.
The privacy rule that already governed the labels still governs them: a button
is visible text on a lock screen, and it says nothing out of context (§31). The
new rule beside it is that a button which asks about an ending may not produce a
beginning — stated as a property, and tested as one, rather than left to whoever
next edits the list.
Everything else in this app is seen only by somebody who already has it open.
The lock screen is different: it is what appears when the owner opens the app in
front of somebody else, and what anybody who picks the phone up sees. Three
consequences, and none of them is a style preference.
**It names nothing.** No "period", no "cycle", no "fertility" — the same list
the notification copy lives under, and `LockCopyTest` checks it against exactly
that list. The strings live in `LockCopy` rather than inline for a reason worth
repeating: a source-grep guard over the screen file cannot work, because the
KDoc explaining the rule contains every word it forbids.
**It has no illustration.** The temptation is a padlock, and this document
already says why not: it "would read as a security product rather than a calm
one". The lock screen is where that pull is strongest and where giving in would
do the most damage — a padlock over a phone reads as *nobody can get in*, and
what this lock actually does is narrower.
**It says what the lock does not do.** The setup flow, before the first digit,
states that the lock stops the app opening and does **not** encrypt what is
recorded. That is an unusual thing for a product to volunteer, and it is the
honest version: `SECURITY.md` lists a rooted device and someone who unlocks the
phone as out of scope, so a screen implying otherwise would be this product's one
dishonest moment.
Three screens, all with a `fontScale = 2.0f` preview beside the light and dark
pair — the first in this repo to carry one, after a 2.0-scale defect shipped in
the navigation bar.
## The states most often left undesigned
Designed here on purpose, because they are the two most people meet first:
- **Empty** — no periods logged yet. It has to make the next action obvious
rather than apologise.
- **Learning** — one or two cycles recorded. The app says it is still learning
rather than showing a confident forecast it has not earned. "Getting to know
your pattern", not a percentage.
## The artwork is real now, and the replaceable names are why that was cheap
Every illustration in the app is the owner's own drawing, in a matched light and
dark pair, in
[`core/designsystem/src/main/res/`](../../core/designsystem/src/main/res/). The
sources and the decisions behind them are in [`dist/`](dist/).
**That swap changed one function body per illustration and no screen's layout**,
because PRODUCT_PLAN.md §42's advice was followed literally when there was
nothing to draw with: polished placeholders behind replaceable names. The names
did not change when the drawings did. That is the whole argument for the rule,
and it is worth keeping for the artwork still to come.
**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.
**They ship as full-bleed heroes in density buckets.** Each illustration spans
the screen and fades into the page, at a height capped to a third of the screen
so the primary button stays reachable — the cap is what makes landscape survive,
where the screen is 411 dp tall and an uncapped 280 dp hero would fill 68% of it.
Four buckets per theme, 617 to 1644 px; a plain `drawable/` would be treated as
mdpi and decoded at 4× on a dense screen. 2.3 MB across every bucket, but a
release is an AAB and Play splits by density, so a device downloads 130500 KB.
**The fade is drawn, not baked**, as a gradient to
`MaterialTheme.colorScheme.background`. Artwork cannot know which theme it is
sitting on, and a baked fade would be a hard-coded colour by another name.
**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.
**The launcher icon is the real emblem now too.** It was the last placeholder,
and it was blocked on a real constraint: an adaptive icon masks roughly a
quarter of the canvas away, and the first emblem had content hard against its
edges, so dropping it in would have cropped the shield.
The emblem supplied for it solves that by putting the meaning in the middle —
calendar, shield, padlock — with the ring and botanicals as the outer decoration.
So it did not need redrawing, it needed *placing*: scaled into the 66dp safe
circle on the Deep Plum background from `BRAND_GUIDE.md` §18, it survives the
circular and rounded-square masks whole. Checked by simulating both masks, and
then by looking at the launcher.
**The monochrome layer is still the simplified vector**, deliberately. A themed
icon is a silhouette — Android discards the colour and tints the alpha — so the
emblem's overlapping calendar, shield, padlock, crescent and leaves would flatten
into an unreadable blob. A ring with one dot still reads at 48dp in one colour,
which is all that layer has to do.
**The discreet launcher is not a second brand.** It is an activity alias named
`Daybook`, with a neutral calendar-and-dot icon that avoids rose accents, cycle
rings, shields, locks and menstrual imagery. The normal launcher keeps the real
brand; the alias exists for shared-home-screen discretion.
### What the artwork has to do
Two things at once, stated by the owner and recorded here because it is the brief
every remaining art issue is judged against: **welcoming, and visibly private.**
Not alternating between them screen by screen — a warm opening followed by a bare
form is how onboarding reads today, and the privacy idea currently appears on
exactly one screen out of seven.
Welcoming is the harder half to get right without breaking [Tone](#tone): warmth
here means an app that looks like it was made with care, never one that
celebrates a period or decorates it. Private is already half-solved in the
vocabulary — `PrivacyIllustration` encloses a ring in a shield, and enclosure
reads as protection without reaching for a padlock, which would read as a
security product rather than a calm one.
The onboarding brief has been answered: eight illustrations, both themes, now
shipping in the app. [`dist/`](dist/) holds the sources and the reasoning — no
text painted into the pixels, no step numbers, and a forecast illustration that
names no fields so it cannot contradict the live one beside it.
Remaining artwork is tracked in the issue tracker, not listed here.
**Screenshots of the running app live in [`screenshots/`](screenshots/)**
eleven frames from a seeded demo history, for the product page on privacyllc.dev
and for the Play listing. Its README carries the seeded dates, the capture
recipe, and the rules every frame has to satisfy; the first of those is that a
screenshot of a period tracker is a screenshot of health data.
**The Play listing exports live in [`brand/play-store/`](brand/play-store/)**
the required feature graphic, Play icon and phone screenshots. They are PNG
exports from the existing brand lockup and seeded screenshots, with Google's
current dimensions checked on 2026-08-20 and recorded beside the files.
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.
Without it the same option is proposed again every few months and re-argued from
nothing. The first entries belong to whoever makes those calls; the
specification's `Avoid:` lists are already a partial record of them.
## What does not belong here
- How it is built — [`../architecture/README.md`](../architecture/README.md)
- Scope and audience — [`../planning/PROJECT_PLAN.md`](../planning/PROJECT_PLAN.md)