diff --git a/README.md b/README.md
index fd85322..2d8ad4f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Twisted
-Twisted is a read-only client for public data on [Tangled](https://tangled.org).
+Twisted is a Tangled client for browsing public projects, profiles, and conversations.
It is built with Ionic Vue and is intended to run as a website, installable PWA, and Capacitor app for Android and iOS.
## Development
diff --git a/ROADMAP.md b/ROADMAP.md
index 67592b0..07eaa77 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -51,15 +51,17 @@ the complete web/PWA experience; it does not reduce the product scope.
The repository is an Ionic Vue app using Vue 3, TypeScript, Vite 5, Vue Router,
Ionic 8, Capacitor 8, Vitest, Cypress, and ESLint. It has a typed Bobbin read
boundary, shared unit and browser-test fixtures, live Hurl contract checks, a
-Base16 theme engine, and a responsive application shell with persistent desktop
-navigation and mobile tabs. The Bobbin boundary deduplicates concurrent reads
-with explicit stale times, and shared route state handles cancellation, retries,
-incomplete coverage, and distinct failures. Shared content components compile
-plain Markdown with Satteri, sanitize it with DOMPurify, route supported record
-identifiers locally, and keep unknown validated records inspectable as text.
-Every planned domain has a stable deep-link route; later feature tickets replace
-the shell's route notices with live data. Capacitor has a starter app identifier
-and no checked-in Android or iOS project.
+Base16 theme engine, and responsive desktop and mobile navigation. Search now
+accepts public links and identifiers, exposes every Bobbin filter, preserves
+result order across cursor pages, and validates every result before display.
+Profile pages resolve handles and DIDs, show safe profile details and links,
+retain the configured order of pinned repositories, and paginate owned
+repositories without coupling section failures. The public landing page and
+shared messages speak to readers rather than exposing implementation details.
+Shared content components sanitize Markdown and route supported record links to
+local views. Every planned domain has a stable deep link; later tickets replace
+the remaining compact availability notices with complete views. 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`.
diff --git a/TODO.md b/TODO.md
index fe5cad2..d8582e9 100644
--- a/TODO.md
+++ b/TODO.md
@@ -143,6 +143,8 @@ in the specification has a navigable read view.
### T08 - Build discovery and filtered search
+**Status:** Complete
+
**What to build:** Add identifier entry, mixed full-text results, every Bobbin
search filter, coverage state, and cursor pagination.
@@ -150,15 +152,17 @@ search filter, coverage state, and cursor pagination.
**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.
+- [x] Handle, DID, repo DID, AT-URI, and Tangled URL input routes correctly.
+- [x] Search preserves relevance order and supports NSID, author, repo, and date filters.
+- [x] Empty, invalid-filter, incomplete-index, and next-page states are tested.
+- [x] Search result records are schema-validated before rendering.
**Verification:** `bun run test:unit --run && bun run build`
### T09 - Build complete profile pages
+**Status:** Complete
+
**What to build:** Resolve handle or DID and render profile metadata, pinned
repositories, owned repositories, links, and independently loading sections.
@@ -166,10 +170,10 @@ repositories, owned repositories, links, and independently loading sections.
**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.
+- [x] Bidirectionally resolved handle, DID, profile fields, avatar, and links render safely.
+- [x] Pinned repo DIDs resolve without losing their configured order.
+- [x] Repository pagination and per-section failures do not blank the profile.
+- [x] The `desertthunder.dev` fixture works in a live development check.
**Verification:** `bun run test:unit --run && bun run build`
diff --git a/index.html b/index.html
index d12b71b..e1884bd 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,8 @@
- Twisted
+ Twisted — Browse public work on Tangled
+
diff --git a/package.json b/package.json
index 763a106..dfd3013 100644
--- a/package.json
+++ b/package.json
@@ -56,5 +56,5 @@
"vitest": "^0.34.6",
"vue-tsc": "^2.1.10"
},
- "description": "A read-only Tangled client"
+ "description": "A Tangled client for browsing public work"
}
diff --git a/src/api/client-provider.ts b/src/api/client-provider.ts
new file mode 100644
index 0000000..9fa27a3
--- /dev/null
+++ b/src/api/client-provider.ts
@@ -0,0 +1,26 @@
+import type { InjectionKey } from 'vue'
+import { inject } from 'vue'
+import { useBobbinService } from '@/settings/service'
+import { createBobbinClient } from './client'
+import type { BobbinClient } from './client'
+
+export type BobbinClientProvider = () => BobbinClient
+
+export const BOBBIN_CLIENT_PROVIDER: InjectionKey = Symbol('BobbinClientProvider')
+
+let activeService = ''
+let activeClient: BobbinClient | undefined
+
+function defaultProvider(): BobbinClient {
+ const service = useBobbinService().service.value
+ if (activeClient === undefined || service !== activeService) {
+ activeService = service
+ activeClient = createBobbinClient({ service })
+ }
+ return activeClient
+}
+
+/** Returns the injected client provider, or the application provider in production. */
+export function useBobbinClientProvider(): BobbinClientProvider {
+ return inject(BOBBIN_CLIENT_PROVIDER, defaultProvider)
+}
diff --git a/src/api/client.ts b/src/api/client.ts
index 4dcb574..c796e2a 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -7,6 +7,8 @@ import {
ShTangledActorGetProfile,
ShTangledActorProfile,
ShTangledRepo,
+ ShTangledRepoGetRepoByRepoDid,
+ ShTangledRepoGetRepos,
ShTangledRepoGetRepo,
ShTangledRepoListRepos,
ShTangledSearchQuery,
@@ -140,6 +142,34 @@ export class BobbinClient {
return validateRecordView(view, ShTangledRepo.mainSchema, 'repository')
}
+ /** Fetches and validates one repository by its repository DID. */
+ async getRepoByRepoDid(
+ repoDid: ShTangledRepoGetRepoByRepoDid.$params['repoDid'],
+ options: RequestOptions = {},
+ ): Promise> {
+ const view = await this.#cached(
+ 'sh.tangled.repo.getRepoByRepoDid',
+ { repoDid },
+ options,
+ STALE_TIMES.record,
+ (signal) => this.#rpc.call(ShTangledRepoGetRepoByRepoDid, { params: { repoDid }, signal }),
+ )
+
+ return validateRecordView(view, ShTangledRepo.mainSchema, 'repository')
+ }
+
+ /** Fetches and validates repository records in one ordered batch. */
+ async getRepos(
+ repos: ShTangledRepoGetRepos.$params['repos'],
+ options: RequestOptions = {},
+ ): Promise[]> {
+ const data = await this.#cached('sh.tangled.repo.getRepos', { repos }, options, STALE_TIMES.record, (signal) =>
+ this.#rpc.call(ShTangledRepoGetRepos, { params: { repos }, signal }),
+ )
+
+ return data.items.map((item) => validateRecordView(item, ShTangledRepo.mainSchema, 'repository'))
+ }
+
/** Lists an actor's repositories and validates every embedded record. */
async listRepos(
subject: ShTangledRepoListRepos.$params['subject'],
@@ -268,10 +298,10 @@ export function validateEmbeddedRecord(
export function normalizeBobbinService(service: string | URL): string {
const url = new URL(service)
if (url.protocol !== 'https:') {
- throw new TypeError('Bobbin services must use HTTPS')
+ throw new TypeError('Data source addresses must use HTTPS')
}
if (url.username || url.password || url.search || url.hash) {
- throw new TypeError('Bobbin service URLs cannot contain credentials, a query, or a fragment')
+ throw new TypeError('Data source addresses cannot contain credentials, a query, or a page fragment')
}
return url.toString().replace(/\/$/, '')
diff --git a/src/api/index.ts b/src/api/index.ts
index 2c76da8..874d969 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -1,4 +1,5 @@
export * from './client'
+export * from './client-provider'
export * from './cache'
export * from './contracts'
export * from './errors'
diff --git a/src/components/AppMenu.vue b/src/components/AppMenu.vue
index adecc66..896524e 100644
--- a/src/components/AppMenu.vue
+++ b/src/components/AppMenu.vue
@@ -1,13 +1,16 @@
-
+ {{ repository.uri }}
+
+
+
+
+
+
diff --git a/src/requests/presentation.ts b/src/requests/presentation.ts
index 8713a2f..cf35f62 100644
--- a/src/requests/presentation.ts
+++ b/src/requests/presentation.ts
@@ -14,34 +14,34 @@ const presentations: Record = {
retryable: false,
},
'malformed-response': {
- title: 'Bobbin returned unexpected data',
- message: 'Twisted stopped before displaying data it could not validate.',
+ title: 'This content could not be displayed',
+ message: 'The response was incomplete or in an unexpected format.',
retryable: false,
},
network: {
- title: 'Bobbin could not be reached',
- message: 'Check the connection or configured service, then try again.',
+ title: 'Tangled could not be reached',
+ message: 'Check your connection, then try again.',
retryable: true,
},
offline: { title: 'You are offline', message: 'Reconnect to load public Tangled data.', retryable: true },
'not-found': {
title: 'Record not found',
- message: 'It may have moved, been deleted, or not reached this Bobbin service yet.',
+ message: 'It may have moved, been deleted, or may not be available yet.',
retryable: false,
},
'rate-limited': {
- title: 'Bobbin asked us to slow down',
- message: 'Existing results remain available. Retry after the waiting period.',
+ title: 'Too many requests',
+ message: 'Your current results are safe. Try again after the waiting period.',
retryable: true,
},
'service-unavailable': {
- title: 'Bobbin is unavailable',
- message: 'The service cannot handle this request right now.',
+ title: 'Tangled is unavailable',
+ message: 'Public content cannot be loaded right now.',
retryable: true,
},
'upstream-unavailable': {
- title: 'The upstream service is unavailable',
- message: 'Bobbin is reachable, but a service it depends on did not respond.',
+ title: 'Part of Tangled is unavailable',
+ message: 'This content cannot be loaded right now.',
retryable: true,
},
}
diff --git a/src/router/index.ts b/src/router/index.ts
index e9a2072..68ae197 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -11,13 +11,13 @@ const routes: RouteRecordRaw[] = [
{
path: '/profiles',
name: 'profiles',
- component: domainPage,
+ component: () => import('../views/ProfilesPage.vue'),
meta: { title: 'People', section: 'Profiles', description: 'Find a Tangled profile by handle or DID.' },
},
{
path: '/profiles/:actor',
name: 'profile',
- component: domainPage,
+ component: () => import('../views/ProfilePage.vue'),
meta: {
title: 'Profile',
section: 'People',
diff --git a/src/views/DomainPage.vue b/src/views/DomainPage.vue
index 55d9a94..af3a687 100644
--- a/src/views/DomainPage.vue
+++ b/src/views/DomainPage.vue
@@ -25,11 +25,8 @@
- {{ identifier ? 'Deep link recognized' : 'Destination ready' }}
-
- This stable route is part of the application shell. Its live Bobbin data view is implemented in the
- corresponding feature ticket.
-
+ This view is on its way
+
You can use search to find related public profiles, projects, and conversations in the meantime.