From fe00645e1883220bce3cb338fa9528a8a35058fc Mon Sep 17 00:00:00 2001 From: Jacob Zweifel Date: Thu, 27 Aug 2026 14:30:32 -0400 Subject: [PATCH] Render Bluesky post embeds instead of a cue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Posts carrying an embed wore a "· view on Bluesky" marker next to the date; the inconsistency read as a glitch. Link cards and images live entirely in the post record, so extraction is pure; only quote posts need the network. Quotes hydrate tolerantly: one DID-document read yields both the quoted author's PDS and their handle, then the post itself, under a 2.5s timeout and only for the posts actually shown. Any failure leaves a bare link. A quote that can be neither read nor linked is dropped so the post falls back to the cue, which now marks only the embed kinds the theme leaves to Bluesky. postPermalink built a /post/ URL for any collection, which would have produced broken links for non-post quote targets; it now matches app.bsky.feed.post only. Wind bless you. Co-Authored-By: Claude Opus 5 --- .../lib/components/theme/SiteSections.svelte | 191 +++++++++++++++++- .../lib/server/atmosphere/atmosphere.test.ts | 186 ++++++++++++++++- apps/web/src/lib/server/atmosphere/bluesky.ts | 142 ++++++++++++- apps/web/src/lib/server/atmosphere/xrpc.ts | 13 +- apps/web/src/lib/site-view.ts | 36 ++++ docs/NEXT.md | 5 +- 6 files changed, 563 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/components/theme/SiteSections.svelte b/apps/web/src/lib/components/theme/SiteSections.svelte index 40af8e7..c01a9fe 100644 --- a/apps/web/src/lib/components/theme/SiteSections.svelte +++ b/apps/web/src/lib/components/theme/SiteSections.svelte @@ -1,5 +1,5 @@ +{#snippet postEmbed(embed: PostEmbed)} + {#if embed.kind === 'images'} + + {:else if embed.kind === 'external'} + + {#if embed.thumbUrl} + + {/if} + + {embed.title} + {#if embed.description}{embed.description}{/if} + {displayHost(embed.uri)} ↗ + + + {:else} +
+ {#if embed.quoted.handle} + @{embed.quoted.handle} + {/if} + {#if embed.quoted.text} +

{embed.quoted.text}

+ {/if} + {#if embed.quoted.url} + + {embed.quoted.text ? 'View on Bluesky' : 'Quoted post'} ↗ + + {/if} +
+ {/if} +{/snippet} + {#snippet nameBlock(section: Extract)}

{section.title ?? site.name}

@@ -254,6 +307,9 @@ {#each section.posts as post (post.uri)}
  • {#each post.segments as segment}{#if segment.url}{segment.text}{:else}{segment.text}{/if}{/each}

    + {#each post.embeds as embed, i (i)} + {@render postEmbed(embed)} + {/each} {#if post.url} {#if post.hasEmbed} · view on Bluesky{/if} ↗{#if post.hasEmbed && post.embeds.length === 0} · view on Bluesky{/if} ↗ {:else} img { + flex: 0 0 4.5rem; + width: 4.5rem; + height: 4.5rem; + object-fit: cover; + border: 1px solid var(--rule); + } + + .notes .card-text { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.15rem; + } + + /* A card is a summary: long titles and blurbs stop at two lines. */ + .notes .card strong, + .notes .card .blurb { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + } + + .notes .card .blurb { + font-size: 0.92rem; + color: var(--muted); + } + + .notes .card .host { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.04em; + color: var(--muted); + } + + .notes .quote { + margin: 0.55rem 0 0.45rem; + padding: 0.5rem 0.75rem; + max-width: var(--measure); + border-left: 2px solid var(--rule); + background: var(--paper-sunk); + } + + .notes .quote cite { + display: block; + font-family: var(--font-mono); + font-size: 0.7rem; + font-style: normal; + letter-spacing: 0.04em; + color: var(--muted); + } + + .notes .quote p { + margin: 0.2rem 0 0; + } + + .notes .quote > a { + display: inline-block; + margin: 0.1rem -0.35rem -0.45rem; + padding: 0.45rem 0.35rem; + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.04em; + color: var(--muted); + text-decoration: none; + } + + .notes .quote > a:hover, + .notes .quote > a:focus-visible { + color: var(--accent); + } diff --git a/apps/web/src/lib/server/atmosphere/atmosphere.test.ts b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts index e1a6b9f..c72dc46 100644 --- a/apps/web/src/lib/server/atmosphere/atmosphere.test.ts +++ b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest'; import { listAllRecords, blobUrl, type AtmosphereContext } from './xrpc'; import { handleFromDidDocument, pdsEndpoint } from './identity'; -import { fetchBlueskyPosts, fetchBlueskyProfile, postPermalink, postSegments } from './bluesky'; +import { + fetchBlueskyPosts, + fetchBlueskyProfile, + postEmbeds, + postPermalink, + postSegments +} from './bluesky'; import { fetchDocuments } from './standard-site'; import { fetchSifaEducation, fetchSifaPositions, fetchSifaProfile, fetchSifaSkills } from './sifa'; import { detectSources } from './sources'; @@ -139,6 +145,183 @@ describe('bluesky adapter', () => { }); }); +describe('post embeds', () => { + const ctx = fakeCtx({}); + + it('reads an external link card, thumb included', () => { + const embeds = postEmbeds(ctx, { + embed: { + $type: 'app.bsky.embed.external', + external: { + uri: 'https://example.com/post', + title: 'A title', + description: 'A blurb', + thumb: { $type: 'blob', ref: { $link: 'bafythumb' }, mimeType: 'image/jpeg' } + } + } + }); + expect(embeds).toHaveLength(1); + expect(embeds[0]).toMatchObject({ kind: 'external', uri: 'https://example.com/post', title: 'A title', description: 'A blurb' }); + expect(embeds[0].kind === 'external' && embeds[0].thumbUrl).toContain('cid=bafythumb'); + }); + + it('falls back to the URI as the title and drops non-web link cards', () => { + const [card] = postEmbeds(ctx, { + embed: { $type: 'app.bsky.embed.external', external: { uri: 'https://example.com/x' } } + }); + expect(card).toMatchObject({ kind: 'external', title: 'https://example.com/x' }); + expect(card.kind === 'external' && card.description).toBeUndefined(); + expect( + postEmbeds(ctx, { + embed: { $type: 'app.bsky.embed.external', external: { uri: 'at://did:plc:x/y/z' } } + }) + ).toEqual([]); + }); + + it('reads images with their alt text and declared proportions', () => { + const [images] = postEmbeds(ctx, { + embed: { + $type: 'app.bsky.embed.images', + images: [ + { + image: { $type: 'blob', ref: { $link: 'bafyone' }, mimeType: 'image/jpeg' }, + alt: 'a cloud', + aspectRatio: { width: 1200, height: 800 } + }, + { image: { $type: 'blob', ref: { $link: 'bafytwo' } } }, + { alt: 'no blob at all' } + ] + } + }); + expect(images.kind).toBe('images'); + if (images.kind !== 'images') return; + expect(images.images).toHaveLength(2); + expect(images.images[0]).toMatchObject({ alt: 'a cloud', aspectRatio: { width: 1200, height: 800 } }); + expect(images.images[0].url).toContain('cid=bafyone'); + // Missing alt is empty, not absent; a zero ratio is dropped rather than divided by. + expect(images.images[1].alt).toBe(''); + expect(images.images[1].aspectRatio).toBeUndefined(); + }); + + it('yields media before the quote for recordWithMedia', () => { + const embeds = postEmbeds(ctx, { + embed: { + $type: 'app.bsky.embed.recordWithMedia', + media: { + $type: 'app.bsky.embed.images', + images: [{ image: { $type: 'blob', ref: { $link: 'bafypic' } }, alt: '' }] + }, + record: { $type: 'app.bsky.embed.record', record: { uri: 'at://did:plc:other/app.bsky.feed.post/abc', cid: 'c' } } + } + }); + expect(embeds.map((e) => e.kind)).toEqual(['images', 'quote']); + }); + + it('leaves embed kinds it cannot render to Bluesky', () => { + expect(postEmbeds(ctx, { embed: { $type: 'app.bsky.embed.video', video: {} } })).toEqual([]); + expect(postEmbeds(ctx, { embed: 'not an object' })).toEqual([]); + expect(postEmbeds(ctx, {})).toEqual([]); + }); +}); + +/** + * A fake network for quote hydration: plc.directory serves a DID document for + * `did:plc:quoted`, whose PDS serves one post record. + */ +function quotingCtx(options?: { pdsFails?: boolean }): AtmosphereContext { + const fakeFetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === 'plc.directory') { + return Response.json({ + alsoKnownAs: ['at://quoted.example'], + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: 'https://other-pds.example' + } + ] + }); + } + if (url.hostname === 'other-pds.example' && !options?.pdsFails) { + return Response.json({ + uri: 'at://did:plc:quoted/app.bsky.feed.post/abc', + cid: 'c', + value: { text: 'the quoted words', createdAt: '2026-01-01T00:00:00Z' } + }); + } + if (url.pathname === '/xrpc/com.atproto.repo.listRecords') { + return Response.json({ + records: [ + { + uri: 'at://did:plc:test123/app.bsky.feed.post/rkey0', + cid: 'cid0', + value: { + text: 'look at this', + createdAt: '2026-02-01T00:00:00Z', + embed: { + $type: 'app.bsky.embed.record', + record: { uri: 'at://did:plc:quoted/app.bsky.feed.post/abc', cid: 'c' } + } + } + } + ] + }); + } + return new Response('not found', { status: 404 }); + }) as typeof fetch; + return { pds: 'https://pds.example', did: 'did:plc:test123', fetch: fakeFetch }; +} + +describe('quote hydration', () => { + it('fills in the quoted post from its own author’s PDS', async () => { + const [post] = await fetchBlueskyPosts(quotingCtx()); + expect(post.embeds[0]).toEqual({ + kind: 'quote', + quoted: { + uri: 'at://did:plc:quoted/app.bsky.feed.post/abc', + url: 'https://bsky.app/profile/did:plc:quoted/post/abc', + handle: 'quoted.example', + text: 'the quoted words', + createdAt: '2026-01-01T00:00:00Z' + } + }); + }); + + it('keeps the link alone when the quoted post cannot be read', async () => { + const [post] = await fetchBlueskyPosts(quotingCtx({ pdsFails: true })); + expect(post.embeds[0]).toEqual({ + kind: 'quote', + quoted: { + uri: 'at://did:plc:quoted/app.bsky.feed.post/abc', + url: 'https://bsky.app/profile/did:plc:quoted/post/abc', + handle: 'quoted.example' + } + }); + }); + + it('does not read quote targets that are not posts', async () => { + const ctx = fakeCtx({ + collections: { + 'app.bsky.feed.post': [ + { + text: 'nice feed', + createdAt: '2026-02-01T00:00:00Z', + embed: { + $type: 'app.bsky.embed.record', + record: { uri: 'at://did:plc:other/app.bsky.feed.generator/xyz', cid: 'c' } + } + } + ] + } + }); + const [post] = await fetchBlueskyPosts(ctx); + // Nothing to show and nowhere to point: the Bluesky cue takes over. + expect(post.embeds).toEqual([]); + expect(post.hasEmbed).toBe(true); + }); +}); + describe('postSegments', () => { it('splits at facet byte ranges, counting UTF-8 bytes not code units', () => { // "☁️ " is 7 bytes; the link facet covers "example.com". @@ -216,6 +399,7 @@ describe('postPermalink', () => { 'https://bsky.app/profile/did:plc:x/post/3kabc' ); expect(postPermalink('not-a-uri')).toBeUndefined(); + expect(postPermalink('at://did:plc:x/app.bsky.feed.generator/xyz')).toBeUndefined(); }); }); diff --git a/apps/web/src/lib/server/atmosphere/bluesky.ts b/apps/web/src/lib/server/atmosphere/bluesky.ts index f3b9017..232c938 100644 --- a/apps/web/src/lib/server/atmosphere/bluesky.ts +++ b/apps/web/src/lib/server/atmosphere/bluesky.ts @@ -8,9 +8,11 @@ import { blobUrl, getRecord, listAllRecords, + obj, str } from './xrpc'; -import type { TextSegment } from '../../site-view'; +import { handleFromDidDocument, pdsEndpoint, resolveDidDocument } from './identity'; +import type { EmbedImage, PostEmbed, QuotedPost, TextSegment } from '../../site-view'; export interface BlueskyProfile { displayName?: string; @@ -31,6 +33,8 @@ export interface BlueskyPost { createdAt: string; /** Post has an embed (image, external link, quote). */ hasEmbed: boolean; + /** The embeds a theme can render; empty when the kind isn't one of them. */ + embeds: PostEmbed[]; } interface FacetLink { @@ -94,11 +98,125 @@ export function postSegments(text: string, value: Record): Text return segments; } +/** Only post records get a /post/ link; other collections have no such address. */ export function postPermalink(uri: string): string | undefined { - const match = /^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/.exec(uri); + const match = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/.exec(uri); return match ? `https://bsky.app/profile/${match[1]}/post/${match[2]}` : undefined; } +/* ——— embeds ——— */ + +/** How long a quoted post's author and text may take to read before it renders bare. */ +const QUOTE_TIMEOUT_MS = 2500; + +function aspectRatio(value: unknown): EmbedImage['aspectRatio'] { + const ratio = obj(value); + if (!ratio) return undefined; + const { width, height } = ratio; + if (typeof width !== 'number' || typeof height !== 'number') return undefined; + return width > 0 && height > 0 ? { width, height } : undefined; +} + +function imagesEmbed(ctx: AtmosphereContext, embed: Record): PostEmbed | undefined { + if (!Array.isArray(embed.images)) return undefined; + const images: EmbedImage[] = []; + for (const entry of embed.images) { + const image = obj(entry); + if (!image) continue; + const cid = blobRefCid(image, 'image'); + if (!cid) continue; + images.push({ + url: blobUrl(ctx, cid), + alt: str(image, 'alt') ?? '', + aspectRatio: aspectRatio(image.aspectRatio) + }); + } + return images.length > 0 ? { kind: 'images', images } : undefined; +} + +function externalEmbed( + ctx: AtmosphereContext, + embed: Record +): PostEmbed | undefined { + const external = obj(embed.external); + if (!external) return undefined; + const uri = str(external, 'uri'); + // Only web URLs become a card; anything else in a record stays unrendered. + if (!uri || !/^https?:\/\//.test(uri)) return undefined; + const thumbCid = blobRefCid(external, 'thumb'); + return { + kind: 'external', + uri, + title: str(external, 'title') || uri, + description: str(external, 'description') || undefined, + thumbUrl: thumbCid ? blobUrl(ctx, thumbCid) : undefined + }; +} + +/** Images and link cards are the media kinds a `recordWithMedia` can also carry. */ +function mediaEmbed( + ctx: AtmosphereContext, + embed: Record | undefined +): PostEmbed | undefined { + if (!embed) return undefined; + if (embed.$type === 'app.bsky.embed.images') return imagesEmbed(ctx, embed); + if (embed.$type === 'app.bsky.embed.external') return externalEmbed(ctx, embed); + return undefined; +} + +function quoteEmbed(embed: Record | undefined): PostEmbed | undefined { + const target = obj(embed?.record); + const uri = target && str(target, 'uri'); + if (!uri || !uri.startsWith('at://')) return undefined; + return { kind: 'quote', quoted: { uri, url: postPermalink(uri) } }; +} + +/** The renderable embeds on a post record, newest media first for mixed embeds. */ +export function postEmbeds(ctx: AtmosphereContext, value: Record): PostEmbed[] { + const embed = obj(value.embed); + if (!embed) return []; + if (embed.$type === 'app.bsky.embed.recordWithMedia') { + // recordWithMedia nests the quote one level deeper than a bare record embed. + return [mediaEmbed(ctx, obj(embed.media)), quoteEmbed(obj(embed.record))].filter( + (e): e is PostEmbed => e !== undefined + ); + } + const single = + embed.$type === 'app.bsky.embed.record' ? quoteEmbed(embed) : mediaEmbed(ctx, embed); + return single ? [single] : []; +} + +/** + * Fill in a quoted post's author and text. Only posts are read; a quote of a + * feed, list or starter pack keeps its link alone, as does one whose target is + * deleted, blocked or slow. + */ +async function hydrateQuote(ctx: AtmosphereContext, quoted: QuotedPost): Promise { + const match = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(quoted.uri); + if (!match) return; + const [, did, collection, rkey] = match; + if (collection !== 'app.bsky.feed.post') return; + try { + const signal = AbortSignal.timeout(QUOTE_TIMEOUT_MS); + let target: AtmosphereContext; + if (did === ctx.did) { + target = { ...ctx, signal }; + } else { + const didDocument = await resolveDidDocument(did, { fetch: ctx.fetch, signal }); + const pds = pdsEndpoint(didDocument); + if (!pds) return; + quoted.handle = handleFromDidDocument(didDocument); + target = { pds, did, fetch: ctx.fetch, signal }; + } + const record = await getRecord(target, 'app.bsky.feed.post', rkey); + if (!record) return; + quoted.text = str(record.value, 'text'); + quoted.createdAt = str(record.value, 'createdAt'); + } catch { + // A bare link is the fallback; the quote still renders. + } +} + export async function fetchBlueskyProfile( ctx: AtmosphereContext ): Promise { @@ -135,9 +253,25 @@ export async function fetchBlueskyPosts( segments: postSegments(text, record.value), url: postPermalink(record.uri), createdAt, - hasEmbed: record.value.embed !== undefined + hasEmbed: record.value.embed !== undefined, + embeds: postEmbeds(ctx, record.value) }); } posts.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - return posts.slice(0, limit); + const shown = posts.slice(0, limit); + // Quotes are the one embed needing a second read; only the shown posts pay for it. + await Promise.all( + shown.flatMap((post) => + post.embeds.map((embed) => + embed.kind === 'quote' ? hydrateQuote(ctx, embed.quoted) : Promise.resolve() + ) + ) + ); + // A quote with nothing to show and nowhere to point falls back to the Bluesky cue. + for (const post of shown) { + post.embeds = post.embeds.filter( + (embed) => embed.kind !== 'quote' || embed.quoted.url || embed.quoted.text + ); + } + return shown; } diff --git a/apps/web/src/lib/server/atmosphere/xrpc.ts b/apps/web/src/lib/server/atmosphere/xrpc.ts index 63ed9da..21757a2 100644 --- a/apps/web/src/lib/server/atmosphere/xrpc.ts +++ b/apps/web/src/lib/server/atmosphere/xrpc.ts @@ -9,6 +9,8 @@ export interface AtmosphereContext { /** The repo (account) to read, as a DID. */ did: string; fetch?: typeof fetch; + /** Aborts every read made through this context. */ + signal?: AbortSignal; } export interface ListedRecord { @@ -34,7 +36,10 @@ export async function xrpcGet( const url = new URL(`/xrpc/${nsid}`, ctx.pds); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); const doFetch = ctx.fetch ?? fetch; - const res = await doFetch(url, { headers: { accept: 'application/json' } }); + const res = await doFetch(url, { + headers: { accept: 'application/json' }, + signal: ctx.signal + }); if (!res.ok) throw new Error(`${nsid} failed (HTTP ${res.status}) against ${ctx.pds}`); return res.json(); } @@ -124,6 +129,12 @@ export function blobUrl(ctx: AtmosphereContext, blobCid: string): string { /* Defensive field extraction: never trust external record shapes. */ +export function obj(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + export function str(value: Record, key: string): string | undefined { const v = value[key]; return typeof v === 'string' ? v : undefined; diff --git a/apps/web/src/lib/site-view.ts b/apps/web/src/lib/site-view.ts index 8e8643e..cc3fb16 100644 --- a/apps/web/src/lib/site-view.ts +++ b/apps/web/src/lib/site-view.ts @@ -44,6 +44,36 @@ export interface TextSegment { url?: string; } +/** An image carried by a post embed. */ +export interface EmbedImage { + /** PDS blob URL; the theme serves it through /blob. */ + url: string; + alt: string; + /** Intrinsic proportions from the record, when it declares them. */ + aspectRatio?: { width: number; height: number }; +} + +/** A post the embed quotes. Everything but the URI needs a second read, which may fail. */ +export interface QuotedPost { + uri: string; + /** bsky.app permalink derived from the record URI. */ + url?: string; + handle?: string; + text?: string; + createdAt?: string; +} + +export type PostEmbed = + | { + kind: 'external'; + uri: string; + title: string; + description?: string; + thumbUrl?: string; + } + | { kind: 'images'; images: EmbedImage[] } + | { kind: 'quote'; quoted: QuotedPost }; + export interface PostView { uri: string; text: string; @@ -52,7 +82,13 @@ export interface PostView { /** bsky.app permalink derived from the record URI. */ url?: string; createdAt: string; + /** The record carries an embed of some kind. */ hasEmbed: boolean; + /** + * The embeds this theme renders. Empty alongside `hasEmbed` means the post + * carries a kind the theme leaves to Bluesky. + */ + embeds: PostEmbed[]; } export interface DocumentView { diff --git a/docs/NEXT.md b/docs/NEXT.md index f0f46c4..982aa24 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,7 +2,7 @@ The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual. -_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, queued as item 2 below. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **Nothing is in flight; the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ +_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, queued as item 2 below. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **In flight: post embed rendering** — branch `claude/post-embed-rendering`, verified locally against live PDS data, not yet merged or deployed. The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ ## Where things stand @@ -14,7 +14,6 @@ Feasibility is done and the verdict was **build it** (see `FEASIBILITY.md`). All Done so far: OAuth login (loopback dev client; hosted-client path ready pending a real key + deploy) with D1-backed state/session stores; lexicon convention tests; **read-only adapters** for Bluesky (profile + posts, replies filtered), standard.site (documents/publications, `pub.leaflet.document` fallback only when no standard.site docs exist), and sifa (profile/positions/education/skills, defensively parsed) — fetch-injected modules in `apps/web/src/lib/server/atmosphere/` with unit tests, plus `detectSources` (drives the ADR 0012 default section order) and a source-overview admin page; **site/page authoring** — record builders, PDS writes through the OAuth session, and the `/admin` + `/admin/pages` CRUD routes; **the professional-presence theme** — the render pipeline in `apps/web/src/lib/server/render/` and the public routes at `/s/[handle]`. Remaining, roughly in dependency order: -- **Post embed rendering** (queued 2026-08-25, Jacob's call): render Bluesky post embeds instead of the "· view on Bluesky" cue that currently marks `hasEmbed` posts (critique 4: "the inconsistency reads as a glitch even though it's signal"). Scope: external-link cards (embed `app.bsky.embed.external` — title/description/thumb, thumb blob via the `/blob` route), image thumbnails (`app.bsky.embed.images` — blobs via `/blob`, alt text from the record), and quote posts (`app.bsky.embed.record` — needs a second `getRecord` for the quoted post; render author/text compactly, tolerate deleted/blocked targets). Adapter work in `atmosphere/bluesky.ts` (embed extraction, defensively parsed like facets), view-model additions in `site-view.ts`, theme rendering in `SiteSections.svelte` posts notes. The `/blob` width allowlist (256/768/1600) probably gains a thumb size. Keep the "view on Bluesky" cue only for embed kinds not rendered. - **Theme UX polish, remaining tail** (all mechanical findings from four critique rounds are fixed and deployed; the contrast guards landed 2026-08-27, see Done; what remains is design-decision territory — mock on a canvas before coding, per this push's working pattern): 1. *Record-level career curation fields* (decided 2026-08-24, staged): hide/order/feature for positions, education, skills — e.g. de-emphasizing a high-school entry or dropping generic imported skills ("Software Development", "Windows 7"); becomes `careerSection` fields once the admin grows a section editor. Framing from the reviews: "honest by default, curatable by choice." 2. *Volume behavior*: every section is an uncapped list — decide caps/pagination ("latest N") before a user with 40 posts finds the missing answer. Posts/writing already honor per-section `limit` in the site record; the question is defaults and UI. @@ -37,6 +36,8 @@ The whole design is settled and recorded — `research/2026-08-26-analytics-offe ## Done +- 2026-08-27 — **Bluesky post embeds render instead of a cue** (branch `claude/post-embed-rendering`, **not yet merged or deployed**). Posts carrying an embed showed "· view on Bluesky" next to the date; critique 4 read the inconsistency as a glitch. Three of the four embed kinds turn out to cost nothing: `app.bsky.embed.external` and `app.bsky.embed.images` carry everything in the post record, so extraction is a pure function (`postEmbeds` in `atmosphere/bluesky.ts`, defensively parsed like the facet code beside it) and `recordWithMedia` is just both. Only `app.bsky.embed.record` needs the network. Jacob's calls: **hydrate quotes tolerantly** and **give images the full text measure with a height cap**. Hydration resolves the quoted author's DID document (one fetch yields both the PDS and the handle via `handleFromDidDocument`) then reads the post, all under a 2.5s `AbortSignal.timeout`, in parallel and only for the posts actually shown; any failure leaves a bare "Quoted post ↗" link. A self-quote skips the DID resolution entirely and renders without a byline. A quote that can neither be read nor linked — a feed generator, a list — is dropped so the post falls back to the Bluesky cue, which is now shown only for embed kinds the theme doesn't render (video, today). Theme work is one `postEmbed` snippet in `SiteSections.svelte`: link cards clamp title and blurb to two lines over a sunk-paper panel with the host in mono; a single image uses the record's `aspectRatio` as `width`/`height` attributes so the browser reserves the space, then `max-height: 22rem` does the fitting; two or more become a square-celled contact sheet. Two things fell out of the work: `AtmosphereContext` gained an optional `signal` that `xrpcGet` honors, and **`postPermalink` was too permissive** — it built a `/post/` URL for any collection, which would have produced broken links for non-post quote targets, so it now matches `app.bsky.feed.post` only (one guard, all callers). The existing `/blob` widths cover both a 4.5rem card thumb and a full-measure image, so the anticipated new thumb size wasn't needed. 16 new tests (217 in `web`); the recordWithMedia test caught a real nesting bug — that embed wraps its quote one level deeper than a bare `record` embed. Verified in a browser against live PDS data (`jzweifel.dev`: two real link cards with PDS thumbs, and the `recordWithMedia` post rendering its card plus a hydrated self-quote, cue gone, no overflow at 375px) and against a temporary preview route covering all seven states in both schemes, deleted before commit. **Not smoke-tested against the deployed origin — CI does not deploy.** + - 2026-08-27 — **User color overrides can no longer render a site unreadable** (PR #39, **merged and deployed 2026-08-27**, Worker version `ea42ea99`). `theme.colors` set `--paper`/`--ink`/`--accent`/`--accent-ink` through the `style` attribute on `.site`, and inline custom properties outrank every stylesheet rule — so the theme's own `@media (prefers-color-scheme: dark)` block could never fire for an overridden token. A site that declared only a background got that background pinned in dark mode while `--ink` still flipped to `#e9e3d6`: near-white text on near-white paper, ~1.06:1, with a near-black `--paper-sunk` panel sitting in the middle of it. Reproduced in a browser before the fix. The record carries one `colors` object and no scheme variants, so the fix takes that at its word: **any color override declares a single palette and pins the site to one scheme**, chosen by the background's luminance (or, with only a foreground declared, its inverse — dark text means light paper). A new `apps/web/src/lib/server/render/palette.ts` builds the whole eight-token palette server-side from whatever subset was declared — the derived roles (`--muted`, `--rule`, `--paper-sunk`) are mixed from the *declared* paper and ink instead of stranding at the other scheme's values — and holds every text-bearing pairing (ink/muted/accent on paper, accent-ink on accent) to WCAG AA 4.5:1. **Failing colors are darkened or lightened toward black or white until they clear, rather than dropped** (Jacob's call): most plausible brand colors fail AA on the near-white paper — Bluesky blue is 3.26:1, a mid teal 2.89:1 — so dropping them would have made "I set my accent and nothing happened" the common case. Hue is preserved; the teal renders 24% darker and still unmistakably teal. The walk always terminates: whichever of black and white is further from the background reaches at least 4.58:1. `color-scheme` is pinned alongside the palette (via `html:has(.site.pinned)`, whose specificity beats the theme's own `html` rule regardless of load order) so scrollbars and form controls stop following the OS against a pinned page. Sites with no override emit no tokens at all and keep both schemes — verified unchanged in both directions. 15 new tests including nine hostile palettes (identical fg/bg, mid-grey, white-on-white); verified in a browser through a temporary preview route, deleted before commit. **Known ceiling:** an accent-only override now also pins the scheme, so a user who only wanted a brand color loses dark mode. The honest upgrade is a `colorsDark` sibling in the `page.mooring.site` lexicon rather than more CSS — the lexicon simply cannot express two schemes today. Its `colors` description was left alone to avoid a republish for prose; fold the pinning semantics in next time that record is republished. Smoke-tested against the deployed origin: `mooring.page/s/jzweifel.dev` renders correctly in both schemes (`--paper` `#101520`/`#f7f3ea` following the reader, empty inline style, no `pinned` class, `color-scheme: light dark`) with no console errors, and the served stylesheet carries the compiled `.site.svelte-8htptl:not(.pinned)` — note Svelte places the scope class *before* the `:not()`, so grep for `pinned` rather than the authored selector. **One gap:** no deployed site declares `theme.colors` yet, so the pinned path itself is covered by the tests and the dev preview, not by a live record — exercise it the first time a real site sets colors. - 2026-08-27 — **The admin and login pages wear the site letterhead** (PR #35, merged and deployed 2026-08-27). The /admin routes and the sign-in page were bare system-ui defaults next to a landing page with a committed identity. The palette and control skin now live once in `apps/web/src/lib/styles/letterhead.css`, class-scoped under `.letterhead` (airmail stripe, paper/ink/terracotta tokens in both color schemes, serif body with the mono-uppercase system voice, one skin for inputs/buttons/links/notices/`code`), imported by a new `admin/+layout.svelte` — masthead nav (Overview / Pages / Hosting, current section marked), container sizing, valediction footer — and by the login page. The admin pages shed their ad-hoc styles and hardcoded colors; per-page "← Admin" back-links gave way to the persistent nav; destructive actions (Delete, Remove, Release, Sign out) wear a quiet outline that warms to `--warn` on hover; the hosting page's paused notice traded its side-stripe for a sunk paper panel. Verified in-browser (login at the real route, admin via a temporary unauthenticated preview route, deleted before commit) in both schemes at desktop and 375px. Shipped with one bug — the white body-margin frame — fixed in the follow-up noted above. -- 2.51.2