diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..417c7b8
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,92 @@
+# Contributing to Floret
+
+Thanks for your interest in Floret — a Material 3 Expressive task app that's a
+pure front-end over the OpenTasks `TaskContract` provider, with no own database
+or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
+(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
+[`docs/PLAN.md`](docs/PLAN.md) (the design rationale). This file covers the
+practical how.
+
+## The one architectural rule
+
+Everything above the data layer talks to `TasksRepository` and sees only domain
+types and Flows. **Provider column names, `TaskContract`, `ContentResolver`, and
+the authority string never leak above `data/tasks/`.** This is what keeps
+"Posture B" (bundling the provider later) an additive change instead of a
+rewrite — see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §7. If a change
+would expose provider details to a ViewModel or the UI, it's in the wrong layer.
+
+## Prerequisites
+
+- JDK 17
+- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
+- A device or emulator with **OpenTasks** or **tasks.org** installed for
+ anything touching the read/write paths (ideally with DAVx5 syncing a CalDAV
+ task list, so there's real data). Debug builds fall back to `DemoSeeder` for
+ sample data.
+
+## Build, test, lint
+
+```sh
+./gradlew :app:assembleDebug # build the debug APK
+./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
+./gradlew lintDebug # Android lint (CI runs this on every push)
+```
+
+CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every
+push, so run these locally before opening a PR. Keep CI green.
+
+## Where to put code
+
+| Layer | Lives in | Rule of thumb |
+|---|---|---|
+| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
+| Provider access | `data/tasks/` | The only place that knows about the provider. New provider work goes through `TasksDataSource`. |
+| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
+| Screens | `ui//` | One ViewModel + immutable `UiState` per area; Compose for the screen. |
+
+## Code style & conventions
+
+- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
+ whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
+ Match the surrounding code.
+- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
+ colour-scheme tokens (never hardcoded colours), and canonical M3 components
+ (e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
+ new screen or component.
+- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
+ the only Android-coupled data implementation so the rest stays JVM-testable.
+
+## Tests
+
+- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
+ come with JVM unit tests under `app/src/test/`. The data source is the
+ JVM-testable seam — mock or fake it rather than reaching for instrumentation.
+- Add an instrumented test only when a path genuinely needs a real
+ `ContentResolver`.
+
+## Commits & PRs
+
+- Write focused commits with clear messages (the existing history uses short,
+ scoped subjects like `UI: ...` / `M1 ...`).
+- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` for any
+ user-visible change — its sections feed the release notes and F-Droid "What's
+ New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
+- If your change shifts the architecture or completes a milestone, update
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
+ in the same PR.
+- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at
+ release time.
+
+## Scope
+
+Floret stays true to its thesis: a front-end over **open** task backends
+(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
+(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
+a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
+richer contract is a possible later addition.
+
+## License
+
+By contributing you agree your contributions are licensed under the project's
+[MIT License](LICENSE).
diff --git a/README.md b/README.md
index 1b343c0..cce585c 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,13 @@ database, no reinvented sync.
A Calendula flower head is botanically made of many small *florets* — the
individual items that make up the bloom. Floret is those items: your tasks.
-> **Status: M0 — skeleton.** Builds and shows a themed placeholder. The
-> `TaskContract` data layer, task screens, and reminder engine are the next
-> milestones. See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap and the
-> A-now-B-later architecture (front-end first; bundle the provider later).
+> **Status: data layer done, UI in progress.** The full non-visual stack over
+> the `TaskContract` provider — provider resolution, live-updating reads,
+> writes, smart-list filtering, and a self-scheduled reminder engine — is built
+> and unit-tested. The Material 3 Expressive screens are now being built on top,
+> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
+> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
+> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale.
## Sync sources (by design)
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..f51ac2c
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,263 @@
+# Floret — architecture
+
+This document describes how Floret is built **as it stands today**. For the
+*why* behind the big decisions and the long-term plan, see
+[`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md).
+
+---
+
+## 1. The thesis in one sentence
+
+Floret is a Material 3 Expressive **front-end** over the OpenTasks
+`TaskContract` provider — it reads, writes, and reminds on top of a tasks store
+that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
+CalDAV. **Floret owns no database and no sync stack.** It is the task-list
+sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
+which does the same thing for `CalendarContract`.
+
+The whole design hangs off one rule:
+
+> The entire app talks to a `TasksRepository`. Only the data layer knows there
+> is a `ContentResolver`, a `TaskContract`, or an authority string behind it.
+> **Provider column names and the authority string never leak above the data
+> layer.**
+
+This is what lets "Posture A" (front-end over an installed provider) become
+"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching
+the UI, the ViewModels, or the domain. See §7.
+
+---
+
+## 2. Layers
+
+```
+ ┌──────────────────────────────────────────────┐
+ UI │ Compose screens + ViewModels (ui/*) │
+ │ RootScreen → permission gate → ListsScreen │
+ └───────────────┬──────────────────────────────┘
+ │ domain models + Flows only
+ ┌───────────────▼──────────────────────────────┐
+ Domain │ Models, TaskForm, TaskFilter, TaskSorting, │
+ │ DayWindow (pure Kotlin, no Android) │
+ └───────────────┬──────────────────────────────┘
+ │ TasksRepository (interface)
+ ┌───────────────▼──────────────────────────────┐
+ Data │ TasksRepositoryImpl │
+ │ └ TasksDataSource (interface) │
+ │ └ AndroidTasksDataSource │
+ │ └ ContentResolver / TaskContract / │
+ │ ProviderResolver / ContentObserver│
+ │ reminders/ prefs/ di/ demo/ │
+ └───────────────┬──────────────────────────────┘
+ │ content:// + dangerous perms
+ ┌───────────────▼──────────────────────────────┐
+ External │ OpenTasks provider ←sync← DAVx5 / DecSync… │
+ └──────────────────────────────────────────────┘
+```
+
+The seam that matters is the pair of interfaces in the data layer:
+
+- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend
+ writes. (`data/tasks/TasksRepository.kt`)
+- **`TasksDataSource`** — the JVM-testable interface that does the actual
+ provider work; `AndroidTasksDataSource` is the only Android-coupled
+ implementation.
+
+Both are bound in Hilt in `data/di/DataModule.kt`.
+
+---
+
+## 3. Module & package layout
+
+Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`.
+
+| Package | Contents |
+|---|---|
+| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `DayWindow` (local-midnight maths). No Android imports. |
+| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. |
+| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. |
+| `data/prefs/` | `SettingsPrefs` (DataStore). |
+| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
+| `data/demo/` | `DemoSeeder` (debug-only sample data). |
+| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
+| root | `FloretApp` (Hilt app), `MainActivity`. |
+
+---
+
+## 4. The data layer (the heart)
+
+### 4.1 Provider targeting — `ProviderResolver`
+
+`ProviderResolver.resolve()` walks a preference-ordered candidate list and
+returns the first provider actually installed (via
+`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate
+is a `TaskProvider(authority, readPermission, writePermission, packageName)`.
+
+| Provider | Authority | Permissions |
+|---|---|---|
+| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` |
+| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` |
+
+Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract`
+columns apply** regardless of which is present. `null` from `resolve()` drives
+the "install a tasks provider" onboarding gate. `hasPermission()` checks both
+runtime perms for the active provider.
+
+### 4.2 `TasksContract`
+
+A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
+table paths, status/priority constants, the local-account type. Floret does
+**not** take a runtime dependency on OpenTasks; the authority is injected from
+`ProviderResolver`, never hardcoded in the contract.
+
+### 4.3 Reads — Instances + `ContentObserver`
+
+`AndroidTasksDataSource` queries the denormalized **instances** view (so each
+occurrence is a row with the joined list colour, account, etc.), maps each
+cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes
+the result as a `Flow`. A `ContentObserver` on the active authority's
+Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
+re-emits** — Floret's own writes *and* external sync (DAVx5 pulling new tasks)
+update the UI live, and multiple sync sources coexist in one list.
+
+### 4.4 Writes — repository API
+
+```kotlin
+interface TasksRepository {
+ fun taskLists(): Flow>
+ fun tasks(filter: TaskFilter): Flow>
+ fun taskDetail(taskId: Long): Flow
+
+ suspend fun createTask(form: TaskForm): Long
+ suspend fun updateTask(taskId: Long, form: TaskForm)
+ suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture
+ suspend fun deleteTask(taskId: Long)
+ suspend fun createLocalList(name: String, color: Int): Long
+
+ fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER
+}
+```
+
+`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion
+sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back
+out as a normal VTODO status change. Writes to local/unsynced lists use the
+sync-adapter URI form where the provider requires it.
+
+### 4.5 Domain model notes
+
+- `Task.id` is the **instance** row id; `Task.taskId` is the underlying
+ `tasks._id` and the stable target for edits/completion.
+- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`);
+ `TaskDetail` bundles a task with its direct children.
+- `effectiveColor` = the task's own colour, else the list colour.
+- iCal priority is bucketed to `NONE/LOW/MEDIUM/HIGH`; status maps to a
+ 4-value enum. Both mappings are pure functions in `Models.kt`, unit-tested.
+
+---
+
+## 5. Smart lists, filtering, sorting
+
+`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists —
+`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due
+dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking
+`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it
+unit-tests with a fixed clock. `TaskSorting` orders within a list (due /
+priority / etc.). None of this touches Android, which is why it's all in
+`domain/`.
+
+---
+
+## 6. Reminders — the one subsystem that does NOT mirror Calendula
+
+Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks
+providers broadcast nothing**, so Floret schedules its own (`data/reminders/`):
+
+- **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks
+ within a rolling **30-day window**, computes each trigger as `due − lead`
+ (lead from `SettingsPrefs`), and **diffs against `ScheduledReminderStore`** so
+ only changed alarms move. It bails and clears everything if the provider is
+ absent or unpermissioned.
+- Alarms are exact where allowed (`setExactAndAllowWhileIdle`, falling back to
+ `set` when `canScheduleExactAlarms()` is false), keyed by `taskId`.
+- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel,
+ `POST_NOTIFICATIONS` gate, dedupe-by-tag).
+- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and
+ **`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external
+ sync changed the data). The store lets each run diff like Calendula diffs
+ reminder rows.
+
+This is the single largest piece of genuinely-new code in Floret.
+
+---
+
+## 7. The A / B seam (why the layering is shaped this way)
+
+- **Posture A (today):** front-end over whatever provider is installed. Ships
+ fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding
+ moment).
+- **Posture B (later):** add a `:provider` module bundling the Apache-2.0
+ `opentasks-provider`. `ProviderResolver` then finds **our own** `org.dmfs.tasks`
+ first; external CalDAV engines sync directly into it. **The UI, ViewModels,
+ domain, and `TasksRepository` do not change** — only the resolver's default and
+ some manifest perms.
+
+Bundling the provider bundles **storage, not sync** — Floret stays a pure
+front-end over open backends either way.
+
+---
+
+## 8. UI
+
+Compose + Material 3 **Expressive** (`MaterialExpressiveTheme`,
+`MotionScheme.standard()`, dynamic colour with a hand-tuned warm-mauve
+fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
+`edit`, `settings`, `permission`) has a ViewModel + immutable `UiState`;
+`ListsScreen` is the first rendered surface.
+
+`RootScreen` is the entry composable: it gates on `ProviderStatus`
+(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
+`ListsScreen`). The remaining screens are being built one at a time — their
+ViewModels exist and are tested against the real data layer; navigation
+callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the
+`material-3` skill for component choices (M3 `ListItem` rows, expressive
+checkbox/FAB/swipe motion).
+
+---
+
+## 9. Dependency injection
+
+Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
+(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` →
+`TasksRepositoryImpl`) and a `@Provides` module (the `floret_prefs` DataStore,
+the `@IoDispatcher`). `FloretApp` is the `@HiltAndroidApp` entry point;
+`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
+
+---
+
+## 10. Build & tooling
+
+| | |
+|---|---|
+| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 |
+| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 |
+| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
+| Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
+| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
+| Versioning | git tag is the source of truth; `versionCode = MAJOR*10000 + MINOR*100 + PATCH`, derived in CI at release. See [`RELEASING.md`](RELEASING.md). |
+| CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) |
+| Distribution | F-Droid (`fdroid-metadata/`) |
+
+---
+
+## 11. Manifest surface
+
+- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms
+ declared statically (the active set is requested at runtime);
+ `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm
+ (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32).
+- **``** for package visibility: both provider authorities + a LAUNCHER
+ intent (so `resolveContentProvider` works and onboarding can open the
+ provider / a store listing).
+- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`,
+ `ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver —
+ that's a Calendula thing that doesn't apply here.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..98a26a2
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,27 @@
+# Floret — documentation
+
+Floret is a Material 3 Expressive **task** app for Android: a pure front-end over
+the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
+over CalDAV), with no own database or sync stack. Sibling to
+[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the
+top-level [`../README.md`](../README.md) for the project pitch.
+
+## Index
+
+| Doc | What it covers |
+|---|---|
+| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Floret is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
+| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0–M6 + Posture B), what's done, open decisions, how to build/verify. |
+| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". |
+| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |
+
+Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections
+feed the release notes).
+
+## How the docs relate
+
+- **PLAN** is the design decisions (mostly stable; the "why").
+- **ARCHITECTURE** is the current shape of the code (kept in sync with the
+ source as it grows).
+- **ROADMAP** is the moving status layer (update as milestones land).
+- **RELEASING** is the operational runbook.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..e26130e
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,101 @@
+# Floret — releasing
+
+Floret is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a
+human-readable **Gitea release** per tag. Both are produced automatically by
+`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on
+the Gitea release itself — distribution lives in the F-Droid repo; the release is
+the changelog of record.
+
+---
+
+## The one source of truth: the git tag
+
+The git tag drives the version. You do **not** hand-edit version numbers for a
+release — CI substitutes them from the tag:
+
+- `versionName` = the tag without a leading `v` (e.g. `v0.2.0` → `0.2.0`).
+- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (e.g. `0.2.0` → `200`,
+ `1.3.4` → `10304`).
+
+The values committed in `app/build.gradle.kts` are just the local/dev default;
+keep them roughly matching the latest released tag, but the tag wins at release
+time.
+
+---
+
+## Cutting a release
+
+1. **Update `CHANGELOG.md`.** Move items out of `[Unreleased]` into a new
+ `## [X.Y.Z]` section. The release pipeline extracts everything between
+ `## [X.Y.Z]` and the next `## [` heading and uses it verbatim as both the
+ Gitea release notes and the F-Droid per-version "What's New"
+ (`changelogs/.txt`). If no matching section exists, a fallback
+ line is used — so the heading **must** match the tag's version exactly.
+2. **Commit** the changelog on `main`.
+3. **Tag and push:**
+ ```sh
+ git tag v0.2.0
+ git push origin v0.2.0
+ ```
+4. CI takes over (see below). Watch the run in Gitea Actions.
+
+---
+
+## What CI does on a tag
+
+`release.yaml` runs three jobs:
+
+| Job | Purpose |
+|---|---|
+| `ci` | Sanity gate: unit tests + a debug build (catches version-substitution drift). The other jobs depend on this. |
+| `build-and-deploy` | Substitute version from the tag → build signed release APK → fetch the existing F-Droid repo from Hetzner → add the new APK + per-version changelog → `fdroid update -c` → upload `repo/` + `metadata/` back. Also attaches the R8 `mapping.txt.gz` to the Gitea release (best-effort) so crash stacktraces stay deobfuscatable. |
+| `gitea-release` | Create/update the Gitea release for the tag, body = the extracted CHANGELOG section. Gated on `ci` only (not deploy), so notes still publish if the F-Droid upload hiccups. |
+
+Both deploy and release steps are **re-run safe** (idempotent upserts), so a
+failed run can be retried.
+
+---
+
+## Required CI secrets
+
+Configured in the Gitea repo settings; the workflow fails loudly if the F-Droid
+ones are missing (it will **never** auto-generate a repo key — that would rotate
+the repo fingerprint and break every user's pinned repo).
+
+| Secret | Used for |
+|---|---|
+| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | App signing keystore (the APK). |
+| `FDROID_KEYSTORE_BASE64`, `FDROID_CONFIG_BASE64` | The F-Droid **repo** signing key + `config.yml`. Never uploaded to the server. |
+| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | SFTP target for the published `repo/` + `metadata/`. |
+| `GITHUB_TOKEN` | Gitea API (release create/patch, asset upload). |
+
+The repo signing key and `config.yml` come from secrets at build time and are
+**never** pulled from or pushed back to the server, so they can't leak into the
+web-served tree (nginx serves only `repo/`).
+
+---
+
+## Key rotation / repo recovery
+
+A manual `workflow_dispatch` run (ref = a branch, not a tag) skips all the
+tag-only build steps: it just re-signs the existing index with the configured
+repo key and re-uploads. Use this to recover the repo or rotate infrastructure
+**without** publishing a new APK.
+
+---
+
+## Push CI (non-tag)
+
+Every push to any branch runs `.gitea/workflows/ci.yaml`: `lintDebug` →
+`testDebugUnitTest` → `assembleDebug`, plus a Trivy filesystem scan on `main`.
+Keep this green before tagging.
+
+---
+
+## F-Droid metadata
+
+App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.floret.yml`
+plus `en-US/` summary/description). Per-version changelogs are generated into the
+repo's `metadata/.../en-US/changelogs/.txt` from `CHANGELOG.md` at
+release time; metadata is uploaded alongside `repo/` so changelog history
+survives across releases.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..38f5c16
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,105 @@
+# Floret — roadmap
+
+Where the project is and where it's going. This is the **status** view; the
+design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the
+pieces fit together is in [`ARCHITECTURE.md`](ARCHITECTURE.md).
+
+Status legend: ✅ done · 🚧 in progress · ⬜ not started
+
+---
+
+## Current state (one line)
+
+The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
+provider is **done and unit-tested**; the Material 3 Expressive UI is being
+built screen by screen on top of it. App builds, gates on provider/permission,
+and renders the lists overview.
+
+---
+
+## Milestones
+
+### ✅ M0 — Skeleton
+Project scaffolding copied from Calendula (Gradle, version catalog, Hilt,
+Material 3 Expressive theme, Gitea CI/release workflows, F-Droid metadata),
+reseeded to a warm-mauve fallback palette. Builds, shows a themed placeholder.
+
+### ✅ M1 — Read path + logic (the "backoffice")
+The complete non-visual stack:
+- Vendored `TaskContract` subset, `ProviderResolver` (runtime authority
+ detection), `ColumnReader`, mappers, `AndroidTasksDataSource` (Instances
+ query + `ContentObserver`), and `TasksRepository` exposing live Flows of
+ lists / tasks / detail.
+- Domain models, smart-list filtering (Today / Upcoming / Overdue / No-date /
+ All / Completed), sorting, form validation, subtasks via `parentId`.
+- Self-scheduled due-reminder engine (AlarmManager + boot / provider-change
+ re-sync), notifications, DataStore prefs.
+- Render-only ViewModels + UiState for **every** screen, so the UI is build-only
+ from here.
+- JVM unit tests across mappers, filtering, sorting, form, value mapping, and
+ day windows.
+
+### 🚧 M2 — Screens: lists, task list, complete & CRUD
+Replace the functional scaffold with the real Material 3 Expressive UI, screen
+by screen, against the already-built ViewModels.
+- ✅ Provider/permission onboarding gate (`RootScreen`).
+- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
+- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in
+ `RootScreen` are currently stubs).
+- ⬜ Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete,
+ inline add field, section headers for smart lists, FAB.
+- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list
+ creation wired to the UI.
+
+### ⬜ M3 — Detail / edit polish
+Task detail and edit screens: due/start date-time pickers, priority,
+percent-complete, conflict-safe saves (re-check before overwrite), smart-list
+section presentation.
+
+### ⬜ M4 — Subtasks (UI)
+`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The
+data layer already reads `parentId`; this is the trickiest UI piece.
+
+### ⬜ M5 — Reminders onboarding & polish
+The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm
+onboarding flow, per-task / default reminder-offset settings UI, and
+end-to-end verification on device.
+
+### ⬜ M6 — Settings, widget, i18n, release
+Settings screen (theme / dynamic-color / language, default list, default
+reminder), Glance task-list widget, translations, finalize F-Droid metadata,
+confirm CI release flow.
+
+### ⬜ Posture B (separate track, later)
+Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;
+`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter
+permissions; ship self-contained. UI / repository / domain untouched — see
+[`ARCHITECTURE.md`](ARCHITECTURE.md) §7.
+
+---
+
+## Open decisions / to verify
+
+These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
+
+1. ~~**Name** — `Floret`~~ confirmed (appId `de.jeanlucmakiola.floret`).
+2. ~~**tasks.org provider authority**~~ verified on device:
+ `org.tasks.opentasks` + `org.tasks.permission.*`.
+3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
+ (Not in the candidate list today.)
+4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Floret a
+ *replacement* for OpenTasks (one authority owner per device). Intended, but a
+ conscious choice.
+5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);
+ recurrence-aware editing is out of scope for v1.
+
+---
+
+## How to contribute / verify
+
+- Build: `./gradlew :app:assembleDebug`
+- Unit tests: `./gradlew :app:testDebugUnitTest`
+- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and
+ ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real
+ data. Debug builds use `DemoSeeder` for sample data when no provider data is
+ present.