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 @@ @@ -85,9 +88,11 @@ function isActive(path: string): boolean { align-items: center; gap: var(--space-3); padding: calc(var(--safe-top) + var(--space-6)) var(--space-5) var(--space-6); + color: var(--app-text); + text-decoration: none; } -.app-menu__brand > div:last-child { +.app-menu__brand-copy { display: grid; gap: 0.125rem; } @@ -97,14 +102,16 @@ function isActive(path: string): boolean { font-size: var(--text-xl); } -.app-menu__brand span { +.app-menu__brand small { color: var(--app-text-muted); font-size: var(--text-xs); } .app-menu__mark { + position: relative; display: grid; place-items: center; + flex: 0 0 auto; inline-size: 2.5rem; block-size: 2.5rem; border: 1px solid var(--app-accent); @@ -116,6 +123,37 @@ function isActive(path: string): boolean { transform: rotate(-3deg); } +.app-menu__mark img, +.app-menu__mark > span { + position: absolute; + transition: + opacity 150ms ease-out, + transform 150ms ease-out; +} + +.app-menu__mark img { + inline-size: 1.85rem; + block-size: 1.85rem; + object-fit: contain; +} + +.app-menu__mark > span { + opacity: 0; + transform: scale(0.8); +} + +.app-menu__brand:hover .app-menu__mark img, +.app-menu__brand:focus-visible .app-menu__mark img { + opacity: 0; + transform: scale(0.8); +} + +.app-menu__brand:hover .app-menu__mark > span, +.app-menu__brand:focus-visible .app-menu__mark > span { + opacity: 1; + transform: scale(1); +} + .app-menu__group { padding-inline: var(--space-3); } diff --git a/src/components/CoverageNotice.vue b/src/components/CoverageNotice.vue index d411ca0..2f977d4 100644 --- a/src/components/CoverageNotice.vue +++ b/src/components/CoverageNotice.vue @@ -2,8 +2,8 @@ diff --git a/src/content/links.ts b/src/content/links.ts index e8f8576..752f525 100644 --- a/src/content/links.ts +++ b/src/content/links.ts @@ -25,7 +25,8 @@ export function localRecordLink(value: string, context: RecordLinkContext = {}): const atUri = parseAtUri(value) if (atUri) { if (atUri.collection === 'sh.tangled.repo') return links.repository(value) - if (atUri.collection === 'sh.tangled.feed.string') return links.string(value) + if (atUri.collection === 'sh.tangled.string' || atUri.collection === 'sh.tangled.feed.string') + return links.string(value) return links.search(value) } diff --git a/src/features/discovery/identifiers.ts b/src/features/discovery/identifiers.ts new file mode 100644 index 0000000..135d27e --- /dev/null +++ b/src/features/discovery/identifiers.ts @@ -0,0 +1,41 @@ +import { parseAtUri } from '@/content/links' + +export type IdentifierInput = + | { kind: 'actor'; actor: string } + | { kind: 'at-uri'; uri: string } + | { kind: 'did'; did: string } + | { kind: 'repo-url'; owner: string; repo: string } + | { kind: 'search' } + +const handlePattern = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i + +/** Classifies direct identifiers before full-text search is attempted. */ +export function classifyIdentifier(input: string): IdentifierInput { + const value = input.trim() + const atUri = parseAtUri(value) + if (atUri) { + if (atUri.collection === 'sh.tangled.actor.profile') return { kind: 'actor', actor: atUri.authority } + return { kind: 'at-uri', uri: value } + } + if (/^did:[a-z0-9]+:[^\s/]+$/i.test(value)) return { kind: 'did', did: value } + const handle = value.startsWith('@') ? value.slice(1) : value + if (handlePattern.test(handle)) return { kind: 'actor', actor: handle } + + try { + const url = new URL(value) + if (url.protocol !== 'https:' || !['tangled.org', 'www.tangled.org'].includes(url.hostname.toLowerCase())) { + return { kind: 'search' } + } + const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent) + if (parts.length === 1 && parts[0].startsWith('@') && handlePattern.test(parts[0].slice(1))) { + return { kind: 'actor', actor: parts[0].slice(1) } + } + if (parts.length >= 2 && !parts[0].includes(':')) { + return { kind: 'repo-url', owner: parts[0].replace(/^@/, ''), repo: parts[1] } + } + } catch { + // Ordinary search text is expected to fail URL parsing. + } + + return { kind: 'search' } +} diff --git a/src/features/discovery/records.ts b/src/features/discovery/records.ts new file mode 100644 index 0000000..786ea6b --- /dev/null +++ b/src/features/discovery/records.ts @@ -0,0 +1,105 @@ +import { + ShTangledActorProfile, + ShTangledFeedComment, + ShTangledFeedReaction, + ShTangledFeedStar, + ShTangledGitRefUpdate, + ShTangledGraphFollow, + ShTangledGraphVouch, + ShTangledKnot, + ShTangledKnotMember, + ShTangledLabelDefinition, + ShTangledLabelOp, + ShTangledPipeline, + ShTangledPipelineStatus, + ShTangledPublicKey, + ShTangledRepo, + ShTangledRepoArtifact, + ShTangledRepoCollaborator, + ShTangledRepoIssue, + ShTangledRepoIssueComment, + ShTangledRepoIssueState, + ShTangledRepoPull, + ShTangledRepoPullComment, + ShTangledRepoPullStatus, + ShTangledSpindle, + ShTangledSpindleMember, + ShTangledString, +} from '@atcute/tangled' + +/** Every Tangled record collection that can appear in mixed search results. */ +export const searchableRecordSchemas = { + 'sh.tangled.actor.profile': ShTangledActorProfile.mainSchema, + 'sh.tangled.feed.comment': ShTangledFeedComment.mainSchema, + 'sh.tangled.feed.reaction': ShTangledFeedReaction.mainSchema, + 'sh.tangled.feed.star': ShTangledFeedStar.mainSchema, + 'sh.tangled.git.refUpdate': ShTangledGitRefUpdate.mainSchema, + 'sh.tangled.graph.follow': ShTangledGraphFollow.mainSchema, + 'sh.tangled.graph.vouch': ShTangledGraphVouch.mainSchema, + 'sh.tangled.knot': ShTangledKnot.mainSchema, + 'sh.tangled.knot.member': ShTangledKnotMember.mainSchema, + 'sh.tangled.label.definition': ShTangledLabelDefinition.mainSchema, + 'sh.tangled.label.op': ShTangledLabelOp.mainSchema, + 'sh.tangled.pipeline': ShTangledPipeline.mainSchema, + 'sh.tangled.pipeline.status': ShTangledPipelineStatus.mainSchema, + 'sh.tangled.publicKey': ShTangledPublicKey.mainSchema, + 'sh.tangled.repo': ShTangledRepo.mainSchema, + 'sh.tangled.repo.artifact': ShTangledRepoArtifact.mainSchema, + 'sh.tangled.repo.collaborator': ShTangledRepoCollaborator.mainSchema, + 'sh.tangled.repo.issue': ShTangledRepoIssue.mainSchema, + 'sh.tangled.repo.issue.comment': ShTangledRepoIssueComment.mainSchema, + 'sh.tangled.repo.issue.state': ShTangledRepoIssueState.mainSchema, + 'sh.tangled.repo.pull': ShTangledRepoPull.mainSchema, + 'sh.tangled.repo.pull.comment': ShTangledRepoPullComment.mainSchema, + 'sh.tangled.repo.pull.status': ShTangledRepoPullStatus.mainSchema, + 'sh.tangled.spindle': ShTangledSpindle.mainSchema, + 'sh.tangled.spindle.member': ShTangledSpindleMember.mainSchema, + 'sh.tangled.string': ShTangledString.mainSchema, +} as const + +const collectionNames: Record = { + 'sh.tangled.actor.profile': 'Profile', + 'sh.tangled.feed.comment': 'Comment', + 'sh.tangled.feed.reaction': 'Reaction', + 'sh.tangled.feed.star': 'Star', + 'sh.tangled.git.refUpdate': 'Repository update', + 'sh.tangled.graph.follow': 'Follow', + 'sh.tangled.graph.vouch': 'Vouch', + 'sh.tangled.knot': 'Knot', + 'sh.tangled.knot.member': 'Knot member', + 'sh.tangled.label.definition': 'Label', + 'sh.tangled.label.op': 'Label change', + 'sh.tangled.pipeline': 'Pipeline', + 'sh.tangled.pipeline.status': 'Pipeline status', + 'sh.tangled.publicKey': 'Public key', + 'sh.tangled.repo': 'Repository', + 'sh.tangled.repo.artifact': 'Artifact', + 'sh.tangled.repo.collaborator': 'Collaborator', + 'sh.tangled.repo.issue': 'Issue', + 'sh.tangled.repo.issue.comment': 'Issue comment', + 'sh.tangled.repo.issue.state': 'Issue state', + 'sh.tangled.repo.pull': 'Pull request', + 'sh.tangled.repo.pull.comment': 'Pull request comment', + 'sh.tangled.repo.pull.status': 'Pull request status', + 'sh.tangled.spindle': 'Spindle', + 'sh.tangled.spindle.member': 'Spindle member', + 'sh.tangled.string': 'String', +} + +export function collectionName(nsid: string): string { + return collectionNames[nsid] ?? nsid +} + +export function recordTitle(value: Record, fallback: string): string { + for (const field of ['name', 'title', 'preferredHandle']) { + if (typeof value[field] === 'string' && value[field].trim()) return value[field] + } + return fallback +} + +export function recordExcerpt(value: Record): string | undefined { + for (const field of ['description', 'body', 'text', 'message']) { + if (typeof value[field] === 'string' && value[field].trim()) return value[field] + } + return undefined +} diff --git a/src/features/profiles/OwnedRepositories.vue b/src/features/profiles/OwnedRepositories.vue new file mode 100644 index 0000000..eb58f1a --- /dev/null +++ b/src/features/profiles/OwnedRepositories.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/features/profiles/PinnedRepositories.vue b/src/features/profiles/PinnedRepositories.vue new file mode 100644 index 0000000..af4589d --- /dev/null +++ b/src/features/profiles/PinnedRepositories.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/features/profiles/RepositoryCard.vue b/src/features/profiles/RepositoryCard.vue new file mode 100644 index 0000000..f4b79b5 --- /dev/null +++ b/src/features/profiles/RepositoryCard.vue @@ -0,0 +1,75 @@ + + + + + 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.

diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue index f23d72c..3090668 100644 --- a/src/views/HomePage.vue +++ b/src/views/HomePage.vue @@ -1,63 +1,110 @@ diff --git a/src/views/SettingsPage.vue b/src/views/SettingsPage.vue index 5804aa5..620e8d2 100644 --- a/src/views/SettingsPage.vue +++ b/src/views/SettingsPage.vue @@ -6,7 +6,7 @@

Make Twisted yours.

-

Appearance and network settings stay on this device. They are stored separately.

+

Choose how Twisted looks and where it finds public Tangled content.

@@ -14,7 +14,7 @@ 01

Theme

-

Choose a reviewed Base16 palette or import a compatible JSON file.

+

Choose a built-in color theme or bring your own Base16 theme file.

@@ -51,15 +51,15 @@
02
-

Bobbin service

-

Advanced setting. Twisted accepts HTTPS Bobbin instances only.

+

Data source

+

Advanced: choose where Twisted finds public Tangled content.

- +
- +

@@ -73,7 +73,7 @@ 03

About

-

Twisted is an independent, read-only client for public Tangled data.

+

Twisted gives you a focused way to browse public work on Tangled.

@@ -82,12 +82,12 @@
{{ activeScheme.name }}
-
Bobbin service
+
Data source
{{ service }}
Access
-
Unauthenticated and read-only
+
No sign-in needed
@@ -134,7 +134,7 @@ function saveService(): void { try { serviceInput.value = updateService(serviceInput.value) serviceError.value = false - serviceMessage.value = 'Bobbin service saved.' + serviceMessage.value = 'Data source saved.' } catch (error) { serviceError.value = true serviceMessage.value = error instanceof Error ? error.message : 'The service URL is invalid.' @@ -145,7 +145,7 @@ function resetService(): void { restoreDefault() serviceInput.value = service.value serviceError.value = false - serviceMessage.value = 'The default Bobbin service was restored.' + serviceMessage.value = 'The default data source was restored.' } diff --git a/tests/e2e/specs/foundation.cy.ts b/tests/e2e/specs/foundation.cy.ts index 26d7aa9..7ae0765 100644 --- a/tests/e2e/specs/foundation.cy.ts +++ b/tests/e2e/specs/foundation.cy.ts @@ -6,7 +6,7 @@ describe('Twisted foundation', () => { it('loads the app and intercepts a Bobbin XRPC query', () => { cy.interceptBobbin('sh.tangled.bobbin.getCoverage', 'xrpc/coverage.json') cy.visit('/') - cy.contains('h1', 'Follow the thread.') + cy.contains('h1', 'See where the work leads.') cy.window().then(async (window) => { const response = await window.fetch('/xrpc/sh.tangled.bobbin.getCoverage') @@ -23,7 +23,7 @@ describe('Twisted foundation', () => { expect(document.fonts.check('16px "Azeret Mono Variable"')).to.equal(true) }) cy.get('h1').should('have.css', 'font-family').and('contain', 'Commissioner Variable') - cy.get('.home-page__eyebrow').should('have.css', 'font-family').and('contain', 'Azeret Mono Variable') + cy.get('.public-trail__number').first().should('have.css', 'font-family').and('contain', 'Azeret Mono Variable') }) it('persists theme and service settings independently', () => { @@ -31,7 +31,7 @@ describe('Twisted foundation', () => { cy.contains('label', 'Catppuccin Latte').click() cy.get('html').should('have.attr', 'data-theme', 'catppuccin-latte') cy.get('#service-url').clear().type('https://bobbin.example.com/') - cy.contains('button', 'Save service').click() + cy.contains('button', 'Save data source').click() cy.reload() cy.get('html').should('have.attr', 'data-theme', 'catppuccin-latte') @@ -44,13 +44,17 @@ describe('Twisted foundation', () => { it('keeps recovery and navigation usable at a native-sized viewport', () => { cy.viewport(390, 844) - cy.visit('/profiles/not%20valid') + cy.visit('/repositories/not%20valid') cy.get('.mobile-tabs').should('be.visible') cy.get('.mobile-tabs a').should('have.length', 4) cy.get('ion-menu-button').should('be.visible') cy.contains('h2', 'This identifier is not supported') cy.contains('a', 'Return home').should('be.visible').click() - cy.contains('h1', 'Follow the thread.') + cy.contains('h1', 'See where the work leads.') + cy.get('.public-trail').should('exist') + cy.get('.home-page').then(($page) => { + expect($page[0].scrollWidth).to.be.at.most($page[0].clientWidth) + }) cy.get('.mobile-tabs').contains('Search').click() cy.location('pathname').should('equal', '/search') cy.get('.mobile-tabs a[aria-current="page"]').should('contain.text', 'Search') @@ -67,6 +71,10 @@ describe('Twisted foundation', () => { }) }) cy.get('.mobile-tabs').should('not.be.visible') + cy.get('.public-trail').should('be.visible') + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most(document.documentElement.clientWidth) + }) }) it('offers recovery links for unknown deep links', () => { diff --git a/tests/unit/api/client.spec.ts b/tests/unit/api/client.spec.ts index 429a72f..2be10c0 100644 --- a/tests/unit/api/client.spec.ts +++ b/tests/unit/api/client.spec.ts @@ -36,6 +36,28 @@ describe('BobbinClient', () => { expect(fetch).toHaveBeenCalledOnce() }) + test('validates repository DID and ordered batch lookups', async () => { + const secondUri = repoUri.replace('3mho6hukiei22', 'second') + const fetch = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ uri: repoUri, value: repoRecord() })) + .mockResolvedValueOnce( + jsonResponse({ + items: [ + { uri: repoUri, value: repoRecord() }, + { uri: secondUri, value: { ...repoRecord(), name: 'second' } }, + ], + }), + ) + const client = new BobbinClient({ fetch }) + + await expect(client.getRepoByRepoDid('did:plc:4iw5fospv2asv3344au236ka')).resolves.toMatchObject({ uri: repoUri }) + await expect(client.getRepos([repoUri, secondUri])).resolves.toEqual([ + expect.objectContaining({ uri: repoUri }), + expect.objectContaining({ uri: secondUri }), + ]) + }) + test('rejects malformed embedded records', async () => { const fetch = vi .fn() diff --git a/tests/unit/discovery/identifiers.spec.ts b/tests/unit/discovery/identifiers.spec.ts new file mode 100644 index 0000000..28d7ea1 --- /dev/null +++ b/tests/unit/discovery/identifiers.spec.ts @@ -0,0 +1,23 @@ +import { classifyIdentifier } from '@/features/discovery/identifiers' +import { describe, expect, test } from 'vitest' + +describe('classifyIdentifier', () => { + test.each([ + ['desertthunder.dev', { kind: 'actor', actor: 'desertthunder.dev' }], + ['@desertthunder.dev', { kind: 'actor', actor: 'desertthunder.dev' }], + ['did:plc:account', { kind: 'did', did: 'did:plc:account' }], + ['at://did:plc:account/sh.tangled.actor.profile/self', { kind: 'actor', actor: 'did:plc:account' }], + [ + 'at://did:plc:account/sh.tangled.repo/project', + { kind: 'at-uri', uri: 'at://did:plc:account/sh.tangled.repo/project' }, + ], + ['https://tangled.org/@desertthunder.dev', { kind: 'actor', actor: 'desertthunder.dev' }], + [ + 'https://tangled.org/desertthunder.dev/twisted', + { kind: 'repo-url', owner: 'desertthunder.dev', repo: 'twisted' }, + ], + ['search these words', { kind: 'search' }], + ] as const)('classifies %s', (input, expected) => { + expect(classifyIdentifier(input)).toEqual(expected) + }) +}) diff --git a/tests/unit/home-page.spec.ts b/tests/unit/home-page.spec.ts index ed85758..3bf96dc 100644 --- a/tests/unit/home-page.spec.ts +++ b/tests/unit/home-page.spec.ts @@ -8,12 +8,15 @@ describe('HomePage', () => { const emptyPage = { template: '
' } const wrapper = await mountIonicRoute(HomePage, '/home', [ { path: '/home', name: 'home', component: HomePage }, + { path: '/search', name: 'search', component: emptyPage }, { path: '/profiles', name: 'profiles', component: emptyPage }, + { path: '/profiles/:actor', name: 'profile', component: emptyPage }, { path: '/repositories', name: 'repositories', component: emptyPage }, + { path: '/repositories/:repo', name: 'repository', component: emptyPage }, { path: '/infrastructure', name: 'infrastructure', component: emptyPage }, ]) - expect(wrapper.get('h1').text()).toBe('Follow the thread.') + expect(wrapper.get('h1').text()).toBe('See where the work leads.') expect(warning).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/profile-page.spec.ts b/tests/unit/profile-page.spec.ts new file mode 100644 index 0000000..cdf9bc2 --- /dev/null +++ b/tests/unit/profile-page.spec.ts @@ -0,0 +1,99 @@ +import type { BobbinClient } from '@/api' +import { BOBBIN_CLIENT_PROVIDER, BobbinError } from '@/api' +import ProfilePage from '@/views/ProfilePage.vue' +import { flushPromises } from '@vue/test-utils' +import { describe, expect, test, vi } from 'vitest' +import { mountIonicRoute } from './support/mount' + +const actorDid = 'did:plc:person' + +function repository(name: string, repoDid: string) { + return { + uri: `at://${actorDid}/sh.tangled.repo/${name.toLowerCase()}`, + value: { + $type: 'sh.tangled.repo', + createdAt: '2026-08-01T00:00:00.000Z', + knot: 'https://tangled.org', + name, + repoDid, + }, + } +} + +function routes() { + return [ + { path: '/profiles/:actor', name: 'profile', component: ProfilePage }, + { path: '/repositories/:repo', name: 'repository', component: { template: '
' } }, + ] +} + +describe('ProfilePage', () => { + test('renders verified identity and metadata while preserving pinned repository order', async () => { + const first = repository('First pinned', 'did:plc:first') + const second = repository('Second pinned', 'did:plc:second') + const client = { + resolveIdentity: vi + .fn() + .mockResolvedValue({ did: actorDid, handle: 'person.example', pds: 'https://pds.example', signing_key: 'key' }), + getProfile: vi + .fn() + .mockResolvedValue({ + uri: `at://${actorDid}/sh.tangled.actor.profile/self`, + value: { + $type: 'sh.tangled.actor.profile', + bluesky: false, + description: 'Makes small, useful tools.', + location: 'Chicago', + pronouns: 'they/them', + links: ['https://person.example/about', 'javascript:alert(1)'], + pinnedRepositories: ['did:plc:first', 'did:plc:second'], + }, + }), + getRepoByRepoDid: vi.fn((did: string) => Promise.resolve(did === 'did:plc:first' ? first : second)), + listRepos: vi + .fn() + .mockResolvedValueOnce({ items: [repository('Owned', 'did:plc:owned')], cursor: 'next' }) + .mockResolvedValueOnce({ items: [repository('Owned later', 'did:plc:later')] }), + } as unknown as BobbinClient + const wrapper = await mountIonicRoute(ProfilePage, '/profiles/person.example', routes(), { + [BOBBIN_CLIENT_PROVIDER]: () => client, + navManager: { handleNavigateBack: vi.fn() }, + }) + await flushPromises() + await flushPromises() + + expect(wrapper.get('h1').text()).toBe('@person.example') + expect(wrapper.text()).toContain(actorDid) + expect(wrapper.text()).toContain('Makes small, useful tools.') + expect(wrapper.text()).toContain('Chicago') + expect(wrapper.findAll('.profile-details__links a')).toHaveLength(1) + expect(wrapper.findAll('[aria-labelledby="pinned-heading"] .repo-card strong').map((item) => item.text())).toEqual([ + 'First pinned', + 'Second pinned', + ]) + expect(wrapper.text()).toContain('Owned') + await wrapper.get('.show-more').trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Owned later') + expect(client.listRepos).toHaveBeenCalledTimes(2) + }) + + test('keeps repositories visible when profile details fail', async () => { + const client = { + resolveIdentity: vi + .fn() + .mockResolvedValue({ did: actorDid, handle: 'person.example', pds: 'https://pds.example', signing_key: 'key' }), + getProfile: vi.fn().mockRejectedValue(new BobbinError('upstream-unavailable', 'unavailable')), + listRepos: vi.fn().mockResolvedValue({ items: [repository('Still here', 'did:plc:owned')] }), + } as unknown as BobbinClient + const wrapper = await mountIonicRoute(ProfilePage, '/profiles/person.example', routes(), { + [BOBBIN_CLIENT_PROVIDER]: () => client, + navManager: { handleNavigateBack: vi.fn() }, + }) + await flushPromises() + await flushPromises() + + expect(wrapper.text()).toContain('Part of Tangled is unavailable') + expect(wrapper.text()).toContain('Still here') + }) +}) diff --git a/tests/unit/request-components.spec.ts b/tests/unit/request-components.spec.ts index c928c63..bac9449 100644 --- a/tests/unit/request-components.spec.ts +++ b/tests/unit/request-components.spec.ts @@ -8,11 +8,11 @@ describe('RequestState', () => { test.each([ ['invalid-request', 'Check this request'], ['not-found', 'Record not found'], - ['rate-limited', 'Bobbin asked us to slow down'], - ['upstream-unavailable', 'The upstream service is unavailable'], - ['service-unavailable', 'Bobbin is unavailable'], + ['rate-limited', 'Too many requests'], + ['upstream-unavailable', 'Part of Tangled is unavailable'], + ['service-unavailable', 'Tangled is unavailable'], ['offline', 'You are offline'], - ['malformed-response', 'Bobbin returned unexpected data'], + ['malformed-response', 'This content could not be displayed'], ] as const)('renders %s as a distinct state', (kind, title) => { const wrapper = mount(RequestState, { props: { error: new BobbinError(kind, 'Test') } }) @@ -47,7 +47,7 @@ describe('CoverageNotice', () => { }) expect(wrapper.get('#results').text()).toBe('Indexed result') - expect(wrapper.text()).toContain('results may be incomplete') + expect(wrapper.text()).toContain('Some recent results may be missing') }) test('stays hidden when indexing is ready', () => { diff --git a/tests/unit/search-page.spec.ts b/tests/unit/search-page.spec.ts new file mode 100644 index 0000000..8ff01e5 --- /dev/null +++ b/tests/unit/search-page.spec.ts @@ -0,0 +1,103 @@ +import type { BobbinClient } from '@/api' +import { BOBBIN_CLIENT_PROVIDER } from '@/api' +import SearchPage from '@/views/SearchPage.vue' +import { flushPromises } from '@vue/test-utils' +import { describe, expect, test, vi } from 'vitest' +import { mountIonicRoute } from './support/mount' + +function repoHit(name: string, uri: string) { + return { + uri, + nsid: 'sh.tangled.repo', + score: 1, + value: { $type: 'sh.tangled.repo', createdAt: '2026-08-01T00:00:00.000Z', knot: 'tangled.org', name }, + } +} + +describe('SearchPage', () => { + test('passes every filter, preserves result order, and appends the next page once', async () => { + const search = vi + .fn() + .mockResolvedValueOnce({ + items: [ + repoHit('First', 'at://did:plc:a/sh.tangled.repo/first'), + repoHit('Second', 'at://did:plc:a/sh.tangled.repo/second'), + ], + cursor: 'next', + }) + .mockResolvedValueOnce({ items: [repoHit('Third', 'at://did:plc:a/sh.tangled.repo/third')] }) + const client = { + service: 'https://example.test', + getCoverage: vi.fn().mockResolvedValue({ ready: false, eventsProcessed: 1, lastCursor: 2 }), + search, + } as unknown as BobbinClient + const wrapper = await mountIonicRoute( + SearchPage, + '/search?q=twisted&nsid=sh.tangled.repo&author=did:plc:a&repo=did:plc:r&since=2026-07-01&until=2026-08-01', + [ + { path: '/search', name: 'search', component: SearchPage }, + { path: '/repositories/:repo', name: 'repository', component: { template: '
' } }, + ], + { [BOBBIN_CLIENT_PROVIDER]: () => client }, + ) + await flushPromises() + + expect(search.mock.calls[0][0]).toMatchObject({ + q: 'twisted', + nsid: 'sh.tangled.repo', + author: 'did:plc:a', + repo: 'did:plc:r', + since: '2026-07-01T00:00:00.000Z', + until: '2026-08-01T23:59:59.999Z', + }) + expect(wrapper.findAll('.search-results li strong').map((item) => item.text())).toEqual(['First', 'Second']) + expect(wrapper.text()).toContain('Some recent results may be missing') + + await wrapper.get('.search-results__more').trigger('click') + await flushPromises() + expect(wrapper.findAll('.search-results li strong').map((item) => item.text())).toEqual([ + 'First', + 'Second', + 'Third', + ]) + expect(search).toHaveBeenCalledTimes(2) + }) + + test('shows invalid filters without making a request', async () => { + const search = vi.fn() + const client = { + service: 'https://example.test', + getCoverage: vi.fn().mockResolvedValue({ ready: true, eventsProcessed: 1, lastCursor: 1 }), + search, + } as unknown as BobbinClient + const wrapper = await mountIonicRoute( + SearchPage, + '/search', + [{ path: '/search', name: 'search', component: SearchPage }], + { [BOBBIN_CLIENT_PROVIDER]: () => client }, + ) + await wrapper.get('#search-query').setValue('twisted') + await wrapper.get('input[placeholder="did:plc:…"]').setValue('invalid') + await wrapper.get('form').trigger('submit') + + expect(wrapper.get('[role="alert"]').text()).toContain('author must be a complete DID') + expect(search).not.toHaveBeenCalled() + }) + + test('shows a useful empty state', async () => { + const client = { + service: 'https://example.test', + getCoverage: vi.fn().mockResolvedValue({ ready: true, eventsProcessed: 1, lastCursor: 1 }), + search: vi.fn().mockResolvedValue({ items: [] }), + } as unknown as BobbinClient + const wrapper = await mountIonicRoute( + SearchPage, + '/search?q=nothing-here', + [{ path: '/search', name: 'search', component: SearchPage }], + { [BOBBIN_CLIENT_PROVIDER]: () => client }, + ) + await flushPromises() + + expect(wrapper.get('.search-empty h2').text()).toBe('No matches yet') + }) +}) diff --git a/tests/unit/support/mount.ts b/tests/unit/support/mount.ts index 718780a..02ab05c 100644 --- a/tests/unit/support/mount.ts +++ b/tests/unit/support/mount.ts @@ -9,10 +9,11 @@ export async function mountIonicRoute( component: Component, path = '/', routes: RouteRecordRaw[] = [{ path: '/', component }], + provide: Record = {}, ) { const router = createRouter({ history: createMemoryHistory(), routes }) await router.push(path) await router.isReady() - return mount(component, { global: { plugins: [IonicVue, router] } }) + return mount(component, { global: { plugins: [IonicVue, router], provide } }) }