Privacy-Period-Tracker/docs/design
null bde6528547 fix: stop "Ended" from recording the start of a period
The period-end check-in asks "Is your period over?" and offers Ended and
Still going. Tapping Ended inserted a NEW period record starting today,
in the middle of the period it was asking about. Still going filed a
censoring observation against a forecast that had already arrived.

The labels were chosen in NotificationCopy and the writes were attached
in ReminderWorker by position -- index 0 to "started", index 1 to "not
yet", for every kind of reminder. That holds while every reminder asks
the same question. It stopped holding the moment one did not.

It corrupted the health record and every forecast built on it, and the
user had no way to see it happen.

A button is now one thing carrying both halves: NotificationCopy.buttons
returns the label and the action together, and nothing downstream is
allowed to pair them up again. ENDED closes the period that is running
through setPeriodEnd -- the same call the Today screen makes -- and never
opens one. STILL_GOING deliberately writes nothing: it is the state the
record is already in, and the in-app equivalent is a no-op that would
still move updatedAt and read, in the history, as an edit she never made.

A start confirmed from a notification is now sourced
NOTIFICATION_CONFIRMATION rather than MANUAL. How a record arrived is
part of the record.

Actions travel as their enum name, and anything unrecognised -- including
the strings used before this change -- writes nothing. A notification
sitting in somebody's shade across the upgrade still opens the app; it
just does not guess what she meant. The extra key is now declared once in
core/notifications and read by MainActivity rather than repeated as a
literal on both sides.

The handler had no test at all, which is how this survived: it owns the
only two writes reachable from a locked phone. It has eight now, and the
first is not about a write -- it asserts the two halves agree, in every
privacy mode, as a property.

Proved: mutating the already-closed guard out reddens exactly one test
(scripts/prove-guard.sh). Reverting ENDED to its old write reddens three,
which is the whole ENDED semantics and not a coincidence.

closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:28:47 -05:00
..
brand ui: add Play listing assets 2026-08-20 02:14:08 -05:00
dist ui: fix dark welcome artwork 2026-08-20 01:59:30 -05:00
screenshots ui: add Play listing assets 2026-08-20 02:14:08 -05:00
BRAND_GUIDE.md ui: add discreet launcher alias 2026-08-20 02:37:29 -05:00
README.md fix: stop "Ended" from recording the start of a period 2026-08-20 21:28:47 -05:00

README.md

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, 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; see 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. 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 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).

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, supplied by the project owner on 2026-08-18. It supersedes the sketch in ../planning/PRODUCT_PLAN.md §39 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 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 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/. The sources and the decisions behind them are in 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: 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/ 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/ — 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/ — 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