diff --git a/packages/docs/content/blog/looking-ahead-with-happyview.md b/packages/docs/content/blog/looking-ahead-with-happyview.md
index d2629e4..41f4388 100644
--- a/packages/docs/content/blog/looking-ahead-with-happyview.md
+++ b/packages/docs/content/blog/looking-ahead-with-happyview.md
@@ -8,6 +8,7 @@ author:
tags:
- announcements
- roadmap
+atUri: "at://did:plc:qneu5uamqs6cug7sjobbaexm/site.standard.document/3mpybucncxh2y"
---
HappyView is a hell of a project. It's ridiculously fast, it's chock full of **big** features, and it does an excellent job of providing the tools you need and then getting out of your way. I'm proud of everything I've built so far, and I am _so excited_ for what's to come.
diff --git a/packages/docs/sequoia.json b/packages/docs/sequoia.json
index 95b77e4..df25d65 100644
--- a/packages/docs/sequoia.json
+++ b/packages/docs/sequoia.json
@@ -16,6 +16,9 @@
"bluesky": {
"enabled": true
},
+ "ui": {
+ "components": "src/components"
+ },
"autoSync": true,
"publishContent": true
}
diff --git a/packages/docs/src/app/blog/[slug]/page.tsx b/packages/docs/src/app/blog/[slug]/page.tsx
index 327da97..74e0bbc 100644
--- a/packages/docs/src/app/blog/[slug]/page.tsx
+++ b/packages/docs/src/app/blog/[slug]/page.tsx
@@ -2,6 +2,7 @@ import { blogSource } from '@/lib/source';
import { notFound } from 'next/navigation';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Mermaid } from '@/components/mermaid';
+import { SequoiaCommentsLoader } from '@/components/sequoia-comments-loader';
import { VaporwaveGrid } from '@/components/vaporwave-grid';
import { getSequoiaPublicationUri } from '@/lib/sequoia';
import Image from 'next/image';
@@ -78,6 +79,10 @@ export default async function BlogPost(props: {
+
+
+
+
);
diff --git a/packages/docs/src/app/global.css b/packages/docs/src/app/global.css
index d2155a1..a2cad09 100644
--- a/packages/docs/src/app/global.css
+++ b/packages/docs/src/app/global.css
@@ -169,6 +169,17 @@
background: rgb(var(--color-bg)) !important;
}
+/* --- Sequoia comments --- */
+
+sequoia-comments {
+ --sequoia-fg-color: rgb(var(--color-fg));
+ --sequoia-bg-color: rgb(var(--color-surface));
+ --sequoia-border-color: rgb(var(--color-border));
+ --sequoia-accent-color: rgb(var(--color-aqua));
+ --sequoia-secondary-color: rgb(var(--color-fg-muted));
+ --sequoia-border-radius: 12px;
+}
+
/* --- Blog layout --- */
#nd-home-layout {
diff --git a/packages/docs/src/components/sequoia-comments-loader.tsx b/packages/docs/src/components/sequoia-comments-loader.tsx
new file mode 100644
index 0000000..5256cbf
--- /dev/null
+++ b/packages/docs/src/components/sequoia-comments-loader.tsx
@@ -0,0 +1,11 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export function SequoiaCommentsLoader() {
+ useEffect(() => {
+ import('./sequoia-comments.js');
+ }, []);
+
+ return null;
+}
diff --git a/packages/docs/src/components/sequoia-comments.js b/packages/docs/src/components/sequoia-comments.js
new file mode 100644
index 0000000..c1490e7
--- /dev/null
+++ b/packages/docs/src/components/sequoia-comments.js
@@ -0,0 +1,1048 @@
+/**
+ * Sequoia Comments - A Bluesky-powered comments component
+ *
+ * A self-contained Web Component that displays comments from Bluesky posts
+ * linked to documents via the AT Protocol.
+ *
+ * Usage:
+ *
+ *
+ * The component looks for a document URI in two places:
+ * 1. The `document-uri` attribute on the element
+ * 2. A tag in the document head
+ *
+ * Custom reply button:
+ * Place any element with slot="reply-button" to replace the default Bluesky/Blacksky buttons.
+ * It stays in the light DOM, so your page CSS applies to it normally.
+ * Only practical with post-uri, since that's the only time the URL is known at authoring time:
+ *
+ * Reply
+ *
+ *
+ * Attributes:
+ * - post-uri: Bluesky post as AT-URI (at://...) or bsky.app URL — skips PDS document lookup
+ * - document-uri: AT Protocol URI for the document (optional if link tag exists)
+ * - depth: Maximum depth of nested replies to fetch (default: 6)
+ * - hide: Set to "auto" to hide if no document link is detected
+ *
+ * CSS Custom Properties:
+ * - --sequoia-fg-color: Text color (default: #1f2937)
+ * - --sequoia-bg-color: Background color (default: #ffffff)
+ * - --sequoia-border-color: Border color (default: #e5e7eb)
+ * - --sequoia-accent-color: Accent/link color (default: #2563eb)
+ * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
+ * - --sequoia-font-family: Font family (default: system-ui stack)
+ * - --sequoia-border-radius: Border radius (default: 8px)
+ */
+
+// ============================================================================
+// Styles
+// ============================================================================
+
+const styles = `
+:host {
+ display: block;
+ font-family: var(--sequoia-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
+ color: var(--sequoia-fg-color, #1f2937);
+ line-height: 1.5;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+.sequoia-comments-container {
+ max-width: 100%;
+}
+
+.sequoia-loading,
+.sequoia-error,
+.sequoia-empty,
+.sequoia-warning {
+ padding: 1rem;
+ border-radius: var(--sequoia-border-radius, 8px);
+ text-align: center;
+}
+
+.sequoia-loading {
+ background: var(--sequoia-bg-color, #ffffff);
+ border: 1px solid var(--sequoia-border-color, #e5e7eb);
+ color: var(--sequoia-secondary-color, #6b7280);
+}
+
+.sequoia-loading-spinner {
+ display: inline-block;
+ width: 1.25rem;
+ height: 1.25rem;
+ border: 2px solid var(--sequoia-border-color, #e5e7eb);
+ border-top-color: var(--sequoia-accent-color, #2563eb);
+ border-radius: 50%;
+ animation: sequoia-spin 0.8s linear infinite;
+ margin-right: 0.5rem;
+ vertical-align: middle;
+}
+
+@keyframes sequoia-spin {
+ to { transform: rotate(360deg); }
+}
+
+.sequoia-error {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #dc2626;
+}
+
+.sequoia-warning {
+ background: #fffbeb;
+ border: 1px solid #fde68a;
+ color: #d97706;
+}
+
+.sequoia-empty {
+ background: var(--sequoia-bg-color, #ffffff);
+ border: 1px solid var(--sequoia-border-color, #e5e7eb);
+ color: var(--sequoia-secondary-color, #6b7280);
+}
+
+.sequoia-comments-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+ padding-bottom: 0.75rem;
+}
+
+.sequoia-comments-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.sequoia-reply-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: var(--sequoia-border-radius, 15px);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color 0.15s ease;
+ margin-left:10px;
+}
+
+.sequoia-reply-bluesky {
+ background: var(--sequoia-accent-color, #2563eb);
+ color: #ffffff;
+}
+
+.sequoia-reply-blacksky {
+ background: var(--sequoia-accent-color, #6060E9);
+ color: #ffffff;
+}
+
+.sequoia-reply-bluesky:hover {
+ background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
+}
+
+.sequoia-reply-blacksky:hover {
+ background: color-mix(in srgb, var(--sequoia-accent-color, #5252c3) 85%, black);
+}
+
+.sequoia-reply-button svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+.sequoia-comments-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.sequoia-thread {
+ border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
+ padding-bottom: 1rem;
+}
+
+.sequoia-thread + .sequoia-thread {
+ margin-top: 0.5rem;
+}
+
+.sequoia-thread:last-child {
+ border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
+}
+
+.sequoia-comment {
+ display: flex;
+ gap: 0.75rem;
+ padding-top: 1rem;
+}
+
+.sequoia-comment-avatar-column {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex-shrink: 0;
+ width: 2.5rem;
+ position: relative;
+}
+
+.sequoia-comment-avatar {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 50%;
+ background: var(--sequoia-border-color, #e5e7eb);
+ object-fit: cover;
+ flex-shrink: 0;
+ position: relative;
+ z-index: 1;
+}
+
+.sequoia-comment-avatar-placeholder {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 50%;
+ background: var(--sequoia-border-color, #e5e7eb);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: var(--sequoia-secondary-color, #6b7280);
+ font-weight: 600;
+ font-size: 1rem;
+ position: relative;
+ z-index: 1;
+}
+
+.sequoia-thread-line {
+ position: absolute;
+ top: 2.5rem;
+ bottom: calc(-1rem - 0.5rem);
+ left: 50%;
+ transform: translateX(-50%);
+ width: 2px;
+ background: var(--sequoia-border-color, #e5e7eb);
+}
+
+.sequoia-comment-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.sequoia-comment-header {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ margin-bottom: 0.25rem;
+ flex-wrap: wrap;
+}
+
+.sequoia-comment-author {
+ font-weight: 600;
+ color: var(--sequoia-fg-color, #1f2937);
+ text-decoration: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sequoia-comment-author:hover {
+ color: var(--sequoia-accent-color, #2563eb);
+}
+
+.sequoia-comment-handle {
+ font-size: 0.875rem;
+ color: var(--sequoia-secondary-color, #6b7280);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sequoia-comment-handle::after {
+ content: "·";
+ margin-left: 0.5rem;
+}
+
+.sequoia-comment-time {
+ font-size: 0.875rem;
+ color: var(--sequoia-secondary-color, #6b7280);
+ flex-shrink: 0;
+}
+
+.sequoia-comment-text {
+ margin: 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.sequoia-comment-text a {
+ color: var(--sequoia-accent-color, #2563eb);
+ text-decoration: none;
+}
+
+.sequoia-comment-text a:hover {
+ text-decoration: underline;
+}
+
+.sequoia-bsky-logo {
+ width: 1rem;
+ height: 1rem;
+}
+
+.sequoia-quotes-section {
+ margin-top: 1.75rem;
+}
+
+.sequoia-quotes-header {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--sequoia-secondary-color, #6b7280);
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ margin: 0;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
+}
+
+a.sequoia-comment-time {
+ text-decoration: none;
+ color: var(--sequoia-secondary-color, #6b7280);
+}
+
+a.sequoia-comment-time:hover {
+ text-decoration: underline;
+}
+`;
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+/**
+ * Format a relative time string (e.g., "2 hours ago")
+ * @param {string} dateString - ISO date string
+ * @returns {string} Formatted relative time
+ */
+function formatRelativeTime(dateString) {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSeconds = Math.floor(diffMs / 1000);
+ const diffMinutes = Math.floor(diffSeconds / 60);
+ const diffHours = Math.floor(diffMinutes / 60);
+ const diffDays = Math.floor(diffHours / 24);
+ const diffWeeks = Math.floor(diffDays / 7);
+ const diffMonths = Math.floor(diffDays / 30);
+ const diffYears = Math.floor(diffDays / 365);
+
+ if (diffSeconds < 60) {
+ return "just now";
+ }
+ if (diffMinutes < 60) {
+ return `${diffMinutes}m ago`;
+ }
+ if (diffHours < 24) {
+ return `${diffHours}h ago`;
+ }
+ if (diffDays < 7) {
+ return `${diffDays}d ago`;
+ }
+ if (diffWeeks < 4) {
+ return `${diffWeeks}w ago`;
+ }
+ if (diffMonths < 12) {
+ return `${diffMonths}mo ago`;
+ }
+ return `${diffYears}y ago`;
+}
+
+/**
+ * Escape HTML special characters
+ * @param {string} text - Text to escape
+ * @returns {string} Escaped HTML
+ */
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+/**
+ * Convert post text with facets to HTML
+ * @param {string} text - Post text
+ * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
+ * @returns {string} HTML string with links
+ */
+function renderTextWithFacets(text, facets) {
+ if (!facets || facets.length === 0) {
+ return escapeHtml(text);
+ }
+
+ // Convert text to bytes for proper indexing
+ const encoder = new TextEncoder();
+ const decoder = new TextDecoder();
+ const textBytes = encoder.encode(text);
+
+ // Sort facets by start index
+ const sortedFacets = [...facets].sort(
+ (a, b) => a.index.byteStart - b.index.byteStart,
+ );
+
+ let result = "";
+ let lastEnd = 0;
+
+ for (const facet of sortedFacets) {
+ const { byteStart, byteEnd } = facet.index;
+
+ // Add text before this facet
+ if (byteStart > lastEnd) {
+ const beforeBytes = textBytes.slice(lastEnd, byteStart);
+ result += escapeHtml(decoder.decode(beforeBytes));
+ }
+
+ // Get the facet text
+ const facetBytes = textBytes.slice(byteStart, byteEnd);
+ const facetText = decoder.decode(facetBytes);
+
+ // Find the first renderable feature
+ const feature = facet.features[0];
+ if (feature) {
+ if (feature.$type === "app.bsky.richtext.facet#link") {
+ result += `${escapeHtml(facetText)}`;
+ } else if (feature.$type === "app.bsky.richtext.facet#mention") {
+ result += `${escapeHtml(facetText)}`;
+ } else if (feature.$type === "app.bsky.richtext.facet#tag") {
+ result += `${escapeHtml(facetText)}`;
+ } else {
+ result += escapeHtml(facetText);
+ }
+ } else {
+ result += escapeHtml(facetText);
+ }
+
+ lastEnd = byteEnd;
+ }
+
+ // Add remaining text
+ if (lastEnd < textBytes.length) {
+ const remainingBytes = textBytes.slice(lastEnd);
+ result += escapeHtml(decoder.decode(remainingBytes));
+ }
+
+ return result;
+}
+
+/**
+ * Get initials from a name for avatar placeholder
+ * @param {string} name - Display name
+ * @returns {string} Initials (1-2 characters)
+ */
+function getInitials(name) {
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return name.substring(0, 2).toUpperCase();
+}
+
+// ============================================================================
+// AT Protocol Client Functions
+// ============================================================================
+
+/**
+ * Parse an AT URI into its components
+ * Format: at://did/collection/rkey
+ * @param {string} atUri - AT Protocol URI
+ * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
+ */
+function parseAtUri(atUri) {
+ const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
+ if (!match) return null;
+ return {
+ did: match[1],
+ collection: match[2],
+ rkey: match[3],
+ };
+}
+
+/**
+ * Resolve a DID to its PDS URL
+ * Supports did:plc and did:web methods
+ * @param {string} did - Decentralized Identifier
+ * @returns {Promise} PDS URL
+ */
+async function resolvePDS(did) {
+ let pdsUrl;
+
+ if (did.startsWith("did:plc:")) {
+ // Fetch DID document from plc.directory
+ const didDocUrl = `https://plc.directory/${did}`;
+ const didDocResponse = await fetch(didDocUrl);
+ if (!didDocResponse.ok) {
+ throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
+ }
+ const didDoc = await didDocResponse.json();
+
+ // Find the PDS service endpoint
+ const pdsService = didDoc.service?.find(
+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
+ );
+ pdsUrl = pdsService?.serviceEndpoint;
+ } else if (did.startsWith("did:web:")) {
+ // For did:web, fetch the DID document from the domain
+ const domain = did.replace("did:web:", "");
+ const didDocUrl = `https://${domain}/.well-known/did.json`;
+ const didDocResponse = await fetch(didDocUrl);
+ if (!didDocResponse.ok) {
+ throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
+ }
+ const didDoc = await didDocResponse.json();
+
+ const pdsService = didDoc.service?.find(
+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
+ );
+ pdsUrl = pdsService?.serviceEndpoint;
+ } else {
+ throw new Error(`Unsupported DID method: ${did}`);
+ }
+
+ if (!pdsUrl) {
+ throw new Error("Could not find PDS URL for user");
+ }
+
+ return pdsUrl;
+}
+
+/**
+ * Fetch a record from a PDS using the public API
+ * @param {string} did - DID of the repository owner
+ * @param {string} collection - Collection name
+ * @param {string} rkey - Record key
+ * @returns {Promise} Record value
+ */
+async function getRecord(did, collection, rkey) {
+ const pdsUrl = await resolvePDS(did);
+
+ const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
+ url.searchParams.set("repo", did);
+ url.searchParams.set("collection", collection);
+ url.searchParams.set("rkey", rkey);
+
+ const response = await fetch(url.toString());
+ if (!response.ok) {
+ throw new Error(`Failed to fetch record: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data.value;
+}
+
+/**
+ * Fetch a document record from its AT URI
+ * @param {string} atUri - AT Protocol URI for the document
+ * @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record
+ */
+async function getDocument(atUri) {
+ const parsed = parseAtUri(atUri);
+ if (!parsed) {
+ throw new Error(`Invalid AT URI: ${atUri}`);
+ }
+
+ return getRecord(parsed.did, parsed.collection, parsed.rkey);
+}
+
+/**
+ * Fetch a post thread from the public Bluesky API
+ * @param {string} postUri - AT Protocol URI for the post
+ * @param {number} [depth=6] - Maximum depth of replies to fetch
+ * @returns {Promise} Thread view post
+ */
+async function getPostThread(postUri, depth = 6) {
+ const url = new URL(
+ "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
+ );
+ url.searchParams.set("uri", postUri);
+ url.searchParams.set("depth", depth.toString());
+
+ const response = await fetch(url.toString());
+ if (!response.ok) {
+ throw new Error(`Failed to fetch post thread: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
+ throw new Error("Post not found or blocked");
+ }
+
+ return data.thread;
+}
+
+/**
+ * Build a Bluesky app URL for a post
+ * @param {string} postUri - AT Protocol URI for the post
+ * @returns {string} Bluesky app URL
+ */
+function buildBskyAppUrl(postUri) {
+ const parsed = parseAtUri(postUri);
+ if (!parsed) {
+ throw new Error(`Invalid post URI: ${postUri}`);
+ }
+
+ return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
+}
+
+/**
+ * Build a Blacksky app URL for a post
+ * @param {string} postUri - AT Protocol URI for the post
+ * @returns {string} Blacksky app URL
+ */
+function buildBlackskyAppUrl(postUri) {
+ const parsed = parseAtUri(postUri);
+ if (!parsed) {
+ throw new Error(`Invalid post URI: ${postUri}`);
+ }
+
+ return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`;
+}
+
+/**
+ * Type guard for ThreadViewPost
+ * @param {any} post - Post to check
+ * @returns {boolean} True if post is a ThreadViewPost
+ */
+function isThreadViewPost(post) {
+ return post?.$type === "app.bsky.feed.defs#threadViewPost";
+}
+
+/**
+ * Fetch all quote posts for a given post URI, paginating through all results.
+ * Uses the public Bluesky AppView — gaps are expected for posts from
+ * less-connected PDS instances.
+ * @param {string} postUri - AT Protocol URI for the post
+ * @returns {Promise} Array of PostView objects
+ */
+/**
+ * Normalise a user-supplied post reference to an AT-URI.
+ * Accepts:
+ * - AT-URIs as-is: at://did:plc:.../app.bsky.feed.post/rkey
+ * - bsky.app post URLs: https://bsky.app/profile//post/
+ * When the profile segment is already a DID no network request is made.
+ * @param {string} uriOrUrl
+ * @returns {Promise} AT-URI
+ */
+async function resolvePostUri(uriOrUrl) {
+ if (uriOrUrl.startsWith("at://")) return uriOrUrl;
+
+ const match = uriOrUrl.match(
+ /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/,
+ );
+ if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`);
+
+ const [, handleOrDid, rkey] = match;
+
+ let did = handleOrDid;
+ if (!handleOrDid.startsWith("did:")) {
+ const url = new URL(
+ "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle",
+ );
+ url.searchParams.set("handle", handleOrDid);
+ const response = await fetch(url.toString());
+ if (!response.ok)
+ throw new Error(`Failed to resolve handle: ${response.status}`);
+ did = (await response.json()).did;
+ }
+
+ return `at://${did}/app.bsky.feed.post/${rkey}`;
+}
+
+async function getQuotes(postUri) {
+ const quotes = [];
+ let cursor;
+
+ do {
+ const url = new URL(
+ "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes",
+ );
+ url.searchParams.set("uri", postUri);
+ url.searchParams.set("limit", "100");
+ if (cursor) url.searchParams.set("cursor", cursor);
+
+ const response = await fetch(url.toString());
+ if (!response.ok) {
+ throw new Error(`Failed to fetch quotes: ${response.status}`);
+ }
+
+ const data = await response.json();
+ quotes.push(...(data.posts ?? []));
+ cursor = data.cursor;
+ } while (cursor);
+
+ return quotes;
+}
+
+// ============================================================================
+// Bluesky Icon
+// ============================================================================
+
+const BLUESKY_ICON = ``;
+const BLACKSKY_ICON =
+ '';
+
+// ============================================================================
+// Web Component
+// ============================================================================
+
+// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
+const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
+
+class SequoiaComments extends BaseElement {
+ constructor() {
+ super();
+ const shadow = this.attachShadow({ mode: "open" });
+
+ const styleTag = document.createElement("style");
+ shadow.appendChild(styleTag);
+ styleTag.innerText = styles;
+
+ const container = document.createElement("div");
+ shadow.appendChild(container);
+ container.className = "sequoia-comments-container";
+ container.part = "container";
+
+ this.commentsContainer = container;
+ this.state = { type: "loading" };
+ this.abortController = null;
+ }
+
+ static get observedAttributes() {
+ return ["post-uri", "document-uri", "depth", "hide"];
+ }
+
+ connectedCallback() {
+ this.initialized = true;
+ this.render();
+ this.loadComments();
+ }
+
+ disconnectedCallback() {
+ this.abortController?.abort();
+ }
+
+ attributeChangedCallback() {
+ // attributeChangedCallback fires for pre-existing attributes during
+ // element upgrade, *before* connectedCallback — skip until we've done
+ // the initial load, otherwise every attribute triggers a duplicate fetch.
+ if (this.initialized) {
+ this.loadComments();
+ }
+ }
+
+ get documentUri() {
+ // First check attribute
+ const attrUri = this.getAttribute("document-uri");
+ if (attrUri) {
+ return attrUri;
+ }
+
+ // Then scan for link tag in document head
+ const linkTag = document.querySelector(
+ 'link[rel="site.standard.document"]',
+ );
+ return linkTag?.href ?? null;
+ }
+
+ get depth() {
+ const depthAttr = this.getAttribute("depth");
+ return depthAttr ? parseInt(depthAttr, 10) : 6;
+ }
+
+ get hide() {
+ const hideAttr = this.getAttribute("hide");
+ return hideAttr === "auto";
+ }
+
+ async loadComments() {
+ // Cancel any in-flight request
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+
+ this.state = { type: "loading" };
+ this.render();
+
+ try {
+ // Resolve the post URI — either directly from the attribute or via the
+ // document record (which requires a PDS roundtrip)
+ const rawPostUri = this.getAttribute("post-uri");
+ let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null;
+ if (!postUri) {
+ const docUri = this.documentUri;
+ if (!docUri) {
+ this.state = { type: "no-document" };
+ this.render();
+ return;
+ }
+
+ const document = await getDocument(docUri);
+ if (!document.bskyPostRef) {
+ this.state = { type: "no-comments-enabled" };
+ this.render();
+ return;
+ }
+
+ postUri = document.bskyPostRef.uri;
+ }
+
+ const postUrl = buildBskyAppUrl(postUri);
+ const blackskyPostUrl = buildBlackskyAppUrl(postUri);
+
+ // Fetch thread and quotes in parallel; quote failures degrade gracefully
+ const [threadResult, quotesResult] = await Promise.allSettled([
+ getPostThread(postUri, this.depth),
+ getQuotes(postUri),
+ ]);
+
+ if (threadResult.status === "rejected") {
+ throw threadResult.reason;
+ }
+
+ const thread = threadResult.value;
+ const quotes =
+ quotesResult.status === "fulfilled" ? quotesResult.value : [];
+
+ const replies = thread.replies?.filter(isThreadViewPost) ?? [];
+ if (replies.length === 0 && quotes.length === 0) {
+ this.state = { type: "empty", postUrl, blackskyPostUrl };
+ this.render();
+ return;
+ }
+
+ this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl };
+ this.render();
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to load comments";
+ this.state = { type: "error", message };
+ this.render();
+ }
+ }
+
+ render() {
+ switch (this.state.type) {
+ case "loading":
+ this.commentsContainer.innerHTML = `
+
Comments
+