§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>
Asked whether encryption at rest could be added. It can, and it should not be
yet — so the reasoning goes in the document that owns the claim, before it is
forgotten and re-derived from scratch.
The architecture decides it. ReminderCoordinator collects Room flows at every
process start, including processes started with no Activity after a reboot,
and ReminderWorker reads the forecast on a schedule. The key would have to be
readable with no user present, which is the availability condition the
platform key already has. A key anything running as this app can reach
unattended defends a file, not a process — and against every adversary this
document already lists, it adds nothing.
What it would genuinely add is narrow: a file copied off the device and read
elsewhere, a phone with no screen lock, and crypto-shredding on delete. The
cost is about +1 MB downloaded and +2 MB installed on a 2 MB app, and a new
class of total loss — a Keystore key can be lost in the field, platform backup
is off, and there is no telemetry that would tell us it happened. Today a
database that will not open is a bug. Afterwards it would be somebody's
history.
The condition that reverses it is written down too: a key bound to the app
lock's user secret, once export has given the user a copy they control.
BRAND_GUIDE carried an artwork brief reading "explaining encrypted local data
storage" — a false claim waiting for somebody to draw it, on the one subject
where a padlock reads as a promise. Replaced with what is true and is a better
story anyway: the data never leaves, so there is no server to breach and
nothing to sell. SECURITY_CHECKLIST gains the row that would have caught it.
Nothing shipped ever claimed encryption; grep over app, core and domain for
strings and illustrations comes back empty. This closes the gap between that
and the documents.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
closes#39
Sixteen files, 3:2 landscape, composed natively for the wide frame rather than
cropped or padded from the portrait originals.
THE FIRST ATTEMPT MET THE CANVAS AND NOT THE BRIEF
It reached 2048x1365 by upscaling the old portrait art ~2.8x and padding the
sides with a blurred copy of itself. The sharp region was 1024x1365 — aspect
0.750 against the originals' 0.748, the same composition — and measured edge
detail fell 2.1x-8.8x in the outer quarters on 15 of 16 files. At real hero size
a hard seam showed around the sharp centre.
This set is natively wide: real painted content to all four edges, checked by
measuring detail across each image and looking for the step change a pasted
rectangle leaves. None has one.
A DUPLICATE THAT WOULD HAVE SHIPPED
07_Forecast dark was byte-identical to 01_Welcome dark. Light was correct, so
light mode looked perfect and only dark mode showed the wrong picture, on the
last onboarding step — the silent failure OnboardingPreviews.kt exists to expose
and that #42 would fail a build over. Replaced; all sixteen hashes are unique.
Dark 07 is 1536x1024 where the rest are 2048x1365 — the same 3:2 to within
0.02%, 274.00dp against 273.93dp at a 411dp hero, and ample resolution at every
density the app ships to.
The set still carries no text, no step numbers, and a forecast illustration that
names no fields. Recorded in dist/README.md, along with two things deliberately
left unmatched: light and dark 07 are different compositions, and eight files
have a busy bottom quarter under the fade.
Sources only. 4.2 MB here; the shipped copies are downscaled into density
buckets, which is #41.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#28
The owner supplied a redrawn lockup, docs/design/dist/splash.webp, which sets
the name as "Privacy: Period Tracker" — with the space after the colon.
NECESSARY AND NOT SUFFICIENT
The tempting move was to look at the new artwork and close the issue. #28's
Verify names the two DERIVED assets, not the lockup: the wordmark in
docs/data/img/logo.webp and banner.webp had to match app_full_name, and both
still carried the old spaceless form because they were generated from the
earlier source. Regenerated from the new lockup and checked by cropping the
colon out of each finished webp and reading it at 1350px wide.
THE BANNER IS COMPOSED, NEVER CROPPED
The lockup is square and the banner is 3:1, so a crop takes the ends off the
wordmark — the trap the first pass at these assets hit. A 660px square card
centred on 2176x725, matching how the existing asset did it. No flood-fill this
time either: splash.webp has real transparency where the earlier sources
rendered their rounded corners against black.
icon.webp is deliberately untouched. It carries no wordmark, it is the same
visual family, and #28 is about the name — regenerating it would be churn
dressed as consistency.
BRAND_GUIDE.md §10 carried a callout stating the drawn mark disagreed with the
written name. That is now false, so it records what happened instead and marks
lockup-light-source.png and lockup-dark-source.png as superseded rather than
deleting them; they are the owner's files.
Also corrects a path in the previous log entry, which named splash.png in
backticks — doc-claims reads that as a claim the file is present, and it is now
splash.webp. Named bare, per the convention DOC_TRUST_MAP.md documents for a
path that deliberately does not exist. Second time this session; the lesson is
that converting a file means grepping the docs for its old name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
EmptyStateIllustration in both themes — Today's "nothing logged yet" state, and
the one illustration the onboarding set did not cover. Without it, onboarding
would have shipped real art while Today kept a placeholder a user sees in the
same session.
Deliberately unnumbered: it is not an onboarding step. Its ring is empty because
nothing has been logged, which is the same ring the other illustrations fill with
cycle-day markers — that is what makes the empty state read as waiting rather
than broken.
Two things recorded in dist/README.md for whoever redraws these as vectors:
- The fourteen onboarding illustrations are contained cards with a rounded
frame; these two are full-bleed with none. The frame should be consistent
across the set or deliberately absent from all of it.
- TodayScreen.kt calls EmptyStateIllustration(color = ..., size = ...) with a
single colour. These are full-colour drawings, so the vector version takes no
colour parameter and that call site changes with it.
Both were checked at the 110-160 dp the illustrations are actually drawn at, by
rendering them at that size rather than assuming: the detail survives, including
the five faint dots under the ring.
WebP at delivered resolution: 312 KB for all sixteen against 6.8 MB as PNG.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven illustrations in a matched pair, replacing the single light-only concept
set added in 9d2a4b2. Named for the composables they replace, and identical in
dimensions per card so a theme switch swaps the image without moving the layout.
Three corrections happened during review and each is easy to reintroduce by
regenerating carelessly, so dist/README.md records them:
- No text painted into the pixels. The app renders all seven titles through
Heading(...) already; art carrying its own title prints every heading twice,
cannot be translated, cannot scale with font size, and is invisible to
TalkBack.
- No step-number badge. LearningIllustration is also the Insights empty state,
where a "4" means nothing, and baked numbers freeze a running order that has
already changed once.
- 07_Forecast names no fields. One draft listed Fertile window and Ovulation,
which the first-forecast screen deliberately withholds — on a fresh install it
reports confidence Low and Today reads "Not enough history to estimate". A
later draft named the right fields but painted "Confidence — Low"
permanently, wrong for anyone with a settled cycle. Naming nothing is the only
version that cannot contradict the live forecast.
The dark set is a real dark composition rather than the light one dimmed, on the
midnight-plum ground BRAND_GUIDE.md §22 specifies.
WebP at the delivered resolution: 204 KB for all fourteen against 3.4 MB as PNG.
Delivery .zip archives are gitignored; the superseded concept set is removed and
remains recoverable at 9d2a4b2.
#29 stays open — these are references, and the vectors that ship are drawn from
them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
"Welcoming, and visibly private" is the owner's directive for the app's artwork,
and it needs somewhere to live that outlasts the issues it was given for. A brief
that exists only in issue bodies disappears the day those issues close.
docs/design/README.md owns what the app should feel like, so the artwork section
now carries it, along with why the two halves are stated together: onboarding
today opens warm and then turns into a form, and the privacy idea appears on one
screen out of seven.
Also notes that the enclosure motif already in PrivacyIllustration is the cheaper
route to the second half than a padlock, which would read as a security product
rather than a calm one.
The work itself stays in the tracker as #29 to #32.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
"Period" was always a working name — PRODUCT_PLAN.md §55 said so. The real
identity arrived with the brand guide and artwork, and this makes the project
call itself by it.
The repository is renamed in place on Forgejo (null/Period ->
null/Privacy-Period-Tracker), which keeps all 30 commits, all 27 issues, all 8
milestones and the four severity labels — verified by counting them on both
sides rather than assuming a rename is lossless. The git remote follows in the
same breath, so this commit's automatic push is what proves the new URL works.
WHAT DELIBERATELY DID NOT MOVE
"Period" is the product name AND the central domain word, and a mass rename
would have turned the data model into nonsense. PeriodRecord, PeriodWriteResult,
confirmPeriodStart, the period_records table and the button that says "Started
period" all describe a menstrual period rather than a product, and all 239
references to them are untouched.
So are the Kotlin package and applicationId. dev.privacyllc.period already reads
correctly under the new name — privacyllc is the company, period is the app —
and changing it would rewrite 68 files, rename the Room schema directory and
break the hardcoded path in schema-guard.sh for no gain. period.db stays for a
sharper reason: renaming a database file orphans the data on every device that
already has it.
Sixteen files changed, and the diff is small on purpose.
THE NAME HAS A SPACE AND THE ARTWORK DOES NOT
Canonical is "Privacy: Period Tracker". The supplied wordmark sets it without
one, so logo.webp and banner.webp now disagree with every document and with the
app itself. BRAND_GUIDE.md §10 is updated to the space form and says plainly
that the drawn mark has not caught up. Filed separately rather than papered
over, because closing that gap needs an artist and not a rename.
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
§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
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
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
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#1closes#2