···4040 **sanitises** HTML before injecting it (`src/lib/reader/sanitize.ts`). Three standing
4141 rules for the read path: (a) any server-side `fetch` to a host derived from user input
4242 (a handle, `did:web`, a PDS `serviceEndpoint`) MUST go through `src/lib/net/safe-fetch.ts`
4343- (SSRF guard); (b) never inject PDS-sourced HTML without sanitising; (c) read routes
4343+ (SSRF guard); (b) never inject PDS-sourced HTML without sanitising — turn document
4444+ blocks into HTML through `src/lib/reader/render-article.ts`, which runs
4545+ blob-resolve → render → sanitise in the one safe order (Decision 0017); (c) read routes
4446 resolve author/publication/document through `src/lib/reader/read-context.ts` — don't
4547 re-assemble the handle → DID → PDS → slug-match → site-join chain in page frontmatter.
4648 (Decision 0016)
···3939- `records.getRecord` / `listRecords` — public `com.atproto.repo` XRPC (no auth).
40404141### Rendering
4242+4343+> **Amended by Decision 0017 (2026-06-10):** callers no longer assemble this pipeline
4444+> themselves — `src/lib/reader/render-article.ts` owns the ordering behind one
4545+> interface. The pipeline itself is unchanged.
4646+4247`fetch document → resolveBlobImageUrls → renderBlocks → sanitizeArticleHtml → inject`.
4348- **Images:** `resolveBlobImageUrls` rebuilds each blob-backed image's `getBlob` URL from
4449 the article's DID + the stored CID (portable across PDS migrations, Decision 0006);
+2-1
docs/decisions/0011-full-content-rss-feeds.md
···4242`buildPublicationFeedXml` is a pure transform fed by the same `listRecords` call the
4343publication page already makes (`listRecords` returns each document's full `content.blocks`,
4444so there is **no per-article round-trip**). Each item runs through the exact reader pipeline:
4545-`resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml`. The feed body is therefore
4545+`resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml` (since Decision 0017, owned
4646+by `src/lib/reader/render-article.ts`). The feed body is therefore
4647the same sanitised HTML a browser sees (AGENTS.md #6b satisfied by reuse), and the route's
4748single PDS fetch goes through `safeFetch` (AGENTS.md #6a satisfied by reuse).
4849
+10
docs/decisions/0015-social-actions-on-posts.md
···6161 there is no SkyPress server write endpoint. Rate-limiting / abuse is the PDS's concern.
6262- **Legacy documents without `bskyPostRef`** (pre–Decision 0013, or a failed third write)
6363 render no action bar — there is no Bluesky thread to act on.
6464+- **Deleted companion post** (the `bskyPostRef` exists, but the post it points at was later
6565+ deleted on Bluesky): the island confirms the post is live before showing anything, and
6666+ renders **nothing** when it is gone — no buttons, note, or thread link, since none would
6767+ work. The check is client-side and three-state: signed-in readers learn it from the
6868+ authenticated `getPosts` (a `null` result ⇒ gone); signed-out readers from an
6969+ unauthenticated `fetchPostExists` against the public AppView. It is **optimistic** — the
7070+ bar shows by default and only hides on a *definitive* "gone", so a transient network error
7171+ never hides a live post (fail open). `getPosts` can't distinguish *deleted* from
7272+ *not-yet-indexed*, so a just-published post viewed before AppView indexing is briefly
7373+ hidden too (self-heals on reload).
6474- **"Don't surprise users" (brief §10):** the UI states, in both signed-out and signed-in
6575 states, that actions are public and happen on Bluesky.
6676
+2-1
docs/decisions/0016-read-context-module.md
···3030 in parallel inside the module; the old pages ran them serially.
3131- The article render pipeline (`resolveBlobImageUrls → renderBlocks →
3232 sanitizeArticleHtml`) deliberately stays in the article page / feed builder — sealing
3333- it behind its own interface is a separate, orthogonal deepening.
3333+ it behind its own interface is a separate, orthogonal deepening. *(Since done:
3434+ Decision 0017 sealed it behind `src/lib/reader/render-article.ts`.)*
3435- The shallow wrappers this obsoleted (`listReaderPublications`,
3536 `resolveReaderPublication`) were deleted; `listAllReaderPublications` remains the one
3637 reader-side publication fetch.
+44
docs/decisions/0017-known-provider-detection.md
···11+# 0017 — Recognising the app behind a foreign publication
22+33+- **Status:** Accepted
44+- **Date:** 2026-06-10
55+- **Scope:** foreign `site.standard.publication` records on both the authed dashboard
66+ ("From other apps") and the public profile ("Elsewhere") — `src/lib/publish/providers.ts`,
77+ the two `publications.ts` mappers, `ProviderLogo.{tsx,astro}`.
88+99+## Context
1010+1111+`site.standard.publication` is a shared collection: other apps (Leaflet, pckt, Offprint,
1212+…) write their own records into a writer's repo. We list these as "foreign" and want to
1313+show the originating app's logo next to its hostname. The obvious signal — the
1414+publication's hostname — fails for the paid tiers of these services, which serve from a
1515+**custom domain** (`myblog.com`, not `*.leaflet.pub`).
1616+1717+## Decision
1818+1919+`detectProvider( url, value )` recognises a provider in two steps, framework-agnostic
2020+(no JSX) so the React dashboard and the server-rendered Astro reader share one core:
2121+2222+1. **Record discriminator (primary).** App-specific namespaced `$type`s embedded in the
2323+ record survive a custom domain. Observed in real records: pckt writes
2424+ `theme.$type === "blog.pckt.theme"`. This is the only custom-domain-proof signal.
2525+2. **Hostname suffix (fallback).** `*.leaflet.pub`, `*.pckt.blog`, `*.offprint.app`,
2626+ matched at a dot boundary (`host === domain || host.endsWith('.' + domain)`) so
2727+ `evil-leaflet.pub.com` and `notleaflet.pub` do **not** match.
2828+2929+The logo glyphs live as monochrome (`currentColor`) inner-SVG `body` strings in
3030+`KNOWN_PROVIDERS`, rendered inside each framework's own fixed-size `<svg>` wrapper.
3131+3232+## Consequences
3333+3434+- **Leaflet and Offprint on a custom domain are unrecognised** — their records carry no
3535+ app-specific `$type` (verified against a real `*.leaflet.pub` record; Offprint
3636+ unsampled), so they fall back to a bare hostname, exactly as before. Accepted for v1.
3737+ If either later adds a discriminator, extend step 1.
3838+- Recognising a new provider = one `KNOWN_PROVIDERS` entry + (its discriminator and/or)
3939+ one `PROVIDER_DOMAINS` line + a `detectProvider` test case. No renderer changes.
4040+- The glyph data is trusted constant content, so `ProviderLogo` injects it via
4141+ `dangerouslySetInnerHTML` / `set:html` without sanitising — it is never PDS-derived,
4242+ unlike article HTML (which still goes through the reader sanitiser, AGENTS.md rule 6).
4343+- Leaflet's brand asset was a raster PNG, so its glyph is a substitute vector feather
4444+ (Lucide, ISC) rather than the official mark.
+47
docs/decisions/0017-render-article-module.md
···11+# 0017 — The article render pipeline lives in one render-article module
22+33+- **Status:** Accepted
44+- **Date:** 2026-06-10
55+- **Scope:** the article render pipeline (`src/lib/reader/render-article.ts`, the
66+ article page, `src/lib/feed/publication-feed.ts`)
77+88+## Context
99+1010+Turning a stored block tree into safe HTML takes three calls in one load-bearing
1111+order — `resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml` — plus the
1212+plain-text fallback (`doc.textContent || blocksToText( blocks )`). The ordering is the
1313+AGENTS.md #6b invariant (sanitise is the LAST step before injection), but no module
1414+owned it: the article page and the feed builder each assembled it by hand. A third
1515+read surface (AMP, email, embeds) would have had to re-learn the order, and getting it
1616+wrong ships unsanitised PDS HTML. Decision 0016 sealed route *resolution* behind
1717+`read-context.ts` and explicitly deferred this orthogonal deepening.
1818+1919+## Decision
2020+2121+One deep module, `src/lib/reader/render-article.ts`:
2222+2323+```ts
2424+renderArticle( doc, { pdsUrl, did } ) → { html, text }
2525+```
2626+2727+- It accepts the document-value slice (`textContent` + `content.blocks`) rather than
2828+ raw blocks, so the text fallback concentrates here too. `read-context.ts`'s
2929+ `SkyDocumentValue` and the feed's `FeedDocumentValue` both satisfy the
3030+ `RenderableDocument` parameter structurally — no adapter layer.
3131+- The pipeline-order invariant gains locality: it lives once, behaviourally tested in
3232+ `render-article.test.ts` (a `<script>` in block content never survives; blob-backed
3333+ image URLs are rebuilt against the author's current PDS before render).
3434+- Like `render.ts` underneath, the module is dependency-free — it must never import
3535+ `@wordpress/*` (Decision 0003). It wraps `render.ts`, sitting beside it; render
3636+ fidelity stays locked by `render.test.ts`, untouched.
3737+- The article page and `buildPublicationFeedXml` are now callers;
3838+ `publication-feed.ts` stays a pure transform. New read surfaces get sanitisation for
3939+ free by construction — AGENTS.md rule 6(b) now points here.
4040+4141+## Consequences
4242+4343+- `resolveBlobImageUrls`, `renderBlocks`, and `sanitizeArticleHtml` keep their own
4444+ unit suites; no page or transform should compose them by hand again
4545+ (`_[rkey].meta.test.ts` pins the article page's delegation).
4646+- A surface needing different rendering rules (e.g. email's stricter tag set) extends
4747+ this module's interface rather than re-assembling the pipeline at the call site.
···17171818- **Section heading:** "Elsewhere".
1919- **Per-item content:** publication **name** only (plus its logo). No hostname.
2020+ - **Superseded 2026-06-10** (`2026-06-10-provider-logos-design.md`): each row now also
2121+ shows a hostname pill, with the originating service's logo before it when recognised
2222+ (Decision 0017). `ReaderForeignPublication` gained `hostname` + `provider` for this.
2023- Render the section only when there is at least one foreign publication.
21242225## Data layer — `src/lib/reader/publications.ts`
···11+# Provider logos next to foreign-publication hostnames — design
22+33+**Date:** 2026-06-10
44+**Status:** Approved (ready for implementation)
55+66+## Goal
77+88+When a "foreign" publication (a `site.standard.publication` record SkyPress doesn't
99+own — written by Leaflet, pckt, Offprint, …) is shown, display the originating
1010+service's logo next to its hostname **if** we can recognise the service.
1111+1212+Two interfaces, sharing one detection + logo core:
1313+1414+- **Dashboard "From other apps"** (`src/components/Dashboard.tsx`) — logo before the
1515+ hostname in the existing `.dash__pubhost` pill.
1616+- **Public profile "Elsewhere"** (`src/pages/[author]/index.astro`) — currently shows
1717+ the publication name only; now also shows a hostname pill with the logo before it.
1818+ (This reverses the "name only, no hostname" decision in
1919+ `2026-06-09-profile-elsewhere-section-design.md`, which is updated accordingly.)
2020+2121+## Detecting the service
2222+2323+A two-step strategy, because the hostname alone misses services on custom domains:
2424+2525+1. **Record discriminator (primary).** App-specific records carry namespaced `$type`s
2626+ that survive a custom domain. Observed: pckt embeds `theme.$type ===
2727+ "blog.pckt.theme"`. Detect pckt this way regardless of hostname.
2828+2. **Hostname suffix (fallback).** `*.leaflet.pub` → leaflet, `*.pckt.blog` → pckt,
2929+ `*.offprint.app` → offprint. Suffix test is exact-or-dot-boundary
3030+ (`host === domain || host.endsWith('.' + domain)`) to avoid `evil-leaflet.pub.com`.
3131+3232+**Known limitation:** Leaflet records are fully standard (no app-specific `$type` —
3333+verified against a real `*.leaflet.pub` record) and Offprint is unsampled. So Leaflet
3434+and Offprint published on a **custom domain** can't be recognised and fall back to a
3535+bare hostname, exactly as today. Acceptable for v1.
3636+3737+## Shared core — `src/lib/publish/providers.ts` (new)
3838+3939+Framework-agnostic, pure TS (no JSX), the reusable part both interfaces consume:
4040+4141+```ts
4242+export type ProviderId = 'leaflet' | 'pckt' | 'offprint';
4343+export interface KnownProvider { id: ProviderId; label: string; viewBox: string; body: string; }
4444+export const KNOWN_PROVIDERS: Record<ProviderId, KnownProvider>;
4545+export function detectProvider( url: string, value: unknown ): ProviderId | null;
4646+```
4747+4848+- `body` is the inner SVG markup (paths/etc.), self-contained and monochrome
4949+ (`currentColor` — fill-based for pckt/offprint, `fill="none" stroke="currentColor"`
5050+ for the leaflet feather). Rendered inside a fixed-size `<svg viewBox>` wrapper, so the
5151+ source asset's dimensions don't matter, and both fill- and stroke-based glyphs work.
5252+- `label` (e.g. "Leaflet") is the SVG's accessible name (`aria-label`).
5353+5454+### Logo assets
5555+5656+- **pckt, offprint** — extracted from the vector SVGs the user saved in `public/`
5757+ (already single-colour). pckt's hardcoded `fill="black"` becomes `currentColor`.
5858+- **leaflet** — the saved `public/leaflet.svg` is a 147KB raster PNG wrapped in SVG, so
5959+ there is no vector to recolour. Substitute a clean vector **feather** glyph
6060+ (Leaflet's mark is a feather), monochrome, `currentColor`.
6161+- The now-inlined `public/{leaflet,pckt,offprint}.svg` files are removed.
6262+6363+## Data layer
6464+6565+Both mappers call `detectProvider( url, value )` and store the result.
6666+6767+- `ForeignPublication` (`src/lib/publish/publications.ts`) gains
6868+ `provider: ProviderId | null`.
6969+- `ReaderForeignPublication` (`src/lib/reader/publications.ts`) gains
7070+ `provider: ProviderId | null` **and** `hostname: string` (it has neither today; the
7171+ profile page needs the hostname to display it).
7272+7373+## Renderers — share the data, one thin renderer per framework
7474+7575+Astro can't server-render a React component, and the read path must not take a client
7676+island just for a logo. So the *data + detection* is shared; each framework gets a
7777+~5-line SVG renderer reading from `KNOWN_PROVIDERS[ id ]`:
7878+7979+- **`src/components/ProviderLogo.tsx`** (React) — used in `Dashboard.tsx`. Renders
8080+ `null` when `provider` is null. Placed before the hostname text in `.dash__pubhost`.
8181+- **`src/components/ProviderLogo.astro`** — used in `[author]/index.astro` "Elsewhere"
8282+ rows, which gain a hostname pill (logo + hostname) reusing the dashboard's pill look.
8383+8484+## Tests (TDD — failing test first)
8585+8686+- `src/lib/publish/providers.test.ts` (new): `detectProvider` →
8787+ - pckt via `theme.$type === 'blog.pckt.theme'` even on a non-pckt hostname;
8888+ - leaflet/pckt/offprint via hostname suffix (exact + subdomain);
8989+ - rejects look-alike hosts (`evil-leaflet.pub.com`, `notleaflet.pub`);
9090+ - returns null for unknown hosts / unparseable urls;
9191+ - `KNOWN_PROVIDERS` has an entry for every `ProviderId`.
9292+- `src/lib/publish/publications.test.ts`: `listAllPublications` sets `provider` on
9393+ foreign entries (pckt by discriminator, leaflet by host, null when unknown).
9494+- `src/lib/reader/publications.test.ts`: `listAllReaderPublications` sets `provider`
9595+ and `hostname` on foreign entries; owned-only regression still holds.
9696+9797+## Out of scope
9898+9999+No lexicon changes, no curated-block changes, no reader-sanitiser changes. The read
100100+path still never imports `@wordpress/*`. Custom-domain Leaflet/Offprint detection is
101101+explicitly not solved.
+71
src/components/AppBar.test.tsx
···11+import { describe, it, expect } from 'vitest';
22+import { createElement } from 'react';
33+import { renderToStaticMarkup } from 'react-dom/server';
44+import AppBar from './AppBar';
55+import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider';
66+import type { AppBarContext } from '../lib/auth/nav';
77+88+/** Render the AppBar under a hand-rolled auth context (bypasses real OAuth). */
99+function renderBar( current: AppBarContext, auth: Partial< AuthContextValue > ): string {
1010+ const value: AuthContextValue = {
1111+ status: 'signed-out',
1212+ agent: null,
1313+ did: null,
1414+ handle: null,
1515+ displayName: null,
1616+ avatar: null,
1717+ pdsUrl: null,
1818+ error: null,
1919+ signIn: async () => {},
2020+ signOut: async () => {},
2121+ ...auth,
2222+ };
2323+ return renderToStaticMarkup(
2424+ createElement( AuthContext.Provider, { value }, createElement( AppBar, { current } ) )
2525+ );
2626+}
2727+2828+describe( 'AppBar contextual nav visibility', () => {
2929+ it( 'hides the Publications link on the editor when signed out', () => {
3030+ const markup = renderBar( 'editor', { status: 'signed-out' } );
3131+ expect( markup ).not.toContain( 'Publications' );
3232+ expect( markup ).not.toContain( 'href="/dashboard"' );
3333+ } );
3434+3535+ it( 'hides the Write link on the dashboard when signed out', () => {
3636+ const markup = renderBar( 'dashboard', { status: 'signed-out' } );
3737+ expect( markup ).not.toContain( 'Write' );
3838+ expect( markup ).not.toContain( 'href="/editor"' );
3939+ } );
4040+4141+ it( 'hides the contextual nav while auth is still loading', () => {
4242+ const markup = renderBar( 'editor', { status: 'loading' } );
4343+ expect( markup ).not.toContain( 'Publications' );
4444+ } );
4545+4646+ it( 'shows the Publications link on the editor when signed in', () => {
4747+ const markup = renderBar( 'editor', {
4848+ status: 'signed-in',
4949+ agent: {} as never,
5050+ did: 'did:plc:writer',
5151+ handle: 'writer.test',
5252+ } );
5353+ expect( markup ).toContain( 'Publications' );
5454+ expect( markup ).toContain( 'href="/dashboard"' );
5555+ } );
5656+5757+ it( 'shows the Write link on the dashboard when signed in', () => {
5858+ const markup = renderBar( 'dashboard', {
5959+ status: 'signed-in',
6060+ agent: {} as never,
6161+ did: 'did:plc:writer',
6262+ handle: 'writer.test',
6363+ } );
6464+ expect( markup ).toContain( 'Write' );
6565+ expect( markup ).toContain( 'href="/editor"' );
6666+ } );
6767+6868+ it( 'always shows the SkyPress home link, regardless of auth state', () => {
6969+ expect( renderBar( 'editor', { status: 'signed-out' } ) ).toContain( 'SkyPress home' );
7070+ } );
7171+} );
+4-3
src/components/AppBar.tsx
···4444/**
4545 * The shared top bar for the editor + dashboard islands. Logo on the left;
4646 * contextual nav + account + sign-out on the right. Rendered inside AuthProvider
4747- * in every auth state: logo-only while loading, + nav when signed out, + account
4848- * and sign-out when signed in.
4747+ * in every auth state: logo-only while loading or signed out, + contextual nav,
4848+ * account, and sign-out once signed in. The cross-link to Publications / Write is
4949+ * gated on auth so signed-out visitors aren't pointed at editor-only routes.
4950 */
5051export default function AppBar( { current }: { current: AppBarContext } ) {
5152 const { status, handle, displayName, avatar, did, signOut } = useAuth();
···67686869 <span className="app-bar__spacer" />
69707070- { status !== 'loading' && (
7171+ { signedIn && (
7172 <a className="app-bar__nav" href={ nav.href }>
7273 <NavIcon name={ nav.icon } />
7374 { nav.label }
+2-2
src/components/CoverImagePicker.tsx
···8484 { uploading ? 'Uploading…' : 'Upload cover image' }
8585 </button>
8686 <p className="studio__cover-hint">
8787- No cover set — the first image in your article will be used. PNG, JPG,
8888- or GIF, max 1 MB.
8787+ No cover set. If you don't add one, the first image in your
8888+ article will be used. PNG, JPG, or GIF, max 1 MB.
8989 </p>
9090 </div>
9191 ) }
···11+import { describe, it, expect, vi, beforeEach } from 'vitest';
22+import { act, createElement } from 'react';
33+import { createRoot } from 'react-dom/client';
44+import type { Agent } from '@atproto/api';
55+66+( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true;
77+88+// Mock the orchestration layer so we control whether the companion post "exists".
99+const { fetchPostState, fetchPostExists } = vi.hoisted( () => ( {
1010+ fetchPostState: vi.fn(),
1111+ fetchPostExists: vi.fn(),
1212+} ) );
1313+vi.mock( '../lib/social/interactions', () => ( { fetchPostState, fetchPostExists } ) );
1414+1515+import { ActionsGate } from './PostActions';
1616+import { AuthContext, type AuthContextValue } from '../lib/auth/AuthProvider';
1717+1818+const PROPS = { postUri: 'at://did:plc:writer/app.bsky.feed.post/3kpost', postCid: 'bafypost' };
1919+2020+function authValue( overrides: Partial< AuthContextValue > ): AuthContextValue {
2121+ return {
2222+ status: 'signed-out',
2323+ agent: null,
2424+ did: null,
2525+ handle: null,
2626+ displayName: null,
2727+ avatar: null,
2828+ pdsUrl: null,
2929+ error: null,
3030+ signIn: async () => {},
3131+ signOut: async () => {},
3232+ ...overrides,
3333+ };
3434+}
3535+3636+/** Mount ActionsGate under a hand-rolled auth context and flush its effects. */
3737+async function mountGate( auth: Partial< AuthContextValue > ): Promise< { html: string; cleanup: () => void } > {
3838+ const container = document.createElement( 'div' );
3939+ document.body.appendChild( container );
4040+ const root = createRoot( container );
4141+ await act( async () => {
4242+ root.render(
4343+ createElement(
4444+ AuthContext.Provider,
4545+ { value: authValue( auth ) },
4646+ createElement( ActionsGate, PROPS )
4747+ )
4848+ );
4949+ } );
5050+ return {
5151+ html: container.innerHTML,
5252+ cleanup: () => {
5353+ act( () => {
5454+ root.unmount();
5555+ } );
5656+ container.remove();
5757+ },
5858+ };
5959+}
6060+6161+beforeEach( () => {
6262+ fetchPostState.mockReset();
6363+ fetchPostExists.mockReset();
6464+} );
6565+6666+describe( 'PostActions presence gate', () => {
6767+ it( 'renders nothing when the companion post is gone (signed in)', async () => {
6868+ fetchPostState.mockResolvedValue( null ); // getPosts found no post
6969+ const { html, cleanup } = await mountGate( {
7070+ status: 'signed-in',
7171+ agent: {} as Agent,
7272+ did: 'did:plc:reader',
7373+ handle: 'reader.test',
7474+ } );
7575+ expect( html ).toBe( '' );
7676+ cleanup();
7777+ } );
7878+7979+ it( 'renders nothing when the companion post is gone (signed out)', async () => {
8080+ fetchPostExists.mockResolvedValue( false ); // public AppView says: deleted
8181+ const { html, cleanup } = await mountGate( { status: 'signed-out' } );
8282+ expect( html ).toBe( '' );
8383+ cleanup();
8484+ } );
8585+8686+ it( 'keeps the action bar when the post exists (signed in)', async () => {
8787+ fetchPostState.mockResolvedValue( {
8888+ likeCount: 0,
8989+ repostCount: 0,
9090+ replyCount: 0,
9191+ viewerLikeUri: null,
9292+ viewerRepostUri: null,
9393+ } );
9494+ const { html, cleanup } = await mountGate( {
9595+ status: 'signed-in',
9696+ agent: {} as Agent,
9797+ did: 'did:plc:reader',
9898+ handle: 'reader.test',
9999+ } );
100100+ expect( html ).toContain( 'Like' );
101101+ expect( html ).toContain( 'Reply' );
102102+ cleanup();
103103+ } );
104104+105105+ it( 'keeps the sign-in prompt when the post exists (signed out)', async () => {
106106+ fetchPostExists.mockResolvedValue( true );
107107+ const { html, cleanup } = await mountGate( { status: 'signed-out' } );
108108+ expect( html ).toContain( 'Sign in to react' );
109109+ cleanup();
110110+ } );
111111+112112+ it( 'keeps the action bar when existence is undetermined (signed out, network error → fail open)', async () => {
113113+ fetchPostExists.mockResolvedValue( null );
114114+ const { html, cleanup } = await mountGate( { status: 'signed-out' } );
115115+ expect( html ).toContain( 'Sign in to react' );
116116+ cleanup();
117117+ } );
118118+} );
+37-11
src/components/PostActions.tsx
···99 postReply,
1010 postQuote,
1111 fetchPostState,
1212+ fetchPostExists,
1213 type PostState,
1314} from '../lib/social/interactions';
1415import {
···4647 const subject: StrongRef = { uri: postUri, cid: postCid };
47484849 const [ state, setState ] = useState< PostState | null >( null );
5050+ // Whether the companion Bluesky post still exists. Optimistic: we show the bar and only
5151+ // hide it once a read confirms the post is gone — a transient error must never hide a
5252+ // live post, and this keeps the common (post present) case flicker-free.
5353+ const [ present, setPresent ] = useState( true );
4954 const [ busy, setBusy ] = useState< null | 'like' | 'repost' >( null );
5055 const [ composer, setComposer ] = useState< Composer >( null );
5156 const [ text, setText ] = useState( '' );
···6065 mounted.current = false;
6166 }, [] );
62676363- // Load counts + viewer state once signed in (and refresh after each write).
6868+ // Resolve the companion post once auth settles: signed-in readers get counts + viewer
6969+ // state from an authenticated read (a `null` result means the post is gone); signed-out
7070+ // readers get an unauthenticated existence check (only a definitive `false` hides the
7171+ // bar — `true`/`null` keep it, so a transient error fails open).
6472 useEffect( () => {
6565- if ( status !== 'signed-in' || ! agent ) {
6666- return;
7373+ if ( status === 'loading' ) {
7474+ return; // wait for auth before choosing the read path
6775 }
6876 let cancelled = false;
6969- fetchPostState( agent, postUri )
7070- .then( ( next ) => ! cancelled && next && setState( next ) )
7171- .catch( () => {
7272- /* counts are best-effort; the action buttons still work */
7373- } );
7777+ if ( status === 'signed-in' && agent ) {
7878+ fetchPostState( agent, postUri )
7979+ .then( ( next ) => {
8080+ if ( cancelled ) {
8181+ return;
8282+ }
8383+ if ( next ) {
8484+ setState( next );
8585+ } else {
8686+ setPresent( false ); // getPosts found no post → deleted/unindexed
8787+ }
8888+ } )
8989+ .catch( () => {
9090+ /* counts are best-effort; fail open — the action buttons still work */
9191+ } );
9292+ } else {
9393+ fetchPostExists( postUri )
9494+ .then( ( exists ) => ! cancelled && exists === false && setPresent( false ) )
9595+ .catch( () => {} );
9696+ }
7497 return () => {
7598 cancelled = true;
7699 };
···158181159182 if ( status === 'loading' ) {
160183 return <div className="post-actions post-actions--loading">Loading actions…</div>;
184184+ }
185185+186186+ // The companion post is gone — there's no live Bluesky thread to act on, so render
187187+ // nothing rather than buttons / a sign-in prompt / a dead thread link that won't work.
188188+ if ( ! present ) {
189189+ return null;
161190 }
162191163192 const threadUrl = bskyPostWebUrl( postUri );
···303332 <p className="post-actions__note">
304333 Likes, reposts, quotes, and replies are public and happen on Bluesky.
305334 </p>
306306- <a className="post-actions__thread" href={ threadUrl } target="_blank" rel="noopener noreferrer">
307307- View the full thread on Bluesky
308308- </a>
309335 </div>
310336 );
311337}
+29
src/components/ProviderLogo.astro
···11+---
22+import { KNOWN_PROVIDERS, type ProviderId } from '../lib/publish/providers';
33+44+interface Props {
55+ provider: ProviderId | null;
66+ size?: number;
77+ class?: string;
88+}
99+1010+const { provider, size = 14, class: className } = Astro.props;
1111+1212+// Glyph + label data is shared with the React `ProviderLogo.tsx` via lib/publish/providers.
1313+// `body` is a hardcoded registry constant (never user/PDS content), so `set:html` is trusted.
1414+const logo = provider ? KNOWN_PROVIDERS[ provider ] : null;
1515+---
1616+1717+{
1818+ logo && (
1919+ <svg
2020+ class={className}
2121+ width={size}
2222+ height={size}
2323+ viewBox={logo.viewBox}
2424+ role="img"
2525+ aria-label={logo.label}
2626+ set:html={logo.body}
2727+ />
2828+ )
2929+}
+18
src/components/ProviderLogo.test.tsx
···11+import { describe, it, expect } from 'vitest';
22+import { createElement } from 'react';
33+import { renderToStaticMarkup } from 'react-dom/server';
44+import ProviderLogo from './ProviderLogo';
55+66+describe( 'ProviderLogo', () => {
77+ it( 'renders nothing when the provider is null', () => {
88+ expect( renderToStaticMarkup( createElement( ProviderLogo, { provider: null } ) ) ).toBe( '' );
99+ } );
1010+1111+ it( "renders the provider's labelled svg glyph", () => {
1212+ const html = renderToStaticMarkup( createElement( ProviderLogo, { provider: 'leaflet' } ) );
1313+ expect( html ).toContain( '<svg' );
1414+ expect( html ).toContain( 'aria-label="Leaflet"' );
1515+ expect( html ).toContain( 'role="img"' );
1616+ expect( html ).toContain( 'currentColor' );
1717+ } );
1818+} );
+37
src/components/ProviderLogo.tsx
···11+import { KNOWN_PROVIDERS, type ProviderId } from '../lib/publish/providers';
22+33+/**
44+ * A small monochrome logo for a recognised foreign-publication provider (Leaflet, pckt,
55+ * …). Renders nothing for an unrecognised provider, so callers can pass `provider`
66+ * straight through. The glyph inherits `currentColor`; size it with the `size` prop.
77+ *
88+ * Shares its glyph + label data with the reader's `ProviderLogo.astro` via
99+ * `lib/publish/providers` — the detection/asset core lives there, not in either renderer.
1010+ */
1111+export default function ProviderLogo( {
1212+ provider,
1313+ size = 14,
1414+ className,
1515+}: {
1616+ provider: ProviderId | null;
1717+ size?: number;
1818+ className?: string;
1919+} ) {
2020+ if ( ! provider ) {
2121+ return null;
2222+ }
2323+ // `body` is a hardcoded constant from our own registry (never user/PDS content), and
2424+ // `provider` is constrained to the ProviderId union — so this innerHTML is trusted.
2525+ const { label, viewBox, body } = KNOWN_PROVIDERS[ provider ];
2626+ return (
2727+ <svg
2828+ className={ className }
2929+ width={ size }
3030+ height={ size }
3131+ viewBox={ viewBox }
3232+ role="img"
3333+ aria-label={ label }
3434+ dangerouslySetInnerHTML={ { __html: body } }
3535+ />
3636+ );
3737+}
+23-2
src/components/Studio.tsx
···4242 const [ publications, setPublications ] = useState< Publication[] | null >( null );
4343 // Shared between mediaUpload (writes blob refs) and publish (reads them).
4444 const registry = useRef< BlobRegistry >( new Map() ).current;
4545+ const titleRef = useRef< HTMLTextAreaElement >( null );
4546 const ledeRef = useRef< HTMLTextAreaElement >( null );
46474748 // Load the writer's SkyPress publications (the publish targets / selector).
···113114114115 // Release the preview object URLs this session minted when the Studio unmounts.
115116 useEffect( () => () => revokeBlobRegistry( registry ), [ registry ] );
117117+118118+ // Grow the title textarea to fit its content so long titles wrap into view
119119+ // instead of clipping on one line (esp. on narrow mobile viewports). Layout
120120+ // effect so it sizes before paint — same reasoning as the lede below.
121121+ useLayoutEffect( () => {
122122+ const el = titleRef.current;
123123+ if ( ! el ) {
124124+ return;
125125+ }
126126+ el.style.height = 'auto';
127127+ el.style.height = `${ el.scrollHeight }px`;
128128+ }, [ title ] );
116129117130 // Grow the lede textarea to fit its content (and on hydrate from an edit-load).
118131 // Layout effect so it sizes before paint — avoids a one-row collapse flash when
···229242 }
230243 } }
231244 />
232232- <input
245245+ <textarea
246246+ ref={ titleRef }
233247 className="studio__title"
234234- type="text"
248248+ rows={ 1 }
235249 placeholder="Add title"
236250 aria-label="Article title"
237251 value={ title }
252252+ // The title is a single-line string: let it wrap visually, but
253253+ // don't let Enter insert a literal newline into the stored value.
254254+ onKeyDown={ ( event ) => {
255255+ if ( event.key === 'Enter' ) {
256256+ event.preventDefault();
257257+ }
258258+ } }
238259 onChange={ ( event ) => {
239260 setPublished( null );
240261 setTitle( event.target.value );
+8-14
src/lib/feed/publication-feed.ts
···11/**
22 * Assemble a publication's full-content RSS feed (Decision 0011).
33 *
44- * Pure transform — no network. It reuses the EXACT reader pipeline the article page uses
55- * (`resolveBlobImageUrls` → `renderBlocks` → `sanitizeArticleHtml`), so the feed body is
66- * the same sanitised HTML a browser would see (AGENTS.md #6b), then hands off to the
77- * test-locked `buildRssFeed`. The feed route fetches the records (SSRF-guarded) and calls
88- * this; keeping the transform pure makes the sort/cap/filter rules unit-testable.
44+ * Pure transform — no network. It renders each item through `renderArticle` — the same
55+ * deep module the article page uses — so the feed body is the same sanitised HTML a
66+ * browser would see (AGENTS.md #6b), then hands off to the test-locked `buildRssFeed`.
77+ * The feed route fetches the records (SSRF-guarded) and calls this; keeping the
88+ * transform pure makes the sort/cap/filter rules unit-testable.
99 */
1010-import { renderBlocks, blocksToText, type BlockNode } from '../blocks/render';
1111-import { sanitizeArticleHtml } from '../reader/sanitize';
1212-import { resolveBlobImageUrls } from '../media/blob';
1010+import type { BlockNode } from '../blocks/render';
1111+import { renderArticle } from '../reader/render-article';
1312import { canonicalArticleUrl, publicationHomeUrl } from '../publish/records';
1413import { buildRssFeed, type FeedChannel, type FeedItem } from './rss';
1514import type { RepoRecord } from '../reader/records';
···5958 .slice( 0, FEED_ITEM_LIMIT )
6059 .map( ( record ) => {
6160 const rkey = record.uri.split( '/' ).pop() as string;
6262- const blocks = resolveBlobImageUrls( record.value.content?.blocks ?? [], {
6363- pdsUrl,
6464- did,
6565- } );
6666- const contentHtml = sanitizeArticleHtml( renderBlocks( blocks ) );
6767- const text = record.value.textContent || blocksToText( blocks );
6161+ const { html: contentHtml, text } = renderArticle( record.value, { pdsUrl, did } );
6862 const description =
6963 record.value.description?.trim() ||
7064 text.slice( 0, DESCRIPTION_FALLBACK_CHARS ).trim() ||
···1111 isSkyPressPublicationUrl,
1212 publicationSlugFromUrl,
1313} from '../publish/records';
1414+import { detectProvider, type ProviderId } from '../publish/providers';
1415import type { BlobRefJson } from '../media/blob';
1516import { parseBasicTheme, type BasicTheme } from '../publish/themes';
1617···2728export interface ReaderForeignPublication {
2829 uri: string;
2930 name: string;
3131+ hostname: string;
3032 url: string;
3133 icon: BlobRefJson | null;
3434+ /** The app that wrote the record (Leaflet, pckt, …), or null when unrecognised. */
3535+ provider: ProviderId | null;
3236}
33373438interface RawPublication {
···7882 return {
7983 uri: record.uri,
8084 name: value.name ?? parsed.hostname,
8585+ hostname: parsed.hostname,
8186 url: value.url,
8287 icon: value.icon ?? null,
8888+ provider: detectProvider( value.url, value ),
8389 };
8490}
8591
+124
src/lib/reader/render-article.test.ts
···11+import { describe, expect, it } from 'vitest';
22+import { renderArticle } from './render-article';
33+44+const AUTHOR = { pdsUrl: 'https://pds.example', did: 'did:plc:alice' };
55+66+describe( 'renderArticle', () => {
77+ it( 'renders block content to HTML', () => {
88+ const { html } = renderArticle(
99+ {
1010+ content: {
1111+ blocks: [
1212+ { name: 'core/heading', attributes: { level: 2, content: 'Section' } },
1313+ { name: 'core/paragraph', attributes: { content: 'A paragraph.' } },
1414+ ],
1515+ },
1616+ },
1717+ AUTHOR
1818+ );
1919+ expect( html ).toContain( '<h2 class="wp-block-heading">Section</h2>' );
2020+ expect( html ).toContain( '<p>A paragraph.</p>' );
2121+ } );
2222+2323+ it( 'never lets a script in block content survive (sanitise is the last step)', () => {
2424+ const { html } = renderArticle(
2525+ {
2626+ content: {
2727+ blocks: [
2828+ {
2929+ name: 'core/paragraph',
3030+ attributes: { content: 'Hello <script>alert(1)</script>world' },
3131+ },
3232+ ],
3333+ },
3434+ },
3535+ AUTHOR
3636+ );
3737+ expect( html ).not.toContain( '<script' );
3838+ expect( html ).not.toContain( 'alert(1)' );
3939+ expect( html ).toContain( 'Hello' );
4040+ } );
4141+4242+ it( 'strips event-handler attributes smuggled through rich text', () => {
4343+ const { html } = renderArticle(
4444+ {
4545+ content: {
4646+ blocks: [
4747+ {
4848+ name: 'core/paragraph',
4949+ attributes: { content: '<em onmouseover="steal()">hover me</em>' },
5050+ },
5151+ ],
5252+ },
5353+ },
5454+ AUTHOR
5555+ );
5656+ expect( html ).toContain( '<em>hover me</em>' );
5757+ expect( html ).not.toContain( 'onmouseover' );
5858+ } );
5959+6060+ it( 'rewrites blob-backed image URLs to the author PDS before rendering', () => {
6161+ const { html } = renderArticle(
6262+ {
6363+ content: {
6464+ blocks: [
6565+ {
6666+ name: 'core/image',
6767+ attributes: {
6868+ url: 'https://stale-pds.example/old-blob.png',
6969+ alt: 'A photo',
7070+ skypressBlob: {
7171+ $type: 'blob',
7272+ ref: { $link: 'bafyreidcid' },
7373+ mimeType: 'image/png',
7474+ size: 1234,
7575+ },
7676+ },
7777+ },
7878+ ],
7979+ },
8080+ },
8181+ AUTHOR
8282+ );
8383+ // `&` (not `&`) — the URL came out the far side of the sanitiser.
8484+ expect( html ).toContain(
8585+ 'src="https://pds.example/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aalice&cid=bafyreidcid"'
8686+ );
8787+ expect( html ).not.toContain( 'stale-pds.example' );
8888+ } );
8989+9090+ it( 'prefers the stored textContent for the plain-text view', () => {
9191+ const { text } = renderArticle(
9292+ {
9393+ textContent: 'Stored summary.',
9494+ content: {
9595+ blocks: [ { name: 'core/paragraph', attributes: { content: 'Body.' } } ],
9696+ },
9797+ },
9898+ AUTHOR
9999+ );
100100+ expect( text ).toBe( 'Stored summary.' );
101101+ } );
102102+103103+ it( 'falls back to the block text when textContent is absent or empty', () => {
104104+ const { text } = renderArticle(
105105+ {
106106+ textContent: '',
107107+ content: {
108108+ blocks: [
109109+ { name: 'core/heading', attributes: { level: 2, content: 'Section' } },
110110+ { name: 'core/paragraph', attributes: { content: 'Body text.' } },
111111+ ],
112112+ },
113113+ },
114114+ AUTHOR
115115+ );
116116+ expect( text ).toBe( 'Section\n\nBody text.' );
117117+ } );
118118+119119+ it( 'returns empty html and text for a document with no blocks', () => {
120120+ const { html, text } = renderArticle( {}, AUTHOR );
121121+ expect( html ).toBe( '' );
122122+ expect( text ).toBe( '' );
123123+ } );
124124+} );
+46
src/lib/reader/render-article.ts
···11+/**
22+ * The render-article module: one deep interface for turning a stored document
33+ * into safe HTML + plain text (Decision 0017).
44+ *
55+ * Turning a block tree into injectable HTML requires three calls in one
66+ * load-bearing order — `resolveBlobImageUrls` → `renderBlocks` →
77+ * `sanitizeArticleHtml` — and the text fallback (`textContent || blocksToText`)
88+ * alongside it. That ordering is the AGENTS.md #6b invariant (sanitise is the
99+ * LAST step before any injection); it lives here, once, so every read surface
1010+ * (article page, RSS, future AMP/email/embeds) gets it by construction instead
1111+ * of by convention. Like `render.ts` underneath, this module is dependency-free
1212+ * and must never import `@wordpress/*` (Decision 0003).
1313+ */
1414+import { renderBlocks, blocksToText } from '../blocks/render';
1515+import { resolveBlobImageUrls } from '../media/blob';
1616+import { sanitizeArticleHtml } from './sanitize';
1717+import type { BlockNode } from '../blocks/render';
1818+1919+/** The slice of a `site.standard.document` value the renderer consumes. */
2020+export interface RenderableDocument {
2121+ textContent?: string;
2222+ content?: { blocks?: BlockNode[] };
2323+}
2424+2525+export interface RenderedArticle {
2626+ /** Sanitised article HTML, safe to inject (`set:html`, RSS CDATA, …). */
2727+ html: string;
2828+ /** Plain text: the stored `textContent`, else derived from the blocks. */
2929+ text: string;
3030+}
3131+3232+/**
3333+ * Render a document's blocks to sanitised HTML and plain text. `author` is the
3434+ * document's writer (current PDS + DID) — blob-backed image URLs are rebuilt
3535+ * against it before rendering, so images survive a PDS migration.
3636+ */
3737+export function renderArticle(
3838+ doc: RenderableDocument,
3939+ author: { pdsUrl: string; did: string }
4040+): RenderedArticle {
4141+ const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], author );
4242+ return {
4343+ html: sanitizeArticleHtml( renderBlocks( blocks ) ),
4444+ text: doc.textContent || blocksToText( blocks ),
4545+ };
4646+}
+37
src/lib/social/interactions.test.ts
···77 postReply,
88 postQuote,
99 fetchPostState,
1010+ fetchPostExists,
1011} from './interactions';
1112import type { StrongRef } from './records';
1213···149150 expect( await fetchPostState( agent, SUBJECT.uri ) ).toBeNull();
150151 } );
151152} );
153153+154154+describe( 'fetchPostExists', () => {
155155+ const ok = ( body: unknown ) =>
156156+ ( { ok: true, json: async () => body } ) as unknown as Response;
157157+ const notOk = () => ( { ok: false, json: async () => ( {} ) } ) as unknown as Response;
158158+159159+ it( 'queries the public AppView getPosts endpoint with the encoded uri', async () => {
160160+ const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) );
161161+ await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch );
162162+ expect( fetchImpl ).toHaveBeenCalledWith(
163163+ `https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent(
164164+ SUBJECT.uri
165165+ ) }`
166166+ );
167167+ } );
168168+169169+ it( 'returns true when the post is present', async () => {
170170+ const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [ { uri: SUBJECT.uri } ] } ) );
171171+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( true );
172172+ } );
173173+174174+ it( 'returns false when the post is gone (no posts returned)', async () => {
175175+ const fetchImpl = vi.fn().mockResolvedValue( ok( { posts: [] } ) );
176176+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBe( false );
177177+ } );
178178+179179+ it( 'returns null (undetermined) on a non-ok response', async () => {
180180+ const fetchImpl = vi.fn().mockResolvedValue( notOk() );
181181+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull();
182182+ } );
183183+184184+ it( 'returns null (undetermined) when the network throws', async () => {
185185+ const fetchImpl = vi.fn().mockRejectedValue( new Error( 'offline' ) );
186186+ expect( await fetchPostExists( SUBJECT.uri, fetchImpl as unknown as typeof fetch ) ).toBeNull();
187187+ } );
188188+} );
+32
src/lib/social/interactions.ts
···1919const REPOST_COLLECTION = 'app.bsky.feed.repost';
2020const POST_COLLECTION = 'app.bsky.feed.post';
21212222+/** The public, unauthenticated Bluesky AppView (same host as the landing actor lookup). */
2323+const APPVIEW = 'https://public.api.bsky.app';
2424+2225/** createRecord types `record` as an open index signature; our records are precise. */
2326function asRecord( value: object ): Record< string, unknown > {
2427 return value as Record< string, unknown >;
···128131 viewerRepostUri: post.viewer?.repost ?? null,
129132 };
130133}
134134+135135+/**
136136+ * Does the companion post still exist on Bluesky? An UNAUTHENTICATED read of the public
137137+ * AppView (no OAuth, no agent), so signed-out readers can also tell when a post is gone.
138138+ * Mirrors `landing/actor-lookup.ts`: fixed host, the post AT-URI URL-encoded as the only
139139+ * input, and a deliberate three-state result —
140140+ *
141141+ * - `true` — the post is present,
142142+ * - `false` — the post is gone (deleted, or not yet indexed),
143143+ * - `null` — undetermined (network/HTTP error), so callers can fail OPEN and keep the
144144+ * action bar rather than hide a live post on a transient blip.
145145+ */
146146+export async function fetchPostExists(
147147+ postUri: string,
148148+ fetchImpl: typeof fetch = fetch
149149+): Promise< boolean | null > {
150150+ try {
151151+ const res = await fetchImpl(
152152+ `${ APPVIEW }/xrpc/app.bsky.feed.getPosts?uris=${ encodeURIComponent( postUri ) }`
153153+ );
154154+ if ( ! res.ok ) {
155155+ return null;
156156+ }
157157+ const data = ( await res.json() ) as { posts?: unknown[] };
158158+ return Array.isArray( data?.posts ) && data.posts.length > 0;
159159+ } catch {
160160+ return null;
161161+ }
162162+}
+6-7
src/pages/[author]/[slug]/[rkey].astro
···33import PublicationFooter from '../../../components/PublicationFooter.astro';
44import { resolveArticleContext } from '../../../lib/reader/read-context';
55import { formatLongDate } from '../../../lib/reader/dates';
66-import { resolveBlobImageUrls } from '../../../lib/media/blob';
77-import { renderBlocks, blocksToText } from '../../../lib/blocks/render';
88-import { sanitizeArticleHtml } from '../../../lib/reader/sanitize';
66+import { renderArticle } from '../../../lib/reader/render-article';
97import { canonicalArticleUrl } from '../../../lib/publish/records';
108import { deriveExcerpt } from '../../../lib/publish/excerpt';
119import { themeStyleBlock } from '../../../lib/publish/themes';
···2624const { author, slug, rkey } = Astro.params;
27252826// Resolve author → publication → document (site-joined) behind one interface;
2929-// this page keeps the render pipeline and presentation.
2727+// rendering lives behind render-article — this page keeps only presentation.
3028const result = await resolveArticleContext( author, slug, rkey );
3129const error = result.ok ? null : result.error;
3230const ctx = result.ok ? result.context : null;
···6058 handle = readAuthor.handle;
61596260 const doc = document.value;
6363- const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } );
6464- html = sanitizeArticleHtml( renderBlocks( blocks ) );
6565- const textContent = doc.textContent || blocksToText( blocks );
6161+ // Blob URLs, render, sanitise, and the text fallback all live in render-article.
6262+ const rendered = renderArticle( doc, { pdsUrl, did } );
6363+ html = rendered.html;
6464+ const textContent = rendered.text;
66656766 title = doc.title ?? 'Untitled';
6867 description = doc.description || deriveExcerpt( textContent );
+11
src/pages/[author]/[slug]/_[rkey].meta.test.ts
···6262 expect( page ).toMatch( /Astro\.response\.status\s*=/ );
6363 } );
64646565+ it( 'delegates the render pipeline to the render-article module', () => {
6666+ // The ordering invariant (resolve blobs → render → sanitise) is behaviourally
6767+ // tested in src/lib/reader/render-article.test.ts; the page only injects.
6868+ expect( page ).toMatch(
6969+ /import\s*\{\s*renderArticle\s*\}\s*from\s*'[^']*lib\/reader\/render-article'/
7070+ );
7171+ expect( page ).toMatch( /renderArticle\(/ );
7272+ // No hand-assembled pipeline: these belong inside render-article now.
7373+ expect( page ).not.toMatch( /sanitizeArticleHtml|renderBlocks|resolveBlobImageUrls/ );
7474+ } );
7575+6576 it( 'derives the og:description fallback via deriveExcerpt (shared with publish)', () => {
6677 expect( page ).toMatch(
6778 /import\s*\{\s*deriveExcerpt\s*\}\s*from\s*'[^']*lib\/publish\/excerpt'/
···3333 </Fragment>
34343535 <div class="page">
3636- <div class="sky" aria-hidden="true">
3737- <div class="stars"></div>
3838- <span class="shootingstar"></span>
3939- <div class="bloom"></div>
4040- <div class="halo"></div>
4141- <div class="horizon"></div>
4242- </div>
4343-4444- <header class="masthead">
4545- <Logo />
4646- <div class="masthead__right">
4747- <a class="btn btn--ghost masthead-write" href="/editor">Write</a>
4848- <AccountMenu client:only="react" />
3636+ <!-- The sky backs the masthead + hero together. Wrapping them lets the sky fill the
3737+ zone exactly (inset: 0) instead of guessing a fixed height — on a narrow phone the
3838+ wrapping title can push the hero past any fixed height, dropping the trailing copy
3939+ onto the bare dark page background as unreadable dark-on-dark text. -->
4040+ <div class="skyzone">
4141+ <div class="sky" aria-hidden="true">
4242+ <div class="stars"></div>
4343+ <span class="shootingstar"></span>
4444+ <div class="bloom"></div>
4545+ <div class="halo"></div>
4646+ <div class="horizon"></div>
4947 </div>
5050- </header>
51485252- <main class="hero">
5353- <p class="eyebrow" id="greet">{fallback.greeting}</p>
5454- <h1 class="hero__title" id="headline" set:html={fallback.headlineHtml} />
5555- <p class="hero__lede" id="lede">{fallback.lede}</p>
5656- <div class="hero__cta">
5757- <HandleStart client:only="react" />
5858- </div>
5959- <p class="hero__free">Free & open-source. Your words live in your account, not ours.</p>
6060- </main>
4949+ <header class="masthead">
5050+ <Logo />
5151+ <div class="masthead__right">
5252+ <a class="btn btn--ghost masthead-write" href="/editor">Write</a>
5353+ <AccountMenu client:only="react" />
5454+ </div>
5555+ </header>
5656+5757+ <main class="hero">
5858+ <p class="eyebrow" id="greet">{fallback.greeting}</p>
5959+ <h1 class="hero__title" id="headline" set:html={fallback.headlineHtml} />
6060+ <p class="hero__lede" id="lede">{fallback.lede}</p>
6161+ <div class="hero__cta">
6262+ <HandleStart client:only="react" />
6363+ </div>
6464+ <p class="hero__free">Free & open-source. Your words live in your account, not ours.</p>
6565+ </main>
6666+ </div>
61676268 <section class="showcase">
6369 <p class="showcase__label">See it in action</p>
···105111 overflow: hidden;
106112 }
107113114114+ /* The sky zone holds the masthead + hero; the sky fills it exactly so the backdrop always
115115+ reaches the bottom of the hero, however tall the (wrapping) title makes it. flex: 1 lets
116116+ it grow to push the showcase down when the content is short. */
117117+ .skyzone {
118118+ position: relative;
119119+ flex: 1;
120120+ display: flex;
121121+ flex-direction: column;
122122+ }
123123+108124 /* ===== Atmospheric sky (hero backdrop) — varies by [data-phase] ===== */
109125 .sky {
110126 position: absolute;
111111- inset: 0 0 auto 0;
112112- height: 78vh;
113113- min-height: 32rem;
127127+ inset: 0;
114128 z-index: 0;
115129 pointer-events: none;
116130 }
+7-1
src/styles/editor-chrome.css
···137137}
138138139139/* Borderless article title, sitting above the framed editor canvas — echoes the
140140- block-editor post title (large display heading, no box). */
140140+ block-editor post title (large display heading, no box). A `<textarea>` (not a
141141+ single-line input) so long titles wrap and the field auto-grows to fit instead
142142+ of clipping on one line — it stays single-line semantically (Enter is blocked
143143+ in the markup). Auto-grows via JS, like the lede; resize/overflow off. */
141144.studio__title {
142145 display: block;
143146 max-width: var(--studio-measure);
···152155 font-size: clamp(1.9rem, 4vw, 2.6rem);
153156 font-weight: 700;
154157 line-height: 1.15;
158158+ resize: none;
159159+ overflow: hidden;
160160+ overflow-wrap: break-word;
155161}
156162.studio__title::placeholder {
157163 color: var(--muted);