Commits
The read renderer resolved every did:plc author's PDS endpoint from
plc.directory, the canonical directory run by Bluesky PBC in the US.
That was the last centralised, US-operated dependency on an otherwise
self-hostable read path (handle resolution already prefers the handle's
own .well-known; did:web resolves from the domain). For an instance
built around European data sovereignty, every reader request leaking to
plc.directory undercut the premise.
Resolve did:plc through an ordered host list -- Eurosky's EU-hosted
plc-mirror (https://plc.eurosky.network) first, plc.directory as
fallback -- trying the next host on any non-ok response or thrown
request. The mirror speaks the same GET /{did} API and DID-doc shape,
so this is a host-ordering change, not a new protocol path. did:web is
unchanged. SSRF guarantees hold: both hosts are public so each request
passes safeFetch, and the serviceEndpoint is still validated through
assertSafeUrl.
Mirror-first accepts one trade-off: a mirror lagging the canonical log
could briefly return a stale endpoint right after a writer migrates
their PDS (the article 404s until the mirror syncs). The canonical
fallback covers misses and outages. Rationale and the deferred
handle-resolver follow-up are recorded in
docs/decisions/0022-eurosky-plc-primary.md.
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).
Signing in from /write returned the writer to /editor with an empty editor:
atproto only redirects to a registered redirect_uri and, called without one,
defaults to client-metadata.json's first entry — which was the lone /editor/.
The writing-first draft survived (draft-store persists across the redirect) but
the publish-intent resume was orphaned on the wrong page.
Register every OAuth-island route and pin the round-trip to the originating page:
- OAUTH_REDIRECT_PATHS is the single source of truth (/editor/ first as the
safe fallback); client-metadata.json generates redirect_uris from it.
- AuthProvider.signIn passes redirect_uri for the current page in hosted mode;
loopback (dev) already round-trips per-page, so it passes none.
Regression tests cover the redirect helper, the generated metadata, and the
signIn wiring.
Studio and WriteStudio rendered near-identical title + lede + SkyEditor +
cover markup, and Studio carried the textarea auto-grow that WriteStudio was
missing. Pull the surface into a presentational EditorCanvas (title/lede with
auto-grow, the block editor, the optional cover picker) and have both islands
render it, each still wiring its own content state, media-upload handler, and
cover upload path (eager in /editor, deferred in /write). /write gains the
auto-grow it lacked; the auth/media/publish differences stay in each island.
The editor ran with isolated-block-editor's default fixed top toolbar
(hasFixedToolbar defaults to true), so the block tools docked into the
header instead of following the selected block. Set hasFixedToolbar to
false to get the floating contextual toolbar.
That exposed three chrome gaps, all stemming from isolated-block-editor
shipping minimal chrome and omitting @wordpress/edit-post and
@wordpress/interface layout CSS:
- The package still renders a docked block toolbar in the header (gated
only by viewport, not by the fixed-toolbar setting), so the floating
and docked bars showed at once. Hide the docked copy.
- The header Settings (cog) button rendered but inherited Gutenberg's
hard-coded #1e1e1e icon colour, invisible on the dark paper surface,
and .editor-header had no flex rule so its settings region wasn't
pushed to the top-right. Lay out the header and tint the button from
the ink token.
- The block-inspector popover (.iso-inspector) had no width of its own
and collapsed to its content. Give it the standard 280px sidebar
width.
All verified in-browser against the live editor.
Drop the HandleStart handle-input CTA and its "Already have an account?" label
from the landing hero (and the now-dead handlestart styles). The home page's
only action is "Start writing → /write"; signing in happens via Publish on
/write, consistent with the rest of the flow. Retire the obsolete
_index.handlestart.test.ts CSS guard and update the landing-content guard.
The /write route tested well, so promote it from a hidden parallel route to
the home page's front door: the hero's primary CTA is now "Start writing →"
to /write, with the handle sign-in demoted to a secondary "Already have an
account?" path. Repoint the masthead "Write" button and the account menu's
"Write" item (accountMenuItems) at /write too; the home page no longer links
to the login-gated /editor. Update the prior landing-redesign guard (the new
single "Start writing" CTA is intentional) and record the shift in 0020.
Once the publish flow takes over (signed in + flow open), the top "Publish…"
button is redundant with the stepper's own publish control. Stop rendering
the actions bar in that state so the action isn't shown twice.
The publish flow's confirm step had an unstyled publication <select> and an
unstyled publish button, plus a "Keep editing" cancel. Remove the cancel
button (onCancel stays for the inline create-publication step), and style the
picker + primary action to match the editor's .publish__select / .publish__button
(token border, sun fill), with the working-status text muted.
When signed in, the editor showed two identical identity pills: WriteStudio's
own AccountPill in the actions bar and the app bar's app-bar__identity (which
already provides the profile link, the Publications nav, and Sign out). Remove
WriteStudio's pill — and the now-unused AccountPill component, its test, and
its CSS — leaving the actions bar with just the Publish button.
Replace the bare "→" after "Create one on Bluesky" with the project's
standard external-link glyph (the same icon as the author page's Bluesky
link), keep target="_blank" with a hardened rel, and add an aria-label so
the open-in-new-tab behaviour is announced to assistive tech.
The SignInPanel's inner elements were unstyled (default input + grey
buttons), and the panel/menu cards used rgba()/Canvas colours that wash
out on the dark canvas. Mirror the editor's LoginForm (login.css) — display
title, muted lede, framed input, sun primary button, outline cancel, accent
signup link — and reframe the cards + account-pill menu on the paper-raised
surface with token borders/shadow so they read in light and dark.
The Publish button and account pill floated at the viewport edge and the
title hugged the far left, because `.write-corner` had no column constraint
and wrapping the title in `.write-header` overrode its shared-column rules.
Drop the wrapper (title stays a direct child, reusing .studio__title) and
put the pill + Publish into one `.write-actions` bar constrained to
var(--studio-measure), so everything lines up with the app bar and editor.
Also remove the standalone signed-out "Sign in" button: signing in is now
offered only on the way to publishing (the publish-framed SignInPanel), so
onSignIn always stamps the resume intent. Drop the now-dead `restoredRef`
and a redundant displayName ternary flagged in review.
When a writing-first draft is restored, mergeAssets swapped each body image
token back to its data: URL. If the localStorage skeleton survived but the
IndexedDB bytes were evicted, the token had no entry in the assets map and the
old fallback kept the literal token (e.g. "a0") as the image url -- a broken
src that, not being a data: URL, also got published verbatim by uploadHeldAssets.
Drop the dangling token instead (matching the cover path's graceful null
fallback), while leaving external image URLs untouched since they are not tokens.
The previous version animated the icon pseudo-elements' width 0 -> 0.85em on
hover, which pushed any text after the mention sideways (only unnoticed because
the test mention ended its paragraph). Reserve the icon width + margin permanently
(invisible at rest via opacity 0) and animate only opacity + transform on hover —
neither affects layout, so following text stays put. Same technique the author
handle link uses. Color stays var(--sun)/currentColor, i.e. the per-publication
theme accent. Verified in-browser: trailing text holds its x-position between rest
and hover while both icons fade in.
Match the profile-page author handle link more fully: on hover/focus the mention
brightens to --sun and a Bluesky logo (left) and external-arrow (right) slide in.
They're drawn as currentColor mask pseudo-elements (::before/::after) because the
mention arrives as sanitised HTML and can't carry nested <svg> children; the icons
inherit the link colour and collapse to zero width at rest so the mention reads as
plain inline text. The sanitiser already gives the anchor target="_blank" + safe rel,
so mentions open in a new tab. Verified in the browser (hover reveals both icons in
--sun).
Mention anchors render with class="skypress-mention" (the sanitizer keeps the
class, strips data-did). The generic article-link rule painted them solid --sun;
give them the quieter author__handle-link treatment instead: inherit the prose
color, no underline, brighten to --sun on hover/focus. The class selector
out-specifies the generic :global(a) rule. Verified in the browser against the
live reader page.
Two issues surfaced by an in-browser smoke test (publish from @jeherve.com
mentioning @jeremy.herve.bzh):
1. The @ autocomplete never ran ours — every keystroke hit WordPress's built-in
user completer (GET /wp/v2/users → 404). Both completers claim the '@' trigger
and the block editor resolves a trigger to the FIRST matching completer, so
appending ours left it shadowed. Replace every '@'-triggered completer with ours
instead of appending (extracted as replaceAtMentionCompleter).
2. Selecting an option inserted nothing: getOptionCompletion returned
{ action: 'replace', value }, but 'replace' swaps the whole block (the slash-
completer contract) and dropped the inline anchor. Return the anchor element
directly so it inserts at the caret in place of the typed @query — the core
link-completer pattern.
Also: the live grapheme counter showed 'Bluesky post: N/300' at all times; only
warn when actually over the limit (hidden otherwise).
Verified end to end in the browser: autocomplete resolves jeremy.herve.bzh from
public.api.bsky.app, inserts the class+href+data-did anchor, the confirm dialog
discloses the notify target, and the published post carries the #mention facet
(correct DID + byte offsets) plus the document's flat mentions interop list.
The 300-grapheme backstop lived inside buildBskyPost, which publish() calls
at step 2 — after step 1 has already created the document record. If it fired,
the PDS kept a published-but-postless document (and a retry minted a second
orphan). Hoist the check into a shared assertBskyPostWithinLimit() and run it
in publish() before any record is written; buildBskyPost keeps calling it as a
final backstop. Adds a regression test asserting an over-limit publish rejects
with zero records written.
The read renderer resolved every did:plc author's PDS endpoint from
plc.directory, the canonical directory run by Bluesky PBC in the US.
That was the last centralised, US-operated dependency on an otherwise
self-hostable read path (handle resolution already prefers the handle's
own .well-known; did:web resolves from the domain). For an instance
built around European data sovereignty, every reader request leaking to
plc.directory undercut the premise.
Resolve did:plc through an ordered host list -- Eurosky's EU-hosted
plc-mirror (https://plc.eurosky.network) first, plc.directory as
fallback -- trying the next host on any non-ok response or thrown
request. The mirror speaks the same GET /{did} API and DID-doc shape,
so this is a host-ordering change, not a new protocol path. did:web is
unchanged. SSRF guarantees hold: both hosts are public so each request
passes safeFetch, and the serviceEndpoint is still validated through
assertSafeUrl.
Mirror-first accepts one trade-off: a mirror lagging the canonical log
could briefly return a stale endpoint right after a writer migrates
their PDS (the article 404s until the mirror syncs). The canonical
fallback covers misses and outages. Rationale and the deferred
handle-resolver follow-up are recorded in
docs/decisions/0022-eurosky-plc-primary.md.
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).
Signing in from /write returned the writer to /editor with an empty editor:
atproto only redirects to a registered redirect_uri and, called without one,
defaults to client-metadata.json's first entry — which was the lone /editor/.
The writing-first draft survived (draft-store persists across the redirect) but
the publish-intent resume was orphaned on the wrong page.
Register every OAuth-island route and pin the round-trip to the originating page:
- OAUTH_REDIRECT_PATHS is the single source of truth (/editor/ first as the
safe fallback); client-metadata.json generates redirect_uris from it.
- AuthProvider.signIn passes redirect_uri for the current page in hosted mode;
loopback (dev) already round-trips per-page, so it passes none.
Regression tests cover the redirect helper, the generated metadata, and the
signIn wiring.
Studio and WriteStudio rendered near-identical title + lede + SkyEditor +
cover markup, and Studio carried the textarea auto-grow that WriteStudio was
missing. Pull the surface into a presentational EditorCanvas (title/lede with
auto-grow, the block editor, the optional cover picker) and have both islands
render it, each still wiring its own content state, media-upload handler, and
cover upload path (eager in /editor, deferred in /write). /write gains the
auto-grow it lacked; the auth/media/publish differences stay in each island.
The editor ran with isolated-block-editor's default fixed top toolbar
(hasFixedToolbar defaults to true), so the block tools docked into the
header instead of following the selected block. Set hasFixedToolbar to
false to get the floating contextual toolbar.
That exposed three chrome gaps, all stemming from isolated-block-editor
shipping minimal chrome and omitting @wordpress/edit-post and
@wordpress/interface layout CSS:
- The package still renders a docked block toolbar in the header (gated
only by viewport, not by the fixed-toolbar setting), so the floating
and docked bars showed at once. Hide the docked copy.
- The header Settings (cog) button rendered but inherited Gutenberg's
hard-coded #1e1e1e icon colour, invisible on the dark paper surface,
and .editor-header had no flex rule so its settings region wasn't
pushed to the top-right. Lay out the header and tint the button from
the ink token.
- The block-inspector popover (.iso-inspector) had no width of its own
and collapsed to its content. Give it the standard 280px sidebar
width.
All verified in-browser against the live editor.
Drop the HandleStart handle-input CTA and its "Already have an account?" label
from the landing hero (and the now-dead handlestart styles). The home page's
only action is "Start writing → /write"; signing in happens via Publish on
/write, consistent with the rest of the flow. Retire the obsolete
_index.handlestart.test.ts CSS guard and update the landing-content guard.
The /write route tested well, so promote it from a hidden parallel route to
the home page's front door: the hero's primary CTA is now "Start writing →"
to /write, with the handle sign-in demoted to a secondary "Already have an
account?" path. Repoint the masthead "Write" button and the account menu's
"Write" item (accountMenuItems) at /write too; the home page no longer links
to the login-gated /editor. Update the prior landing-redesign guard (the new
single "Start writing" CTA is intentional) and record the shift in 0020.
The publish flow's confirm step had an unstyled publication <select> and an
unstyled publish button, plus a "Keep editing" cancel. Remove the cancel
button (onCancel stays for the inline create-publication step), and style the
picker + primary action to match the editor's .publish__select / .publish__button
(token border, sun fill), with the working-status text muted.
When signed in, the editor showed two identical identity pills: WriteStudio's
own AccountPill in the actions bar and the app bar's app-bar__identity (which
already provides the profile link, the Publications nav, and Sign out). Remove
WriteStudio's pill — and the now-unused AccountPill component, its test, and
its CSS — leaving the actions bar with just the Publish button.
The SignInPanel's inner elements were unstyled (default input + grey
buttons), and the panel/menu cards used rgba()/Canvas colours that wash
out on the dark canvas. Mirror the editor's LoginForm (login.css) — display
title, muted lede, framed input, sun primary button, outline cancel, accent
signup link — and reframe the cards + account-pill menu on the paper-raised
surface with token borders/shadow so they read in light and dark.
The Publish button and account pill floated at the viewport edge and the
title hugged the far left, because `.write-corner` had no column constraint
and wrapping the title in `.write-header` overrode its shared-column rules.
Drop the wrapper (title stays a direct child, reusing .studio__title) and
put the pill + Publish into one `.write-actions` bar constrained to
var(--studio-measure), so everything lines up with the app bar and editor.
Also remove the standalone signed-out "Sign in" button: signing in is now
offered only on the way to publishing (the publish-framed SignInPanel), so
onSignIn always stamps the resume intent. Drop the now-dead `restoredRef`
and a redundant displayName ternary flagged in review.
When a writing-first draft is restored, mergeAssets swapped each body image
token back to its data: URL. If the localStorage skeleton survived but the
IndexedDB bytes were evicted, the token had no entry in the assets map and the
old fallback kept the literal token (e.g. "a0") as the image url -- a broken
src that, not being a data: URL, also got published verbatim by uploadHeldAssets.
Drop the dangling token instead (matching the cover path's graceful null
fallback), while leaving external image URLs untouched since they are not tokens.
The previous version animated the icon pseudo-elements' width 0 -> 0.85em on
hover, which pushed any text after the mention sideways (only unnoticed because
the test mention ended its paragraph). Reserve the icon width + margin permanently
(invisible at rest via opacity 0) and animate only opacity + transform on hover —
neither affects layout, so following text stays put. Same technique the author
handle link uses. Color stays var(--sun)/currentColor, i.e. the per-publication
theme accent. Verified in-browser: trailing text holds its x-position between rest
and hover while both icons fade in.
Match the profile-page author handle link more fully: on hover/focus the mention
brightens to --sun and a Bluesky logo (left) and external-arrow (right) slide in.
They're drawn as currentColor mask pseudo-elements (::before/::after) because the
mention arrives as sanitised HTML and can't carry nested <svg> children; the icons
inherit the link colour and collapse to zero width at rest so the mention reads as
plain inline text. The sanitiser already gives the anchor target="_blank" + safe rel,
so mentions open in a new tab. Verified in the browser (hover reveals both icons in
--sun).
Mention anchors render with class="skypress-mention" (the sanitizer keeps the
class, strips data-did). The generic article-link rule painted them solid --sun;
give them the quieter author__handle-link treatment instead: inherit the prose
color, no underline, brighten to --sun on hover/focus. The class selector
out-specifies the generic :global(a) rule. Verified in the browser against the
live reader page.
Two issues surfaced by an in-browser smoke test (publish from @jeherve.com
mentioning @jeremy.herve.bzh):
1. The @ autocomplete never ran ours — every keystroke hit WordPress's built-in
user completer (GET /wp/v2/users → 404). Both completers claim the '@' trigger
and the block editor resolves a trigger to the FIRST matching completer, so
appending ours left it shadowed. Replace every '@'-triggered completer with ours
instead of appending (extracted as replaceAtMentionCompleter).
2. Selecting an option inserted nothing: getOptionCompletion returned
{ action: 'replace', value }, but 'replace' swaps the whole block (the slash-
completer contract) and dropped the inline anchor. Return the anchor element
directly so it inserts at the caret in place of the typed @query — the core
link-completer pattern.
Also: the live grapheme counter showed 'Bluesky post: N/300' at all times; only
warn when actually over the limit (hidden otherwise).
Verified end to end in the browser: autocomplete resolves jeremy.herve.bzh from
public.api.bsky.app, inserts the class+href+data-did anchor, the confirm dialog
discloses the notify target, and the published post carries the #mention facet
(correct DID + byte offsets) plus the document's flat mentions interop list.
The 300-grapheme backstop lived inside buildBskyPost, which publish() calls
at step 2 — after step 1 has already created the document record. If it fired,
the PDS kept a published-but-postless document (and a retry minted a second
orphan). Hoist the check into a shared assertBskyPostWithinLimit() and run it
in publish() before any record is written; buildBskyPost keeps calling it as a
final backstop. Adds a regression test asserting an over-limit publish rejects
with zero records written.