Commits
The react@18.3.1 pin flows from @wordpress/block-editor → @wordpress/element
peer-depping react@^18, which reflects Gutenberg reverting its React 19
upgrade (WordPress/gutenberg#78940, merged 2026-06-04). Record the cause and
the upgrade trigger so the bump can happen routinely once Gutenberg re-lands
React 19.
- AGENTS.md: expand hard-constraint #1 with the upstream cause and trigger.
- Decision 0001: add a dated addendum with the React 19 upgrade checklist.
Three fixes from review of the @wordpress/block-editor migration (Decision 0021):
- Debounce draft persistence. onInput fires per keystroke and forward() ran a
synchronous JSON.stringify of the whole tree + localStorage.setItem each time,
which lags typing on long articles. Split the cheap publish-state forward
(immediate, so publish stays current) from the costly localStorage write
(debounced, flushed on unmount so the last edits survive the publish remount).
- Scope the Cmd/Ctrl+Z capture handler. It claimed undo for the whole editor
subtree, including chrome text fields (inspector Alt text, link popover URL),
reverting a block change and swallowing the field's native undo. shouldHandle-
EditorUndo() now defers to native undo for editable fields outside the canvas.
- Restore the editor error boundary. The old IsolatedBlockEditor rendered an
onError fallback; the direct composition had none, so a render throw blanked
the whole client-only island. EditorErrorBoundary shows the recovery notice.
Adds regression tests for the undo-scoping predicate and the error boundary.
The block-editor migration shipped a fixed BlockToolbar docked in the
editor header because the floating toolbar appeared not to render. That
diagnosis was wrong on two counts:
- BlockTools was missing __unstableContentRef, so the toolbar popover
had no content element to anchor to and scroll-follow — it could not
position against the selected block.
- It was only ever observed on an unmodified-empty or actively-typing
block, both of which Gutenberg's useShowBlockTools suppresses by
design: an empty default paragraph shows the side inserter instead,
and while typing the block interface is hidden until the next pointer
move. Synthetic test input left the editor in that typing state, so
the toolbar never showed.
Set hasFixedToolbar: false and pass the canvas element to BlockTools via
__unstableContentRef, restoring the original SkyPress behaviour where the
toolbar floats just above the selected block and follows it on scroll.
The header keeps the document-level tools (inserter, undo/redo, the
block-settings cog).
Verified in-browser on /write: selecting a non-empty block floats the
toolbar (Paragraph / Bold / Italic / Link / More / Options) anchored to
it, themed against the paper surface. npm test (592), check, and build
all pass.
useStateWithHistory's undo/redo swap BlockEditorProvider's controlled
value, but the provider doesn't re-fire onInput/onChange for a
controlled-value change, so the localStorage draft and Studio's publish
state silently missed every undo/redo. Typing, undoing, then publishing
would publish the pre-undo content.
Route undo/redo through wrapped handlers that flag a pending forward; an
effect on value then forwards the restored tree exactly once. Also fix
two comments left stale by the IBE migration (iso.blocks.allowBlocks and
onLoad no longer exist).
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 react@18.3.1 pin flows from @wordpress/block-editor → @wordpress/element
peer-depping react@^18, which reflects Gutenberg reverting its React 19
upgrade (WordPress/gutenberg#78940, merged 2026-06-04). Record the cause and
the upgrade trigger so the bump can happen routinely once Gutenberg re-lands
React 19.
- AGENTS.md: expand hard-constraint #1 with the upstream cause and trigger.
- Decision 0001: add a dated addendum with the React 19 upgrade checklist.
Three fixes from review of the @wordpress/block-editor migration (Decision 0021):
- Debounce draft persistence. onInput fires per keystroke and forward() ran a
synchronous JSON.stringify of the whole tree + localStorage.setItem each time,
which lags typing on long articles. Split the cheap publish-state forward
(immediate, so publish stays current) from the costly localStorage write
(debounced, flushed on unmount so the last edits survive the publish remount).
- Scope the Cmd/Ctrl+Z capture handler. It claimed undo for the whole editor
subtree, including chrome text fields (inspector Alt text, link popover URL),
reverting a block change and swallowing the field's native undo. shouldHandle-
EditorUndo() now defers to native undo for editable fields outside the canvas.
- Restore the editor error boundary. The old IsolatedBlockEditor rendered an
onError fallback; the direct composition had none, so a render throw blanked
the whole client-only island. EditorErrorBoundary shows the recovery notice.
Adds regression tests for the undo-scoping predicate and the error boundary.
The block-editor migration shipped a fixed BlockToolbar docked in the
editor header because the floating toolbar appeared not to render. That
diagnosis was wrong on two counts:
- BlockTools was missing __unstableContentRef, so the toolbar popover
had no content element to anchor to and scroll-follow — it could not
position against the selected block.
- It was only ever observed on an unmodified-empty or actively-typing
block, both of which Gutenberg's useShowBlockTools suppresses by
design: an empty default paragraph shows the side inserter instead,
and while typing the block interface is hidden until the next pointer
move. Synthetic test input left the editor in that typing state, so
the toolbar never showed.
Set hasFixedToolbar: false and pass the canvas element to BlockTools via
__unstableContentRef, restoring the original SkyPress behaviour where the
toolbar floats just above the selected block and follows it on scroll.
The header keeps the document-level tools (inserter, undo/redo, the
block-settings cog).
Verified in-browser on /write: selecting a non-empty block floats the
toolbar (Paragraph / Bold / Italic / Link / More / Options) anchored to
it, themed against the paper surface. npm test (592), check, and build
all pass.
useStateWithHistory's undo/redo swap BlockEditorProvider's controlled
value, but the provider doesn't re-fire onInput/onChange for a
controlled-value change, so the localStorage draft and Studio's publish
state silently missed every undo/redo. Typing, undoing, then publishing
would publish the pre-undo content.
Route undo/redo through wrapped handlers that flag a pending forward; an
effect on value then forwards the restored tree exactly once. Also fix
two comments left stale by the IBE migration (iso.blocks.allowBlocks and
onLoad no longer exist).
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.