browse the protocol like its 2008 ibex.desertthunder.dev
ubuntu atproto svelte
7

Configure Feed

Select the types of activity you want to include in your feed.

feat: Lexicon browser (epiphany!)

Owais Jamil (Jun 19, 2026, 1:09 PM -0500) 9418bf77 795bf72a

+1337 -28
+5
.changeset/breezy-emus-smile.md
··· 1 + --- 2 + 'intrepid-ibex': minor 3 + --- 4 + 5 + gnome browser/epiphany for lexicon schema browsing
+4 -4
TODO.md
··· 85 85 - [ ] **Network Servers** 86 86 - Inspect public PDS host info, version, available domains, and repo list. 87 87 - Link repos into Nautilus and open server firehose views in System Monitor. 88 - - [ ] **Web Browser** 88 + - [x] **Web Browser** 89 89 - GNOME-style browser for reading Lexicon schemas and resolution traces. 90 90 - Accept NSIDs, `at://` lexicon records, and HTTPS URLs in an address bar. 91 91 - Parse NSIDs into domain authority, authority domain, name segment, and optional 92 92 fragment. 93 - - For live lookup, resolve the NSID authority domain through DNS from the browser, 94 - likely via DNS-over-HTTPS rather than a backend resolver. 93 + - For live lookup, use the public lexdns API to resolve the NSID authority domain 94 + through DNS-over-HTTPS. 95 95 - Use the goat/Indigo Lexicon publication convention: read `did=...` TXT records 96 96 from the NSID authority domain. 97 97 - Show a resolution trace with each attempted DNS name, record type, answer, fetch URL, ··· 116 116 - [ ] Add Archive Manager. 117 117 - [ ] Add Eye of GNOME blob browsing and previews. 118 118 - [ ] Add Label Browser. 119 - - [ ] Add Web Browser for Lexicon viewing. 119 + - [x] Add Web Browser for Lexicon viewing. 120 120 - [ ] Add Log Viewer. 121 121 - [ ] Add Network Servers. 122 122
+102
src/lib/atproto/lexicon.svelte.ts
··· 1 + import { normalizeLexiconInput, resolveLexicon, type LexiconTrace, type ResolvedLexicon } from './lexicon'; 2 + 3 + class LexiconBrowserState { 4 + address = $state(''); 5 + activeInput = $state(''); 6 + result = $state<ResolvedLexicon | null>(null); 7 + error = $state<string | null>(null); 8 + errorCode = $state<string | null>(null); 9 + errorTrace = $state<LexiconTrace | null>(null); 10 + isLoading = $state(false); 11 + history = $state<string[]>([]); 12 + historyIndex = $state(-1); 13 + 14 + get activeNsid() { 15 + return this.result?.nsid ?? this.activeInput; 16 + } 17 + 18 + get canGoBack() { 19 + return this.historyIndex > 0; 20 + } 21 + 22 + get canGoForward() { 23 + return this.historyIndex >= 0 && this.historyIndex < this.history.length - 1; 24 + } 25 + 26 + async open(input: string, options: { recordHistory?: boolean } = {}) { 27 + const recordHistory = options.recordHistory ?? true; 28 + const normalized = normalizeLexiconInput(input); 29 + this.address = normalized; 30 + this.activeInput = normalized; 31 + this.isLoading = true; 32 + this.error = null; 33 + this.errorCode = null; 34 + this.errorTrace = null; 35 + 36 + try { 37 + const resolved = await resolveLexicon(normalized); 38 + this.result = resolved; 39 + this.address = resolved.nsid; 40 + 41 + if (recordHistory) this.remember(resolved.nsid); 42 + } catch (unknownError) { 43 + this.result = null; 44 + 45 + if (unknownError instanceof Error) { 46 + const details = unknownError as Error & { code?: unknown; trace?: unknown }; 47 + this.error = unknownError.message; 48 + this.errorCode = details.code ? String(details.code) : 'lookup_error'; 49 + this.errorTrace = details.trace ? (details.trace as LexiconTrace) : null; 50 + } else { 51 + this.error = String(unknownError); 52 + this.errorCode = 'lookup_error'; 53 + } 54 + } finally { 55 + this.isLoading = false; 56 + } 57 + } 58 + 59 + async reload() { 60 + const target = this.activeNsid || this.address; 61 + if (!target) return; 62 + 63 + await this.open(target, { recordHistory: false }); 64 + } 65 + 66 + async goBack() { 67 + if (!this.canGoBack) return; 68 + 69 + this.historyIndex -= 1; 70 + const target = this.history[this.historyIndex]; 71 + if (target) await this.open(target, { recordHistory: false }); 72 + } 73 + 74 + async goForward() { 75 + if (!this.canGoForward) return; 76 + 77 + this.historyIndex += 1; 78 + const target = this.history[this.historyIndex]; 79 + if (target) await this.open(target, { recordHistory: false }); 80 + } 81 + 82 + clear() { 83 + this.address = ''; 84 + this.activeInput = ''; 85 + this.result = null; 86 + this.error = null; 87 + this.errorCode = null; 88 + this.errorTrace = null; 89 + this.isLoading = false; 90 + } 91 + 92 + private remember(nsid: string) { 93 + if (this.history[this.historyIndex] === nsid) return; 94 + 95 + const nextHistory = this.history.slice(0, this.historyIndex + 1); 96 + nextHistory.push(nsid); 97 + this.history = nextHistory.slice(-25); 98 + this.historyIndex = this.history.length - 1; 99 + } 100 + } 101 + 102 + export const lexiconBrowser = new LexiconBrowserState();
+28
src/lib/atproto/lexicon.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { lexiconApiUrl, lexiconPath, normalizeLexiconInput } from './lexicon'; 3 + 4 + describe('lexicon helpers', () => { 5 + it('normalizes NSID input', () => { 6 + expect(normalizeLexiconInput(' app.bsky.feed.post ')).toBe('app.bsky.feed.post'); 7 + }); 8 + 9 + it('normalizes at:// lexicon records', () => { 10 + expect(normalizeLexiconInput('at://did:plc:abc/com.atproto.lexicon.schema/app.bsky.feed.post#main')).toBe( 11 + 'app.bsky.feed.post' 12 + ); 13 + }); 14 + 15 + it('normalizes lexdns and app route URLs', () => { 16 + expect(normalizeLexiconInput('https://lex.desertthunder.dev/api/resolve/com.atproto.repo.getRecord')).toBe( 17 + 'com.atproto.repo.getRecord' 18 + ); 19 + expect(normalizeLexiconInput('https://ibex.example/lexicons/app.bsky.actor.profile')).toBe( 20 + 'app.bsky.actor.profile' 21 + ); 22 + }); 23 + 24 + it('builds lexdns and app URLs', () => { 25 + expect(lexiconApiUrl('app.bsky.feed.post')).toBe('https://lex.desertthunder.dev/api/resolve/app.bsky.feed.post'); 26 + expect(lexiconPath('app.bsky.feed.post')).toBe('/lexicons/app.bsky.feed.post'); 27 + }); 28 + });
+163
src/lib/atproto/lexicon.ts
··· 1 + export const LEXDNS_API_BASE = 'https://lex.desertthunder.dev/api/resolve'; 2 + 3 + export type ParsedLexiconNsid = { nsid: string; authority: string; domain: string; name: string; dnsName: string }; 4 + 5 + export type LexiconDocument = { 6 + $type?: string; 7 + lexicon: 1; 8 + id: string; 9 + defs: Record<string, LexiconDefinition>; 10 + [key: string]: unknown; 11 + }; 12 + 13 + export type LexiconDefinition = { 14 + type?: string; 15 + description?: string; 16 + key?: string; 17 + required?: string[]; 18 + record?: LexiconObject; 19 + parameters?: LexiconObject; 20 + input?: LexiconEncoding; 21 + output?: LexiconEncoding; 22 + properties?: Record<string, LexiconProperty>; 23 + refs?: string[]; 24 + ref?: string; 25 + knownValues?: string[]; 26 + [key: string]: unknown; 27 + }; 28 + 29 + export type LexiconObject = { 30 + type?: string; 31 + required?: string[]; 32 + properties?: Record<string, LexiconProperty>; 33 + [key: string]: unknown; 34 + }; 35 + 36 + export type LexiconEncoding = { encoding?: string; schema?: LexiconObject; [key: string]: unknown }; 37 + 38 + export type LexiconProperty = { 39 + type?: string; 40 + description?: string; 41 + format?: string; 42 + ref?: string; 43 + refs?: string[]; 44 + items?: LexiconProperty; 45 + knownValues?: string[]; 46 + required?: string[]; 47 + properties?: Record<string, LexiconProperty>; 48 + [key: string]: unknown; 49 + }; 50 + 51 + export type LexiconTrace = { 52 + dnsName: string | null; 53 + txtRecords: Array<{ name: string; data: string; ttl?: number }>; 54 + selectedDid: string | null; 55 + didDocument: { url: string | null; status: number | null }; 56 + pdsEndpoint: string | null; 57 + repoGetRecord: { url: string | null; status: number | null }; 58 + final: { success: boolean; code?: string; message: string }; 59 + }; 60 + 61 + export type ResolvedLexicon = { 62 + version: string; 63 + nsid: string; 64 + parsed: ParsedLexiconNsid; 65 + hash: string; 66 + fetchedAt: string; 67 + source: { name: string; url: string }; 68 + lexicon: LexiconDocument; 69 + cache: 'hit' | 'miss'; 70 + trace: LexiconTrace; 71 + _links: { self: { href: string }; source: { href: string } }; 72 + }; 73 + 74 + export type LexiconApiError = { version?: string; error: { code: string; message: string }; trace?: LexiconTrace }; 75 + 76 + export class LexiconResolveError extends Error { 77 + constructor( 78 + readonly status: number, 79 + readonly code: string, 80 + message: string, 81 + readonly trace: LexiconTrace | null 82 + ) { 83 + super(message); 84 + this.name = 'LexiconResolveError'; 85 + } 86 + } 87 + 88 + export async function resolveLexicon(input: string, fetcher: typeof fetch = fetch): Promise<ResolvedLexicon> { 89 + const nsid = normalizeLexiconInput(input); 90 + const response = await fetcher(lexiconApiUrl(nsid), { headers: { accept: 'application/json' } }); 91 + const body = (await response.json()) as ResolvedLexicon | LexiconApiError; 92 + 93 + if (!response.ok || isLexiconApiError(body)) { 94 + const apiError = isLexiconApiError(body) 95 + ? body.error 96 + : { code: 'upstream_error', message: `Lexicon lookup failed with ${response.status}` }; 97 + throw new LexiconResolveError(response.status, apiError.code, apiError.message, body.trace ?? null); 98 + } 99 + 100 + return body; 101 + } 102 + 103 + export function lexiconApiUrl(nsid: string) { 104 + return `${LEXDNS_API_BASE}/${encodeURIComponent(nsid)}`; 105 + } 106 + 107 + export function lexiconPath(input: string) { 108 + return `/lexicons/${encodeURIComponent(normalizeLexiconInput(input))}`; 109 + } 110 + 111 + export function normalizeLexiconInput(input: string) { 112 + const trimmed = input.trim(); 113 + if (!trimmed) throw new Error('Enter an NSID or Lexicon record URL.'); 114 + 115 + const withoutHash = trimmed.split('#', 1)[0] ?? trimmed; 116 + 117 + if (withoutHash.startsWith('at://')) { 118 + const nsid = nsidFromAtUri(withoutHash); 119 + if (nsid) return nsid; 120 + } 121 + 122 + if (/^https?:\/\//i.test(withoutHash)) { 123 + const nsid = nsidFromUrl(withoutHash); 124 + if (nsid) return nsid; 125 + } 126 + 127 + const nsid = withoutHash.replace(/^\/lexicons\//, ''); 128 + return decodeURIComponent(nsid); 129 + } 130 + 131 + function nsidFromAtUri(input: string) { 132 + const withoutProtocol = input.slice('at://'.length); 133 + const [, collection, rkey] = withoutProtocol.split('/'); 134 + if (collection === 'com.atproto.lexicon.schema' && rkey) return decodeURIComponent(rkey); 135 + return null; 136 + } 137 + 138 + function nsidFromUrl(input: string) { 139 + try { 140 + const url = new URL(input); 141 + const apiMatch = url.pathname.match(/\/api\/resolve\/([^/]+)$/); 142 + if (apiMatch?.[1]) return decodeURIComponent(apiMatch[1]); 143 + 144 + const routeMatch = url.pathname.match(/\/lexicons\/([^/]+)$/); 145 + if (routeMatch?.[1]) return decodeURIComponent(routeMatch[1]); 146 + 147 + const queryNsid = url.searchParams.get('nsid'); 148 + if (queryNsid) return queryNsid; 149 + } catch { 150 + return null; 151 + } 152 + 153 + return null; 154 + } 155 + 156 + function isLexiconApiError(value: unknown): value is LexiconApiError { 157 + return ( 158 + typeof value === 'object' && 159 + value !== null && 160 + 'error' in value && 161 + typeof (value as LexiconApiError).error?.message === 'string' 162 + ); 163 + }
+2
src/lib/atproto/routes.ts
··· 6 6 7 7 export const blobsPath = (did: string) => `${repoPath(did)}/blobs`; 8 8 9 + export const lexiconsPath = () => '/lexicons'; 10 + 9 11 export function blobPath(did: string, cid: string) { 10 12 return `${blobsPath(did)}/${encodeURIComponent(cid)}`; 11 13 }
+21 -9
src/lib/components/CollectionBrowser.svelte
··· 7 7 import { accountSetup } from '$lib/atproto/setup.svelte'; 8 8 import { repoBrowser } from '$lib/atproto/repo.svelte'; 9 9 import { collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; 10 + import { lexiconPath } from '$lib/atproto/lexicon'; 10 11 import { isRecordValue } from '$lib/atproto/types'; 11 12 import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; 12 13 import { SvelteMap } from 'svelte/reactivity'; ··· 17 18 let collectionFilter = $state(''); 18 19 let reverseRecords = $state(false); 19 20 let previewField = $state<PreviewField>('summary'); 20 - 21 21 const filteredCollections = $derived.by(() => { 22 22 const query = collectionFilter.trim().toLowerCase(); 23 23 if (!query) return repoBrowser.collections; ··· 27 27 return collection.name.toLowerCase().includes(query) || label.includes(query); 28 28 }); 29 29 }); 30 + 30 31 const collectionGroups = $derived.by(() => groupCollections(filteredCollections)); 31 32 const visibleRecords = $derived.by(() => { 32 33 if (!reverseRecords) return repoBrowser.records; 33 34 return [...repoBrowser.records].reverse(); 34 35 }); 36 + 35 37 const identity = $derived(repoSession.identity ?? (page.route.id === '/browse' ? accountSetup.identity : null)); 36 38 const canUseAsDefault = $derived(Boolean(identity && identity.did !== accountSetup.identity?.did)); 37 39 type RepoPathname = `/repos/${string}`; ··· 64 66 65 67 function openRecord(record: RepoRecordSummary) { 66 68 repoBrowser.selectedRecord = record; 67 - 68 69 if (identity) { 69 70 navigate(recordPath({ did: identity.did, collection: record.collection, rkey: record.rkey })); 70 71 } ··· 72 73 73 74 function openIdentityInspector() { 74 75 if (!identity) return; 75 - 76 76 navigate(identityPath(identity.did)); 77 77 } 78 78 79 + function openCollectionSchema() { 80 + if (!repoBrowser.selectedCollection) return; 81 + void navigateTo(resolve(lexiconPath(repoBrowser.selectedCollection) as `/lexicons/${string}`), { 82 + keepFocus: true, 83 + noScroll: true 84 + }); 85 + } 86 + 79 87 function updatePageSize(event: Event) { 80 88 const select = event.currentTarget; 81 - 82 89 if (!(select instanceof HTMLSelectElement) || !identity) return; 83 - 84 90 void repoBrowser.setRecordPageSize(identity, Number(select.value)); 85 91 } 86 92 87 93 function updatePreviewField(event: Event) { 88 94 const select = event.currentTarget; 89 95 if (!(select instanceof HTMLSelectElement)) return; 90 - 91 96 previewField = select.value as PreviewField; 92 97 } 93 98 99 + // TODO: change to switch...case 94 100 function previewTitle(record: RepoRecordSummary) { 95 101 if (previewField === 'summary') return record.title; 96 102 if (previewField === 'text') return stringRecordField(record, 'text') ?? record.title; ··· 98 104 if (previewField === 'createdAt') return stringRecordField(record, 'createdAt') ?? record.modified; 99 105 if (previewField === 'uri') return record.uri; 100 106 if (previewField === 'cid') return record.cid || 'No CID'; 101 - 102 107 return record.title; 103 108 } 104 109 110 + // TODO: change to switch...case 105 111 function previewBody(record: RepoRecordSummary) { 106 112 if (previewField === 'summary') return record.body; 107 113 if (previewField === 'text') return record.body; ··· 109 115 if (previewField === 'createdAt') return `Indexed as ${record.modified}`; 110 116 if (previewField === 'uri') return `${record.collection}/${record.rkey}`; 111 117 if (previewField === 'cid') return record.cid ? `Record CID: ${record.cid}` : 'Record returned without a CID.'; 112 - 113 118 return record.body; 114 119 } 115 120 116 121 function stringRecordField(record: RepoRecordSummary, key: string) { 117 122 if (!isRecordValue(record.value)) return null; 118 - 119 123 const value = record.value[key]; 120 124 return typeof value === 'string' && value.length > 0 ? value : null; 121 125 } ··· 283 287 <button class="identity-launcher" type="button" disabled={!identity} onclick={openIdentityInspector}> 284 288 <img src="/icons/humanity/apps/identity-inspector.svg" alt="" width="22" height="22" /> 285 289 <span>Identity</span> 290 + </button> 291 + <button 292 + class="identity-launcher" 293 + type="button" 294 + disabled={!repoBrowser.selectedCollection} 295 + onclick={openCollectionSchema}> 296 + <img src="/icons/humanity/apps/web-browser.svg" alt="" width="22" height="22" /> 297 + <span>Schema</span> 286 298 </button> 287 299 {#if canUseAsDefault} 288 300 <button class="identity-launcher" type="button" onclick={useCurrentRepoAsDefault}>
+19 -7
src/lib/components/Gedit.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import type { ThemeRegistration } from 'shiki'; 4 + import { lexiconPath } from '$lib/atproto/lexicon'; 4 5 import { blobPath } from '$lib/atproto/routes'; 5 6 import { blobReferences, isRenderableBlob, rawBlobUrl, repoBlobs } from '$lib/atproto/blobs.svelte'; 6 7 import { repoSession } from '$lib/atproto/session.svelte'; ··· 32 33 33 34 return record.collection; 34 35 }); 36 + 35 37 const schemaFields = $derived.by(() => schemaFieldsForRecord(record.value)); 36 38 const attachments = $derived.by(() => blobReferences(record.value, record.uri)); 37 39 const identity = $derived(repoSession.identity); ··· 39 41 const pds = identity?.pds; 40 42 const repo = identity?.did; 41 43 if (!pds || !repo) return null; 42 - 43 44 const params = new URLSearchParams({ repo, collection: record.collection, rkey: record.rkey }); 44 - 45 45 return `${pds}/xrpc/com.atproto.repo.getRecord?${params.toString()}`; 46 46 }); 47 + 47 48 const externalLinks = $derived.by(() => linksForRecord(record)); 48 49 49 50 const themeName = 'ubuntu-iterm2b24'; ··· 70 71 ] 71 72 }; 72 73 73 - let highlighterPromise: Promise<{ 74 - codeToTokensBase: (code: string, options: { lang: 'json'; theme: string }) => TokenLine[]; 75 - }> | null = null; 74 + type P = (code: string, options: { lang: 'json'; theme: string }) => TokenLine[]; 75 + let highlighterPromise: Promise<{ codeToTokensBase: P }> | null = null; 76 76 77 77 async function getJsonHighlighter() { 78 78 highlighterPromise ??= Promise.all([ ··· 87 87 }); 88 88 89 89 return { 90 - codeToTokensBase: (code: string, options: { lang: 'json'; theme: string }) => 91 - highlighter.codeToTokensBase(code, options) as TokenLine[] 90 + codeToTokensBase(code: string, options: { lang: 'json'; theme: string }) { 91 + return highlighter.codeToTokensBase(code, options) as TokenLine[]; 92 + } 92 93 }; 93 94 }); 94 95 ··· 169 170 window.open(rawBlobUrl(identity, attachment.cid), '_blank', 'noopener,noreferrer'); 170 171 } 171 172 173 + function openSchemaBrowser() { 174 + void navigateTo(resolve(lexiconPath(recordType) as `/lexicons/${string}`), { keepFocus: true, noScroll: true }); 175 + } 176 + 172 177 function attachmentSizeLabel(size: number | null) { 173 178 if (size === null) return 'Unknown size'; 174 179 if (size < 1024) return `${size} B`; ··· 251 256 <h2>{recordType}</h2> 252 257 <p>{record.collection}</p> 253 258 </div> 259 + <button type="button" onclick={openSchemaBrowser}>Open in Web Browser</button> 254 260 </section> 255 261 256 262 <table> ··· 572 578 573 579 td:first-child, 574 580 td:nth-child(2) { 581 + width: 1%; 575 582 color: var(--base0a); 576 583 white-space: nowrap; 584 + } 585 + 586 + td:nth-child(3) { 587 + overflow-wrap: break-word; 588 + word-break: normal; 577 589 } 578 590 579 591 .info-panel {
+13 -2
src/lib/components/GnomePanel.svelte
··· 1 1 <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { resolve } from '$app/paths'; 2 4 import { notImplemented } from '$lib/not-implemented.svelte'; 3 5 import { windowManager } from '$lib/window-manager.svelte'; 4 6 import GnomeMenuBar from '$lib/components/GnomeMenuBar.svelte'; 5 7 6 8 const launchers = [ 7 - { label: 'Browser', icon: '/icons/humanity/apps/web-browser.svg' }, 9 + { label: 'Browser', icon: '/icons/humanity/apps/web-browser.svg', path: '/lexicons' }, 8 10 { label: 'Mail', icon: '/icons/humanity/apps/evolution-mail.svg' }, 9 11 { label: 'Terminal', icon: '/icons/humanity/apps/utilities-terminal.svg' } 10 12 ]; 13 + 14 + function openLauncher(launcher: (typeof launchers)[number]) { 15 + if (launcher.path) { 16 + void goto(resolve(launcher.path as '/lexicons')); 17 + return; 18 + } 19 + 20 + notImplemented.show(); 21 + } 11 22 </script> 12 23 13 24 <header class="gnome-panel" aria-label="GNOME top panel"> ··· 15 26 16 27 <div class="panel-launchers" aria-label="Launchers"> 17 28 {#each launchers as launcher (launcher.label)} 18 - <button type="button" title={launcher.label} onclick={() => notImplemented.show()}> 29 + <button type="button" title={launcher.label} onclick={() => openLauncher(launcher)}> 19 30 <img src={launcher.icon} alt="" width="22" height="22" /> 20 31 </button> 21 32 {/each}
+116
src/lib/components/browser/BrowserSummary.svelte
··· 1 + <script lang="ts"> 2 + import { recordPath } from '$lib/atproto/routes'; 3 + import type { ResolvedLexicon } from '$lib/atproto/lexicon'; 4 + 5 + type Props = { result: ResolvedLexicon | null; error: string | null }; 6 + 7 + let { result, error }: Props = $props(); 8 + 9 + function sourceRecordPath(sourceUrl: string) { 10 + if (!sourceUrl.startsWith('at://')) return sourceUrl; 11 + const [did, collection, rkey] = sourceUrl.slice('at://'.length).split('/'); 12 + if (!did || !collection || !rkey) return sourceUrl; 13 + return recordPath({ did, collection, rkey: decodeURIComponent(rkey) }); 14 + } 15 + </script> 16 + 17 + <aside class="page-summary" aria-label="Lexicon summary"> 18 + <img src="/icons/humanity/apps/web-browser.svg" alt="" width="48" height="48" /> 19 + {#if result} 20 + <h2>{result.nsid}</h2> 21 + <dl> 22 + <div> 23 + <dt>Authority</dt> 24 + <dd>{result.parsed.authority}</dd> 25 + </div> 26 + <div> 27 + <dt>DNS</dt> 28 + <dd>{result.parsed.dnsName}</dd> 29 + </div> 30 + <div> 31 + <dt>Cache</dt> 32 + <dd>{result.cache}</dd> 33 + </div> 34 + <div> 35 + <dt>Fetched</dt> 36 + <dd>{new Date(result.fetchedAt).toLocaleString()}</dd> 37 + </div> 38 + </dl> 39 + <a href={result._links.self.href} target="_blank" rel="external noreferrer">lexdns API</a> 40 + <a href={sourceRecordPath(result.source.url)}>AT URI</a> 41 + {:else if error} 42 + <h2>Lookup failed</h2> 43 + <p>{error}</p> 44 + {:else} 45 + <h2>Lexicon Browser</h2> 46 + <p>Enter an NSID or Lexicon schema record URL.</p> 47 + {/if} 48 + </aside> 49 + 50 + <style> 51 + .page-summary { 52 + display: grid; 53 + align-content: start; 54 + gap: var(--space-3); 55 + min-height: 0; 56 + padding: var(--space-3); 57 + overflow: auto; 58 + background: linear-gradient(90deg, #cdb690, #e8d9bf); 59 + border-right: 1px solid #9f835d; 60 + } 61 + 62 + .page-summary h2 { 63 + overflow-wrap: anywhere; 64 + font-size: var(--text-4); 65 + line-height: var(--leading-tight); 66 + } 67 + 68 + .page-summary p { 69 + color: #5f4d3b; 70 + font-size: var(--text-1); 71 + } 72 + 73 + dl { 74 + display: grid; 75 + gap: 1px; 76 + margin: 0; 77 + background: #a58a63; 78 + border: 1px solid #a58a63; 79 + } 80 + 81 + dl div { 82 + display: grid; 83 + gap: 1px; 84 + background: #fff8ed; 85 + } 86 + 87 + dt, 88 + dd { 89 + margin: 0; 90 + padding: var(--space-1) var(--space-2); 91 + font-size: var(--text-1); 92 + } 93 + 94 + dt { 95 + color: #4b3824; 96 + font-weight: 700; 97 + } 98 + 99 + dd { 100 + overflow-wrap: anywhere; 101 + font-family: var(--font-mono); 102 + } 103 + 104 + a { 105 + color: #235a97; 106 + font-size: var(--text-1); 107 + font-weight: 700; 108 + text-decoration: underline; 109 + } 110 + 111 + @media (max-width: 760px) { 112 + .page-summary { 113 + display: none; 114 + } 115 + } 116 + </style>
+55
src/lib/components/browser/BrowserTabs.svelte
··· 1 + <script lang="ts"> 2 + // should go in types.ts 3 + export type BrowserTab = 'schema' | 'json' | 'trace'; 4 + type Props = { activeTab: BrowserTab; onselect: (tab: BrowserTab) => void }; 5 + 6 + let { activeTab, onselect }: Props = $props(); 7 + </script> 8 + 9 + <nav class="browser-tabs" aria-label="Browser view tabs"> 10 + <button 11 + type="button" 12 + class:active={activeTab === 'schema'} 13 + aria-pressed={activeTab === 'schema'} 14 + onclick={() => onselect('schema')}>Schema</button> 15 + <button 16 + type="button" 17 + class:active={activeTab === 'json'} 18 + aria-pressed={activeTab === 'json'} 19 + onclick={() => onselect('json')}>JSON</button> 20 + <button 21 + type="button" 22 + class:active={activeTab === 'trace'} 23 + aria-pressed={activeTab === 'trace'} 24 + onclick={() => onselect('trace')}>Trace</button> 25 + </nav> 26 + 27 + <style> 28 + .browser-tabs { 29 + display: flex; 30 + gap: 2px; 31 + padding: var(--space-2) var(--space-2) 0; 32 + background: #d8c7ab; 33 + border-bottom: 1px solid #8d704b; 34 + } 35 + 36 + button { 37 + min-height: 1.65rem; 38 + padding: 0 var(--space-2); 39 + color: #251b13; 40 + background: linear-gradient(#fffaf0, #d7c6a9); 41 + border: 1px solid #8d704b; 42 + border-bottom: 0; 43 + border-radius: var(--radius-2) var(--radius-2) 0 0; 44 + box-shadow: var(--shadow-raised); 45 + font: inherit; 46 + font-size: var(--text-1); 47 + cursor: default; 48 + } 49 + 50 + button.active { 51 + color: #fffaf0; 52 + background: linear-gradient(#cf7125, #8a5527); 53 + text-shadow: 0 1px 0 rgb(0 0 0 / 0.45); 54 + } 55 + </style>
+153
src/lib/components/browser/BrowserToolbar.svelte
··· 1 + <script lang="ts"> 2 + type Props = { 3 + address: string; 4 + isLoading: boolean; 5 + canGoBack: boolean; 6 + canGoForward: boolean; 7 + canReload: boolean; 8 + onaddress: (address: string) => void; 9 + onsubmit: () => void; 10 + onback: () => void; 11 + onforward: () => void; 12 + onreload: () => void; 13 + onhome: () => void; 14 + }; 15 + 16 + let { 17 + address, 18 + isLoading, 19 + canGoBack, 20 + canGoForward, 21 + canReload, 22 + onaddress, 23 + onsubmit, 24 + onback, 25 + onforward, 26 + onreload, 27 + onhome 28 + }: Props = $props(); 29 + </script> 30 + 31 + <form 32 + class="browser-toolbar" 33 + aria-label="Navigation toolbar" 34 + onsubmit={(event) => { 35 + event.preventDefault(); 36 + onsubmit(); 37 + }}> 38 + <div class="nav-buttons" aria-label="Page navigation"> 39 + <button type="button" disabled={!canGoBack} onclick={onback}>Back</button> 40 + <button type="button" disabled={!canGoForward} onclick={onforward}>Forward</button> 41 + <button type="button" disabled={!canReload} onclick={onreload}>Reload</button> 42 + <button type="button" onclick={onhome}>Home</button> 43 + </div> 44 + 45 + <label class="location-field"> 46 + <span>Location:</span> 47 + <input 48 + value={address} 49 + aria-label="Lexicon address" 50 + placeholder="app.bsky.feed.post or at://.../com.atproto.lexicon.schema/..." 51 + oninput={(event) => onaddress(event.currentTarget.value)} /> 52 + </label> 53 + 54 + <button class="go-button" type="submit" disabled={isLoading}>Go</button> 55 + 56 + <div class="throbber" class:loading={isLoading} aria-label="Loading status"> 57 + <img src="/icons/humanity/apps/web-browser.svg" alt="" width="28" height="28" /> 58 + </div> 59 + </form> 60 + 61 + <style> 62 + .browser-toolbar { 63 + display: grid; 64 + grid-template-columns: auto minmax(12rem, 1fr) auto auto; 65 + align-items: center; 66 + gap: var(--space-2); 67 + padding: var(--space-2); 68 + background: linear-gradient(#eee5d4, #cdb690); 69 + border-bottom: 1px solid #8d704b; 70 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.5) inset; 71 + } 72 + 73 + .nav-buttons { 74 + display: flex; 75 + gap: 2px; 76 + } 77 + 78 + button { 79 + min-height: 1.65rem; 80 + padding: 0 var(--space-2); 81 + color: #251b13; 82 + background: linear-gradient(#fffaf0, #d7c6a9); 83 + border: 1px solid #8d704b; 84 + border-radius: var(--radius-1); 85 + box-shadow: var(--shadow-raised); 86 + font: inherit; 87 + font-size: var(--text-1); 88 + cursor: default; 89 + } 90 + 91 + button:disabled { 92 + color: #827b70; 93 + background: #d8c7ab; 94 + box-shadow: none; 95 + } 96 + 97 + .location-field { 98 + display: grid; 99 + grid-template-columns: auto minmax(0, 1fr); 100 + align-items: center; 101 + gap: var(--space-2); 102 + min-width: 0; 103 + font-size: var(--text-1); 104 + font-weight: 700; 105 + } 106 + 107 + input { 108 + width: 100%; 109 + min-width: 0; 110 + height: 1.75rem; 111 + padding: 0 var(--space-2); 112 + background: #fffdf7; 113 + border: 1px solid #8d704b; 114 + border-radius: var(--radius-1); 115 + box-shadow: var(--shadow-sunken); 116 + font: inherit; 117 + font-family: var(--font-mono); 118 + } 119 + 120 + .go-button { 121 + min-width: 3rem; 122 + } 123 + 124 + .throbber { 125 + display: grid; 126 + place-items: center; 127 + width: 2.25rem; 128 + height: 2.25rem; 129 + border: 1px solid #8d704b; 130 + background: #f7efe1; 131 + box-shadow: var(--shadow-sunken); 132 + } 133 + 134 + .throbber.loading img { 135 + animation: throb 900ms steps(8) infinite; 136 + } 137 + 138 + @keyframes throb { 139 + to { 140 + transform: rotate(360deg); 141 + } 142 + } 143 + 144 + @media (max-width: 760px) { 145 + .browser-toolbar { 146 + grid-template-columns: 1fr; 147 + } 148 + 149 + .location-field { 150 + grid-template-columns: 1fr; 151 + } 152 + } 153 + </style>
+224
src/lib/components/browser/SchemaDocument.svelte
··· 1 + <script lang="ts"> 2 + import type { LexiconDocument } from '$lib/atproto/lexicon'; 3 + import { defRows, referenceNsid } from './schema'; 4 + 5 + type Props = { lexicon: LexiconDocument; onreference: (nsid: string) => void }; 6 + 7 + let { lexicon, onreference }: Props = $props(); 8 + const schemaRows = $derived.by(() => defRows(lexicon.defs)); 9 + </script> 10 + 11 + <div class="schema-document"> 12 + <header> 13 + <p>Lexicon {lexicon.lexicon}</p> 14 + <h1>{lexicon.id}</h1> 15 + </header> 16 + 17 + {#each schemaRows as def (def.name)} 18 + <section class="definition"> 19 + <div class="definition-heading"> 20 + <div> 21 + <p>{def.type}</p> 22 + <h2>#{def.name}</h2> 23 + </div> 24 + {#if def.required.length > 0} 25 + <span>{def.required.length} required</span> 26 + {/if} 27 + </div> 28 + 29 + {#if def.description} 30 + <p class="definition-description">{def.description}</p> 31 + {/if} 32 + 33 + {#if def.properties.length > 0} 34 + <table> 35 + <thead> 36 + <tr> 37 + <th>Field</th> 38 + <th>Type</th> 39 + <th>Description</th> 40 + </tr> 41 + </thead> 42 + <tbody> 43 + {#each def.properties as property (property.name)} 44 + <tr> 45 + <td> 46 + <strong>{property.name}</strong> 47 + {#if def.required.includes(property.name)} 48 + <small>required</small> 49 + {/if} 50 + </td> 51 + <td>{property.type}</td> 52 + <td>{property.description || 'No description'}</td> 53 + </tr> 54 + {/each} 55 + </tbody> 56 + </table> 57 + {/if} 58 + 59 + {#if def.refs.length > 0} 60 + <div class="reference-list" aria-label={`References for ${def.name}`}> 61 + <span>References</span> 62 + {#each def.refs as ref (ref)} 63 + {@const nsid = referenceNsid(ref)} 64 + {#if nsid} 65 + <button type="button" onclick={() => onreference(nsid)}>{ref}</button> 66 + {:else} 67 + <code>{ref}</code> 68 + {/if} 69 + {/each} 70 + </div> 71 + {/if} 72 + </section> 73 + {/each} 74 + </div> 75 + 76 + <style> 77 + .schema-document { 78 + display: grid; 79 + gap: var(--space-3); 80 + padding: var(--space-4); 81 + } 82 + 83 + .schema-document > header { 84 + padding-bottom: var(--space-3); 85 + border-bottom: 1px solid #d1b183; 86 + } 87 + 88 + .schema-document > header p, 89 + .definition-heading p { 90 + color: #6b401e; 91 + font-size: var(--text-0); 92 + font-weight: 700; 93 + text-transform: uppercase; 94 + } 95 + 96 + .schema-document h1 { 97 + font-size: var(--text-6); 98 + line-height: var(--leading-tight); 99 + overflow-wrap: anywhere; 100 + } 101 + 102 + .definition { 103 + border: 1px solid #b89a70; 104 + background: #fffdf7; 105 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.75) inset; 106 + } 107 + 108 + .definition-heading { 109 + display: flex; 110 + align-items: start; 111 + justify-content: space-between; 112 + gap: var(--space-3); 113 + padding: var(--space-3); 114 + background: linear-gradient(#f2ead9, #dfcfaf); 115 + border-bottom: 1px solid #b89a70; 116 + } 117 + 118 + .definition-heading h2 { 119 + font-size: var(--text-4); 120 + overflow-wrap: anywhere; 121 + } 122 + 123 + .definition-heading span { 124 + padding: 0.1rem var(--space-2); 125 + color: #fffaf0; 126 + background: #6b401e; 127 + border: 1px solid #4a2d1a; 128 + font-size: var(--text-0); 129 + font-weight: 700; 130 + white-space: nowrap; 131 + } 132 + 133 + .definition-description { 134 + padding: var(--space-3); 135 + color: #4d4740; 136 + border-bottom: 1px solid #e1d0b2; 137 + } 138 + 139 + table { 140 + width: 100%; 141 + border-collapse: collapse; 142 + font-size: var(--text-1); 143 + } 144 + 145 + th, 146 + td { 147 + padding: var(--space-2); 148 + border: 1px solid #d7c6a9; 149 + text-align: left; 150 + vertical-align: top; 151 + } 152 + 153 + th { 154 + background: #ede2cf; 155 + color: #4b3824; 156 + font-size: var(--text-0); 157 + text-transform: uppercase; 158 + } 159 + 160 + td { 161 + overflow-wrap: anywhere; 162 + } 163 + 164 + th:first-child, 165 + th:nth-child(2), 166 + td:first-child, 167 + td:nth-child(2) { 168 + width: 1%; 169 + white-space: nowrap; 170 + } 171 + 172 + td:nth-child(2) { 173 + font-family: var(--font-mono); 174 + color: #235a97; 175 + } 176 + 177 + td:nth-child(3) { 178 + overflow-wrap: break-word; 179 + word-break: normal; 180 + } 181 + 182 + td small { 183 + display: block; 184 + width: fit-content; 185 + margin-top: var(--space-1); 186 + padding: 0 var(--space-1); 187 + color: #fffaf0; 188 + background: #8a5527; 189 + font-size: var(--text-0); 190 + } 191 + 192 + .reference-list { 193 + display: flex; 194 + flex-wrap: wrap; 195 + gap: var(--space-2); 196 + align-items: center; 197 + padding: var(--space-3); 198 + border-top: 1px solid #e1d0b2; 199 + } 200 + 201 + .reference-list span { 202 + font-size: var(--text-0); 203 + font-weight: 700; 204 + text-transform: uppercase; 205 + } 206 + 207 + .reference-list code, 208 + .reference-list button { 209 + font-family: var(--font-mono); 210 + font-size: var(--text-1); 211 + } 212 + 213 + button { 214 + min-height: 1.65rem; 215 + padding: 0 var(--space-2); 216 + color: #251b13; 217 + background: linear-gradient(#fffaf0, #d7c6a9); 218 + border: 1px solid #8d704b; 219 + border-radius: var(--radius-1); 220 + box-shadow: var(--shadow-raised); 221 + font: inherit; 222 + cursor: default; 223 + } 224 + </style>
+79
src/lib/components/browser/TraceView.svelte
··· 1 + <script lang="ts"> 2 + import type { LexiconTrace } from '$lib/atproto/lexicon'; 3 + 4 + type Props = { trace: LexiconTrace | null }; 5 + 6 + let { trace }: Props = $props(); 7 + const traceRows = $derived.by(() => rowsForTrace(trace)); 8 + 9 + function rowsForTrace(currentTrace: LexiconTrace | null) { 10 + if (!currentTrace) return []; 11 + 12 + return [ 13 + { step: 'DNS name', value: currentTrace.dnsName ?? 'Not attempted' }, 14 + { 15 + step: 'TXT records', 16 + value: 17 + currentTrace.txtRecords.length > 0 ? currentTrace.txtRecords.map((record) => record.data).join('\n') : 'None' 18 + }, 19 + { step: 'Selected DID', value: currentTrace.selectedDid ?? 'None' }, 20 + { 21 + step: 'DID document', 22 + value: currentTrace.didDocument.url 23 + ? `${currentTrace.didDocument.url} (${currentTrace.didDocument.status ?? 'unknown'})` 24 + : 'Not fetched' 25 + }, 26 + { step: 'PDS endpoint', value: currentTrace.pdsEndpoint ?? 'None' }, 27 + { 28 + step: 'Record fetch', 29 + value: currentTrace.repoGetRecord.url 30 + ? `${currentTrace.repoGetRecord.url} (${currentTrace.repoGetRecord.status ?? 'unknown'})` 31 + : 'Not fetched' 32 + }, 33 + { step: 'Final', value: currentTrace.final.message } 34 + ]; 35 + } 36 + </script> 37 + 38 + <div class="trace-view"> 39 + {#each traceRows as row (row.step)} 40 + <section> 41 + <h2>{row.step}</h2> 42 + <pre>{row.value}</pre> 43 + </section> 44 + {:else} 45 + <section> 46 + <h2>Trace</h2> 47 + <pre>No resolution trace is available.</pre> 48 + </section> 49 + {/each} 50 + </div> 51 + 52 + <style> 53 + .trace-view { 54 + display: grid; 55 + gap: var(--space-3); 56 + padding: var(--space-4); 57 + } 58 + 59 + .trace-view section { 60 + border: 1px solid #b89a70; 61 + background: #fffdf7; 62 + } 63 + 64 + .trace-view h2 { 65 + padding: var(--space-2) var(--space-3); 66 + background: linear-gradient(#f2ead9, #dfcfaf); 67 + border-bottom: 1px solid #b89a70; 68 + font-size: var(--text-2); 69 + } 70 + 71 + pre { 72 + margin: 0; 73 + padding: var(--space-3); 74 + font-family: var(--font-mono); 75 + font-size: var(--text-1); 76 + white-space: pre-wrap; 77 + overflow-wrap: anywhere; 78 + } 79 + </style>
+71
src/lib/components/browser/schema.ts
··· 1 + import type { LexiconDefinition, LexiconProperty } from '$lib/atproto/lexicon'; 2 + 3 + export type LexiconDefRow = { 4 + name: string; 5 + type: string; 6 + description: string; 7 + required: string[]; 8 + properties: Array<{ name: string; type: string; description: string }>; 9 + refs: string[]; 10 + }; 11 + 12 + export function defRows(defs: Record<string, LexiconDefinition>): LexiconDefRow[] { 13 + return Object.entries(defs).map(([name, def]) => { 14 + const object = def.record ?? def.parameters ?? def.input?.schema ?? def.output?.schema ?? def; 15 + const properties = Object.entries(object.properties ?? {}).map(([propertyName, property]) => ({ 16 + name: propertyName, 17 + type: propertyType(property), 18 + description: property.description ?? '' 19 + })); 20 + 21 + return { 22 + name, 23 + type: def.type ?? object.type ?? 'definition', 24 + description: def.description ?? '', 25 + required: object.required ?? def.required ?? [], 26 + properties, 27 + refs: refsForDefinition(def) 28 + }; 29 + }); 30 + } 31 + 32 + export function referenceNsid(ref: string) { 33 + const [nsid] = ref.split('#'); 34 + if (!nsid) return null; 35 + if (nsid.startsWith('#')) return null; 36 + if (!nsid.includes('.')) return null; 37 + return nsid; 38 + } 39 + 40 + function propertyType(property: LexiconProperty): string { 41 + if (property.ref) return property.ref; 42 + if (property.refs) return property.refs.join(' | '); 43 + if (property.type === 'array' && property.items) return `array<${propertyType(property.items)}>`; 44 + if (property.format) return `${property.type ?? 'value'}:${property.format}`; 45 + return property.type ?? 'unknown'; 46 + } 47 + 48 + function refsForDefinition(def: LexiconDefinition) { 49 + const refs = new Set<string>(); 50 + collectRefs(def, refs); 51 + return [...refs].sort(); 52 + } 53 + 54 + function collectRefs(value: unknown, refs: Set<string>) { 55 + if (Array.isArray(value)) { 56 + for (const item of value) collectRefs(item, refs); 57 + return; 58 + } 59 + 60 + if (typeof value !== 'object' || value === null) return; 61 + 62 + const record = value as Record<string, unknown>; 63 + if (typeof record.ref === 'string') refs.add(record.ref); 64 + if (Array.isArray(record.refs)) { 65 + for (const ref of record.refs) { 66 + if (typeof ref === 'string') refs.add(ref); 67 + } 68 + } 69 + 70 + for (const child of Object.values(record)) collectRefs(child, refs); 71 + }
+38 -6
src/routes/+layout.svelte
··· 45 45 if (page.route.id === '/') return 'Welcome to Intrepid Ibex'; 46 46 // TODO: this should be slug aware 47 47 if (page.route.id?.startsWith('/docs')) return 'Document Viewer'; 48 + if (page.route.id?.startsWith('/lexicons')) return 'Web Browser'; 48 49 return 'AT Protocol Collections - Intrepid Ibex'; 49 50 }); 50 51 ··· 52 53 if (routeRequiresSetup) return '/icons/humanity/places/user-home.svg'; 53 54 if (page.route.id === '/') return '/icons/humanity/devices/computer.svg'; 54 55 if (page.route.id?.startsWith('/docs')) return '/icons/humanity/mimes/gnome-mime-application-pdf.svg'; 56 + if (page.route.id?.startsWith('/lexicons')) return '/icons/humanity/apps/web-browser.svg'; 55 57 return '/icons/humanity/apps/internet-feed-reader.svg'; 56 58 }); 57 59 ··· 63 65 const identityInspectorWindow = $derived(windowManager.getWindow('identity-inspector')); 64 66 const eogWindow = $derived(windowManager.getWindow('eog')); 65 67 const repoIdentity = $derived(repoSession.identity ?? accountSetup.identity); 68 + const trashShortcut = { 69 + label: 'Trash', 70 + icon: '/icons/humanity/places/user-trash.svg', 71 + onactivate: () => window.open('https://github.com/desertthunder.dev/ibex', '_blank', 'noopener,noreferrer') 72 + }; 66 73 const shortcuts = $derived([ 67 74 { 68 75 label: 'ibex Home', ··· 111 118 } 112 119 }, 113 120 { 121 + label: 'Web Browser', 122 + icon: '/icons/humanity/apps/web-browser.svg', 123 + selected: page.route.id?.startsWith('/lexicons'), 124 + onactivate: () => { 125 + windowManager.restore('main'); 126 + void goto(resolve('/lexicons')); 127 + } 128 + }, 129 + { 114 130 label: 'Computer', 115 131 icon: '/icons/humanity/devices/computer.svg', 116 132 selected: showAboutComputer, ··· 127 143 windowManager.restore('main'); 128 144 void goto(resolve('/docs')); 129 145 } 130 - }, 131 - { 132 - label: 'Trash', 133 - icon: '/icons/humanity/places/user-trash.svg', 134 - onactivate: () => window.open('https://github.com/desertthunder.dev/ibex', '_blank', 'noopener,noreferrer') 135 146 } 136 147 ]); 137 148 ··· 241 252 } 242 253 243 254 function closeMainWindow() { 244 - if (page.route.id === '/browse' || page.route.id?.startsWith('/repos')) { 255 + if (page.route.id === '/browse' || page.route.id?.startsWith('/repos') || page.route.id?.startsWith('/lexicons')) { 245 256 void goto(resolve('/'), { keepFocus: true, noScroll: true }); 246 257 } 247 258 } ··· 276 287 {#each shortcuts as shortcut (shortcut.label)} 277 288 <DesktopIcon {...shortcut} /> 278 289 {/each} 290 + </section> 291 + 292 + <section class="desktop-trash" aria-label="Trash"> 293 + <DesktopIcon {...trashShortcut} /> 279 294 </section> 280 295 281 296 {#if mainWindow?.isOpen && !mainWindow.isMinimized} ··· 451 466 gap: var(--space-4); 452 467 } 453 468 469 + .desktop-trash { 470 + position: absolute; 471 + top: var(--space-5); 472 + right: var(--space-5); 473 + display: grid; 474 + justify-items: center; 475 + } 476 + 454 477 .primary-window { 455 478 position: relative; 456 479 align-self: center; ··· 536 559 padding: var(--space-3); 537 560 } 538 561 562 + .desktop-trash { 563 + top: var(--space-3); 564 + right: var(--space-3); 565 + } 566 + 539 567 .about-window, 540 568 .gedit-window, 541 569 .identity-inspector-window, ··· 551 579 } 552 580 553 581 .desktop-icons { 582 + display: none; 583 + } 584 + 585 + .desktop-trash { 554 586 display: none; 555 587 } 556 588
+244
src/routes/lexicons/+layout.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { resolve } from '$app/paths'; 4 + import { page } from '$app/state'; 5 + import { onMount } from 'svelte'; 6 + import { lexiconBrowser } from '$lib/atproto/lexicon.svelte'; 7 + import { lexiconPath } from '$lib/atproto/lexicon'; 8 + import BrowserSummary from '$lib/components/browser/BrowserSummary.svelte'; 9 + import BrowserTabs, { type BrowserTab } from '$lib/components/browser/BrowserTabs.svelte'; 10 + import BrowserToolbar from '$lib/components/browser/BrowserToolbar.svelte'; 11 + import SchemaDocument from '$lib/components/browser/SchemaDocument.svelte'; 12 + import TraceView from '$lib/components/browser/TraceView.svelte'; 13 + 14 + let { children } = $props(); 15 + let activeTab = $state<BrowserTab>('schema'); 16 + let handledRouteInput = $state<string | null>(null); 17 + const routeInput = $derived.by(() => page.params.nsid ?? page.url.searchParams.get('q') ?? ''); 18 + const rawJson = $derived.by(() => { 19 + if (!lexiconBrowser.result) return ''; 20 + return JSON.stringify(lexiconBrowser.result.lexicon, null, 2); 21 + }); 22 + const statusText = $derived.by(() => { 23 + if (lexiconBrowser.isLoading) return `Contacting ${lexiconBrowser.address || 'lexdns'}...`; 24 + if (lexiconBrowser.error) return lexiconBrowser.error; 25 + if (lexiconBrowser.result) 26 + return `${lexiconBrowser.result.nsid} resolved from ${lexiconBrowser.result.source.name}`; 27 + return 'Ready'; 28 + }); 29 + const currentTrace = $derived(lexiconBrowser.result?.trace ?? lexiconBrowser.errorTrace); 30 + 31 + onMount(() => { 32 + openRouteInput(); 33 + }); 34 + 35 + $effect(() => { 36 + if (routeInput !== handledRouteInput) openRouteInput(); 37 + }); 38 + 39 + function openRouteInput() { 40 + handledRouteInput = routeInput; 41 + if (!routeInput) { 42 + if (!lexiconBrowser.result && !lexiconBrowser.error) lexiconBrowser.address = 'app.bsky.feed.post'; 43 + return; 44 + } 45 + 46 + void lexiconBrowser.open(routeInput, { recordHistory: false }); 47 + } 48 + 49 + function submitAddress() { 50 + const target = lexiconBrowser.address.trim(); 51 + if (!target) return; 52 + 53 + void goto(resolve(lexiconPath(target) as `/lexicons/${string}`), { keepFocus: true, noScroll: true }); 54 + } 55 + 56 + function openHome() { 57 + lexiconBrowser.address = 'app.bsky.feed.post'; 58 + submitAddress(); 59 + } 60 + 61 + function openReference(nsid: string) { 62 + lexiconBrowser.address = nsid; 63 + submitAddress(); 64 + } 65 + </script> 66 + 67 + <section class="web-browser" aria-label="Lexicon Web Browser"> 68 + <div class="browser-menubar" role="menubar" aria-label="Browser menus"> 69 + <span role="menuitem">File</span> 70 + <span role="menuitem">Edit</span> 71 + <span role="menuitem">View</span> 72 + <span role="menuitem">Go</span> 73 + <span role="menuitem">Bookmarks</span> 74 + <span role="menuitem">Help</span> 75 + </div> 76 + 77 + <BrowserToolbar 78 + address={lexiconBrowser.address} 79 + isLoading={lexiconBrowser.isLoading} 80 + canGoBack={lexiconBrowser.canGoBack} 81 + canGoForward={lexiconBrowser.canGoForward} 82 + canReload={Boolean(lexiconBrowser.activeNsid)} 83 + onaddress={(address) => (lexiconBrowser.address = address)} 84 + onsubmit={submitAddress} 85 + onback={() => void lexiconBrowser.goBack()} 86 + onforward={() => void lexiconBrowser.goForward()} 87 + onreload={() => void lexiconBrowser.reload()} 88 + onhome={openHome} /> 89 + 90 + <div class="browser-content"> 91 + <BrowserSummary result={lexiconBrowser.result} error={lexiconBrowser.error} /> 92 + 93 + <section class="page-view" aria-label="Lexicon document"> 94 + <BrowserTabs {activeTab} onselect={(tab) => (activeTab = tab)} /> 95 + 96 + <div class="browser-page"> 97 + {#if lexiconBrowser.isLoading} 98 + <div class="empty-state"> 99 + <img src="/icons/humanity/apps/web-browser.svg" alt="" width="54" height="54" /> 100 + <h2>Resolving Lexicon schema...</h2> 101 + <p>{lexiconBrowser.address}</p> 102 + </div> 103 + {:else if lexiconBrowser.error} 104 + <div class="empty-state error"> 105 + <img src="/icons/humanity/status/image-missing.svg" alt="" width="54" height="54" /> 106 + <h2>{lexiconBrowser.errorCode ?? 'Lookup error'}</h2> 107 + <p>{lexiconBrowser.error}</p> 108 + </div> 109 + {:else if !lexiconBrowser.result} 110 + <div class="empty-state"> 111 + <img src="/icons/humanity/apps/web-browser.svg" alt="" width="54" height="54" /> 112 + <h2>No page loaded</h2> 113 + <p>Try app.bsky.feed.post or com.atproto.repo.getRecord.</p> 114 + </div> 115 + {:else if activeTab === 'schema'} 116 + <SchemaDocument lexicon={lexiconBrowser.result.lexicon} onreference={openReference} /> 117 + {:else if activeTab === 'json'} 118 + <pre class="json-view"><code>{rawJson}</code></pre> 119 + {:else} 120 + <TraceView trace={currentTrace} /> 121 + {/if} 122 + </div> 123 + </section> 124 + </div> 125 + 126 + <footer class="browser-statusbar" aria-live="polite"> 127 + <span>{statusText}</span> 128 + {#if lexiconBrowser.result} 129 + <span>{lexiconBrowser.result.source.url}</span> 130 + {:else} 131 + <span>lex.desertthunder.dev</span> 132 + {/if} 133 + </footer> 134 + </section> 135 + 136 + {@render children()} 137 + 138 + <style> 139 + .web-browser { 140 + display: grid; 141 + grid-template-rows: auto auto minmax(0, 1fr) auto; 142 + height: 100%; 143 + min-height: 0; 144 + overflow: hidden; 145 + color: #201d1a; 146 + background: #d8c7ab; 147 + } 148 + 149 + .browser-menubar { 150 + display: flex; 151 + gap: var(--space-3); 152 + padding: 0.2rem var(--space-2); 153 + background: linear-gradient(#f5efe4, #d4c1a0); 154 + border-bottom: 1px solid #9f835d; 155 + font-size: var(--text-1); 156 + } 157 + 158 + .browser-menubar span { 159 + padding: 0.05rem var(--space-1); 160 + } 161 + 162 + .browser-content { 163 + display: grid; 164 + grid-template-columns: minmax(12rem, 17rem) minmax(0, 1fr); 165 + min-height: 0; 166 + overflow: hidden; 167 + } 168 + 169 + .page-view { 170 + display: grid; 171 + grid-template-rows: auto minmax(0, 1fr); 172 + min-width: 0; 173 + min-height: 0; 174 + background: #fff8ed; 175 + } 176 + 177 + .browser-page { 178 + min-height: 0; 179 + overflow: auto; 180 + background: #fffaf0; 181 + } 182 + 183 + .empty-state { 184 + display: grid; 185 + place-items: center; 186 + align-content: center; 187 + gap: var(--space-2); 188 + min-height: 100%; 189 + padding: var(--space-5); 190 + text-align: center; 191 + } 192 + 193 + .empty-state h2 { 194 + font-size: var(--text-5); 195 + } 196 + 197 + .empty-state p { 198 + max-width: 34rem; 199 + color: #67594c; 200 + overflow-wrap: anywhere; 201 + } 202 + 203 + .empty-state.error h2 { 204 + color: #9d2516; 205 + } 206 + 207 + .json-view { 208 + min-height: 100%; 209 + margin: 0; 210 + padding: var(--space-4); 211 + color: #eeeeec; 212 + background: #300a24; 213 + font-family: var(--font-mono); 214 + font-size: var(--text-1); 215 + white-space: pre-wrap; 216 + overflow-wrap: anywhere; 217 + } 218 + 219 + .browser-statusbar { 220 + display: grid; 221 + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); 222 + gap: 1px; 223 + padding: 1px; 224 + background: #9f835d; 225 + border-top: 1px solid #8d704b; 226 + font-size: var(--text-1); 227 + } 228 + 229 + .browser-statusbar span { 230 + min-width: 0; 231 + overflow: hidden; 232 + padding: 0.2rem var(--space-2); 233 + background: #e6d5b8; 234 + text-overflow: ellipsis; 235 + white-space: nowrap; 236 + } 237 + 238 + @media (max-width: 760px) { 239 + .browser-content, 240 + .browser-statusbar { 241 + grid-template-columns: 1fr; 242 + } 243 + } 244 + </style>
src/routes/lexicons/+page.svelte

This is a binary file and will not be displayed.

src/routes/lexicons/[nsid]/+page.svelte

This is a binary file and will not be displayed.