Add a thumbnail to the companion Bluesky post embed
The companion app.bsky.feed.post already carried a clickable link facet
and associatedRefs for the rich standard.site card (Decision 0013), but
embed.external.thumb was unset. Clients that don't resolve standard.site
cards — and the default card before the AppView resolves the refs —
therefore showed no image, unlike the reference post (pckt.blog).
Set embed.external.thumb to the first uploaded content image's existing
in-repo blob ref (image/*, size <= 1MB), selected depth-first from the
document's block tree. Omit thumb when there is no usable image; the
card still renders via associatedRefs.
Reuse the existing blob ref rather than re-uploading: atproto blobs are
content-addressed and repo-scoped, so re-uploading identical bytes only
yields the same CID — there is no separate post-owned copy. The publish
flow creates the document first, committing the image blob, before the
post, so the post can reference that same CID and the AppView resolves
the thumb by did + cid. This needs no uploadBlob, no byte-fetch, and so
no SSRF guard.
Researched against pckt.blog (live record) and Leaflet (source): both
upload a fresh, OG-cropped 1.91:1 blob server-side (sharp / screenshot).
SkyPress is a browser client + static renderer with no such pipeline, so
v1 follows the spirit (a real image blob) not the letter (no OG-shaping;
bsky center-crops the native image). Decision 0014 records the rationale
and the follow-ups (canvas OG-crop, cover-image picker, icon fallback).
Show known-provider logos beside foreign publication hostnames
Foreign `site.standard.publication` records (written by Leaflet, pckt,
Offprint, …) now display the originating service's logo next to their
hostname — on the dashboard "From other apps" list and the public
profile "Elsewhere" section, which also gains the hostname it didn't
show before.
The hostname alone can't identify the service, because the paid tiers
serve from a custom domain. So detection is two-step: an app-specific
`$type` discriminator embedded in the record first (pckt writes
`theme.$type === "blog.pckt.theme"`, which survives a custom domain),
then a dot-boundary hostname-suffix fallback (`*.leaflet.pub`,
`*.pckt.blog`, `*.offprint.app`). Leaflet records carry no such
discriminator and Offprint is unsampled, so those two on a custom
domain stay logo-less, exactly as before — see Decision 0017.
Detection and the monochrome glyph data live in one framework-agnostic
module (`lib/publish/providers.ts`), since Astro can't server-render a
React component and the read path takes no client island: the React
dashboard and the Astro profile each render the shared data through a
tiny `ProviderLogo` of their own. Leaflet's saved asset was a raster
PNG, so its glyph is a substitute vector feather (Lucide, ISC).
Show known-provider logos beside foreign publication hostnames
Foreign `site.standard.publication` records (written by Leaflet, pckt,
Offprint, …) now display the originating service's logo next to their
hostname — on the dashboard "From other apps" list and the public
profile "Elsewhere" section, which also gains the hostname it didn't
show before.
The hostname alone can't identify the service, because the paid tiers
serve from a custom domain. So detection is two-step: an app-specific
`$type` discriminator embedded in the record first (pckt writes
`theme.$type === "blog.pckt.theme"`, which survives a custom domain),
then a dot-boundary hostname-suffix fallback (`*.leaflet.pub`,
`*.pckt.blog`, `*.offprint.app`). Leaflet records carry no such
discriminator and Offprint is unsampled, so those two on a custom
domain stay logo-less, exactly as before — see Decision 0017.
Detection and the monochrome glyph data live in one framework-agnostic
module (`lib/publish/providers.ts`), since Astro can't server-render a
React component and the read path takes no client island: the React
dashboard and the Astro profile each render the shared data through a
tiny `ProviderLogo` of their own. Leaflet's saved asset was a raster
PNG, so its glyph is a substitute vector feather (Lucide, ISC).
Replace isolated-block-editor with @wordpress/block-editor directly
Compose SkyEditor from the Gutenberg block-editor packages directly
instead of wrapping @automattic/isolated-block-editor, and upgrade the
whole @wordpress/* tree from IBE's frozen line to the current release.
IBE is effectively maintenance-only (Dependabot-only commits, README
self-describes as "experimental", pins Gutenberg 16.9) and it forced
the entire @wordpress/* tree to be version-pinned via a ~60-package
overrides map — what Decision 0003 called the project's biggest
maintenance liability. That override map only existed to reconcile
IBE's old pinned line against transitive caret ranges floating to a
newer one. Depending on @wordpress/block-editor directly at one
current line removes that collision: the tree resolves to a single
coherent copy of every store singleton with no overrides, so upgrading
becomes a normal version bump instead of regenerating the map.
SkyEditor now wires BlockEditorProvider over a header toolbar (Inserter
+ a fixed BlockToolbar + undo/redo + a BlockInspector cog popover) and
the canvas (BlockTools / WritingFlow / ObserveTyping / BlockList), with
app-level undo via useStateWithHistory. The prop contract, curated
allowlist, @-mention format/autocompleter, and media-upload filter are
unchanged. The reader/render split (Decision 0003, Finding 1) is
untouched — reading pages still use the dependency-free render.ts and
the render-fidelity test-lock still passes against the new packages.
Two sharp edges, both recorded in Decision 0021 and AGENTS.md:
- core-data/notices/date install as nested copies with no hoisted
top-level one, so each registers its store ("Store 'core' is already
registered"). Fixed with npm dedupe + an expanded resolve.dedupe in
both astro.config and vitest.config (deduping before the hoist breaks
the build).
- In vitest, @wordpress/* must be Vite-inlined (Node rejects
@wordpress/blocks' attribute-less JSON import) while moment stays
external, or moment-timezone's augmentation of moment breaks.
The floating block toolbar needs iframe/content-ref plumbing a bespoke
inline canvas doesn't provide, so a fixed BlockToolbar is placed in the
header per the framework's guidance for custom editors.
Verified: npm run check (0 errors), npm test (592 pass incl. the render
fidelity lock), npm run build, and an in-browser smoke test of /write
on the production preview (boot, insert, type, draft-save, undo/redo,
publish-enable, allowlist, clean console).