Selecting an atom used to clear every native range and leave the editable
root focused with NO selection. The browser then invents a caret at the
START of the content, `selectionchange` reads it, and the model's node
selection is overwritten by a text caret in the first block — so the
Enter meant to exit the freshly inserted atom split the opening
paragraph, and typing replaced its text. Reproduced live within a minute
of using the slash menu.
- the bridge now writes a node selection as `range.selectNode(blockEl)`;
the read path maps that range to null, so selectionchange keeps its
hands off the model
- beforeinput guards the node-selection state: browser edits through the
element-wrapping range are prevented; delete falls through to
deleteSelection, insertParagraph to exitAtom
- browser regression test walks the exact race: select atom → DOM range
exists → selectionchange rewrites nothing → Enter lands a paragraph
below the atom
writekit 0.0.4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps an author hits within the first minute of using atom blocks:
- there was no way to add a paragraph after a non-text block. Enter on a
selected atom now starts a paragraph below it (exitAtom, chained before
splitBlock), and a click on the root's padding below a trailing atom
does the same — ends-in-text just places the caret at the end
- the slash menu ignored its own overflow: keyboard navigation walked the
highlight out of view (now scrollIntoView nearest), and a background
wheel scroll tore the menu off its caret anchor (now prevented outside
the menu; scrolling the list itself stays native)
- BlockMeta gains `description`; the menu shows a detail pane beside the
list with the highlighted item's description, replaceable wholesale via
the new #preview slot. Headless stays headless: the pane is unstyled
text and appears only when there is a description or a slot. Preset
blocks are all described
Both floating menus (slash, bubble) are re-layered to the combobox
convention: PopperRoot provides the positioning context OUTSIDE a bare
Portal, which resolves its target from the ConfigProvider's
teleportTarget — the previous hardcoded to="body" was overriding the
app's configured target. A Combobox itself is the wrong base here on
purpose: its keyboard lives on ComboboxInput, while a suggestion menu
must leave focus in the contenteditable — the editor is the input.
writekit 0.0.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-part regex only saw defineEmits<{ inline literal }>(), so a named
interface — how Flow, Popover, Dialog, Menu and Drawer all declare their
events — extracted as zero emits, and defineExpose was not extracted at
all: 36 components' template-ref surfaces were invisible. Consumers
rebuilt what existed (nodeDragStop from @nodes-change, a renderless
child to reach fitView).
- extractor: one type-checking project per components package with a
virtual <file>.vue.ts mirror per SFC, so cross-file emits interfaces
(extends included) and expose spreads resolve through the checker —
...api expands into the composable's full return with its JSDoc
- parts gain exposes; emits/exposes members carry their JSDoc text;
update:* model emits get a stock description
- UI: Description column on emits, an Exposes (template ref) table; MCP
get_doc renders the same
- FlowRoot: JSDoc on every emit and exposed member
- new primitives guide page: building flow graphs — sizing, custom
nodes, .nodrag, click family, instance API, edge labels
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- pane fills its parent instead of collapsing to the 0px strip every
consumer debugged as a data bug; background/viewport/panel get a
default stacking triple (0/1/2) so chrome no longer paints over nodes
- nodeClick/edgeClick/paneClick were declared in FlowRootEmits but never
emitted — wired for real; nodeDoubleClick synthesized in the drag
layer (it already tells clicks from drags), and dblclick-zoom ignores
[data-flow-node] so opening a node no longer also zooms the canvas
- FlowEdge.label was typed but never rendered — the default edge now
draws a haloed midpoint label, and label joins the v-memo keys so
edits are not frozen by the memo
- fitViewOnMount prop fits once nodes AND the pane are measured (either
can finish first), skipped when the viewport is controlled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes driven by building a real consumer (cyrille studio) on 0.0.1, each
pinned by tests:
- `coerceAttrs` treated the spec as a whitelist: any attribute a block
definition did not declare was silently deleted by the first
`normalizeDocument` pass — and since normalization runs on load and consumers
autosave, the erasure wrote itself back to storage. Coercion now fills
defaults and keeps unknown keys verbatim. Parse rules build attrs explicitly,
so pasted markup cannot smuggle keys through this path; the CRDT never calls
coercion, so replica semantics are unchanged.
- `AttrSpec.validate` was consulted only by `validateDocument`, which nothing
in the library calls — it looked like enforcement and was inert. A provided
value failing `validate` now falls back to the declared default,
deterministically (CRDT-safe given one spec) and loudly in dev.
- Undo recorded one entry per transaction — one keystroke per Ctrl+Z, and 200
keystrokes evicted the entire earlier history. Plain typing in one block now
coalesces within a 500ms window by concatenation, which preserves the replay
invariant (`inverted` stays in application order, replayed reversed), counts
as ONE entry against maxSize, and never merges across blocks, structural
changes, or a foreign transaction (remote setDoc, undo/redo, selection-only
moves interrupt the chain). `coalesceMs: 0` opts out.
Also: `component` in a block definition may now be a lazy loader
(`() => import('./Card.vue')`) — a registry imported for its schema (codecs,
tests, server-side normalizers) then carries no view graph; the view wraps the
loader in a cached async component on first render.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`position()` interleaved geometry reads with inline-style writes: the horizontal
branch wrote `minWidth`/`left` and the vertical branch then read `scrollHeight`,
`getComputedStyle`, `offsetHeight` and `offsetTop`, and a second write of
`bottom` was followed by a read of `clientHeight`. Each read after a write
forces a synchronous layout, so a single placement cost at least two — and
placement runs on mount, on resize and while scrolling an expanding panel.
Split it into measure, compute and commit: every read now happens before the
first write, and the result is applied as one object rather than nine separate
property assignments.
The commit also always writes both edges of each axis. A resize can flip the
vertical branch from `bottom` to `top`, and the previous code left the old edge
in place, over-constraining the box.
No behavioural change — the arithmetic is untouched, and the placement tests
pass unchanged.
Item-aligned placement centres the panel on the selected item, and `position()`
returned early unless both the item and its text node were known. The content
already adopts the first valid item as the anchor when the model matches no
option, but only the item is registered — the text node registers solely for the
selected value, so the pair was never complete and the early return fired. The
wrapper is `position: fixed`, so it stayed at the viewport origin: to a user the
dropdown simply does not open.
This is not an edge case. A stale id, a deleted record or a directory that has
not finished loading all leave the model unmatched, and the whole select then
looks broken rather than merely unlabelled.
Recover the text node from the item's own `aria-labelledby` instead of adding a
second registration — writing the anchor refs from inside the item's tracking
effect closes a reactive cycle ("Maximum recursive updates exceeded"). Reset the
text ref alongside the item ref per open cycle so a stale node cannot pair with
a fresh item. Finally, keep the guard from ever stranding the panel again: with
no anchors at all — an empty option list — fall back to a plain trigger-aligned
drop instead of returning with the wrapper unplaced.
Both paths are covered by browser tests that fail on the previous code.
Consuming the package under `verbatimModuleSyntax` + `strictTemplates` surfaced
four defects that forced workarounds downstream.
- Drop the deprecated `SelectValue` string alias. It collided with the
`SelectValue` component exported from the same barrel, so the component
resolved to the type meaning and could not be imported (TS1484).
- Narrow the select's model to `SelectModelValue<T, Multiple>` and make
`TabsRoot` generic over its value, so a plain `v-model` on a `Ref<string>`
type-checks. Both roots declare the value prop and emit explicitly instead of
via `defineModel`, which would widen the payload with `| undefined` even
though neither control ever clears its value.
- Stop declaring `defineModel` keys in `defineEmits` as well. The duplicate
erased the payload type from the generated declarations, shipping
`(...args: unknown[]) => any` for eleven components' model events.
- Let every part accept global DOM attributes (`id`, `role`, `aria-*`,
`data-*`, ...) through `PrimitiveAttributes`. The heritage clause is marked
`@vue-ignore`, so they stay out of the runtime props and keep falling through
via `$attrs` exactly as before.
- Give `SelectContent` and `SelectViewport` a single styleable root: the
content forwards `$attrs` onto the panel, and the viewport's scrollbar CSS
moves to a reference-counted `<head>` style tag. A forwarded `class` was
previously dropped, leaving the panel unstyled.
The tsconfig vue preset gains `htmlAttributes: ["aria-*", "data-*"]` so
hyphenated data attributes are not camelized before they reach those types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Genuinely type composable any usages (useStepper/useStorage/useForm/
createEventHook/useSorted/etc.) as proper generics/unknown; keep idiomatic
any-function and overload-impl signatures with comments; skipped test -> .todo.
Reworks the editor playground into a tabbed showcase:
- Rich text & blocks: drag-to-reorder handles, bubble/slash menus, and a live output panel (block/word counts, selection readout, document JSON).
- Multiplayer: two CRDT replicas synced over an in-memory channel with remote cursors, an in-sync indicator, and a Connected/Offline toggle that demonstrates divergence → convergence.
Also removes the focus outline on the contenteditable (outline: none on the editable surface).
A live @robonen/editor instance (default registry) as a doc section at /editor/playground: a reactive formatting toolbar, the bubble/slash menus, and a sample document exercising every block and mark — styled with the docs design tokens and wrapped in <ClientOnly> for SSR. Links it from the editor intro.
Setting a custom property to 'initial' (a CSS-wide keyword) computes to the guaranteed-invalid value, so getComputedStyle returns '' — correct per spec and now implemented by jsdom 29 (the deps bump from jsdom 28). Use a normal token ('start') so the read returns the value.
selectionchange is dispatched on a macrotask, so awaiting nextTick (microtasks) didn't wait for the editor to sync the native selection into the model — the assertion saw the initial selection. Poll with vi.waitFor instead. (Surfaced now that per-package CI runs editor's browser tests, which the root vitest projects list omitted.)
An intro.vue landing for all 12 packages, plus a multi-section crdt guide (Concepts, Primitives, Replication & Sync, and an interactive convergence Playground).