diff --git a/assets/flutter.svg b/assets/flutter.svg
new file mode 100644
index 0000000..1e58034
--- /dev/null
+++ b/assets/flutter.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/gh.svg b/assets/gh.svg
new file mode 100644
index 0000000..1577a09
--- /dev/null
+++ b/assets/gh.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 0000000..b933902
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/tangled.svg b/assets/tangled.svg
new file mode 100644
index 0000000..d9ce621
--- /dev/null
+++ b/assets/tangled.svg
@@ -0,0 +1,27 @@
+
diff --git a/assets/typography.svg b/assets/typography.svg
new file mode 100644
index 0000000..906284a
--- /dev/null
+++ b/assets/typography.svg
@@ -0,0 +1,3 @@
+
diff --git a/docs/specs/phase-3.md b/docs/specs/phase-3.md
index 3d025ba..5ff5ccb 100644
--- a/docs/specs/phase-3.md
+++ b/docs/specs/phase-3.md
@@ -119,123 +119,157 @@ Display an unread count badge on the Notifications nav bar item. Poll
Build a `NotificationBloc` with events: `NotificationsRequested`,
`NotificationsRefreshed`, `NotificationsPageLoaded`, `NotificationsMarkedRead`.
-## Direct Messages
+## Post & Profile Actions
-DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a
-conversation list and a message thread.
+All post and profile interactions use the AT Protocol record model. Actions
+that create a relationship (like, repost, follow, block) write a record via
+`com.atproto.repo.createRecord` and undo by deleting via
+`com.atproto.repo.deleteRecord`. Muting is a server-side procedure call with
+no persistent record.
### API
-| Endpoint | Purpose |
-| -------------------------------------- | ----------------------------- |
-| `chat.bsky.convo.listConvos` | Paginated conversation list |
-| `chat.bsky.convo.getConvo` | Single conversation metadata |
-| `chat.bsky.convo.getMessages` | Paginated messages in a convo |
-| `chat.bsky.convo.sendMessage` | Send a message |
-| `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally |
-| `chat.bsky.convo.muteConvo` | Mute a conversation |
-| `chat.bsky.convo.unmuteConvo` | Unmute a conversation |
-| `chat.bsky.convo.updateRead` | Mark conversation as read |
-| `chat.bsky.convo.getLog` | Polling for new events |
+| Endpoint | Purpose |
+| ------------------------------------- | --------------------------------------- |
+| `com.atproto.repo.createRecord` | Create like/repost/follow/block records |
+| `com.atproto.repo.deleteRecord` | Delete like/repost/follow/block records |
+| `app.bsky.graph.muteActor` | Mute an account |
+| `app.bsky.graph.unmuteActor` | Unmute an account |
+| `com.atproto.moderation.createReport` | Report a post or account |
-### Conversation List
+### Like
-`listConvos` returns conversations sorted by last message time. Each convo
-includes `id`, `members` (array of `profileViewBasic`), `lastMessage`,
-`unreadCount`, `muted`.
+Collection: `app.bsky.feed.like`. Record contains a `subject` (RepoStrongRef
+with the post's AT-URI and CID) and `createdAt`.
-Filter conversations into two tabs: **Primary** (accepted) and **Requests**
-(conversations the user has not yet responded to). A conversation is a
-"request" if the user has never sent a message in it.
+To unlike, extract the record key (rkey) from the `viewer.like` AT-URI and
+call `deleteRecord` with collection `app.bsky.feed.like` and that rkey.
-### Message Thread
+The `PostView.viewer.like` field is non-null when the current user has liked
+the post. Use this to drive the filled/outlined heart icon state.
-`getMessages` returns paginated `messageView` objects. Each message has `id`,
-`text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat
-bubble layout — the current user's messages right-aligned, others left-aligned.
+### Repost
-Support long-press to copy individual messages. Provide a "Copy All" option in
-the conversation overflow menu to copy the full thread.
+Collection: `app.bsky.feed.repost`. Record structure is identical to like —
+`subject` (RepoStrongRef) + `createdAt`.
-### Sending Messages
+To un-repost, extract the rkey from `viewer.repost` and delete the record.
-`sendMessage` takes `convoId` and `message` (object with `text`). To start a
-new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the
-target DID(s) — this returns an existing convo or creates a new one.
+The `PostView.viewer.repost` field is non-null when the current user has
+reposted. Use this for the repost icon state.
-| Endpoint | Purpose |
-| ------------------------------------ | --------------------- |
-| `chat.bsky.convo.getConvoForMembers` | Get or create a convo |
+### Follow
-Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`,
-`ConvoMuted`, `ConvoUnmuted`.
+Collection: `app.bsky.graph.follow`. Record contains `subject` (the target
+user's DID as a string) and `createdAt`.
-Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`,
-`MessageSent`, `MessageDeleted`, `ConvoMarkedRead`.
+To unfollow, extract the rkey from `viewer.following` and delete the record.
-## Account Switching
+Viewer state fields on profiles:
-Support multiple authenticated accounts with full data isolation. The
-`accounts` table (from Phase 1) already supports multiple rows keyed by DID.
+- `viewer.following` — non-null AT-URI if the current user follows this profile
+- `viewer.followedBy` — non-null AT-URI if this profile follows the current user
-### Active Account
+### Mute
-Store the active account DID in the Drift `settings` table under key
-`active_account_did`. On launch, read this value and restore the session for
-that account.
+Mute and unmute are procedure calls (not record creation):
-### Data Isolation
+- `app.bsky.graph.muteActor` — input: `{ actor: DID }`
+- `app.bsky.graph.unmuteActor` — input: `{ actor: DID }`
-All user-scoped tables must include an `account_did` FK column. Queries always
-filter by the active account's DID. Tables requiring this constraint:
+Both return empty responses. The `viewer.muted` boolean on profiles reflects
+the current mute state. Muted accounts' posts are still fetched but should be
+visually de-emphasised or filtered in the UI based on user preference.
-- `drafts`
-- `saved_posts`
-- `search_history` (Phase 2)
-- `cached_posts` (add `account_did` if not present)
+### Block
-### Switching Flow
+Collection: `app.bsky.graph.block`. Record contains `subject` (the target
+user's DID) and `createdAt`.
-1. User opens account switcher (bottom sheet or settings).
-2. Selects a different account.
-3. App updates `active_account_did` in settings.
-4. All Blocs receive a `AccountSwitched` event and reload their state for the
- new account.
-5. If the selected account's tokens are expired, attempt silent refresh. If
- refresh fails, navigate to login.
+To unblock, extract the rkey from `viewer.blocking` and delete the record.
-### Adding Accounts
+Viewer state fields on profiles:
-"Add Account" triggers the same OAuth flow from Phase 1. On success, a new row
-is inserted into `accounts`. The new account becomes the active account.
+- `viewer.blocking` — non-null AT-URI if the current user blocks this profile
+- `viewer.blockedBy` — boolean, true if this profile blocks the current user
-Build an `AccountSwitcherCubit` that exposes the list of accounts and the
-active DID.
+When a user is blocked, their posts should be hidden from feeds and threads.
+Display a "You have blocked this user" placeholder in their profile view.
-## Offline Reading
+### Report
-The app should render cached data when the network is unavailable. This builds
-on the `cached_posts` and `cached_profiles` tables from Phase 1.
+Reports use `com.atproto.moderation.createReport` with two subject types:
-### Cache Strategy
+| Subject Type | Usage | Fields |
+| --------------- | ---------------------- | ------------- |
+| `RepoStrongRef` | Report a specific post | `uri` + `cid` |
+| `RepoRef` | Report an account | `did` |
-Cache the last-fetched page of each feed (timeline, pinned generators) in Drift
-as serialised JSON. On launch or feed switch, display cached data immediately,
-then fetch fresh data in the background. If the fetch fails, keep showing the
-cache with a "You're offline" banner.
+Report reasons (from `com.atproto.moderation.defs`):
-### Offline Indicators
+| Reason | Description |
+| ------------------ | --------------------------------- |
+| `reasonSpam` | Spam or unsolicited content |
+| `reasonViolation` | Violates community guidelines |
+| `reasonMisleading` | Misleading or deceptive content |
+| `reasonSexual` | Unwanted sexual content |
+| `reasonRude` | Harassment or rude behaviour |
+| `reasonOther` | Other (requires text explanation) |
-- A persistent banner at the top of the screen when connectivity is lost.
-- Disable actions that require network (compose, like, repost, follow) and show
- a tooltip explaining why.
-- Notifications and DM screens show an empty state with "No connection" when
- offline and no cached data exists.
+The report dialog should present the reason picker and an optional free-text
+description field. Submitting returns a report ID for confirmation.
-### Network Detection
+### Optimistic Updates
-Use the **connectivity_plus** package to monitor network state changes. Expose
-connectivity as a stream via a `ConnectivityCubit` that all screens observe.
+All toggle actions (like, repost, follow, mute, block) should use optimistic
+UI updates:
+
+1. Immediately update the local state (icon, count, button label).
+2. Fire the API call in the background.
+3. On success, reconcile with the server response (update the viewer URI).
+4. On failure, roll back the local state and show a snackbar error.
+
+Build a `PostActionCubit` that manages per-post action state (like, repost,
+save). It accepts the initial `ViewerState` from the post and exposes
+toggleable methods.
+
+Build a `ProfileActionCubit` that manages per-profile action state (follow,
+mute, block). It accepts the initial `ViewerState` from the profile and
+exposes toggleable methods.
+
+### Post Action Bar
+
+The post action bar appears below every post and contains four buttons:
+
+| Button | Icon | Tap action | Long-press |
+| ------ | ------ | ------------- | ----------------- |
+| Reply | chat | Open compose | — |
+| Repost | repeat | Toggle repost | Quote post option |
+| Like | heart | Toggle like | — |
+| Share | share | Share sheet | — |
+
+The bookmark (save) icon is placed in the post overflow menu alongside
+"Report" and "Copy link".
+
+Like and repost counts are displayed next to their respective icons. Counts
+update optimistically. The repost long-press opens a bottom sheet with
+"Repost" and "Quote Post" options.
+
+### Profile Action Buttons
+
+The profile header shows a primary action button based on the relationship:
+
+| State | Button label | Tap action |
+| ---------------- | ------------ | -------------- |
+| Not following | "Follow" | Create follow |
+| Following | "Following" | Unfollow sheet |
+| Blocked by them | — | No button |
+| You blocked them | "Unblock" | Delete block |
+
+The profile overflow menu (three-dot icon) contains: Mute / Unmute, Block /
+Unblock, Report, Copy DID, Share profile.
+
+Mute and block actions should show a confirmation dialog before proceeding.
## Saved Posts
@@ -264,16 +298,3 @@ profile screen or settings.
Build a `SavedPostsCubit` that reads/writes the `saved_posts` table and
exposes a stream of saved post URIs for quick lookup (to show filled vs
outlined bookmark icons in the feed).
-
-## Jump to Profile
-
-Add a floating action button on the search screen. Tapping it opens a dialog
-with a text field for entering a handle. Use
-`app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as
-the user types. Selecting a result or pressing enter navigates to that user's
-profile screen.
-
-| Endpoint | Purpose |
-| -------------------------------------- | ------------------- |
-| `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete |
-| `app.bsky.actor.getProfile` | Full profile fetch |
diff --git a/docs/specs/phase-4.md b/docs/specs/phase-4.md
new file mode 100644
index 0000000..c7df020
--- /dev/null
+++ b/docs/specs/phase-4.md
@@ -0,0 +1,329 @@
+# Phase 4
+
+## Direct Messages
+
+DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a
+conversation list and a message thread.
+
+### API
+
+| Endpoint | Purpose |
+| -------------------------------------- | ----------------------------- |
+| `chat.bsky.convo.listConvos` | Paginated conversation list |
+| `chat.bsky.convo.getConvo` | Single conversation metadata |
+| `chat.bsky.convo.getMessages` | Paginated messages in a convo |
+| `chat.bsky.convo.sendMessage` | Send a message |
+| `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally |
+| `chat.bsky.convo.muteConvo` | Mute a conversation |
+| `chat.bsky.convo.unmuteConvo` | Unmute a conversation |
+| `chat.bsky.convo.updateRead` | Mark conversation as read |
+| `chat.bsky.convo.getLog` | Polling for new events |
+
+### Conversation List
+
+`listConvos` returns conversations sorted by last message time. Each convo
+includes `id`, `members` (array of `profileViewBasic`), `lastMessage`,
+`unreadCount`, `muted`.
+
+Filter conversations into two tabs: **Primary** (accepted) and **Requests**
+(conversations the user has not yet responded to). A conversation is a
+"request" if the user has never sent a message in it.
+
+### Message Thread
+
+`getMessages` returns paginated `messageView` objects. Each message has `id`,
+`text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat
+bubble layout — the current user's messages right-aligned, others left-aligned.
+
+Support long-press to copy individual messages. Provide a "Copy All" option in
+the conversation overflow menu to copy the full thread.
+
+### Sending Messages
+
+`sendMessage` takes `convoId` and `message` (object with `text`). To start a
+new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the
+target DID(s) — this returns an existing convo or creates a new one.
+
+| Endpoint | Purpose |
+| ------------------------------------ | --------------------- |
+| `chat.bsky.convo.getConvoForMembers` | Get or create a convo |
+
+Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`,
+`ConvoMuted`, `ConvoUnmuted`.
+
+Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`,
+`MessageSent`, `MessageDeleted`, `ConvoMarkedRead`.
+
+## Account Switching
+
+Support multiple authenticated accounts with full data isolation. The
+`accounts` table (from Phase 1) already supports multiple rows keyed by DID.
+
+### Active Account
+
+Store the active account DID in the Drift `settings` table under key
+`active_account_did`. On launch, read this value and restore the session for
+that account.
+
+### Data Isolation
+
+All user-scoped tables must include an `account_did` FK column. Queries always
+filter by the active account's DID. Tables requiring this constraint:
+
+- `drafts`
+- `saved_posts`
+- `search_history` (Phase 2)
+- `cached_posts` (add `account_did` if not present)
+
+### Switching Flow
+
+1. User opens account switcher (bottom sheet or settings).
+2. Selects a different account.
+3. App updates `active_account_did` in settings.
+4. All Blocs receive a `AccountSwitched` event and reload their state for the
+ new account.
+5. If the selected account's tokens are expired, attempt silent refresh. If
+ refresh fails, navigate to login.
+
+### Adding Accounts
+
+"Add Account" triggers the same OAuth flow from Phase 1. On success, a new row
+is inserted into `accounts`. The new account becomes the active account.
+
+Build an `AccountSwitcherCubit` that exposes the list of accounts and the
+active DID.
+
+## Offline Reading
+
+The app should render cached data when the network is unavailable. This builds
+on the `cached_posts` and `cached_profiles` tables from Phase 1.
+
+### Cache Strategy
+
+Cache the last-fetched page of each feed (timeline, pinned generators) in Drift
+as serialised JSON. On launch or feed switch, display cached data immediately,
+then fetch fresh data in the background. If the fetch fails, keep showing the
+cache with a "You're offline" banner.
+
+### Offline Indicators
+
+- A persistent banner at the top of the screen when connectivity is lost.
+- Disable actions that require network (compose, like, repost, follow) and show
+ a tooltip explaining why.
+- Notifications and DM screens show an empty state with "No connection" when
+ offline and no cached data exists.
+
+### Network Detection
+
+Use the **connectivity_plus** package to monitor network state changes. Expose
+connectivity as a stream via a `ConnectivityCubit` that all screens observe.
+
+## Jump to Profile
+
+Add a floating action button on the search screen. Tapping it opens a dialog
+with a text field for entering a handle. Use
+`app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as
+the user types. Selecting a result or pressing enter navigates to that user's
+profile screen.
+
+| Endpoint | Purpose |
+| -------------------------------------- | ------------------- |
+| `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete |
+| `app.bsky.actor.getProfile` | Full profile fetch |
+
+## Labelers & Content Moderation
+
+Labelers are independent services that produce metadata labels about content
+and accounts. Users subscribe to labelers and configure how each label type
+affects their experience. The `bluesky` Dart package includes a built-in
+moderation decision engine that handles label interpretation.
+
+### Architecture Overview
+
+```text
+User Preferences ──► ModerationOpts ──► moderatePost() ──► ModerationDecision
+Label Definitions ─┘ moderateProfile() └─► getUI(context)
+ moderateNotification() └─► ModerationUI
+ ├─ filters
+ ├─ blurs
+ ├─ alerts
+ └─ informs
+```
+
+### API
+
+| Endpoint | Purpose |
+| ------------------------------- | ----------------------------------- |
+| `app.bsky.labeler.getServices` | Fetch labeler details by DID |
+| `app.bsky.actor.getPreferences` | Read labeler subscriptions + prefs |
+| `app.bsky.actor.putPreferences` | Write labeler subscriptions + prefs |
+
+### Labeler Subscriptions
+
+Users subscribe to up to 20 labelers. Subscriptions are stored as a
+`labelersPref` entry in the user's preferences. Each entry contains a labeler
+DID. The official Bluesky moderation labeler is always active and does not
+count against the 20-labeler limit.
+
+On every XRPC request that returns content (feed, thread, profile, search,
+notifications), include the `atproto-accept-labelers` HTTP header with a
+comma-separated list of subscribed labeler DIDs. The AppView fetches labels
+from those labelers and attaches them to the response.
+
+### Label Data Model
+
+A label is a lightweight annotation attached to content or an account:
+
+| Field | Type | Description |
+| ----- | --------- | --------------------------------------------------- |
+| `src` | string | DID of the labeler that created the label |
+| `uri` | string | AT-URI of the target resource (or DID for accounts) |
+| `cid` | string? | Optional CID for a specific version of the target |
+| `val` | string | Label value identifier (e.g. "porn", "spam") |
+| `neg` | bool? | If true, negates (retracts) a previous label |
+| `cts` | datetime | Creation timestamp |
+| `exp` | datetime? | Expiration timestamp |
+
+### Label Behaviour Definitions
+
+Each label value is defined by three axes that determine its UI effect:
+
+| Axis | Values | Description |
+| ---------------- | -------------------------- | -------------------------------- |
+| `blurs` | `content`, `media`, `none` | What gets blurred |
+| `severity` | `inform`, `alert`, `none` | Badge type (neutral vs warning) |
+| `defaultSetting` | `ignore`, `warn`, `hide` | Default user-configurable action |
+
+Global (protocol-defined) label values include:
+
+| Label | Blurs | Default | Notes |
+| ---------------- | ------- | ------- | ------------------------------- |
+| `!hide` | content | hide | Non-configurable, no override |
+| `!warn` | content | warn | Non-configurable, click-through |
+| `porn` | media | hide | Adult, 18+ required |
+| `sexual` | media | warn | Adult, 18+ required |
+| `graphic-media` | media | warn | Adult, 18+ required |
+| `nudity` | media | ignore | Not 18+ restricted |
+| `dmca-violation` | content | hide | Non-configurable |
+| `doxxing` | content | hide | Non-configurable |
+
+Labelers may also define custom label values with localised names and
+descriptions via `LabelValueDefinition`.
+
+### Self-Labels
+
+Authors can embed `selfLabels` directly in their posts and profiles. Only
+global label values are valid as self-labels. Self-labels are treated as if
+the author is the label source and follow the same behaviour rules.
+
+### Moderation Decision Pipeline
+
+Use the `bluesky` package's moderation engine for all content display:
+
+1. **Build `ModerationOpts`** from user preferences:
+ - `adultContentEnabled` — boolean from preferences
+ - `labels` — map of label value → preference (`ignore` / `warn` / `hide`)
+ - `labelers` — list of subscribed labeler DIDs
+ - `labelDefs` — map of labeler DID → list of custom `InterpretedLabelValueDefinition`
+
+2. **Run moderation** on every piece of content before display:
+ - `moderatePost(subject, opts)` for posts
+ - `moderateProfile(subject, opts)` for profiles
+ - `moderateNotification(subject, opts)` for notifications
+
+3. **Apply `ModerationUI`** via `decision.getUI(context)` for each display
+ context:
+ - `contentList` — post in a feed or search results
+ - `contentView` — post in a thread view
+ - `contentMedia` — images/media within a post
+ - `avatar` — profile avatar
+ - `profileList` — profile in a list
+ - `profileView` — full profile screen
+
+The `ModerationUI` object contains:
+
+- `filters` — content should be removed from the list entirely
+- `blurs` — content should be placed behind a click-through overlay
+- `alerts` — show a warning badge (negative connotation)
+- `informs` — show an informational badge (neutral)
+- `noOverride` — blur cannot be dismissed by the user
+
+### Content Label Preferences
+
+Users configure per-label visibility via `contentLabelPref` entries in
+preferences. Each entry specifies:
+
+| Field | Description |
+| ------------ | ------------------------------------------- |
+| `labelerDid` | Scope to a specific labeler (null = global) |
+| `label` | The label value string |
+| `visibility` | `ignore`, `warn`, `hide`, or `show` |
+
+Labels with `adultOnly: true` in their definition require the user to have
+`adultContentEnabled` set to true. If adult content is disabled, these labels
+always apply as `hide` regardless of user preference.
+
+### Rendering Rules
+
+**Blur overlay**: When `blurs` is non-empty, render a semi-transparent overlay
+with the label name and a "Show" button. If `noOverride` is true, omit the
+"Show" button — the content cannot be revealed.
+
+**Alert badge**: When `alerts` is non-empty, show a warning icon with the
+label name below the content or next to the profile name.
+
+**Inform badge**: When `informs` is non-empty, show an info icon with the
+label name. Use a neutral colour (not red/warning).
+
+**Filtering**: When `filters` is non-empty, remove the content from the
+current list view entirely. Do not render a placeholder.
+
+**Media blur**: When the `contentMedia` context has blurs, blur only images
+and embedded media while leaving the post text visible.
+
+**Avatar blur**: When the `avatar` context has blurs, show a generic
+placeholder avatar.
+
+### ModerationService
+
+Build a `ModerationService` that:
+
+1. Loads labeler subscriptions and label preferences from user preferences on
+ login and account switch.
+2. Fetches labeler details (`getServices` with `detailed: true`) for all
+ subscribed labelers to obtain custom label definitions.
+3. Caches label definitions in a Drift `labeler_cache` table for offline use.
+4. Constructs `ModerationOpts` and exposes it as a stream that updates when
+ preferences change.
+5. Provides convenience methods: `moderatePost()`, `moderateProfile()`,
+ `moderateNotification()`.
+
+### Labeler Cache Table
+
+| Column | Type | Notes |
+| --------------- | -------- | -------------------------------- |
+| `labeler_did` | text | PK, DID of the labeler |
+| `policies_json` | text | Serialised `LabelerViewDetailed` |
+| `fetched_at` | datetime | When the data was last refreshed |
+
+Refresh the cache when the user opens the labeler management screen or when a
+subscribed labeler's data is older than 24 hours.
+
+### Labeler Management UI
+
+The labeler management screen (accessible from Settings) shows:
+
+- A list of subscribed labelers, each showing: creator avatar, display name,
+ description, and number of label definitions.
+- Tapping a labeler opens a detail screen showing all label values the labeler
+ publishes, with the user's current preference (ignore/warn/hide) for each.
+- A toggle for subscribing/unsubscribing to the labeler.
+- An "Adult content" toggle at the top of the settings screen that gates all
+ 18+ label preferences.
+
+### Header Integration
+
+The XRPC client must be modified to include the `atproto-accept-labelers`
+header on all outgoing requests. The header value is a comma-separated list of
+labeler DIDs from the user's `labelersPref`. This should be set once on login
+and updated whenever preferences change.
diff --git a/docs/tasks/phase-3.md b/docs/tasks/phase-3.md
index 64a025b..978722b 100644
--- a/docs/tasks/phase-3.md
+++ b/docs/tasks/phase-3.md
@@ -25,50 +25,27 @@
- [ ] Mark as read via `updateSeen` when notifications screen opens
- [ ] Tap notification to navigate to relevant post or profile
-## M10 — Direct Messages
-
-- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination
-- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted`
-- [ ] Primary / Requests tab filtering on conversation list
-- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination
-- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`
-- [ ] Chat bubble layout — current user right-aligned, others left-aligned
-- [ ] Send messages via `chat.bsky.convo.sendMessage`
-- [ ] New conversation via `chat.bsky.convo.getConvoForMembers`
-- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread
-- [ ] Mute / unmute conversations
-- [ ] Mark conversation as read via `chat.bsky.convo.updateRead`
-
-## M11 — Account Switching
-
-- [ ] `AccountSwitcherCubit` exposing account list and active DID
-- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles
-- [ ] Store `active_account_did` in Drift `settings` table
-- [ ] Drift migration: add `account_did` column to `cached_posts` if not present
-- [ ] All user-scoped queries filter by active account DID
-- [ ] Broadcast `AccountSwitched` event to all Blocs on switch
-- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row
-- [ ] Silent token refresh on account switch; navigate to login on failure
-
-## M12 — Offline Reading & Network Resilience
-
-- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream
-- [ ] Cache last-fetched feed page as serialised JSON in Drift
-- [ ] Display cached data immediately on launch, refresh in background
-- [ ] "You're offline" banner when connectivity is lost
-- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip
-- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache
-
-## M13 — Saved Posts
+## M10 — Post & Profile Actions
+
+- [ ] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord`
+- [ ] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure
+- [ ] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount`
+- [ ] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount`
+- [ ] Post action bar UI — like, repost, reply, share buttons with animated state transitions
+- [ ] `ProfileActionRepository` — follow, mute, block, report
+- [ ] `ProfileActionCubit` — optimistic follow/mute/block state with rollback
+- [ ] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following`
+- [ ] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted`
+- [ ] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking`
+- [ ] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu
+- [ ] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport`
+- [ ] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject)
+- [ ] Confirmation dialog before mute / block actions
+- [ ] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator)
+
+## M11 — Saved Posts
- [ ] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri)
- [ ] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state
- [ ] Bookmark icon on post action bar — toggle saved state
- [ ] Saved posts list screen accessible from profile or settings
-
-## M14 — Jump to Profile
-
-- [ ] Floating action button on search screen
-- [ ] Handle input dialog with autocomplete via `searchActorsTypeahead`
-- [ ] Navigate to profile screen on selection or enter
-- [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout)
diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md
new file mode 100644
index 0000000..5bb21c8
--- /dev/null
+++ b/docs/tasks/phase-4.md
@@ -0,0 +1,61 @@
+# Phase 4 Milestones
+
+## M12 — Direct Messages
+
+- [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination
+- [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted`
+- [ ] Primary / Requests tab filtering on conversation list
+- [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination
+- [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`
+- [ ] Chat bubble layout — current user right-aligned, others left-aligned
+- [ ] Send messages via `chat.bsky.convo.sendMessage`
+- [ ] New conversation via `chat.bsky.convo.getConvoForMembers`
+- [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread
+- [ ] Mute / unmute conversations
+- [ ] Mark conversation as read via `chat.bsky.convo.updateRead`
+
+## M13 — Account Switching
+
+- [ ] `AccountSwitcherCubit` exposing account list and active DID
+- [ ] Account switcher bottom sheet UI — list accounts with avatars and handles
+- [ ] Store `active_account_did` in Drift `settings` table
+- [ ] Drift migration: add `account_did` column to `cached_posts` if not present
+- [ ] All user-scoped queries filter by active account DID
+- [ ] Broadcast `AccountSwitched` event to all Blocs on switch
+- [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row
+- [ ] Silent token refresh on account switch; navigate to login on failure
+
+## M14 — Offline Reading & Network Resilience
+
+- [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream
+- [ ] Cache last-fetched feed page as serialised JSON in Drift
+- [ ] Display cached data immediately on launch, refresh in background
+- [ ] "You're offline" banner when connectivity is lost
+- [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip
+- [ ] Notifications and DM screens show "No connection" empty state when offline with no cache
+
+## M15 — Jump to Profile
+
+- [ ] Floating action button on search screen
+- [ ] Handle input dialog with autocomplete via `searchActorsTypeahead`
+- [ ] Navigate to profile screen on selection or enter
+- [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout)
+
+## M16 — Labelers & Content Moderation
+
+- [ ] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`)
+- [ ] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests
+- [ ] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions
+- [ ] Run moderation decisions on all displayed posts and profiles
+- [ ] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView)
+- [ ] Blur overlay on posts/media with click-through "Show content" button
+- [ ] Warning badges on profiles and posts for alert/inform labels
+- [ ] Content filtering — remove posts with `filter` decisions from feed and notification lists
+- [ ] Labeler management screen: list subscribed labelers via `app.bsky.labeler.getServices`
+- [ ] Subscribe / unsubscribe to labelers by updating `labelersPref` via `putPreferences`
+- [ ] Per-label preference configuration: ignore / warn / hide per label value per labeler
+- [ ] Store label preferences as `contentLabelPref` entries via `putPreferences`
+- [ ] Adult content toggle (requires `adultContentEnabled` preference)
+- [ ] Self-label support — render self-labels embedded in posts and profiles
+- [ ] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names
+- [ ] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup
diff --git a/www/client-metadata.json b/www/client-metadata.json
new file mode 100644
index 0000000..8b8d257
--- /dev/null
+++ b/www/client-metadata.json
@@ -0,0 +1,14 @@
+{
+ "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json",
+ "client_name": "Lazurite",
+ "client_uri": "https://lazurite.stormlightlabs.org",
+ "redirect_uris": ["http://127.0.0.1/callback"],
+ "scope": "atproto transition:generic",
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none",
+ "application_type": "native",
+ "dpop_bound_access_tokens": true,
+ "software_id": "org.stormlightlabs.lazurite",
+ "software_version": "1.0.0"
+}
diff --git a/www/index.html b/www/index.html
new file mode 100644
index 0000000..56415f9
--- /dev/null
+++ b/www/index.html
@@ -0,0 +1,384 @@
+
+
+
+ Lazurite is a cross-platform mobile and desktop client for Bluesky, with a mobile-first design for Android
+ and iOS, with desktop support for a seamless experience.
+
+
+
+
+
+
+
+
+
Material You Design
+
Beautiful IBM Oxocarbon-inspired theme with dynamic color adaptation and smooth animations.
+
+
+
+
+
+
+
Full-Featured
+
Timeline, threads, profiles, notifications, search, direct messages, and smart folders.
+
+
+
+
+
+
+
Offline-First
+
Smart caching with Drift database for lightning-fast performance and offline compose.
+
+
+
+
+
+
+
Smart Folders
+
Create custom local-only feeds with powerful filtering and organization tools.