@@ -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.