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

470 lines
25 KiB
Markdown
Raw Permalink Normal View History

chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
# Design
```
Status: Current
Owner: _null
2026-08-20 02:14:08 -05:00
Last reviewed: 2026-08-20
feat: the designed Settings screen, structured as §36 specifies closes #33 Settings is now a root with §36's sections rather than one screen of notification toggles that called itself a working surface. Reminders move underneath it as a child destination; the banner is gone because the claim it made stopped being true, not because the screen changed. A ROW FOR AN UNBUILT FEATURE IS ABSENT, NOT DISABLED §36 lists six sections and this shows the two that have something behind them. A greyed-out "Export My Data" is a promise with no delivery date, and a disabled "Delete My Data" tells a user that control over their own health data exists and that they may not have it. In a product whose whole argument is that the data is theirs, that is a bad first thing to say. Privacy & Security arrives with #34 to #37, Appearance with Batch 08, Premium with Batch 07. ONE COPY OF THE FERTILITY DISCLAIMER About needs it, and it was already a literal in TodayScreen and CalendarScreen — a third copy would have made two versions of a safety promise inevitable. Now a string resource all three read. Notifications still deliberately omit it: a long sentence about fertility on a lock screen is the leak the discreet copy exists to prevent. The version is read from PackageManager rather than BuildConfig, so it reports what is actually installed — the number a person reads out when something is wrong. A DOC TRIGGER THAT COULD NOT FIRE docs/design/README.md's trigger is "any new user-facing screen or state" while its Governs listed only docs/design/** and core/designsystem/**, so adding a screen fired nothing. Same class of defect as the architecture doc's, found the same way — by adding the thing the trigger is about and watching it not fire. Widened, and verified by running doc-triggers.py against the new file. Driven on PeriodMinSdk26: the root renders, rows expand in place, reminders open as a child, and back returns to the root rather than exiting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:44:15 -05:00
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
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
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 |
docs: correct 57 claims the code and tracker disagreed with Every document in the tree audited against the source, the tracker and git history, each finding then given to a second reader tasked with refuting it. 74 raised, 12 refuted, 57 applied. No code changed. THE README DESCRIBED A SKELETON Its Status table — the one place a claim about what is built is allowed to live — still read "there is no usable app yet", with Not built against Room, the four core screens, fertility and notifications, and No round run against QA. Five batches had shipped and three QA rounds had run. TWO DOCUMENTS WERE SILENTLY NEVER FIRING architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle module graph", "the design tokens in core/designsystem"). Neither contains a path token, so doc-triggers.py reduced them to globs matching nothing, and one real glob apiece made them look path-governing rather than subject-governing — the state the script's own header calls invisible. Editing a Room entity never fired the document owning the migration table. Both now fire, proved by running the script. SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS App lock listed among what works offline; biometric/PIN gating described as protecting app launch; the incognito launcher as existing; Play Billing in the third parties table without the "not yet integrated" marker its neighbours carry. All are Batch 06/07 work. The advertising boundary was overstated in SECURITY.md and the README alike: both said the ads module declares no dependency and a guard proves it. There is no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than the sentence it replaced and matches nothing until Batch 07, which is why the guard is proved by injection rather than trusted. SMALLER, EACH A REAL TRAP WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template path absent here — missed by doc-claims.sh, which reads backticked prose and not fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three rounds because ClaudeQAPlan.md's after-a-round list never named that section; the playbook is fixed first. The instrumented-test count was eight in three places and is four. HISTORY.md said the repository had no code and that nothing had been tried and dropped, when three approaches had. DELIBERATELY UNCHANGED ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run since, and moving it would claim a verification nobody performed. Every DEVELOPMENT_LOG entry stays as written. Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all present), doc-triggers.py, and a link sweep over 21 markdown files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:33:14 -05:00
| 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 |
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
| 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
docs: correct 57 claims the code and tracker disagreed with Every document in the tree audited against the source, the tracker and git history, each finding then given to a second reader tasked with refuting it. 74 raised, 12 refuted, 57 applied. No code changed. THE README DESCRIBED A SKELETON Its Status table — the one place a claim about what is built is allowed to live — still read "there is no usable app yet", with Not built against Room, the four core screens, fertility and notifications, and No round run against QA. Five batches had shipped and three QA rounds had run. TWO DOCUMENTS WERE SILENTLY NEVER FIRING architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle module graph", "the design tokens in core/designsystem"). Neither contains a path token, so doc-triggers.py reduced them to globs matching nothing, and one real glob apiece made them look path-governing rather than subject-governing — the state the script's own header calls invisible. Editing a Room entity never fired the document owning the migration table. Both now fire, proved by running the script. SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS App lock listed among what works offline; biometric/PIN gating described as protecting app launch; the incognito launcher as existing; Play Billing in the third parties table without the "not yet integrated" marker its neighbours carry. All are Batch 06/07 work. The advertising boundary was overstated in SECURITY.md and the README alike: both said the ads module declares no dependency and a guard proves it. There is no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than the sentence it replaced and matches nothing until Batch 07, which is why the guard is proved by injection rather than trusted. SMALLER, EACH A REAL TRAP WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template path absent here — missed by doc-claims.sh, which reads backticked prose and not fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three rounds because ClaudeQAPlan.md's after-a-round list never named that section; the playbook is fixed first. The instrumented-test count was eight in three places and is four. HISTORY.md said the repository had no code and that nothing had been tried and dropped, when three approaches had. DELIBERATELY UNCHANGED ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run since, and moving it would claim a verification nobody performed. Every DEVELOPMENT_LOG entry stays as written. Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all present), doc-triggers.py, and a link sweep over 21 markdown files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:33:14 -05:00
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.
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
**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.
feat: project the next year onto the calendar, and say what it assumes "Will I have my period the week of the wedding?" is a core prediction question the app could not answer. The calendar marked only the next forecast window and stopped browsing one month out, so a textbook-regular user paged forward into blank months. CycleProjection reaches a year. It is a separate type from Prediction, deliberately: a forecast is something the app scores itself on -- the period arrives inside the window or it does not, and PredictionRecord writes down which -- while a projection eleven cycles out is never scored, never learned from, and will have been replaced four times before its date arrives. Separate types mean a projection cannot reach the accuracy figures by accident. Three rules keep it honest, each with a test named after it. Uncertainty grows as sqrt(n), because cycle lengths are near-independent draws: eleven cycles out is about three times as uncertain as one, not eleven times. Real cycles do correlate, so that is the optimistic edge -- which is the second reason the assumption sentence is mandatory. It declines rather than stretching. Past ten days either side a projection stops being an answer, so the projection ends and reports that it ended -- FertilityEstimate's precedent, which already refuses to show a seventeen-day fertile window. The caller can tell "no period due here" from "cannot see this far", and only the second invites her to log more. The assumption is stated, not implied. Any month showing projected marks carries "If your cycles continue as they have, this is the forecast" with the confidence for that distance beside it. A year-ahead date drawn without that is the clearest overstatement this app could make. Cycle 1 is the engine's own forecast copied through unchanged, so the calendar and the Today screen cannot disagree about the next period. The typical cycle length comes from her own history and never a population default -- projecting 29 days for a 35-day user is §3's core defect repeated twelve times over. PROJECTED_PERIOD is its own mark: the same dashed ring as a prediction, thinner and finer. The family resemblance is the message -- still a period, weaker claim -- and it survives greyscale, which an opacity difference would not. Screen readers get the distance in the label, since they cannot see that a square is months away. Browsing now reaches a year; logging still stops at today. ConfidenceRow and readable() move to feature/common and are shared with the Today screen rather than copied. Two of them drifting apart is a screen saying Medium in dots and Low in words. closes #57 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:38:30 -05:00
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.
docs: correct 57 claims the code and tracker disagreed with Every document in the tree audited against the source, the tracker and git history, each finding then given to a second reader tasked with refuting it. 74 raised, 12 refuted, 57 applied. No code changed. THE README DESCRIBED A SKELETON Its Status table — the one place a claim about what is built is allowed to live — still read "there is no usable app yet", with Not built against Room, the four core screens, fertility and notifications, and No round run against QA. Five batches had shipped and three QA rounds had run. TWO DOCUMENTS WERE SILENTLY NEVER FIRING architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle module graph", "the design tokens in core/designsystem"). Neither contains a path token, so doc-triggers.py reduced them to globs matching nothing, and one real glob apiece made them look path-governing rather than subject-governing — the state the script's own header calls invisible. Editing a Room entity never fired the document owning the migration table. Both now fire, proved by running the script. SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS App lock listed among what works offline; biometric/PIN gating described as protecting app launch; the incognito launcher as existing; Play Billing in the third parties table without the "not yet integrated" marker its neighbours carry. All are Batch 06/07 work. The advertising boundary was overstated in SECURITY.md and the README alike: both said the ads module declares no dependency and a guard proves it. There is no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than the sentence it replaced and matches nothing until Batch 07, which is why the guard is proved by injection rather than trusted. SMALLER, EACH A REAL TRAP WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template path absent here — missed by doc-claims.sh, which reads backticked prose and not fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three rounds because ClaudeQAPlan.md's after-a-round list never named that section; the playbook is fixed first. The instrumented-test count was eight in three places and is four. HISTORY.md said the repository had no code and that nothing had been tried and dropped, when three approaches had. DELIBERATELY UNCHANGED ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run since, and moving it would claim a verification nobody performed. Every DEVELOPMENT_LOG entry stays as written. Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all present), doc-triggers.py, and a link sweep over 21 markdown files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:33:14 -05:00
**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)).
feat: the Today screen and its six states, with the number as the hero §21 and §22, each state its own screen rather than a variant of one. Which one applies is decided by CycleStatusRules in a pure module with twelve tests on its boundaries — and the boundaries are the point, because they are the days this screen is most read: the day a period is due, the day after one ends, the day a forecast slips. The number dominates (§38): displayLarge at 72sp, in the primary colour, with the unit as a separate quiet line so "4" reads instantly and "DAYS" is there if you look. Its content description carries the whole sentence, so TalkBack says "Period likely in: 4 days" rather than reading a bare numeral. The state this screen exists to get right is the last one. Past the forecast the app NEVER says late — late implies a schedule the user failed to keep, and the truth is that an estimate was imprecise. It shows what it originally said, what it says now, and asks. A UX DEFECT FOUND BY DRIVING IT Tapping "Period ended" changed nothing on screen. The logic was right — a period that ends today still includes today, so the state does not change — but the button looked broken, which is worse than being broken somewhere visible. DuringPeriod now carries the end date, so the screen shows "Ended 18 August" and offers only the useful action (undo) rather than a button that visibly does nothing. §24's "Updated ✓" acknowledgement is there too. No test would have caught this; it needed somebody to tap the button and look. The banner slot is reserved and empty. §48 wants no layout jump when an ad loads and a graceful gap when one fails, and both are properties of the space existing whether or not it is filled — reserving it in Batch 07 instead means shipping the jump first. Deliberately not a "your ad here" box, which would be a placeholder for the thing a user pays to remove. Fertility lines are absent rather than faked: §22 shows them and Batch 04 estimates them, and a placeholder number there would be inventing a fertility estimate, which is the one thing this screen must not do. Preview pairs for every state, light and dark. 129 tests, all passing. closes #17
2026-08-18 03:50:28 -05:00
**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.
feat: the real brand — Privacy:Period Tracker The project owner supplied artwork and a full visual brand guide mid-session, which answers #8: the three marks an agent must not fake now exist because a person made them. docs/data/img now holds icon.webp (512x512, 11 KB), logo.webp (1024 long edge, 28 KB) and banner.webp (2176x725, 20 KB) — all webp by magic bytes, all far under the 512 KB ceiling. Produced from the supplied art rather than invented. Two things had to be handled rather than converted blindly. The sources render their rounded corners against black, so a naive crop carried black wedges into the banner; the corners are flood-filled to the artwork's own ground first. And the lockup is square, so a 3:1 crop would have cut the wordmark in half — the banner is the whole lockup on a matching ground, which has no seam to see because the artwork's edges ARE that ground. The source PNGs and the guide moved to docs/design/: docs/data/img is for the three marks the Command Center renders, and its README is explicit that reference art and exported sources do not belong there. THE PALETTE, AND ONE DELIBERATE CONFLICT core/designsystem now carries the guide's colours — deep plum, rich purple, period rose, lavender mist, and an eggplant dark theme that is never flat black. §39 of the specification asks for a muted teal or sage fertile window, to hold it apart from the period colour. The guide is plum and rose throughout and gives fertility its own pink. 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 why the conflict is safe to resolve that way: the calendar's states differ in SHAPE. 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. The departure is written down in docs/design/README.md rather than left to be discovered. The launcher icon keeps its vector for now, in brand colours. The supplied emblem has content close to its edges and an adaptive icon masks about a quarter of the canvas away, so dropping it in unmodified would crop the shield. Fitting it to the safe zone is Batch 08's final-artwork work. closes #8
2026-08-18 15:31:52 -05:00
## 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.
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
feat: onboarding, and the Surface that dark mode was missing Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous history, the privacy promise, notification privacy, first forecast. Verified end to end on a device — the flow produces a forecast, the record persists, and a relaunch goes straight to Today. Three decisions with tests behind them: - Nothing is written until the final step. Somebody who abandons onboarding halfway has not asked this app to remember anything about them. - Notification privacy is Discreet before the user touches anything (§28), and Direct is last and never pre-selected. Checked on the device, not only in a unit test. - "Still going" and "I'm not sure" both mean no end date. §24: never invent one. The date picker refuses future dates by not offering them rather than by rejecting a tap it allowed. DARK MODE WAS BROKEN FOR ALL OF BATCH 01 PeriodTheme never wrapped its content in a Surface, so every Text without an explicit colour inherited Material's default — black — and the app background never painted. In light mode that looked correct by accident, because dark text on cream is what was wanted anyway. In dark mode the onboarding headings rendered near-black on charcoal. No test caught it and no test easily would have. It was found by opening the app on a device and looking at it. The Surface now lives in the theme, so a screen without a Scaffold cannot forget, and every illustration has a light/dark preview pair. A preview is not a test, but it is the cheapest thing that puts the failure in front of whoever is editing the screen. closes #16
2026-08-18 03:44:46 -05:00
## 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.
feat: the designed Settings screen, structured as §36 specifies closes #33 Settings is now a root with §36's sections rather than one screen of notification toggles that called itself a working surface. Reminders move underneath it as a child destination; the banner is gone because the claim it made stopped being true, not because the screen changed. A ROW FOR AN UNBUILT FEATURE IS ABSENT, NOT DISABLED §36 lists six sections and this shows the two that have something behind them. A greyed-out "Export My Data" is a promise with no delivery date, and a disabled "Delete My Data" tells a user that control over their own health data exists and that they may not have it. In a product whose whole argument is that the data is theirs, that is a bad first thing to say. Privacy & Security arrives with #34 to #37, Appearance with Batch 08, Premium with Batch 07. ONE COPY OF THE FERTILITY DISCLAIMER About needs it, and it was already a literal in TodayScreen and CalendarScreen — a third copy would have made two versions of a safety promise inevitable. Now a string resource all three read. Notifications still deliberately omit it: a long sentence about fertility on a lock screen is the leak the discreet copy exists to prevent. The version is read from PackageManager rather than BuildConfig, so it reports what is actually installed — the number a person reads out when something is wrong. A DOC TRIGGER THAT COULD NOT FIRE docs/design/README.md's trigger is "any new user-facing screen or state" while its Governs listed only docs/design/** and core/designsystem/**, so adding a screen fired nothing. Same class of defect as the architecture doc's, found the same way — by adding the thing the trigger is about and watching it not fire. Widened, and verified by running doc-triggers.py against the new file. Driven on PeriodMinSdk26: the root renders, rows expand in place, reminders open as a child, and back returns to the root rather than exiting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:44:15 -05:00
## 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.
feat: app lock, with no way to reset a forgotten PIN §45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has existed since Batch 01 with nothing outside its own module reading it; this wires it, and adds the rest. The recovery question was the reason #34 sat open, and it is decided: there is no recovery. A backdoor into a period tracker's lock would be used by exactly the person the lock exists to stop. Everything below follows from that. ## The gate AppLockGate wraps the whole composition rather than being a screen inside it. Today, Calendar and Insights each start collecting from CycleRepository the moment they compose, so a lock implemented as a nav destination would already have read the history before the user proved anything. content() is invoked only in the unlocked branch. Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings and a permission dialog. Two guards on top: isChangingConfigurations, or rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM biometric overlay that stops the activity produces a lock that can never be opened. No grace period: SECURITY.md leads with "someone who picks up an unlocked phone", which is the window a grace period covers. The unlock flag lives in a @Singleton, never in saved state. rememberSaveable looks like the obvious home and would restore a background-killed app already unlocked — the single most likely way to meet the lock screen would be the one path that skipped it. ## What is stored is not the PIN mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k)) Two layers because they defend different things. The Keystore MAC is what makes a six-digit PIN safe at all — a million candidates is nothing to an attacker who can compute the hash, and impossible for one who cannot get the key off the device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a domain-separation tag; the lockout counter is MACed under 0x02. The key omits six builder calls and the KDoc names every one. setUserAuthenti- cationRequired is the important absence: it would bind the key to the device lock, so changing a passcode would destroy it — and under no-recovery that is somebody's whole history gone for an unrelated reason. It would also be a bypass, since SECURITY.md already names "someone who knows the unlock PIN" as an adversary. The biometric key is separate and takes the opposite policy, where invalidation correctly degrades to "use your PIN". ## Wrong PINs cost time, never data Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a pocket destroy a history while knowing nothing. Both clock bypasses are closed — the wait is the longer of a wall-clock and a monotonic deadline, and a reboot re-applies it in full, detected by elapsedRealtime going backwards. ## Two writes that had to move Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on the phone's own lock screen, reachable by anybody, so the action is now parked in AppLockController and applied only after an unlock — dropped if the session never unlocks. Behaviour is unchanged when the lock is off. The erase behind "Forgot your PIN?" deletes health data, then the Keystore key, then the lock store. Skipping the middle step leaves the user erased AND still locked out; prove-guard mutates that line out and requires exactly one red. ## Found by testing, not by review - A fresh install began in a 15-minute lockout: "no counter yet" and "counter was tampered with" were the same value. They are now distinct. - Setting a PIN locked you out of the session you set it in. Found on the emulator, not in a test. - Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice. ## Verified 244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice here with no margin, and that the key is not auth-bound on either. On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on the lock screen, turning the lock off requires the current PIN, and `adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real. androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed checkPermissions until they were allowed on purpose, and it drags fragment to 1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity. closes #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
## The lock screen is the one semi-public surface
feat: pin down which day, and let the reminder be any time Two specification gaps that were filed as deferred and turned out to be mostly affordable now. §30 asks the follow-up reminder to pin down the day: Yes Today, Yes Yesterday, Choose Date, Not Yet. It offers three of those. Android reliably renders three notification actions, and a fourth that appears on some devices and not others is worse than one that never appears -- a feature the user learns and then loses. The one left out is Choose Date, which was never really a button: it needs a screen, and logging an older start in the app is the route to that today. "Yes, yesterday" is cheap only because an answer now carries the day it was about. It means the day before the QUESTION, never the day before the tap, or it drifts exactly like the defect dated actions fixed. §29 lists a custom reminder time beside the three presets. The preference has always stored an arbitrary minute; only the screen insisted on one of three, and "around dinner" is not the same hour in every house. The keyboard-entry toggle stays on: a dial is quick with a thumb and miserable with a screen reader, and this is a control somebody may set once. One test had pinned the follow-up's exact button list, so adding a third broke it. It asserts the property now -- a question about a start never offers an ending -- which is what it was there to protect and does not have to be edited every time the copy grows. closes #75 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 01:09:33 -05:00
### Three buttons, because Android shows three
§30 asks the follow-up reminder for four: *Yes, Today*, *Yes, Yesterday*,
*Choose Date*, *Not Yet*. Android reliably renders three notification actions,
and a button that appears on some devices and not others is worse than one that
never appears at all — it is a feature the user learns and then loses.
So the follow-up offers the three that fit, and the one left out is *Choose
Date*, which was never really a button: it needs a screen. Logging an older start
in the app is the route to that today, and the notification opens the app.
"Yes, yesterday" is cheap only because the answer already carries the day it was
about. It means the day before the *question*, never the day before the tap —
otherwise it drifts exactly like the defect dated actions fixed.
fix: make a reminder arrive when the user asked for it Changing the reminder time did not move the reminder. WorkManager's UPDATE policy carries the previous request's lastEnqueueTime and periodCount forward, and a periodic request computes its next run as periodCount == 0 ? lastEnqueueTime + initialDelay : lastEnqueueTime + interval. Both halves bit: After the first run, a new initial delay is ignored entirely -- Morning to Evening did nothing at all. Before it, the delay is applied to the ORIGINAL enqueue time, and the coordinator reschedules at every process start, so asking for 19:00 at 09:00 on work enqueued at 08:00 produced 18:00, with every later period anchored off that. The scheduler reads the existing work first: KEEP when nothing is scheduled, leave an overdue run alone -- moving it skips today's reminder entirely -- leave a run already within five minutes alone, and otherwise UPDATE with an explicit setNextScheduleTimeOverride, which is the only way to say WHEN rather than how long from a moment WorkManager has its own opinion about. CANCEL_AND_REENQUEUE is wrong for a subtler reason: this runs at every process start including the one WorkManager started to run the worker, and cancelling the unique work there cancels the worker. A time zone or clock change now re-aims it. The delay was computed once, from the zone in force then, so flying east left the reminder arriving at the old wall-clock time indefinitely. WorkManager's own RescheduleReceiver declares BOOT_COMPLETED and nothing else -- which is why ClockChangeReceiver exists for the other two broadcasts, and why it does not duplicate boot. Unexported, no permission, checkPermissions still green. No flex window was added, and the screen's copy changed instead. Flex would have made "a few minutes either side" true and placed the first run nearly a full period out, skipping the reminder on the day the user set it -- to keep a sentence. It now says Android may deliver a few minutes after, never before, which is what actually happens. schedule() had no test; only the arithmetic beneath it did. Nine now, against WorkManager's own recorded next-run time, sharing one clock with it -- a test that fixes only the scheduler's measures a 2026 delay against a real System.currentTimeMillis(). Writing them was necessary rather than tidy: the first version of the change-the-time test passed with the defect still in place, because both schedules happened at the same instant and the bug only bites once time has moved. The test that catches it advances the clock an hour between them, which is what a real second process start does. closes #72 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:42:09 -05:00
### 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.
fix: stop counting reminders the app could not send A reminder can be switched on and still never arrive: the permission denied, notifications off for the whole app, or the channel blocked. notify() returns false in all three cases, and both call sites discarded it while still counting the check-in. So with notifications denied the counter climbed to §30's limit and the app stopped asking -- permanently, having never once asked. Counted only when posted now. canPost() also asks whether notifications are enabled at all and whether this mode's channel is blocked; below API 33 the permission is granted by definition, so an app whose notifications the user had switched off posted into nothing and called it asking. The settings screen says so, in one row above the toggles, with a button to the system setting that would fix it -- and re-reads on resume, so somebody who leaves to switch notifications back on is believed when she returns. Revocation after the fact was previously undetectable: hasPermission() was called from nowhere in main. 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. ReminderWorker now takes a ReminderNotifier rather than building one from the application context, which is what made it testable. It had no test of any kind -- the class that reads the history, applies the rules, posts, and counts -- and every defect in this batch lived in that gap. Six now, over a real repository and a real preference store with only the notifier faked, since what is asserted is precisely what the worker does with the notifier's answer. Two things the prove-guard discipline caught that a green suite did not. The posted-and-counts guard reddened nothing at first, because the worker had no tests to redden -- the fix was unproven until the harness existed. And an assertion of mine read vm.state.value, which is stateIn(WhileSubscribed): with nobody collecting, it sits on the defaults, where every reminder is already true. That test could not have failed. It reads the store now. closes #71 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:08:44 -05:00
### 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.
fix: give every settings page a way out Reported: "on the settings page, if you go to app lock you can't unnavigate out of it." Two causes, both real. The bottom nav saved and restored per-tab back stacks. From App lock, tapping the Settings tab popped [settings, settings/lock], saved it, and restored it in the same breath -- landing back on App lock. The saved stack survived visiting other tabs, so the Settings tab stayed pinned to App lock for the rest of the process. The KDoc above the NavHost claimed the opposite. And no screen in the app had a back arrow. A grep for TopAppBar, navigationIcon, BackHandler and popBackStack across app/, core/ and domain/ returned nothing at all. App lock had a headline styled like a bar without being one, so the affordance a user reaches for was a label, and after setting a PIN the only button on screen -- "Done" -- cleared a message and navigated nowhere. Settings is now a nested graph. Its children are inside the tab's hierarchy, so the tab renders as selected on App lock rather than looking unselected and inviting the tap that trapped you; and re-tapping the tab you are already on pops to its root, which is the gesture people reach for. Leaving Settings pops without saving, so there is nothing to restore. Today, Calendar and Insights keep their place exactly as before. One SettingsSubpage component carries the bar for all three children. Three copies would drift -- one would get the ellipsis for long titles at font scale 2.0 and the others would wrap mid-word, which is a defect the tab labels already shipped once. App lock's steps are remembered state, not destinations, so its back is step-aware: inside a step the arrow and the system gesture both return to the overview, and at the overview the handler is disabled so the gesture falls through and pops the destination, exactly as the arrow does. Two controls a hand's width apart now do the same thing. ExportHost moves into the Scaffold's topBar. It was a sibling emitted BEFORE the Scaffold inside PeriodTheme's Surface -- a Box, where later siblings draw over earlier ones -- so an opaque Scaffold was painted on top of it. It has almost certainly never been visible to anyone. Compose UI tests run on the JVM under Robolectric; nothing in this project could assert a navigation behaviour before. Proved with prove-guard, one red each: unwiring the arrow, and removing the step-aware BackHandler. Verified on the emulator: from App lock, tapping Settings now lands on the settings tree, the Settings tab is highlighted while on a child, and the arrow returns. closes #61 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:49:57 -05:00
### 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.
fix: make changing the PIN require knowing it Change PIN asked for the current one, and then moved to "Choose a PIN" without waiting for the answer. The check is asynchronous; the screen reassigned its step outside the result and passed an empty callback. Any four digits reached the replacement screen, and setPin enrolls without verifying anything. So anyone holding the phone while it was unlocked could change the app's PIN. Under the no-recovery policy the owner's only way back into her own history is to erase all of it. The file's own KDoc says this must not be possible. The step now advances from inside the verified callback, as "turn the lock off" already did -- 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 the place that writes the PIN is the place that has to refuse. Cancelling withdraws the permission; a successful write spends it. "Turn the lock off" had the same advance-before-answer shape. It was safe -- the work was already inside the callback -- but its wrong-PIN message landed on a screen that had gone, so ConfirmPin.wrong was dead code. Fixed symmetrically. Also fixes the lock-out race in the same function (#62). setPin wrote the PIN and then unlocked the session; the gate is combine(hasPin, unlocked) and closes on (true, false), so DataStore's emission could arrive in between. AppLockGate disposes the whole app subtree when it closes, this ViewModel is scoped to a destination inside it, and the unlock was cancelled with the scope -- the user was thrown to the lock screen to type the PIN she had chosen a second earlier. The development log records the common case as fixed; the fix lived in the scope the race destroyed. Unlocking first makes the bad pair unobservable: unlock() sets a MutableStateFlow synchronously on this thread, before the write begins, and combine always emits with the latest of both. If the write fails there is no PIN and Unlocked is correct anyway. The settings ViewModel had no test at all, which is how a wrong PIN reaching the replacement screen went unnoticed. It has five now, against the real repository over a host-JVM signing key -- core/security gains a small public two-argument constructor for that, since AndroidKeyStore cannot be reached off-device and faking the repository would prove nothing about it. Proved with scripts/prove-guard.sh, one red each: spending the authorisation, and the old write-then-unlock order. Removing the write-site guard entirely reddens three, which is that guard's whole surface rather than a coincidence. closes #60 closes #62 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:35:48 -05:00
### 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.
That now includes the two screens outside the navigation graph. Onboarding steps
back through itself rather than exiting from the middle — and deliberately does
*not* intercept the gesture on the first step, where back means leaving the app,
which is its ordinary meaning everywhere else; silently skipping onboarding would
not be. The "Forgot your PIN?" confirmation returns to the PIN entry, which
matters more than it looks: it is not a destination, so the gesture used to leave
the lock screen entirely from the one screen where the only other control erases
everything.
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
### 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.
feat: app lock, with no way to reset a forgotten PIN §45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has existed since Batch 01 with nothing outside its own module reading it; this wires it, and adds the rest. The recovery question was the reason #34 sat open, and it is decided: there is no recovery. A backdoor into a period tracker's lock would be used by exactly the person the lock exists to stop. Everything below follows from that. ## The gate AppLockGate wraps the whole composition rather than being a screen inside it. Today, Calendar and Insights each start collecting from CycleRepository the moment they compose, so a lock implemented as a nav destination would already have read the history before the user proved anything. content() is invoked only in the unlocked branch. Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings and a permission dialog. Two guards on top: isChangingConfigurations, or rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM biometric overlay that stops the activity produces a lock that can never be opened. No grace period: SECURITY.md leads with "someone who picks up an unlocked phone", which is the window a grace period covers. The unlock flag lives in a @Singleton, never in saved state. rememberSaveable looks like the obvious home and would restore a background-killed app already unlocked — the single most likely way to meet the lock screen would be the one path that skipped it. ## What is stored is not the PIN mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k)) Two layers because they defend different things. The Keystore MAC is what makes a six-digit PIN safe at all — a million candidates is nothing to an attacker who can compute the hash, and impossible for one who cannot get the key off the device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a domain-separation tag; the lockout counter is MACed under 0x02. The key omits six builder calls and the KDoc names every one. setUserAuthenti- cationRequired is the important absence: it would bind the key to the device lock, so changing a passcode would destroy it — and under no-recovery that is somebody's whole history gone for an unrelated reason. It would also be a bypass, since SECURITY.md already names "someone who knows the unlock PIN" as an adversary. The biometric key is separate and takes the opposite policy, where invalidation correctly degrades to "use your PIN". ## Wrong PINs cost time, never data Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a pocket destroy a history while knowing nothing. Both clock bypasses are closed — the wait is the longer of a wall-clock and a monotonic deadline, and a reboot re-applies it in full, detected by elapsedRealtime going backwards. ## Two writes that had to move Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on the phone's own lock screen, reachable by anybody, so the action is now parked in AppLockController and applied only after an unlock — dropped if the session never unlocks. Behaviour is unchanged when the lock is off. The erase behind "Forgot your PIN?" deletes health data, then the Keystore key, then the lock store. Skipping the middle step leaves the user erased AND still locked out; prove-guard mutates that line out and requires exactly one red. ## Found by testing, not by review - A fresh install began in a 15-minute lockout: "no counter yet" and "counter was tampered with" were the same value. They are now distinct. - Setting a PIN locked you out of the session you set it in. Found on the emulator, not in a test. - Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice. ## Verified 244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice here with no margin, and that the key is not auth-bound on either. On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on the lock screen, turning the lock off requires the current PIN, and `adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real. androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed checkPermissions until they were allowed on purpose, and it drags fragment to 1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity. closes #34 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
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.
feat: let an archive come back, and refuse everything that is not one The export was a one-way door. A user changing phone started the prediction model from zero, which made "your history is yours" a smaller promise than it sounded — and the archive says, in its own prose, "keep this file, a future version of the app will be able to read it back". Nothing checked that was true. `ExportReader` mirrors `ExportDocument.render` in the same module, over a hand-written strict JSON reader. Strict is the point: it refuses duplicate keys, trailing content, non-integer numbers and unsupported escapes, because a parser that quietly accepts a trailing comma is a parser that will one day accept somebody else's file and import it as a cycle history. A missing magic string is `NotOurFile`; a version above this build's is `NewerFormat` rather than a half-read; unknown *keys* are ignored, which is what lets the format grow; an unknown `source` becomes `IMPORTED` rather than a guess about the user. The write is `CycleRepository.importHistory`, not a loop over `confirmPeriodStart`, because that would score the standing forecast against history the app never predicted — and the backfill guard does not catch all of it, since a file exported this morning carries this morning's period. So the scoring is not dodged by accident of date; it is not reached. The engine runs exactly once, at the end, over the whole history. Merge keeps what is on this phone untouched (§14 — a file does not get to rewrite a record she made here), and replace goes through the new `PeriodDatabase.replaceEverything`, which empties the tables inside the same transaction as the writes: a failure between the wipe and the inserts would leave her with neither her own history nor the file's. Delete My Data still calls `deleteEverything` and still gets its VACUUM — replacing a history is not erasing one. The screen asks the one question the app must not answer for her, before the picker rather than after, because the screen that asked is the screen a re-lock destroys. Add is the filled button and needs no confirmation; replace is confirmed by a dialog that names the deletion and points back at the safe option. The result lands in the banner above the tabs, beside the export's, as a live region — it arrives with no focus change. The archive cannot touch the app lock. It carries `biometricUnlock` and the importer does not apply it: there is no PIN in the file and there cannot be. Also raises the lock ViewModel test's hang budget to three minutes. 60s passed alone and failed when four modules ran in one invocation — three real PBKDF2 derivations at 210k iterations. The budget is for catching a stuck coroutine, not a slow one. Verified: `:core:export` round trip against the committed golden file (17); `CycleRepositoryImportTest` — one recalculation for a four-record archive, nothing scored, one standing forecast, and a failed replace leaving the history intact (prove-guard: remove `withTransaction` from `replaceEverything`, exactly one red); `DataImporterTest`, `ImportScreenTest`, `ImportControllerTest`, `ImportCopyTest`. On PeriodQA: Settings → Restore → Add → Downloads → records-2026-08-19.json gives "Restored. 4 periods and 2 spotting days added.", Insights then shows three cycles and accuracy still unscored, and the same file a second time gives "Everything in that file was already recorded here, so nothing changed." closes #58 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 02:27:42 -05:00
### A destructive choice is a second tap, and a safe one is not
Restoring from a file asks the user one question the app must not answer for
her: add to what is here, or replace it. Both are on the screen, both are
spelled out in terms of her records rather than the app's vocabulary — "Add to
what is here", "Replace what is here" — and only one of them can lose anything.
So only one of them is confirmed. **Add** is the filled button and goes straight
to the picker: nothing can be lost, so nothing is asked, and a confirmation on a
safe action is how people learn to dismiss confirmations. **Replace** is
outlined and opens a dialog that names what it deletes, says there is no way
back, and points at the other option for anybody who is not sure the file is the
right one. Its confirm button says *"Delete and replace"* — "OK" is how people
agree to things they did not read.
The choice is made **before** the picker opens rather than after the file is
read, where the app could have said how much was in it. That would be friendlier
and it is not available: choosing a file leaves the app, coming back can re-lock
it, and the screen that asked the question is the screen the re-lock destroys.
The result lands in a banner above the tabs instead — the same host as the
export's, for the same reason — and it is a live region, because it arrives with
no focus change and a screen-reader user would otherwise never be told whether
her history came back.
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
## 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.
feat: ship the onboarding artwork in both themes closes #29 All eight illustrations are in the app, every onboarding step has art for the first time, and the theme swap is resource resolution rather than a branch: drawable/ and drawable-night/, so Android picks the asset and the artwork follows a device theme change with nothing of ours running. NOT VECTORS, AND THE ISSUE WAS WRONG TO ASK FOR THEM #29 called for a redraw as Compose vector paths taking colour from theme tokens. That is right for artwork that does not exist yet and wrong for gradient landscapes with glow and depth — there is no honest VectorDrawable of one, and the attempt loses the drawing or produces path soup slower than the image. §42 objects to *unnecessary* raster, which this is not. The cost was never measured before it was argued about: all sixteen files at the height they are actually drawn at come to 130 KB. Release APK 1.90 -> 2.03 MB. WHAT ONLY A DEVICE SHOWED Each drawing is composed as a card with its own rounded corner painted in, so the pixels outside that curve are the card's own backdrop — near-black in the dark set. Rendered unclipped, every illustration showed four notches against the app background. Illustration() now clips just inside the painted curve. WHAT STAYED A VECTOR The calendar markers and CycleProgressMark, because they are drawn from data — a marker's shape is what a day is, and the progress arc is where the user stands in their cycle. CycleProgressMark moves from Illustrations.kt to CycleMarkers.kt so the file layout carries that boundary. Sized by height rather than a square box: the set has no common aspect ratio, and height is the dimension that decides whether the primary button stays on screen. Steps 3 and 6 take 104dp because they carry three buttons and three option cards; verified by driving all seven steps, not by reasoning about it. OnboardingPreviews.kt now has one entry per illustration, because a missing drawable-night asset is invisible in light mode. Driven end to end on PeriodMinSdk26 in both themes, no crash. ./gradlew check, schema-guard and doc-claims all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:25:59 -05:00
## 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
feat: full-bleed onboarding heroes, in density buckets closes #40, closes #41 The illustrations span the screen and fade into the page, at roughly a third of its height, and the seven hand-rolled step layouts became one component. A UNIFIED STEP Every step wrote out Spacer / Illustration(size) / Spacer / Heading / controls, in three different spacer rhythms with a reason recorded for only two. StepBody holds the rhythm; a step declares what is different about it. artHeight stays per-step: the two carrying three or four controls take a shorter hero so the primary button is reachable without scrolling, which is the constraint the old 104dp values existed for. The screen's padding moved from the scrolling column to the content inside each step. Compose has no negative padding, so a full-bleed child cannot live inside a padded parent. THE CAP IS WHAT MAKES LANDSCAPE SURVIVE A landscape phone is 914x411dp. A fixed 280dp hero would be 68% of it and every step would break on rotation — nothing in this app uses WindowSizeClass and onboarding does not lock orientation. Illustration caps at a third of the screen. It reads LocalConfiguration.screenHeightDp rather than BoxWithConstraints: inside a verticalScroll column a child is measured with an INFINITE height constraint, so maxHeight there is Dp.Infinity and the cap would silently never apply. A guard that looks right and does nothing. DENSITY BUCKETS, FIXING A LIVE DEFECT The old set sat in a density-less drawable/, treated as mdpi and pre-scaled at decode — roughly 17 MB of heap for one bitmap on an xxxhdpi device, and this artwork is far larger. Now four widths per theme, 617 to 1644 px, and no plain drawable/ at all. 2.3 MB across every bucket; a release is an AAB and Play splits by density, so a device downloads 130-500 KB. That is also the error-handling fix: painterResource decodes inside composition and there is no boundary anywhere, so an OOM would crash onboarding on first launch. Sizing the bitmaps removes the failure rather than hiding it. The clip is gone with the corners it existed for, and the bottom fade is drawn from the theme background rather than baked into the art. Titles move to headlineLarge, which is what 280dp of artwork above them needs. Driven on PeriodMinSdk26: all steps, both themes, font scale 1.0 and 2.0, and landscape. No crash, no OOM. ./gradlew check green. KNOWN, FILED SEPARATELY: dark 01 Welcome is a forecast scene, so dark mode shows a calendar on step 1. The artwork is wrong, not the wiring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:02:06 -05:00
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.
feat: ship the onboarding artwork in both themes closes #29 All eight illustrations are in the app, every onboarding step has art for the first time, and the theme swap is resource resolution rather than a branch: drawable/ and drawable-night/, so Android picks the asset and the artwork follows a device theme change with nothing of ours running. NOT VECTORS, AND THE ISSUE WAS WRONG TO ASK FOR THEM #29 called for a redraw as Compose vector paths taking colour from theme tokens. That is right for artwork that does not exist yet and wrong for gradient landscapes with glow and depth — there is no honest VectorDrawable of one, and the attempt loses the drawing or produces path soup slower than the image. §42 objects to *unnecessary* raster, which this is not. The cost was never measured before it was argued about: all sixteen files at the height they are actually drawn at come to 130 KB. Release APK 1.90 -> 2.03 MB. WHAT ONLY A DEVICE SHOWED Each drawing is composed as a card with its own rounded corner painted in, so the pixels outside that curve are the card's own backdrop — near-black in the dark set. Rendered unclipped, every illustration showed four notches against the app background. Illustration() now clips just inside the painted curve. WHAT STAYED A VECTOR The calendar markers and CycleProgressMark, because they are drawn from data — a marker's shape is what a day is, and the progress arc is where the user stands in their cycle. CycleProgressMark moves from Illustrations.kt to CycleMarkers.kt so the file layout carries that boundary. Sized by height rather than a square box: the set has no common aspect ratio, and height is the dimension that decides whether the primary button stays on screen. Steps 3 and 6 take 104dp because they carry three buttons and three option cards; verified by driving all seven steps, not by reasoning about it. OnboardingPreviews.kt now has one entry per illustration, because a missing drawable-night asset is invisible in light mode. Driven end to end on PeriodMinSdk26 in both themes, no crash. ./gradlew check, schema-guard and doc-claims all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:25:59 -05:00
**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.
2026-08-18 21:37:11 -05:00
**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.
docs: correct 57 claims the code and tracker disagreed with Every document in the tree audited against the source, the tracker and git history, each finding then given to a second reader tasked with refuting it. 74 raised, 12 refuted, 57 applied. No code changed. THE README DESCRIBED A SKELETON Its Status table — the one place a claim about what is built is allowed to live — still read "there is no usable app yet", with Not built against Room, the four core screens, fertility and notifications, and No round run against QA. Five batches had shipped and three QA rounds had run. TWO DOCUMENTS WERE SILENTLY NEVER FIRING architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle module graph", "the design tokens in core/designsystem"). Neither contains a path token, so doc-triggers.py reduced them to globs matching nothing, and one real glob apiece made them look path-governing rather than subject-governing — the state the script's own header calls invisible. Editing a Room entity never fired the document owning the migration table. Both now fire, proved by running the script. SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS App lock listed among what works offline; biometric/PIN gating described as protecting app launch; the incognito launcher as existing; Play Billing in the third parties table without the "not yet integrated" marker its neighbours carry. All are Batch 06/07 work. The advertising boundary was overstated in SECURITY.md and the README alike: both said the ads module declares no dependency and a guard proves it. There is no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than the sentence it replaced and matches nothing until Batch 07, which is why the guard is proved by injection rather than trusted. SMALLER, EACH A REAL TRAP WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template path absent here — missed by doc-claims.sh, which reads backticked prose and not fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three rounds because ClaudeQAPlan.md's after-a-round list never named that section; the playbook is fixed first. The instrumented-test count was eight in three places and is four. HISTORY.md said the repository had no code and that nothing had been tried and dropped, when three approaches had. DELIBERATELY UNCHANGED ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run since, and moving it would claim a verification nobody performed. Every DEVELOPMENT_LOG entry stays as written. Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all present), doc-triggers.py, and a link sweep over 21 markdown files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:33:14 -05:00
**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.
feat: ship the onboarding artwork in both themes closes #29 All eight illustrations are in the app, every onboarding step has art for the first time, and the theme swap is resource resolution rather than a branch: drawable/ and drawable-night/, so Android picks the asset and the artwork follows a device theme change with nothing of ours running. NOT VECTORS, AND THE ISSUE WAS WRONG TO ASK FOR THEM #29 called for a redraw as Compose vector paths taking colour from theme tokens. That is right for artwork that does not exist yet and wrong for gradient landscapes with glow and depth — there is no honest VectorDrawable of one, and the attempt loses the drawing or produces path soup slower than the image. §42 objects to *unnecessary* raster, which this is not. The cost was never measured before it was argued about: all sixteen files at the height they are actually drawn at come to 130 KB. Release APK 1.90 -> 2.03 MB. WHAT ONLY A DEVICE SHOWED Each drawing is composed as a card with its own rounded corner painted in, so the pixels outside that curve are the card's own backdrop — near-black in the dark set. Rendered unclipped, every illustration showed four notches against the app background. Illustration() now clips just inside the painted curve. WHAT STAYED A VECTOR The calendar markers and CycleProgressMark, because they are drawn from data — a marker's shape is what a day is, and the progress arc is where the user stands in their cycle. CycleProgressMark moves from Illustrations.kt to CycleMarkers.kt so the file layout carries that boundary. Sized by height rather than a square box: the set has no common aspect ratio, and height is the dimension that decides whether the primary button stays on screen. Steps 3 and 6 take 104dp because they carry three buttons and three option cards; verified by driving all seven steps, not by reasoning about it. OnboardingPreviews.kt now has one entry per illustration, because a missing drawable-night asset is invisible in light mode. Driven end to end on PeriodMinSdk26 in both themes, no crash. ./gradlew check, schema-guard and doc-claims all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:25:59 -05:00
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.
2026-08-20 02:14:08 -05:00
Remaining artwork is tracked in the issue tracker, not listed here.
docs: eleven app screenshots, from a seeded demo history Privacy: Period Tracker is the only Privacy LLC product without a page on privacyllc.dev — /work/bill-tracker, /work/closer-couples and /work/fruit-fall exist, /work/privacy-period-tracker returns 404 — and until now no screenshot of this app existed anywhere in the repository. Eleven frames covering all seven onboarding steps plus Today, Calendar, Insights and Settings, in the house style those pages use: portrait, captioned, no device frames, no decorative background. SEEDED, NOT REAL Six invented starts — 8 Mar, 5 Apr, 3 May, 31 May, 28 Jun, 26 Jul 2026 — giving gaps of 28/29/28/28/29 days. On a 19 August clock that is cycle day 25, and the engine earns "period likely in 4 days, most likely 23 August, expected 22-24 August, confidence High". Nothing shown is real data, which #32 states is not a nicety: a screenshot of a period tracker is a screenshot of health data. Today was reshot once. The first seeding landed the capture day on the estimated ovulation date, so the hero read "estimated ovulation in 0 days" — true, and a poor lead. Shifting the history ten days gives the period countdown, which is the screen the app is built around. CHECKED, NOT ASSUMED The status bar is SysUI demo mode so all eleven are identical, and the top strip of every frame was cropped and compared in one image to confirm what #32 warns about: no notification appears in any of them. The not-contraception line is visible in the Today and Calendar frames. Light mode throughout, which also avoids #44's wrong dark Welcome illustration. 520 KB for the set, 1080x2400 WebP, each far under the 512 KB ceiling. The README carries the seeded dates, the demo-mode and text-input-picker recipe, and the rules every frame must satisfy — so the next capture reproduces this one rather than re-deriving it. Also corrects a stale `splash.png` in the trust map, which became splash.webp when it was converted. These are not the three marks privacyllc.dev renders for the project card; that folder says plainly that screenshots do not belong in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 01:47:52 -05:00
**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.
2026-08-20 02:14:08 -05:00
**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.
chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2
2026-08-18 02:16:47 -05:00
## 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)