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.