diff --git a/docs/docs/pages/blog/introducing-sequoia.mdx b/docs/docs/pages/blog/introducing-sequoia.mdx
index 8043d54..f23f12a 100644
--- a/docs/docs/pages/blog/introducing-sequoia.mdx
+++ b/docs/docs/pages/blog/introducing-sequoia.mdx
@@ -52,3 +52,9 @@ pnpm i -g sequoia-cli
bun i -g sequoia-cli
```
:::
+
+
+
diff --git a/docs/docs/public/sequoia-comments.js b/docs/docs/public/sequoia-comments.js
new file mode 100644
index 0000000..b9489c9
--- /dev/null
+++ b/docs/docs/public/sequoia-comments.js
@@ -0,0 +1,796 @@
+/**
+ * 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
+ *
+ * Attributes:
+ * - document-uri: AT Protocol URI for the document (optional if link tag exists)
+ * - depth: Maximum depth of nested replies to fetch (default: 6)
+ *
+ * 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-border-radius: Border radius (default: 8px)
+ */
+
+// ============================================================================
+// Styles
+// ============================================================================
+
+const styles = `
+:host {
+ display: block;
+ 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;
+ border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
+}
+
+.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;
+ background: var(--sequoia-accent-color, #2563eb);
+ color: #ffffff;
+ border: none;
+ border-radius: var(--sequoia-border-radius, 8px);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color 0.15s ease;
+}
+
+.sequoia-reply-button:hover {
+ background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
+}
+
+.sequoia-reply-button svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+.sequoia-comments-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.sequoia-comment {
+ padding: 1rem;
+ background: var(--sequoia-bg-color, #ffffff);
+ border: 1px solid var(--sequoia-border-color, #e5e7eb);
+ border-radius: var(--sequoia-border-radius, 8px);
+ margin-bottom: 0.75rem;
+}
+
+.sequoia-comment-header {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.5rem;
+}
+
+.sequoia-comment-avatar {
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 50%;
+ background: var(--sequoia-border-color, #e5e7eb);
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.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;
+}
+
+.sequoia-comment-meta {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.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-time {
+ font-size: 0.75rem;
+ color: var(--sequoia-secondary-color, #6b7280);
+ margin-left: auto;
+ 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-comment-replies {
+ margin-top: 0.75rem;
+ margin-left: 1.5rem;
+ padding-left: 1rem;
+ border-left: 2px solid var(--sequoia-border-color, #e5e7eb);
+}
+
+.sequoia-comment-replies .sequoia-comment {
+ margin-bottom: 0.5rem;
+}
+
+.sequoia-comment-replies .sequoia-comment:last-child {
+ margin-bottom: 0;
+}
+
+.sequoia-bsky-logo {
+ width: 1rem;
+ height: 1rem;
+}
+`;
+
+// ============================================================================
+// 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}`;
+}
+
+/**
+ * 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";
+}
+
+// ============================================================================
+// Bluesky Icon
+// ============================================================================
+
+const BLUESKY_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();
+ this.shadow = this.attachShadow({ mode: "open" });
+ this.state = { type: "loading" };
+ this.abortController = null;
+ }
+
+ static get observedAttributes() {
+ return ["document-uri", "depth"];
+ }
+
+ connectedCallback() {
+ this.render();
+ this.loadComments();
+ }
+
+ disconnectedCallback() {
+ this.abortController?.abort();
+ }
+
+ attributeChangedCallback() {
+ if (this.isConnected) {
+ 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;
+ }
+
+ async loadComments() {
+ // Cancel any in-flight request
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+
+ this.state = { type: "loading" };
+ this.render();
+
+ const docUri = this.documentUri;
+ if (!docUri) {
+ this.state = { type: "no-document" };
+ this.render();
+ return;
+ }
+
+ try {
+ // Fetch the document record
+ const document = await getDocument(docUri);
+
+ // Check if document has a Bluesky post reference
+ if (!document.bskyPostRef) {
+ this.state = { type: "no-comments-enabled" };
+ this.render();
+ return;
+ }
+
+ const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
+
+ // Fetch the post thread
+ const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
+
+ // Check if there are any replies
+ const replies = thread.replies?.filter(isThreadViewPost) ?? [];
+ if (replies.length === 0) {
+ this.state = { type: "empty", postUrl };
+ this.render();
+ return;
+ }
+
+ this.state = { type: "loaded", thread, postUrl };
+ this.render();
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to load comments";
+ this.state = { type: "error", message };
+ this.render();
+ }
+ }
+
+ render() {
+ const styleTag = ``;
+
+ switch (this.state.type) {
+ case "loading":
+ this.shadow.innerHTML = `
+ ${styleTag}
+
+ `;
+ break;
+
+ case "no-document":
+ this.shadow.innerHTML = `
+ ${styleTag}
+
+ `;
+ break;
+
+ case "no-comments-enabled":
+ this.shadow.innerHTML = `
+ ${styleTag}
+
+ `;
+ break;
+
+ case "empty":
+ this.shadow.innerHTML = `
+ ${styleTag}
+
+ `;
+ break;
+
+ case "error":
+ this.shadow.innerHTML = `
+ ${styleTag}
+
+ `;
+ break;
+
+ case "loaded": {
+ const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? [];
+ const commentsHtml = replies.map((reply) => this.renderComment(reply)).join("");
+ const commentCount = this.countComments(replies);
+
+ this.shadow.innerHTML = `
+ ${styleTag}
+
+ `;
+ break;
+ }
+ }
+ }
+
+ renderComment(thread) {
+ const { post } = thread;
+ const author = post.author;
+ const displayName = author.displayName || author.handle;
+ const avatarHtml = author.avatar
+ ? ``
+ : ``;
+
+ const profileUrl = `https://bsky.app/profile/${author.did}`;
+ const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
+ const timeAgo = formatRelativeTime(post.record.createdAt);
+
+ // Render nested replies
+ const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
+ const repliesHtml =
+ nestedReplies.length > 0
+ ? ``
+ : "";
+
+ return `
+
+ `;
+ }
+
+ countComments(replies) {
+ let count = 0;
+ for (const reply of replies) {
+ count += 1;
+ const nested = reply.replies?.filter(isThreadViewPost) ?? [];
+ count += this.countComments(nested);
+ }
+ return count;
+ }
+}
+
+// Register the custom element
+if (typeof customElements !== "undefined") {
+ customElements.define("sequoia-comments", SequoiaComments);
+}
+
+// Export for module usage
+export { SequoiaComments };
diff --git a/docs/docs/styles.css b/docs/docs/styles.css
new file mode 100644
index 0000000..04cff8e
--- /dev/null
+++ b/docs/docs/styles.css
@@ -0,0 +1,8 @@
+:root {
+ --sequoia-fg-color: var(--vocs-color_text);
+ --sequoia-bg-color: var(--vocs-color_background);
+ --sequoia-border-color: var(--vocs-color_border);
+ --sequoia-accent-color: var(--vocs-color_link);
+ --sequoia-secondary-color: var(--vocs-color_text3);
+ --sequoia-border-radius: 8px;
+}
diff --git a/docs/sequoia.json b/docs/sequoia.json
index 6075b65..3e4dc58 100644
--- a/docs/sequoia.json
+++ b/docs/sequoia.json
@@ -1,14 +1,19 @@
{
- "siteUrl": "https://sequoia.pub",
- "contentDir": "docs/pages/blog",
- "imagesDir": "docs/public",
- "publicDir": "docs/public",
- "outputDir": "docs/dist",
- "pathPrefix": "/blog",
- "publicationUri": "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdnzt4rqr42v",
- "pdsUrl": "https://andromeda.social",
- "frontmatter": {
- "publishDate": "date"
- },
- "ignore": ["index.mdx"]
-}
+ "siteUrl": "https://sequoia.pub",
+ "contentDir": "docs/pages/blog",
+ "imagesDir": "docs/public",
+ "publicDir": "docs/public",
+ "outputDir": "docs/dist",
+ "pathPrefix": "/blog",
+ "publicationUri": "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdnzt4rqr42v",
+ "pdsUrl": "https://andromeda.social",
+ "frontmatter": {
+ "publishDate": "date"
+ },
+ "ignore": [
+ "index.mdx"
+ ],
+ "ui": {
+ "components": "docs/components"
+ }
+}
\ No newline at end of file