Skip to content

docs(openspec): propose replacing Block Tunes with plugins - #189

Open
gohabereg wants to merge 11 commits into
mainfrom
spec/tunes-as-plugins
Open

gohabereg wants to merge 11 commits into
mainfrom
spec/tunes-as-plugins

Conversation

@gohabereg

Copy link
Copy Markdown
Member

Follows up on the review in #157. Instead of wiring a BlockTuneAdapter and a BlockTunesManager around the Block Tune entity, this proposes removing the entity: a "tune" becomes a plain plugin that stores per-block data in the Model and adds an item to the block settings menu. "Tune" stays as a UI term only.

Artifacts only — no implementation in this PR. Three changes, so each lands on its own:

plugin-block-data (36 tasks, no dependencies)

BlockNode's tunes store becomes plugins, keyed by plugin name, created on first write (today updateTuneData crashes for an absent entry). Adds BlocksAPI.getPluginData/updatePluginData, makes plugin-data changes undoable and collaborative, renames the model's tune vocabulary (PluginDataNode, PluginDataIndex with k: "plugin", PluginDataModifiedEvent), types the accessors through a new augmentable EditorjsPluginDataMap, and carries v2 tunes data across in composeDataFromVersion2.

Breaking, pre-1.0: the serialized block key tunes → plugins, and the serialized index k: "tune" → k: "plugin". Clients and ot-server deploy together, since the server applies operations against its own model.

block-settings-ui (31 tasks)

BlockSettingsUI (name: 'block-settings') renders the settings popover and exposes register(provider), where a provider returns a MenuConfig for the target block each time the menu opens — the same model as InlineTool#getToolbarConfig. ToolbarUI grows a settings button, and the built-in Move up / Move down / Delete become @editorjs/block-actions-plugin. Ports the toolbar button, popover wiring and internal-tune behavior from #157 (credited to @bettysteger).

Assumes test/editorjs-e2e-and-aria-openspec has merged, since it builds on that branch's messages.ts, toolbar ARIA/roving-tabindex behavior, Playwright harness and @editorjs/ui-kit 2.x. Changing a block's appearance from a plugin is deliberately deferred to its own change; the analysis is parked in its design.md.

remove-block-tunes (15 tasks, no dependencies)

Deletes ToolType.Tune, BlockTune, BlockTuneFacade, ToolsCollection#blockTunes, the tool tunes option and its facade getters. A registered tune is currently built into a facade that nothing ever reads, so nothing working is lost.


Plan: this PR reviews the plan; implementation follows as stacked PRs, starting with plugin-block-data and remove-block-tunes. Supersedes #157.

🤖 Generated with Claude Code

gohabereg and others added 2 commits September 26, 2026 00:03
Splits the "tunes as plugins" work from the review on #157 into three
OpenSpec changes:

- plugin-block-data: BlockNode's `tunes` store becomes `plugins` (per-block
  data keyed by plugin name), created on first write, readable and writable
  through BlocksAPI, undoable, and synchronized through OT. Renames the model's
  tune vocabulary (PluginDataNode / PluginDataIndex / PluginDataModifiedEvent)
  and carries v2 `tunes` data over in composeDataFromVersion2.
- block-settings-ui: BlockSettingsUI exposes the block settings menu as a
  plugin API, ToolbarUI grows a settings button, and the built-in move/delete
  actions become @editorjs/block-actions-plugin. Assumes the e2e/aria branch
  has landed.
- remove-block-tunes: deletes ToolType.Tune, BlockTune, BlockTuneFacade and
  the tool `tunes` option. Independent of the other two.

Artifacts only; no implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design and the capability spec already required a null-prototype store,
an empty-name check and attaching the node listener before the first update.
The task list did not, so the implementation steps could miss them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

## What Changes

- **Model — per-block plugin data.** Rename the BlockNode `tunes` store to `plugins`, keyed by plugin `name`. Writing to an absent entry creates it. Setting a key to `undefined` removes it, and empty entries are left out of serialization. **BREAKING**: the serialized block key `tunes` → `plugins`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit uneasy about using the word plugins in data to store "tunes" data. "Plugin" is a code-organization term, and from a data-model perspective it feels a bit out of place. Maybe meta would be better? Or something closer to the data model. In the code, these could all still be plugins. Though I realize that might cause some confusion.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you suggest to rename per-tool configuration as well? We had tunes property in the tool config to list tunes for blocks of the type. Now it's going to be plugins

Something like

