diff --git a/.vscode/extensions.json b/.vscode/extensions.json
deleted file mode 100644
index af3c0b2..0000000
--- a/.vscode/extensions.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "recommendations": [
- "Webnative.webnative"
- ]
-}
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..462d8bf
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,327 @@
+---
+title: Twisted product and implementation specification
+status: ready
+---
+
+# Twisted roadmap
+
+## Objective
+
+Twisted is a read-only Tangled client with its own responsive interface. It
+runs as a website, installable PWA, and Capacitor app for Android and iOS. Users
+can browse the full public Tangled dataset exposed by Bobbin without signing in
+or sending writes to a PDS or knot.
+
+The first release must make Tangled useful as a reading and discovery tool. It
+includes repository hosting data, social records, collaboration records,
+pipelines, labels, strings, and infrastructure views. Mobile packaging follows
+the complete web/PWA experience; it does not reduce the product scope.
+
+## Success criteria
+
+- A user can resolve a handle or DID, open a profile, and navigate all public
+ records associated with that actor.
+- Search supports text, collection, author, repo, and date filters with cursor
+ pagination.
+- Repository pages expose metadata, source, refs, commits, diffs, downloads,
+ issues, pulls, discussions, activity, labels, collaborators, CI, and
+ artifacts.
+- Dedicated views cover follows, vouches, stars, actor-authored activity,
+ strings, knots, spindles, members, public keys, and other public
+ infrastructure data exposed by Bobbin.
+- Every API response uses atcute's generated types and schemas. Local types are
+ limited to Bobbin-specific contracts that atcute does not publish.
+- Loading, empty, incomplete-index, rate-limit, not-found, offline, and upstream
+ failure states keep navigation usable and offer the right recovery action.
+- The UI works with keyboard navigation and screen readers, honors reduced
+ motion and safe areas, and meets WCAG 2.2 AA for application-owned colors and
+ controls.
+- Users can choose and persist a Base16 theme. The same theme works in light and
+ dark system chrome, PWA mode, Android, and iOS.
+- The PWA is installable and its application shell opens offline. Public
+ Tangled data is presented as online data unless a later cache has a clear
+ freshness policy.
+- Unit, component, Cypress, and Hurl checks pass. Hurl verifies the live Bobbin
+ contract against the approved `desertthunder.dev` fixture.
+- Android and iOS builds launch, navigate, and render the same read-only flows
+ as the PWA.
+
+## Current state
+
+The repository is an Ionic Vue starter using Vue 3, TypeScript, Vite 5,
+Vue Router, Ionic 8, Capacitor 8, Vitest, Cypress, and ESLint. It contains one
+placeholder page, no API client, no product navigation, and no meaningful test
+coverage. Capacitor has a starter app identifier and no checked-in Android or
+iOS project.
+
+The API contract is documented in [docs/api.md](docs/api.md). Bobbin is a new,
+read-only XRPC AppView whose hosted instance is `https://api.tangled.org`.
+
+## Users and core flows
+
+Twisted is for people who want to inspect Tangled on desktop or mobile without
+logging in. A user can:
+
+1. Search public Tangled records or enter a handle, DID, repo DID, or AT-URI.
+2. Open a profile and browse its repositories, social graph, activity, public
+ keys, knot memberships, and spindle memberships.
+3. Open a repository and move among its overview, source, history, issues,
+ pulls, CI, labels, collaborators, and activity.
+4. Follow links among authors, subjects, state records, comments, reactions,
+ pipelines, artifacts, knots, spindles, and strings without losing context.
+5. Copy identifiers, open the canonical Tangled page, or download a repository
+ archive. Actions that change data remain on Tangled.
+
+## Product scope
+
+### Discovery and identity
+
+- Resolve handles and DIDs through Bobbin's typed Microcosm identity query.
+- Accept complete Tangled and AT-URI links and route them to the matching view.
+- Provide full-text search with every Bobbin filter and cursor pagination.
+- Group mixed search hits by recognizable record type while preserving Bobbin's
+ relevance order.
+- Show Bobbin coverage when indexed results may be incomplete.
+
+### Profiles and actor activity
+
+- Render profile metadata, links, avatar, location, pronouns, pinned repos, and
+ validated handle/DID information.
+- List owned repositories, stars, follows, followers, vouches, and inbound
+ vouches.
+- Show actor-authored comments, reactions, issues, pulls, state changes, ref
+ updates, collaborator records, label operations, pipelines, statuses,
+ artifacts, and knot or spindle memberships through the `*By` endpoints.
+- Use separate paginated sections so one failed endpoint does not blank the
+ whole profile.
+
+### Repositories and Git data
+
+- Render repository metadata, topics, links, knot, spindle, labels, star count,
+ collaborators, languages, and latest ref activity.
+- Render sanitized Markdown README content when the tree response provides it.
+- Browse branches, tags, trees, files, and commit history. Preserve the selected
+ ref while moving through directories.
+- Render text blobs with line numbers, anchors, wrapping controls, and a safe
+ size limit. Offer download or canonical Tangled links for binary and oversized
+ blobs.
+- Show commits, path-filtered logs, single-ref diffs, and two-revision compares.
+- Download streamed repository archives without loading the full archive into
+ application memory.
+
+### Collaboration and social records
+
+- List and filter issues by current state and author; render issue details,
+ state history, comments, reactions, labels, references, and mentions.
+- List and filter pulls by current status and author; render pull details,
+ status history, comments, reactions, patch information, and comparison data.
+- Render comments and reactions on issues, pulls, and strings using canonical
+ `sh.tangled.feed.*` records while accepting documented legacy aliases.
+- Display stars, follows, vouches, collaborators, and ref updates with links to
+ both ends of each relationship.
+- Display rkeys as canonical issue and pull identifiers. Do not invent
+ sequential numbers Bobbin does not supply.
+
+### Pipelines, labels, strings, and infrastructure
+
+- List pipelines for repos and spindles, their status histories, and artifacts.
+- Display artifact metadata and stream downloads where the upstream response
+ permits it.
+- Display label definitions and label operation history for their scopes.
+- Render Tangled strings and their comments or reactions.
+- Browse public knots and spindles, their owners, versions, keys, and membership
+ records. Clearly label data whose freshness Bobbin cannot report.
+- Display public keys and service diagnostics without exposing management
+ controls.
+
+### Theme and platform behavior
+
+- Represent each Base16 scheme as the sixteen canonical `base00` through
+ `base0F` colors plus a stable id and display name.
+- Map Base16 colors to semantic application and Ionic CSS custom properties.
+ Components consume semantic tokens rather than palette slots.
+- Ship a small set of reviewed light and dark schemes, support user-imported
+ Base16 JSON, validate imports, and persist the selected scheme locally.
+- Fall back to a reviewed default when persisted data is absent or invalid.
+- Use responsive Ionic layouts, native safe-area insets, platform back behavior,
+ and touch targets of at least 44 by 44 CSS pixels.
+- Install as a PWA with manifest metadata and an offline application shell.
+- Package the completed PWA for Android first and iOS second with a final app id,
+ name, icons, splash assets, status-bar treatment, and external-link policy.
+
+## Information architecture
+
+The router must use stable, shareable URLs and encode opaque identifiers safely.
+Routes should cover:
+
+- discovery and search;
+- profiles by handle or DID;
+- repository overview by repo record AT-URI;
+- source tree, blob, commits, branches, tags, diff, and compare;
+- issue and pull lists and details;
+- pipelines and artifacts;
+- actor activity and relationship views;
+- strings, knots, spindles, labels, and public-key diagnostics;
+- settings for theme, API service, and application information.
+
+Keep query filters and cursors in route query parameters when doing so produces
+a useful shareable URL. Do not expose opaque cursors as human identifiers.
+
+## Technical plan
+
+### API and types
+
+- Add `@atcute/client` `5.1.1`, `@atcute/tangled` `2.0.13`,
+ `@atcute/microcosm` `2.0.2`, and an explicit compatible
+ `@atcute/lexicons` dependency.
+- Register Tangled and Microcosm ambient XRPC declarations once.
+- Put the atcute client, endpoint wrappers, runtime validation, error mapping,
+ abort handling, and configuration in one API boundary. Views must not issue
+ raw `fetch` calls.
+- Validate every embedded `value: unknown` with its generated atcute schema
+ before mapping it for display.
+- Keep a local type for `sh.tangled.bobbin.getCoverage` until atcute publishes
+ the lexicon. Use small typed parameter overlays for Bobbin's documented knot
+ proxy differences instead of copying generated response models.
+- Default to `https://api.tangled.org`; allow an advanced user to set another
+ HTTPS Bobbin instance and restore the default.
+
+### Application structure and state
+
+- Organize code by domain: API boundary, shared record views, features, router,
+ theme, and platform integration.
+- Use Vue composables and component-local state. Add a global state library only
+ if implementation proves that shared state cannot stay simple.
+- Cache successful GET responses in memory by NSID plus normalized parameters.
+ Deduplicate concurrent requests and use short, explicit stale times. Never
+ persist API payloads until the product defines cache invalidation and privacy
+ behavior.
+- Each request accepts an `AbortSignal`. Route changes cancel obsolete work.
+- Cursor lists append pages without changing earlier item order and prevent
+ duplicate requests for the same cursor.
+
+### Content safety
+
+- Render Markdown through `marked` `18.0.7`, sanitize the result with DOMPurify
+ `3.4.12`, and restrict URL schemes before inserting HTML.
+- Treat repository blobs as untrusted text or bytes. Never execute HTML,
+ JavaScript, SVG scripts, or repository-provided styles.
+- Open external links with an explicit external-browser policy and safe opener
+ settings. Encode identifiers when constructing routes and query strings.
+
+### PWA and native apps
+
+- Add `vite-plugin-pwa` `1.3.0` for manifest generation, installation, update
+ handling, and application-shell caching.
+- Do not use a service worker to hide Bobbin freshness or failure states.
+- Add `@capacitor/android` and `@capacitor/ios` versions compatible with the
+ existing Capacitor 8 packages. Keep web behavior as the source of truth.
+- Native builds need no authentication, secrets, background jobs, analytics,
+ or push notifications.
+
+## Testing plan
+
+### Stable boundaries
+
+- Unit tests cover identifier parsing, endpoint mapping, embedded-record
+ validation, pagination, caching, error classification, Markdown safety, and
+ Base16 validation or token mapping.
+- Component tests mount each route-level state against a fake API boundary.
+ They cover success, empty, partial, loading, malformed record, and retryable
+ failure states without calling the network.
+- Cypress covers user-visible journeys against deterministic intercepted XRPC
+ fixtures: search to profile, profile to repo, source browsing, issue and pull
+ reading, activity or infrastructure navigation, theme persistence, and PWA
+ routing.
+- [Hurl](https://github.com/Orange-OpenSource/hurl) `8.0.1` is the live contract
+ boundary. It checks Bobbin coverage, identity
+ resolution, profile lookup, repo listing and lookup, search, one aggregation,
+ and one knot-proxied Git query.
+
+### Hurl fixture policy
+
+Use this approved public fixture:
+
+```text
+handle=desertthunder.dev
+actor_did=did:plc:xg2vq45muivyy3xwatcehspu
+profile_uri=at://did:plc:xg2vq45muivyy3xwatcehspu/sh.tangled.actor.profile/self
+repo_uri=at://did:plc:xg2vq45muivyy3xwatcehspu/sh.tangled.repo/3mho6hukiei22
+repo_did=did:plc:4iw5fospv2asv3344au236ka
+```
+
+Hurl asserts status, content type, stable identifiers, `$type`, and response
+shape. It must not assert mutable CIDs, counts, descriptions, dates, branch
+names, or list contents. Run files sequentially with a delay and bounded retry
+to respect Bobbin's hosted rate limit. A live-data failure blocks release only
+after a maintainer distinguishes an upstream outage or fixture change from a
+Twisted contract regression.
+
+### Commands
+
+```sh
+npm install
+npm run lint
+npm run test:unit -- --run
+npm run build
+npm run dev
+npm run test:e2e
+hurl --test --jobs 1 --delay 250ms --retry 2 --retry-interval 2s tests/smoke
+npx cap sync
+```
+
+The Cypress command requires the dev server at `http://localhost:5173`. Native
+release tickets must add the exact Android and iOS build commands once those
+platform projects exist.
+
+## Delivery milestones
+
+1. Establish the typed Bobbin boundary, live contract checks, theme engine, and
+ application shell.
+2. Complete discovery, profiles, repository reading, collaboration, social,
+ activity, pipelines, labels, strings, and infrastructure views.
+3. Add PWA installation, offline shell behavior, responsive and accessibility
+ review, and deterministic end-to-end coverage.
+4. Package and verify Android, then iOS, without forking product behavior.
+
+Every capability listed in this specification has a ticket in [TODO.md](TODO.md).
+
+## Boundaries
+
+### Always
+
+- Keep the client read-only and unauthenticated.
+- Use atcute types and generated validators for published lexicons.
+- Preserve partial results and identify incomplete or stale data.
+- Sanitize untrusted content and test keyboard, narrow-screen, and error states.
+- Keep Git operations read-only when working in this repository.
+
+### Ask first
+
+- Add a backend, proxy, database, global state library, analytics, telemetry,
+ authentication, or push notifications.
+- Persist Bobbin payloads or change the public route scheme.
+- Use undocumented Bobbin endpoints beyond the documented compatibility
+ overlays.
+
+### Never
+
+- Call XRPC procedures or any endpoint that writes to a PDS or knot.
+- Call `sh.tangled.repo.listSecrets`, store credentials, or expose secret
+ metadata.
+- Render unsanitized Markdown, repository HTML, or executable SVG.
+- Convert rkeys into invented sequential issue or pull numbers.
+
+## Risks and open questions
+
+- Bobbin and its lexicons are changing quickly. Package updates require a route
+ inventory review, generated-type compile check, and Hurl run.
+- Coverage describes Hydrant ingestion but cannot prove that knot membership is
+ fresh. Infrastructure views must disclose this limitation.
+- The public fixture will evolve. Shape-based smoke assertions and documented
+ fixture ownership reduce brittleness but do not remove it.
+- Base16 palettes do not guarantee accessible semantic combinations. Reviewed
+ bundled themes must pass contrast tests; imported themes need warnings and a
+ recovery path.
+- iOS distribution requires signing and Apple provisioning outside this
+ repository. The build can be verified before store publication is available.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..74ee715
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,458 @@
+# Twisted implementation tickets
+
+These tickets implement the complete [product specification](ROADMAP.md) and
+the [Bobbin API contract](docs/api.md). Work on one ticket per fresh agent
+context. A ticket is complete only when its acceptance criteria and listed
+checks pass.
+
+## Milestone 1: Foundations
+
+**Exit criterion:** The app has a typed, tested read boundary, stable shell,
+theme engine, and live API contract checks.
+
+### T01 - Replace starter tests with a reliable test baseline
+
+**What to build:** Remove placeholder expectations and establish shared Vitest
+and Cypress fixtures for route-level feature work.
+
+**Blocked by:** None - can start immediately
+
+**Acceptance criteria:**
+
+- [ ] Unit and component tests can mount Ionic route components without warnings.
+- [ ] Cypress starts against the Vite app and intercepts XRPC requests.
+- [ ] Starter copy and example fixtures are removed.
+
+**Verification:** `npm run test:unit -- --run && npm run lint`
+
+### T02 - Add the typed Bobbin client boundary
+
+**What to build:** Add atcute dependencies and one configurable XRPC boundary
+with runtime record validation, abort support, pagination, and typed errors.
+
+**Blocked by:** T01
+
+**Acceptance criteria:**
+
+- [ ] Tangled and Microcosm ambient declarations type every published query.
+- [ ] Embedded records are validated with generated atcute schemas before use.
+- [ ] Coverage and knot-proxy differences use narrow local overlays; views use no raw `fetch`.
+- [ ] Unit tests cover validation, malformed records, aborts, and XRPC error mapping.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T03 - Add live Bobbin smoke tests with Hurl
+
+**What to build:** Create a read-only Hurl suite using the approved
+`desertthunder.dev` fixture and document how to run it.
+
+**Blocked by:** None - can start immediately
+
+**Acceptance criteria:**
+
+- [ ] Tests cover coverage, identity, profile, repo lookup/listing, search, one aggregation, and one Git proxy query.
+- [ ] Assertions use stable identifiers and shapes, never CIDs, counts, branch names, or mutable prose.
+- [ ] Tests run sequentially with delay and bounded retry to respect the hosted rate limit.
+- [ ] `package.json` exposes a `test:smoke` script without installing Hurl as a JavaScript dependency.
+
+**Verification:** `npm run test:smoke`
+
+### T04 - Build the Base16 theme engine
+
+**What to build:** Map validated Base16 schemes to semantic Ionic and app CSS
+tokens, with bundled defaults, JSON import, selection, and persistence.
+
+**Blocked by:** T01
+
+**Acceptance criteria:**
+
+- [ ] Invalid or incomplete schemes cannot replace the active theme.
+- [ ] A reviewed default recovers from absent or corrupt persisted data.
+- [ ] Bundled light and dark schemes meet WCAG AA for application-owned controls.
+- [ ] Unit tests cover parsing, token mapping, persistence, and recovery.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T05 - Replace the starter screen with the application shell
+
+**What to build:** Add responsive navigation, route layouts, settings, back
+behavior, safe areas, and stable deep-link routing for every specified domain.
+
+**Blocked by:** T01, T04
+
+**Acceptance criteria:**
+
+- [ ] Desktop, narrow web, standalone PWA, and native-sized layouts remain usable.
+- [ ] Routes safely encode handles, DIDs, AT-URIs, refs, and paths.
+- [ ] Unknown routes and unsupported identifiers have useful recovery links.
+- [ ] Theme and API-service settings persist independently.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T06 - Add coverage, caching, and shared request states
+
+**What to build:** Add in-memory request deduplication, explicit stale times,
+cursor guards, coverage notices, and shared loading/error/empty components.
+
+**Blocked by:** T02, T05
+
+**Acceptance criteria:**
+
+- [ ] Route changes abort obsolete work and concurrent identical calls share one request.
+- [ ] `400`, `404`, `429`, `502`, `503`, offline, and malformed-data states remain distinct.
+- [ ] Retryable errors preserve existing content and respect `Retry-After` when present.
+- [ ] `ready: false` keeps partial indexed results visible with an explanation.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T07 - Render Markdown and record links safely
+
+**What to build:** Add sanitized Markdown, safe link handling, common record
+headers, identifier copying, and canonical Tangled links.
+
+**Blocked by:** T01
+
+**Acceptance criteria:**
+
+- [ ] Markdown uses Marked and DOMPurify with restricted URL schemes.
+- [ ] Scriptable HTML, SVG, styles, and unsafe links are removed in security tests.
+- [ ] Mentions, references, AT-URIs, DIDs, and known Tangled URLs link to local views.
+- [ ] Unknown record types remain inspectable without unsafe generic HTML.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+## Milestone 2: Complete read-only client
+
+**Exit criterion:** Every public, user-relevant Bobbin record and query family
+in the specification has a navigable read view.
+
+### T08 - Build discovery and filtered search
+
+**What to build:** Add identifier entry, mixed full-text results, every Bobbin
+search filter, coverage state, and cursor pagination.
+
+**Blocked by:** T06, T07
+
+**Acceptance criteria:**
+
+- [ ] Handle, DID, repo DID, AT-URI, and Tangled URL input routes correctly.
+- [ ] Search preserves relevance order and supports NSID, author, repo, and date filters.
+- [ ] Empty, invalid-filter, incomplete-index, and next-page states are tested.
+- [ ] Search result records are schema-validated before rendering.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T09 - Build complete profile pages
+
+**What to build:** Resolve handle or DID and render profile metadata, pinned
+repositories, owned repositories, links, and independently loading sections.
+
+**Blocked by:** T06, T07
+
+**Acceptance criteria:**
+
+- [ ] Bidirectionally resolved handle, DID, profile fields, avatar, and links render safely.
+- [ ] Pinned repo DIDs resolve without losing their configured order.
+- [ ] Repository pagination and per-section failures do not blank the profile.
+- [ ] The `desertthunder.dev` fixture works in a manual development check.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T10 - Build actor activity dashboards
+
+**What to build:** Expose every actor-oriented `*By` query as typed, paginated
+activity sections linked to their subject records.
+
+**Blocked by:** T09
+
+**Acceptance criteria:**
+
+- [ ] Comments, reactions, stars, issues, pulls, states, statuses, and ref updates are covered.
+- [ ] Collaborator, label, pipeline, artifact, knot, and spindle authorings are covered.
+- [ ] Filters supported by issue and pull `*By` endpoints are available.
+- [ ] One section's failure does not block other activity sections.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T11 - Build repository overview pages
+
+**What to build:** Render repository identity, topics, links, knot, spindle,
+languages, README, labels, counts, collaborators, and recent ref activity.
+
+**Blocked by:** T06, T07
+
+**Acceptance criteria:**
+
+- [ ] Repo record AT-URI and repo DID remain visibly distinct and copyable.
+- [ ] README Markdown is sanitized and absent README data has a compact empty state.
+- [ ] Overview sections load independently and link to their full views.
+- [ ] Canonical Tangled and clone/download actions are unambiguous and read-only.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T12 - Browse repository trees and blobs
+
+**What to build:** Add ref-aware directory navigation and safe text, binary,
+large-file, and missing-blob views.
+
+**Blocked by:** T11
+
+**Acceptance criteria:**
+
+- [ ] Directory navigation preserves ref and path in a shareable URL.
+- [ ] Text has line numbers, anchors, wrap controls, and a tested size limit.
+- [ ] Binary and oversized files offer download or canonical links without unsafe rendering.
+- [ ] Tree and blob upstream failures retain repository navigation.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T13 - Browse branches, tags, commits, and ref updates
+
+**What to build:** Add paginated refs and commit history, path-filtered logs,
+commit details, and ref-update records.
+
+**Blocked by:** T11
+
+**Acceptance criteria:**
+
+- [ ] Branch and tag selection updates source and history routes.
+- [ ] Commit cursors append once and preserve order.
+- [ ] Author, time, hash, message, and changed-ref links remain readable on narrow screens.
+- [ ] Empty repositories and missing refs have explicit states.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T14 - Render diffs, compares, and archive downloads
+
+**What to build:** Add single-ref diffs, two-revision comparison, readable patch
+output, and streamed archive downloads.
+
+**Blocked by:** T11
+
+**Acceptance criteria:**
+
+- [ ] Refs are encoded safely and comparison direction is clear.
+- [ ] Large diffs degrade to download or canonical links without locking the UI.
+- [ ] Archive bodies stream to download and are never buffered as app state.
+- [ ] Range, cache, filename, and content-type metadata survive the API boundary.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T15 - Read issue lists and details
+
+**What to build:** Add repo issue filters, pagination, detail pages, derived
+state, state history, labels, mentions, and references.
+
+**Blocked by:** T11
+
+**Acceptance criteria:**
+
+- [ ] State and author filters round-trip through the URL.
+- [ ] Lists use Bobbin's derived state and comment count.
+- [ ] Details use rkeys as identifiers and never invent sequential numbers.
+- [ ] State history names its author and timestamp when available.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T16 - Read pull lists and details
+
+**What to build:** Add repo pull filters, pagination, detail pages, derived
+status, status history, target/source data, and patch links.
+
+**Blocked by:** T11
+
+**Acceptance criteria:**
+
+- [ ] Open, closed, and merged filters round-trip through the URL.
+- [ ] Lists use Bobbin's derived status and comment count.
+- [ ] Pull rkeys remain the displayed identifiers.
+- [ ] Source, target, comparison, and status-author links resolve correctly.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T17 - Read comments and reactions
+
+**What to build:** Add paginated discussion and reaction views for issues,
+pulls, strings, and other valid subjects.
+
+**Blocked by:** T15, T16
+
+**Acceptance criteria:**
+
+- [ ] Canonical feed records and documented legacy issue/pull comment aliases render.
+- [ ] Comments use sanitized markup and link authors, mentions, and references.
+- [ ] Reaction summaries and full actor lists agree with count/list endpoints.
+- [ ] Independent pagination and failure handling work on each thread.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T18 - Read stars, follows, vouches, and collaborators
+
+**What to build:** Add relationship views for repo stars, actor follows and
+vouches, and repo collaborators, with links to both ends of each edge.
+
+**Blocked by:** T09, T11
+
+**Acceptance criteria:**
+
+- [ ] Inbound and actor-authored directions have distinct labels.
+- [ ] Counts, distinct-author counts, lists, and cursors render consistently.
+- [ ] Missing profiles or repos do not hide the relationship record.
+- [ ] Profile and repo summaries link to the full relationship views.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T19 - Read pipelines, statuses, and artifacts
+
+**What to build:** Add repo/spindle pipeline lists, pipeline details, status
+history, artifact metadata, and safe artifact downloads.
+
+**Blocked by:** T11
+
+**Acceptance criteria:**
+
+- [ ] Repo and spindle subject forms are validated and labeled.
+- [ ] Pipeline status history links actors and related records.
+- [ ] Artifact downloads preserve upstream content metadata and avoid buffering large bodies.
+- [ ] Empty, running, failed, unavailable, and incomplete-index states are covered.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T20 - Read labels and Tangled strings
+
+**What to build:** Add label definition/operation views and string detail,
+listing, comment, and reaction flows.
+
+**Blocked by:** T07, T11
+
+**Acceptance criteria:**
+
+- [ ] Label scope, definition, operation author, target, and history are inspectable.
+- [ ] Repo label links open their definition when available.
+- [ ] Strings render safely and expose their valid discussion subjects.
+- [ ] Arbitrary scope identifiers are encoded and validated at the API boundary.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+### T21 - Read knots, spindles, memberships, and public keys
+
+**What to build:** Add public infrastructure views for owned services, members,
+owners, versions, knot keys, actor public keys, and freshness limitations.
+
+**Blocked by:** T09, T11
+
+**Acceptance criteria:**
+
+- [ ] Knot and spindle lists and memberships support every documented direction.
+- [ ] Bobbin's extra knot proxy parameter uses a narrow typed overlay.
+- [ ] Owners, versions, service keys, and actor public keys have copyable identifiers.
+- [ ] Views disclose that Bobbin coverage does not prove knot-roster freshness.
+- [ ] No secret-list or management endpoint is called.
+
+**Verification:** `npm run test:unit -- --run && npm run build`
+
+## Milestone 3: Web and PWA release quality
+
+**Exit criterion:** The complete web client is installable, accessible,
+responsive, deterministic under test, and honest about offline or stale data.
+
+### T22 - Make Twisted an installable PWA
+
+**What to build:** Add manifest, install/update handling, icons, theme colors,
+and an offline application shell with explicit online-data states.
+
+**Blocked by:** T05
+
+**Acceptance criteria:**
+
+- [ ] Browser install checks pass with the final app identity and assets.
+- [ ] A previously loaded shell opens offline and identifies unavailable live data.
+- [ ] Service-worker updates never strand the app on mixed asset versions.
+- [ ] API payloads are not persisted by the service worker.
+
+**Verification:** `npm run build` and a browser PWA audit
+
+### T23 - Cover complete user journeys in Cypress
+
+**What to build:** Add intercepted end-to-end journeys for discovery, profiles,
+repos, source, collaboration, activity, infrastructure, settings, and failures.
+
+**Blocked by:** T08-T21
+
+**Acceptance criteria:**
+
+- [ ] Tests never depend on the live Bobbin service.
+- [ ] Fixtures cover success, partial, empty, invalid-record, offline, rate-limit, and upstream errors.
+- [ ] Theme persistence, deep links, pagination guards, and back navigation are covered.
+- [ ] At least one narrow viewport exercises every primary route family.
+
+**Verification:** Start `npm run dev`, then run `npm run test:e2e`
+
+### T24 - Complete accessibility and responsive review
+
+**What to build:** Audit every route family for keyboard, screen-reader,
+contrast, zoom, reduced motion, touch targets, safe areas, and long content.
+
+**Blocked by:** T08-T23
+
+**Acceptance criteria:**
+
+- [ ] All interactive elements have an accessible name, focus state, and logical order.
+- [ ] Status changes and appended pages are announced without stealing focus.
+- [ ] Reviewed themes meet WCAG 2.2 AA and imported low-contrast themes warn the user.
+- [ ] Layout works at 320 CSS pixels, 200% zoom, and common tablet/desktop widths.
+
+**Verification:** `npm run test:unit -- --run && npm run test:e2e` plus manual assistive-technology review
+
+## Milestone 4: Native apps
+
+**Exit criterion:** Android and iOS packages launch the complete client and pass
+platform smoke checks without product-specific forks.
+
+### T25 - Package and verify Android
+
+**What to build:** Add the Capacitor Android project, final identity/assets,
+safe-area/status-bar behavior, external links, downloads, and back navigation.
+
+**Blocked by:** T22, T24
+
+**Acceptance criteria:**
+
+- [ ] Debug build installs and launches on a supported emulator or device.
+- [ ] Search, profile, repo, source, issue/pull, theme, and offline-shell smoke checks pass.
+- [ ] External URLs and streamed downloads use an explicit safe platform flow.
+- [ ] Native code contains no API secrets or feature fork.
+
+**Verification:** `npm run build && npx cap sync android` plus the documented Android debug build
+
+### T26 - Package and verify iOS
+
+**What to build:** Add the Capacitor iOS project with the same product behavior,
+final assets, safe areas, status bar, external links, and downloads.
+
+**Blocked by:** T25
+
+**Acceptance criteria:**
+
+- [ ] Simulator build launches on a supported iOS runtime.
+- [ ] The Android smoke flows pass on iOS without platform-specific feature gaps.
+- [ ] Rotation, dynamic type, safe areas, external links, and downloads behave correctly.
+- [ ] Signing and provisioning requirements are documented without committing credentials.
+
+**Verification:** `npm run build && npx cap sync ios` plus the documented iOS simulator build
+
+### T27 - Run the release verification matrix
+
+**What to build:** Verify the complete contract and user experience across web,
+PWA, Android, and iOS and resolve release-blocking regressions.
+
+**Blocked by:** T03, T23, T24, T25, T26
+
+**Acceptance criteria:**
+
+- [ ] Lint, unit/component, build, Cypress, and Hurl checks pass.
+- [ ] A maintainer reviews live-test failures for fixture drift or upstream outage before classifying them.
+- [ ] Manual checks cover installation, update, offline shell, theme recovery, downloads, and canonical links.
+- [ ] Documentation matches the shipped routes, commands, API exceptions, and platform support.
+
+**Verification:** Run every command in `ROADMAP.md` and the documented native build commands
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..b6d07a1
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,321 @@
+# Tangled API
+
+Twisted reads public Tangled data from Bobbin, Tangled's read-only XRPC
+AppView. The hosted service is:
+
+```text
+https://api.tangled.org
+```
+
+Every XRPC method lives at `/xrpc/{nsid}` and uses `GET`. Bobbin does not
+require authentication. The hosted service allows cross-origin `GET`, `HEAD`,
+and `OPTIONS` requests, so Twisted can call it from a browser or Capacitor
+WebView.
+
+This document reflects Bobbin and `@atcute/tangled` as checked on 2026-08-01.
+Bobbin is new and its contracts may change. Keep API calls behind one project
+module so an upstream change does not leak into views.
+
+## Sources of truth
+
+Use these sources in this order:
+
+1. [`@atcute/tangled` lexicons](https://github.com/mary-ext/atcute/tree/trunk/packages/definitions/tangled/lexicons/sh/tangled)
+ define request parameters and response types. The package was at version
+ `2.0.13` and atcute commit
+ [`5474d2d`](https://github.com/mary-ext/atcute/commit/5474d2d9d502c4f52c8c13162fac8c2cd8613ec6)
+ during this review.
+2. [Bobbin's router](https://tangled.org/tangled.org/core/blob/master/bobbin/crates/xrpc/src/lib.rs)
+ determines which lexicons the service implements.
+3. [Tangled's Bobbin documentation](https://docs.tangled.org/bobbin) explains
+ runtime behavior, warm-up, and upstream failures. The
+ [announcement](https://blog.tangled.org/bobbin/) gives the architectural
+ background.
+
+The definition package contains Tangled queries and procedures that Bobbin does
+not serve. A generated atcute type proves that a lexicon exists; it does not
+prove that `api.tangled.org` implements it. In particular, Twisted must not call
+the `sh.tangled.ci.*`, `sh.tangled.sync.*`, or `sh.tangled.git.temp.*` methods
+through Bobbin.
+
+## Typed client
+
+Use `@atcute/client` for XRPC requests and register `@atcute/tangled` for its
+ambient query declarations:
+
+```ts
+import { Client, simpleFetchHandler } from "@atcute/client";
+import type {} from "@atcute/microcosm";
+import type {} from "@atcute/tangled";
+
+export const tangled = new Client({
+ handler: simpleFetchHandler({ service: "https://api.tangled.org" }),
+});
+
+const response = await tangled.get("sh.tangled.search.query", {
+ params: { q: "tangled", limit: 20 },
+});
+
+if (!response.ok) {
+ throw new Error(response.data.message ?? response.data.error);
+}
+
+const { hits, cursor } = response.data;
+```
+
+The equivalent project-wide registration is
+`/// ` in a declaration file. Do not copy
+generated interfaces into Twisted. Import a named record type from
+`@atcute/tangled` when a view or mapper needs one.
+
+Bobbin's record views embed `value` as `unknown`. Narrow it with the generated
+record schema before a view uses it:
+
+```ts
+import { is } from "@atcute/lexicons";
+import { ShTangledRepo } from "@atcute/tangled";
+
+if (!is(ShTangledRepo.mainSchema, response.data.value)) {
+ throw new Error("Bobbin returned an invalid repository record");
+}
+
+const repo: ShTangledRepo.Main = response.data.value;
+```
+
+`@atcute/tangled` does not yet define `sh.tangled.bobbin.getCoverage`. Keep the
+following local exception beside the API client until upstream publishes that
+lexicon:
+
+```ts
+export interface BobbinCoverage {
+ ready: boolean;
+ eventsProcessed: number;
+ lastCursor: number;
+}
+```
+
+Bobbin also exposes `com.bad-example.identity.resolveMiniDoc` for resolving a
+handle or DID before constructing a Tangled profile AT-URI. Register
+`@atcute/microcosm` to type this method; do not create another local identity
+type.
+
+## Identifiers
+
+Bobbin uses three identifiers that should remain distinct in application code:
+
+- An actor DID, such as `did:plc:boltless`, identifies an account.
+- A repo DID identifies a git repository as minted by its knot. It is not the
+ repository record's author DID.
+- A record AT-URI includes the author DID, collection, and record key, such as
+ `at://did:plc:boltless/sh.tangled.repo/squid`.
+
+Single-record queries require a complete AT-URI. A handle or bare DID will not
+work in `actor.getProfile`, `repo.getRepo`, `repo.getIssue`, or `repo.getPull`.
+Git proxy calls also receive the repository record AT-URI from Twisted; Bobbin
+resolves its knot and rewrites the parameter for the knot API.
+
+## Common response shapes
+
+Single-record methods return a record view:
+
+```ts
+interface RecordView {
+ uri: string;
+ cid?: string;
+ value: T;
+}
+```
+
+Batch lookups return `{ items: RecordView[] }` and accept at most 50 AT-URIs.
+List methods return `{ items: RecordView[]; cursor?: string }`. Pass the
+returned opaque cursor unchanged to fetch the next page. Most lists accept:
+
+| Parameter | Meaning |
+| --------- | ----------------------------------------------------------- |
+| `subject` | Required DID, AT-URI, or service identifier described below |
+| `limit` | `1..1000`; defaults to `50` |
+| `order` | `asc` or `desc` by `createdAt`; defaults to `desc` |
+| `cursor` | Opaque cursor from the previous response |
+
+Count methods return `{ count: number; distinctAuthors: number }`. Counts and
+lists can be lower bounds while Bobbin warms its in-memory index.
+
+An endpoint ending in `By` reverses the lookup. For example,
+`listComments?subject={issue-uri}` lists comments on an issue, while
+`listCommentsBy?subject={actor-did}` lists comments written by an actor.
+
+## Record lookups
+
+| NSID | Required parameter | Result |
+| ---------------------------------- | --------------------------------------- | ---------------- |
+| `sh.tangled.actor.getProfile` | `actor`: profile record AT-URI | One profile |
+| `sh.tangled.actor.getProfiles` | `actors`: up to 50 profile AT-URIs | Profiles |
+| `sh.tangled.repo.getRepo` | `repo`: repo record AT-URI | One repository |
+| `sh.tangled.repo.getRepos` | `repos`: up to 50 repo record AT-URIs | Repositories |
+| `sh.tangled.repo.getRepoByRepoDid` | `repoDid`: repo DID | One repository |
+| `sh.tangled.repo.getIssue` | `issue`: issue record AT-URI | One issue |
+| `sh.tangled.repo.getIssues` | `issues`: up to 50 issue record AT-URIs | Issues |
+| `sh.tangled.repo.getPull` | `pull`: pull record AT-URI | One pull request |
+| `sh.tangled.repo.getPulls` | `pulls`: up to 50 pull record AT-URIs | Pull requests |
+
+Issue and pull views include Bobbin's derived current state or status. Use those
+fields for list badges and detail headers rather than deriving the latest value
+from a separately fetched history.
+
+## Indexed lists and counts
+
+Each row names a record family, the `subject` required by its non-`By` methods,
+and its implemented methods. Add `By` to each listed `list*` or `count*` method
+when an actor-DID reverse lookup is available.
+
+| Family | Non-`By` subject | Methods | Actor reverse lookup |
+| ----------------- | ----------------------------- | --------------------------------------------------- | -------------------- |
+| Repositories | owner DID | `repo.listRepos`, `repo.countRepos` | No |
+| Issues | repo DID | `repo.listIssues`, `repo.countIssues` | Yes |
+| Pull requests | repo DID | `repo.listPulls`, `repo.countPulls` | Yes |
+| Comments | issue, pull, or string AT-URI | `feed.listComments`, `feed.countComments` | Yes |
+| Reactions | target record AT-URI | `feed.listReactions`, `feed.countReactions` | Yes |
+| Stars | repo DID | `feed.listStars`, `feed.countStars` | Yes |
+| Ref updates | repo DID | `git.listRefUpdates`, `git.countRefUpdates` | Yes |
+| Followers | followed actor DID | `graph.listFollows`, `graph.countFollows` | Yes |
+| Vouches | vouched-for actor DID | `graph.listVouches`, `graph.countVouches` | Yes |
+| Collaborators | repo DID | `repo.listCollaborators`, `repo.countCollaborators` | Yes |
+| Issue states | issue AT-URI | `repo.issue.listStates`, `repo.issue.countStates` | Yes |
+| Pull statuses | pull AT-URI | `repo.pull.listStatuses`, `repo.pull.countStatuses` | Yes |
+| Artifacts | repo or release identifier | `repo.listArtifacts`, `repo.countArtifacts` | Yes |
+| Pipelines | repo or spindle identifier | `pipeline.listPipelines`, `pipeline.countPipelines` | Yes |
+| Pipeline statuses | pipeline AT-URI | `pipeline.listStatuses`, `pipeline.countStatuses` | Yes |
+| Knots | owner DID | `knot.listKnots`, `knot.countKnots` | No |
+| Knot members | knot identifier | `knot.listMembers`, `knot.countMembers` | Yes |
+| Spindles | owner DID | `spindle.listSpindles`, `spindle.countSpindles` | No |
+| Spindle members | spindle identifier | `spindle.listMembers`, `spindle.countMembers` | Yes |
+| Public keys | owner DID | `publicKey.listKeys`, `publicKey.countKeys` | No |
+| Label definitions | scope identifier | `label.listDefinitions`, `label.countDefinitions` | No |
+| Label operations | scope identifier | `label.listOps`, `label.countOps` | Yes |
+| Strings | scope identifier | `string.listStrings`, `string.countStrings` | No |
+
+`repo.listIssues` accepts optional `author` and `state` filters.
+`repo.listPulls` accepts optional `author` and `status` filters. Their `By`
+counterparts accept the state or status filter but already use the subject as
+the author.
+
+## Search
+
+`sh.tangled.search.query` searches Bobbin's in-memory full-text index.
+
+| Parameter | Requirement |
+| ---------------- | ------------------------------------------------------------- |
+| `q` | Required non-empty search text |
+| `limit` | Optional `1..1000`, default `50` |
+| `cursor` | Optional opaque pagination cursor |
+| `nsid` | Optional record-collection filter |
+| `author` | Optional author DID |
+| `repo` | Optional repo DID |
+| `since`, `until` | Optional RFC 3339 timestamps; `since` must not exceed `until` |
+
+The response is `{ hits, cursor? }`. Each hit contains `uri`, optional `cid`,
+`nsid`, numeric relevance `score`, and the embedded `value`. Results are ordered
+by descending relevance.
+
+## Git data proxied to knots
+
+Bobbin resolves the repo record, finds its knot, and streams these calls from
+that knot. It does not cache the response. For Twisted, `repo` is the repo
+record AT-URI.
+
+| NSID | Required parameters | Optional parameters |
+| ---------------------------------- | ---------------------- | ----------------------------------------- |
+| `sh.tangled.repo.getDefaultBranch` | `repo` | None |
+| `sh.tangled.repo.branch` | `repo`, `name` | None |
+| `sh.tangled.repo.branches` | `repo` | `cursor`, `limit` (`1..100`) |
+| `sh.tangled.repo.tag` | `repo`, `tag` | None |
+| `sh.tangled.repo.tags` | `repo` | `cursor`, `limit` (`1..100`) |
+| `sh.tangled.repo.tree` | `repo`, `ref` | `path` |
+| `sh.tangled.repo.blob` | `repo`, `ref`, `path` | `raw` |
+| `sh.tangled.repo.log` | `repo`, `ref` | `path`, `cursor`, `limit` (`1..100`) |
+| `sh.tangled.repo.diff` | `repo`, `ref` | None |
+| `sh.tangled.repo.compare` | `repo`, `rev1`, `rev2` | None |
+| `sh.tangled.repo.languages` | `repo` | `ref` (defaults to `HEAD`) |
+| `sh.tangled.repo.archive` | `repo`, `ref` | `format` (defaults to `tar.gz`), `prefix` |
+
+Several git endpoints declare `*/*` output. Call atcute with `as: 'blob'`,
+`as: 'bytes'`, or `as: 'stream'` instead of relying on JSON parsing. Bobbin
+forwards range and cache validators and preserves response metadata including
+`Content-Type`, `ETag`, `Last-Modified`, and `Content-Range`.
+
+Bobbin also proxies `sh.tangled.repo.listSecrets`, but Twisted has no user-facing
+need for secret configuration and must not call it.
+
+Bobbin has a compatibility route for `sh.tangled.repo.describeRepo`, but its
+Bobbin parameter differs from the current atcute lexicon. Twisted must use the
+typed record lookup methods instead.
+
+The service-level `sh.tangled.owner`, `sh.tangled.knot.version`, and
+`sh.tangled.knot.listKeys` methods require an extra `knot={host}` parameter when
+called through Bobbin. They are useful for diagnostics rather than the first
+client release.
+
+## Coverage and stale data
+
+Call `sh.tangled.bobbin.getCoverage` without parameters:
+
+```json
+{
+ "ready": true,
+ "eventsProcessed": 137863,
+ "lastCursor": 162959
+}
+```
+
+`ready: false` means Bobbin is still rebuilding its index. Single-record
+lookups can remain accurate because Bobbin asks Slingshot for them, but indexed
+lists, counts, and search may be incomplete. Twisted should show a non-blocking
+"data is still loading" notice and retain any returned results.
+
+Coverage only describes Hydrant ingestion. It does not report whether a knot is
+reachable or whether Bobbin's cached knot membership is current.
+
+## Public smoke-test fixture
+
+The project owner has approved their public Tangled data as a smoke-test and
+development fixture:
+
+| Field | Value |
+| ------------------- | --------------------------------------------------------------------- |
+| Handle | `desertthunder.dev` |
+| Actor DID | `did:plc:xg2vq45muivyy3xwatcehspu` |
+| Profile AT-URI | `at://did:plc:xg2vq45muivyy3xwatcehspu/sh.tangled.actor.profile/self` |
+| Twisted repo AT-URI | `at://did:plc:xg2vq45muivyy3xwatcehspu/sh.tangled.repo/3mho6hukiei22` |
+| Twisted repo DID | `did:plc:4iw5fospv2asv3344au236ka` |
+
+Smoke tests may assert stable identity and record fields such as handle, DID,
+AT-URI, collection, and `$type`. Do not pin CIDs, result counts, descriptions,
+branch names, issue lists, or other mutable content. Run live tests in sequence
+with a short delay because the hosted edge applies a pre-authentication rate
+limit.
+
+## Errors
+
+XRPC errors use this JSON shape:
+
+```ts
+interface XrpcErrorBody {
+ error: string;
+ message?: string;
+}
+```
+
+Handle these classes in the client boundary:
+
+| Status | Typical meaning | UI behavior |
+| ----------- | ------------------------------------------------------------------------ | ------------------------------------------------------ |
+| `400` | Missing, malformed, or wrong-shaped identifier; invalid filter or cursor | Show a specific invalid-link or invalid-search message |
+| `404` | Record or route was not found | Show the resource not-found state |
+| `429` | Hosted edge rate limit | Honor `Retry-After` when present and offer retry |
+| `502` | Slingshot or knot failed, disappeared, or returned invalid data | Show an upstream-unavailable state with retry |
+| `503` | Bobbin shed the request under memory pressure | Offer retry; keep cached UI data |
+| Other `5xx` | Bobbin failure | Show a generic service error and preserve navigation |
+
+Treat aborts as canceled navigation, not user-visible failures. Never retry
+`400` or `404` automatically. Use bounded retries with backoff for `429`, `502`,
+and `503`; honor `Retry-After` when present.