357 lines
19 KiB
Markdown
357 lines
19 KiB
Markdown
# 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
|
||
|
||
### 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 130–500 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)
|