EditorJS.use(Paragraph, { plugins: ['spoiler'] }

I think meta wouldn't work there. Or it would be plugins in the config, but meta in the model and the output?

I also would reserve meta for actual metadata (e.g. createdAt, author, etc). I agree from model perspective plugins is not the right term, but from the developer perspective I would load plugins and would expect plugins in the output. So maybe naming consistency is more important here than contextual correctness

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, let's try "plugins" in data.

gohabereg and others added 2 commits October 9, 2026 00:54
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The e2e/ARIA branch landed as #187, which satisfies this change's first
precondition and invalidates the second. Its ARIA requirements turned out to
live beside `Floating toolbar` rather than inside it, so the delta was not
about to revert them -- but it did duplicate one of their scenarios and put
the settings button's ARIA assertions in the wrong requirement.

The delta now modifies all three merged requirements, each header matching
verbatim so the archive fold still applies: behaviour stays in `Floating
toolbar`, accessible names in `Accessible floating toolbar`, and roving
tabindex plus `aria-expanded` in `The block actions toolbar is a single tab
stop`. Drops the duplicated tab-stop scenario and adds the block-settings
counterpart of the toolbox's "keys from inside the popover are left alone".

Also resolves the "block actions" collision #187 made concrete. `main` now
ships that phrase as the toolbar container's accessible name, asserted in two
e2e suites and carried in a requirement title; the move/delete package was
only spec text. It becomes `@editorjs/default-block-settings-plugin`
(`default-block-settings`), which is what it actually is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gohabereg and others added 2 commits October 10, 2026 00:05
The three implementation branches had accumulated spec changes of their own:
delta rewrites made once the code showed what the requirement should say, and
the hand-edits to live specs that the mechanical archive fold cannot do --
preambles naming the tune tool kind, the bundle's default plugin list and the
note claiming `ui` has no test suite.

Specs belong in this PR, so they are here and the implementation branches keep
only their task lists. Nothing is new: every hunk already existed further up the
stack and moves down unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s stack

Review of the three implementation PRs turned up behaviour that the specs
did not cover, so it is written down before it is built:

- block selection is dispatched on focus as well as hover, because the
  settings button acts on "the selected block" and a keyboard-only user
  never produces one
- the settings button closes the menu it opened, per the menu button pattern
- `BlocksAPI` gains `getToolByIndex`, so reading one block's tool name stops
  going through `DocumentAPI.data`, which serializes the whole document
- a plugin data write that changes nothing emits no event, and so records
  neither an operation nor an undo step
- a key removal is specified to survive the wire, where `undefined` does not

The UI package's `MenuConfig` is renamed `BlockSettingsMenuConfig` in the
spec text: `@editorjs/sdk` already exports a different type by that name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## ADDED Requirements

### Requirement: Plugin data operations
`CollaborationManager` SHALL translate a `PluginDataModifiedEvent` into a `Modify` `Operation` on its `PluginDataIndex`, with `payload` set to the new value and `prevPayload` to the previous value. `OperationsTransformer` SHALL shift a plugin data operation's block index against block insertions and removals the same way it does for data index operations, SHALL turn it into a `Neutral` operation when its block is removed, and SHALL leave two concurrent plugin data operations untransformed against each other. Transformation SHALL also accept a plugin data operation as the operation being transformed *against*, rather than rejecting its index kind. The `Modify` operation payload type SHALL admit any JSON value, including primitives.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 concurrent operations may lead to clients divergence:

  • clients A and B write concurrently
  • server applies A, then B
  • client B already applied own op locally, then receives original A (applied before B) and ends with A

Regular block data already handles this case: transformAgainstDataOperation turns an incoming operation into Neutral when it targets the same key as an unsent local one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and the spec sentence was mandating it. I traced it through to be sure:

  • B writes locally, its operation unsent (rev undefined).
  • OTClient#onMessage transforms every incoming remote operation against the pending local ones — but the plugin data branch returned them untouched, so A's value was applied on B.
  • B's own operation is never echoed back to it (message.payload.userId === this.#userId returns early), so nothing restored false.
  • Server false, A false, B true. Exactly the divergence you described.

Fixed by giving the plugin data branch the rule #transformAgainstDataOperation already uses: an against-operation with no revision counts as Infinity, so the incoming one becomes Neutral and the unsent local write stands. Scoped to the same block, plugin and key, since nothing else describes the same value — a write to anchors.visible must not neutralize one to anchors.id.

The delta now states that rule instead of the old "left untransformed", with scenarios for the same-key and different-key cases. Implementation and tests are in #192. The test that proves it: should become Neutral against an unsent write to the same key fails against the previous return Operation.from(operation), and three more fail if the index comparison is dropped, so the narrowing is covered too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec and code match now, but plugin-block-data/design.md (line 58 and the same-key Risks entries) still says plugin data ops stay untransformed.

Comment thread openspec/changes/block-settings-ui/specs/block-settings/spec.md
- *Alternative:* plugins push static item lists at registration. Rejected, because items depend on the block and its state.

Wiring follows the Toolbox pattern:
1. `ToolbarUI`'s settings button dispatches `ui:block-settings:open` with the hovered block index.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This contradicts specs/block-settings/spec.md and specs/ui/spec.md.

Spec claims that

  • BlockSettingsUI
    • renders button
    • tracks block selection
    • announces button via event
  • ToolbarUI
    • reassigns its tab stop when the button joins

Design claims that ToolbarUI owns button and hovered block index.

I suppose design is the right one, but specs require sync.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right that they contradict — but it's the other way round: the specs are current and design.md is stale, from before the ARIA work was folded into this change.

The code agrees with the specs: the button, its aria-haspopup/aria-expanded, the focus-before-open and the block it acts on all live in BlockSettingsUI, and ToolbarUI only mounts the two elements it's handed and reassigns its tab stop. There's no ui:block-settings:open dispatch from the toolbar at all.

I've updated design.md to match, and added the reasoning that was missing: a menu button's aria-expanded has to track the menu, and the menu's open state is the plugin's — split across the two, every open and close needs a round trip through the event bus to keep an attribute honest, and the button can end up reporting a block the menu wasn't built for. ToolbarUI treats it as a control of the container like its own plus button: named and operated by its owner, positioned and tab-managed by the toolbar.

Its remaining MenuConfig references are now BlockSettingsMenuConfig, and the claim that @editorjs/sdk owns the item shape is corrected — the SDK's MenuConfig is a different, narrower type for inline tools, which is why the UI's was renamed apart from it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proposal.md (lines 18–19, 31, 37) and tasks.md (3.1, line 49) still put the button in ToolbarUI.

Comment thread openspec/changes/plugin-block-data/specs/sdk/spec.md Outdated
@gohabereg
gohabereg requested a review from Reversean October 11, 2026 21:00
Four threads from the review on #189.

Concurrent plugin data writes: the delta required the transformer to leave
two concurrent writes untransformed, which diverges the client holding an
unsent write -- it applies the remote value and never re-applies its own,
so it ends on a value the server does not hold. The rule is now the one
data values already use, and two scenarios cover the same-key and
different-key cases.

A failing settings provider: the behaviour for a provider that throws or
returns late was implemented but unspecified, and nothing covered one that
never settles or a close arriving mid-build. Spelled out as a requirement
of its own, since what it protects is the menu opening at all.

design.md still had ToolbarUI owning the settings button and dispatching
ui:block-settings:open, from before the ARIA work was folded in. The specs
and the code give the button to BlockSettingsUI; the doc now says so, and
says why. Its MenuConfig references follow the rename.

The sdk delta's scenarios still said "both" and "either" of what its own
requirement already called three maps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot's review of the implementation PRs. Two of its findings are
pre-existing OT defects that affect plain data values too, and are on the
board rather than here; the rest are specified below.

Plugin names and the keys inside an entry are document data, so neither
can be allowed to collide with Object.prototype, and what the API hands
back has to be detached from the store -- over a live record the no-op
rule turns read-modify-write into silent loss.

The settings button's contract now says the toggle holds however it is
activated, and that the button does not submit a form it sits inside. Two
more ways a menu could come apart from the block it was built for: a
dismissal while a replacement is being built, and selection moving during
a build.

Only caret movement is filtered for repeats. Pointer entry was being
deduplicated too, which stranded listeners that ignore selection while
the menu is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gohabereg and others added 2 commits October 17, 2026 19:30
Reversean's follow-ups: the specs were reconciled but the surrounding
artifacts were not.

plugin-block-data/design.md still said two plugin data operations are
left untransformed and that the last applied wins, and pinned the undo
limitation on that. The rule is now the revision one; the stale undo
entry is dropped rather than replayed, which is checked rather than
assumed. The limits that do remain -- the in-flight window on the client
and the missing Neutral case on the server -- are stated where they are
and pointed at the board card, since both predate this change and affect
plain data values too.

block-settings-ui/proposal.md and tasks.md still had ToolbarUI rendering
the settings button and tracking the hovered block. They now say what the
specs and the code say: the plugin renders the button and follows
selection, the toolbar mounts it and owns the tab stop. MenuConfig
references follow the rename there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflict, in the `ui` capability's note about test coverage: main's
#174 rewrote it for the teardown suites, this branch for the block
settings suites. Both are true, so the resolution names all four files.

#174 also added a "Destroying the blocks UI" scenario inside "Blocks
holder rendering and input capture", which this change's delta MODIFIES.
The archive fold replaces a modified requirement whole, so the delta
would have deleted that scenario on archive; it is carried into the delta
instead, with this change's own `selectionchange` listener added to what
teardown removes. Checked the same way for every other requirement these
deltas modify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants