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
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
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 130–500 KB.
|
|
|
|
|
|
|
|
|
|
|
|
**The fade is drawn, not baked**, as a gradient to
|
|
|
|
|
|
`MaterialTheme.colorScheme.background`. Artwork cannot know which theme it is
|
|
|
|
|
|
sitting on, and a baked fade would be a hard-coded colour by another name.
|
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.
|
|
|
|
|
|
|
feat: the real emblem is the launcher icon
closes #30
The last placeholder. It was blocked on a real constraint rather than taste: 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 NEW EMBLEM DID NOT NEED REDRAWING, IT NEEDED PLACING
It puts the meaning in the middle — calendar, shield, padlock — with the ring and
botanicals as outer decoration. Dropped in full-bleed it still crops: simulating
the circular and rounded-square masks slices the ring apart and cuts the crescent
in half. Scaled into the 66dp safe circle on §18's Deep Plum it survives both
masks whole.
Both were checked by rendering the masks, and then by installing and looking at
the launcher rather than trusting the simulation.
Foreground ships as a density ladder, mdpi through xxxhdpi, 44 KB for all five.
Release APK 2.03 -> 2.08 MB.
THE MONOCHROME LAYER STAYS THE SIMPLIFIED VECTOR
Renamed to ic_launcher_monochrome.xml, which is now what it is. A themed icon is
a silhouette — Android discards 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 a single colour,
which is the whole job of that layer.
Source kept at docs/design/brand/icon.webp: same 1254px, 128 KB instead of 896.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
|
|
|
|
|
2026-08-20 02:37:29 -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.
|
|
|
|
|
|
|
2026-08-18 18:53:25 -05:00
|
|
|
|
### 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.
|
docs: add the onboarding concept cards as references, not assets
Seven cards, one per onboarding step, supplied by the owner and generated from
BRAND_GUIDE.md §22's own master prompt — which is the workflow that section
exists to describe. They settle the direction #29 asked for, including all four
steps that had no art at all.
They are NOT the shipping assets, and docs/design/dist/README.md says so at the
point somebody would otherwise drop them into res/drawable:
- raster, against a 1.9 MB release APK and §42's ask for vector paths
- titles and subtitles painted into the pixels, while the app already renders
all seven through Heading(...) — used as-is every title appears twice, and
baked text cannot be translated, scaled with font size, or read by TalkBack
- light mode only, with dark mode inbound and a preview-pair rule that exists
because dark mode was silently broken for all of Batch 01
- card 7 shows a fertile window and ovulation that the first-forecast screen
deliberately withholds; on a fresh install it shows confidence Low and Today
reads "Not enough history to estimate"
Converted from the delivered PNGs to WebP at the original 1086x1448: same
resolution, 340 KB instead of 12 MB, and the format docs/data/img already uses.
Renamed from the generator's default filenames so the set reads as a sequence.
#29 stays open with the assessment recorded as a comment on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:28:50 -05:00
|
|
|
|
|
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-18 18:53:25 -05:00
|
|
|
|
|
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.
|
|
|
|
|
|
|
feat: placeholder vector artwork, and calendar markers that work in greyscale
Compose vector paths in core/designsystem/art — no raster anywhere under src/,
so everything stays crisp at any density and adds nothing to the APK.
The four calendar markers differ in SHAPE, not only colour: a solid disc for a
confirmed period, a dotted outline for a predicted one, a continuous ring for
the fertile window, and a four-pointed mark for ovulation. §26 and §43 both
require it and for the same reason — a calendar whose states differ only in
colour carries no information for a colourblind user, none in greyscale, and
none in the bug report somebody files about it. Predicted is deliberately not a
lighter confirmed: opacity alone fails exactly when the screen is dim.
Illustrations for welcome, empty state, learning and the privacy promise, plus
the cycle progress mark for Today. The visual language is overlapping circular
forms and nothing else — §42's forbidden list is a product decision, not
squeamishness: this app gets opened in public and a glance over a shoulder
should learn nothing.
Placeholders here and never in docs/data/img, which docs/design/README.md now
explains rather than leaving as an apparent inconsistency: §42 asks for these
explicitly, a replaceable name keeps them replaceable, and a screen with no
illustration cannot be evaluated while a project card with no icon just shows
initials.
closes #15
2026-08-18 03:35:04 -05:00
|
|
|
|
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)
|