Changelog
Unreleased
Nothing yet.
0.1.2 (2026-10-29)
Fixes from the first days of the beta: rich text that stayed split after removing a mark, mark edges, Ctrl+B at the edge of a word, and code block editing. No API change.
Fixed
- A mark removed over exactly the range it was applied to left the text in two pieces. Bolding [18, 21) then un-bolding [18, 21) gave The ford is upstre + am. in the JSON export, the HTML and the DOM (two text nodes: a spellcheck squiggle, a double-click or a word delete could stop at the seam). The cause is in Loro: LoroText.toDelta() keeps the split in that case (a wider unmark merges it), and it is stored in snapshots. Every read of a text delta now goes through one canonical form (readDelta: adjacent runs with the same marks are merged, an off mark is an absent mark), so documents saved with the split are read right too. Covered by tests/unit/delta.test.js, tests/unit/marks-canonical.test.js (seeded fuzz) and tests/e2e/marks-canonical.spec.ts.
- Code block editing. The slash menu no longer opens inside a code block (/ is text there, and Enter after it turned the block into a list). Tab and Shift+Tab indent and outdent every line of a multi-line selection and keep the selection; Shift+Tab also removes a tab character. Enter keeps the indentation of the line. A long line scrolls to keep the caret visible. Ctrl/Cmd+A selects the code first, the whole document on the second press. ArrowUp on the first line of a code block that starts the document creates a paragraph before it, as ArrowDown already did after it. Pasted \r\n and \r line endings become \n. Covered by tests/e2e/code-editing.spec.ts and tests/unit/code-indent.test.js.
- Code block languages. Names such as c++, c# or objective-c work in the ``` + Enter shortcut and on paste; the language select shows a language that is not in its list (ts, js) instead of a blank; the HTML export writes <pre><code class="language-js"> (it dropped the language); the HTML import also reads lang-, data-language and GitHub's highlight-source-* wrapper, and turns <br> inside <pre> into line breaks. The inline-code style no longer tints the code block.
- A code block was a keyboard trap. Tab always indented, so a keyboard user could neither leave the block nor reach its language select (WCAG 2.1.2). Escape then Tab now moves the focus out of the editor, and Escape then Shift+Tab focuses the language select, the same convention as CodeMirror and GitHub. Any other key or a click closes the way out, a plain Tab still indents, and Tab elsewhere is unchanged. The select no longer sits in the Tab order of the page, where it would have caught the Tab of every block above it.
- ``` + Space makes a code block. Like #, > and -, the fence shortcut now fires on Space as well as Enter, with a language too (```js), when the paragraph is exactly the fence and the caret is at its end. One undo gives back what was typed.
- Where marks end no longer depends on the history of the text. Two texts that look the same could behave differently when typed into at the edge of a mark: a sentence in bold with a word then un-bolded made the character typed at the start of that word bold, the same text made of two bold ranges did not (a Loro quirk, loro-dev/loro#1240). Typed text now always follows one rule: it keeps a mark only when the characters on both sides have it (a mark armed with Ctrl+B still wins). The correction is part of the typing step, so one undo removes the text. Canary tests in tests/unit/loro-quirks.test.js fail when Loro fixes this and the correction can go.
- Ctrl+B (and Ctrl+I, Ctrl+E…) at the end of a marked word could not extend the mark. With the caret right after a bold word, the shortcut read the bold of the character before the caret as "active" and armed bold off, although text typed there is plain: the first press did nothing visible and you needed a second one. At a caret, "active" now means what typing there would produce (the same rule as the edges of a mark: the characters on both sides have it), so at the end or the start of a bold word the first press arms bold on, and inside the word it arms it off. A selection range, and a mark that was already armed, behave as before.
0.1.1 (2026-10-29)
Two fixes reported by users of the alpha. No API change.
Fixed
- Japanese (and other IME) input after empty paragraphs (#6). A composition that started after one or more empty paragraphs could land in an earlier block, or leave the editor stuck in a composing state: the text was lost and Enter stopped working. Chromium locates a composition by plain-text offsets and miscounts the line-break node of each empty block before it; during a composition those nodes are now presented as <br> and restored when it ends (nothing changes in the document or the rendering). Covered by tests/e2e/ime-audit.spec.ts, section 9.
- Word deletion follows the platform conventions (#4). Alt+Backspace (macOS) and Ctrl+Backspace (Windows, Linux) no longer delete the space before the word: this is a sentence | now gives this is a |. Word boundaries come from Intl.Segmenter (accents, CJK), punctuation is its own stop (end.| gives end|), and Alt/Ctrl+Delete deletes forward the way each platform does (to the end of the next word on macOS, up to the next word on Windows). Covered by tests/unit/delete-word.test.js and tests/e2e/word-delete.spec.ts.
0.1.0 (2026-10-29)
Nabu is in public beta: a 0. version means the API can still change before 1.0. Versioning policy:
- Breaking changes arrive only in a minor version (0.2.0, 0.3.0, ...), each with a migration note in this file.
- Patch versions (0.1.x) are fixes only: they keep the public API and the stable @aionbuilders/nabu/extend contract.
- nabu.internals is unstable and may change in any version.
- 1.0.0 will mark the stability commitment.
What matters if you upgrade from 0.1.0-beta.0 or an alpha:
- A smaller, stable public API. The package now has three documented entry points: @aionbuilders/nabu (use the editor), @aionbuilders/nabu/ui (toolbar, slash menu, drag handle, command prompts) and @aionbuilders/nabu/extend (write your own blocks and extensions). /extend is a stable contract: every member a third-party block needs is tagged @public and pinned by a test, and the engine internals sit behind one explicitly unstable door, nabu.internals. This is the biggest breaking change of the release: see the migration notes below.
- Read-only mode and accessibility. <NabuEditor readonly> turns an editor into a viewer (live toggle, copy and links still work). The editor has an accessible name (label, labelledby), a visible focus ring, a link colour and other defaults that pass WCAG contrast, an ARIA slash menu with live announcements, keyboard block moves (Mod-Shift-ArrowUp / ArrowDown) and word counts that work for Chinese and Japanese.
- Several editors on one page work. One editor no longer steals the selection of its neighbours, and a read-only neighbour no longer swallows your clicks.
- Fast at 10,000 blocks. A keystroke in a 10,000-block document now reaches the screen in one frame instead of several (details and numbers in "Performance" below).
- Persistence and sync you can rely on. Two tabs on one document merge and stay in sync live; an unreadable saved document is quarantined and recovered from a backup instead of lost; the page closing flushes the last changes; nabu.persistence exposes a reactive save status. Files (images, video, attachments) now live in a separate asset store, deduplicated by hash, with background uploads that resume after a reload, optional history compaction, and drag and drop of files.
- Regions. A new RegionsExtension structures a long text in numbered, typed, nestable divisions (parts, chapters, scenes). A region is a container in the document tree and feels like a continuous text with seams on screen. It is part of FullPreset. This replaces the removed Zone block.
- Modern toolchain. Built and tested with SvelteKit 3, Vite 8, Vitest 5 and TypeScript 6; the nabu() Vite helper no longer depends on vite-plugin-top-level-await (which fails on Vite 8). svelte peer dependency is now ^5.20.0.
- BREAKING (details and migration steps in the next section): imports from the package root were cut down (UI and extend helpers moved to /ui and /extend); DEFAULT_SLASH_COMMANDS is removed; the code action is now code:block; Zone, Context overlay and Variable are removed; the default link colour changed; files are no longer stored inside the Loro document; Mod shortcuts are platform-strict; the markdown form of a dialogue line is -- text.
Breaking changes and migration
- Imports. The package root no longer re-exports everything. Move FloatingToolbar, SlashMenu, DragHandle, CommandInvite, CommandChooser and SlashMenuController to @aionbuilders/nabu/ui; move extension, Extension, Block, TextBlock, MegaBlock, BlockComponent, TextBehavior, RichText, the key-chord helpers and escapeHtml / escapeAttr to @aionbuilders/nabu/extend. 82 names that were exported by accident are internal now (block classes of the built-ins, region operations, selection internals, ...): use the extensions, nabu.blocks, and nabu.exec('region:split' | 'region:insert' | ...) instead. The full list is in "Breaking: public API reduced to three entry points" below.
- Slash menu. DEFAULT_SLASH_COMMANDS is removed; the menu lists what the loaded extensions declare (Extension#slashCommands). commands={[...DEFAULT_SLASH_COMMANDS, mine]} becomes commands={[mine]} (the prop now means extra entries). To change a built-in entry, provide your own extension with that entry. Entry icons no longer use emoji.
- Block code action. nabu.exec('code') now only toggles the inline code mark. For the code block use nabu.exec('code:block') (or exec('block:transform', { type: 'code' })).
- Zone, Context overlay, Variable removed. ZoneBlock, ZoneExtension, Zones, Templates, Counters, ContextExtension, ContextMenu, Block#variables and Block#var() are gone, and FullPreset no longer includes them. Regions are the replacement for structuring a text.
- Files are no longer inside the Loro document. Bytes go to an asset store (IndexedDBAssetStore with createPersistedEditor, MemoryAssetStore otherwise); the document keeps a nabu-asset:sha256-... reference. Documents saved by beta.0 are migrated automatically on open (one silent commit); the IndexedDB database upgrade only adds stores. Code that read the undocumented Loro assets map directly breaks. In host mode (files.onBeforePersist) the block keeps its hash reference forever: read the hosted URL from assetIndex[hash].url (display does it for you) instead of expecting the returned ref in the block.
- Default colours. --nb-link is #0b57d0 on light pages and #8ab4f8 on dark ones (it was #4d9fff, 2.7:1 on white). Other contrast fixes: placeholder, region seam label, muted text. To keep the old blue, set --nb-link: #4d9fff.
- Shortcuts are platform-strict. Mod is Cmd on macOS and Ctrl elsewhere, exclusively (on Windows / Linux the Meta key no longer triggers bold, undo, ...). A real tie between two commands on one key opens a chooser instead of running the first registered one.
- Dialogue markdown. A dialogue line is exported and imported as -- text, whatever dash is displayed. Markdown exported by earlier betas as — text is no longer recognised as dialogue (it is a paragraph). New createDialogueExtension({ dash }) only sets the displayed marker.
- Vite helper. nabu() returns vite-plugin-wasm plus a plugin setting build.target: 'esnext' (unless you set build.target). If you added vite-plugin-top-level-await yourself because of Nabu, remove it. Svelte 5.20 or newer is required (it was ^5.0.0 on paper).
- Smaller behaviour changes. Word counts use Intl.Segmenter (standalone punctuation no longer counts; a hyphenated compound counts as several words). Copy and serialize('json') of a pending upload export status: "missing" instead of a transient reference. A whole block pasted alone into a list item now splits the list. Nested lists under numbered items export with the marker's width of indentation. Backspace at the start of a toggle header turns it into a paragraph instead of merging. Dropping content into the editor now works (and is always handled by Nabu). Unsafe link targets (javascript:, ...) are refused or dropped everywhere (model, paste, import, render).
Added - Regions: parts, chapters and scenes as containers (ADR 018, builds on ADR 016 and 017)
- New extension RegionsExtension(options) (also createRegionsExtension; included in FullPreset as RegionsExtension()). You declare region types (level, name, label template with {index}, {path}, {title}, {type}, {level}, numbering, typed fields); without options the generic section-1 / section-2 / section-3 types apply. See docs/guides/regions.md.
- A region is a container block in the Loro tree: its header is its editable title (a Loro text, inline marks allowed), its body is an ordinary list of blocks with sub-regions. On screen it is a continuous text with seams: the label and hairline above the title, an end marker where text follows a region, and a margin rail for the region holding the caret. The body has no border or background. A sub-region must be strictly deeper than its parent; a normalizer corrects violations and lifts a region out of lists, toggles and callouts.
- Registry Regions.get(nabu) / nabu.regions: list, tree, preamble, at(block), of(region), next / prev / parentOf, caret; each region has type, level, index, path, label, fields, parent, children, blocks / ownBlocks, wordCount / ownWordCount.
- Keyboard and commands: region:split (Mod-Enter), region:exit (Mod-Shift-Enter), region:merge-previous / region:merge-next (hold Backspace / Delete; text never merges silently through a boundary), region:promote / region:demote (Tab / Shift-Tab in the title), region:unwrap, region:move, region:next / prev / parent, region:insert, region:create, region:close; ::: + Space opens a chooser of the region types valid at that place, ::: + Enter closes the enclosing region. The title behaves like a locked field: a text selection only touches text, never the structure.
- Formats: JSON round-trips exactly; markdown writes <!-- region:chapter {json} --> followed by the title heading (and <!-- /region --> only when text follows at the parent's level); HTML is <section data-nabu-region ...>.
- The detailed entries "Regions, lot 1 to 3" further down record the first, marker-based implementation. That model was replaced by containers before release, so where they say "marker" or "root-only", read "region container"; Regions were never part of 0.1.0-beta.0, so none of it is a breaking change for you.
Added - Persistence you can rely on: tabs, recovery, status (ADR 010, themes 8 and 9)
- Several tabs on one document (createPersistedEditor): every save reads the stored record and merges what another tab wrote in one IndexedDB transaction, so tabs no longer overwrite each other. A record that cannot be merged is copied to a quarantine store before being replaced. Live sync (option sync, on by default) broadcasts local commits through a BroadcastChannel (nabu:<docId>): typing in one tab shows in the other, the caret stays on the same character and an IME composition is never interrupted. Remote changes are never undo steps. New Nabu#importRemote(bytes) is the way to feed bytes from your own transport (WebSocket, peer-to-peer) while keeping the caret; nabu.doc is public for the outgoing side. See docs/guides/persistence-and-sync.md.
- A document that cannot be read is never deleted: it is quarantined (<docId>@<timestamp>), the previous save (kept as a backup by every save) is tried, otherwise the editor starts empty; nabu.persistence.recovery and the onRecover(recovery) option tell you. If IndexedDB is unavailable the editor works in memory (nabu.persistence.available === false) instead of rejecting.
- Saving: maxWait (default 3 s) bounds the time without a save during a burst of typing; visibilitychange / pagehide save at once and also write the last changes synchronously to localStorage (nabu:tail:<docId>), merged at the next start; onSaveError(error) reports a failing automatic save; nabu.saveNow() saves on demand. The IndexedDB database is at schema version 4 (upgrades only add stores).
- Reactive state: nabu.persistence (status: saved | pending | saving | error | unavailable, lastSavedAt, lastError, available, recovery, sync, dispose()), nabu.isNew, nabu.clearPersistence().
Breaking changes in detail
Breaking: public API reduced to three entry points (C-01)
- The package root no longer re-exports everything (export * through blocks, utils, behaviors, ui, files, paste: 162 names, internals included). The API is now three entry points, each with an explicit, pinned list of names (tests/unit/public-api.test.js):
- @aionbuilders/nabu (54 names): create and mount an editor (createEditor, createMinimalEditor, createTextEditor, createDocumentEditor, createFullEditor, createPersistedEditor, the four presets, Nabu, NabuEditor), the built-in extensions (blocks, RegionsExtension / createRegionsExtension, DialogueExtension / createDialogueExtension, rich text, typography, markdown shortcuts, BlockExtension, the four paste extensions), the subsystems (Files, FileRejectedError, MemoryOutbox, REASON_CODES, Regions, Region, Todos), insertImageFromFile, insertMediaFromFile, the format versions, deltaToMarkdown, deltaToHtml, sanitizeUrl, formatChord; plus the JSDoc typedefs integrators need (NabuInit, FilesConfig, CommandDef, RegionTypeInit, ...).
- @aionbuilders/nabu/ui (new, 7 names): FloatingToolbar, SlashMenu, DragHandle, CommandInvite, CommandChooser, SlashMenuController, DEFAULT_SLASH_COMMANDS. These are no longer exported from the root: import { FloatingToolbar } from '@aionbuilders/nabu/ui'.
- @aionbuilders/nabu/extend (new, 19 names): writing blocks and extensions: extension, Extension, Block, TextBlock, MegaBlock, BlockComponent, TextBehavior, RichText, the key-chord helpers (parseChord, canonicalChord, chordId, eventChord, eventChordId, isReservedChord, RESERVED_CHORDS, hasCommandModifier, detectPlatform), escapeHtml, escapeAttr. extension, Extension, Block, TextBlock, MegaBlock, BlockComponent, TextBehavior, RichText, the chord helpers and the escape helpers move here from the root.
- Removed from every entry (now internal, 82 names), with where to go instead:
- Block classes and components of the built-ins (Paragraph, Heading, List, ListItem, Dialogue, *Block, *Component, MediaBlock, Preamble): use the extensions; the blocks are reached through nabu.blocks.
- Region operations (insertRegion, createRegion, createMarker, splitRegion, insertNearRegion, mergeRegion, unwrapRegion, exitRegion, moveRegion, demoteRegion, promoteRegion, goToRegion, closeChain, closeOptions, closeRegionAt, createOptions, previewPrefix, needsEndMarker, endMarkerText, regionFacts, normalizeRegions, createRegionCommands, resolveRegionsConfig, validateRegionField): run the commands instead, nabu.exec('region:split' | 'region:insert' | 'region:create' | 'region:merge-previous' | …).
- Container and selection internals (findLCA, findDirectChildOf, wrapOrphan, isolatedRuns, isolationOwnerOf, textBlocksInOrder, selectionRange, deleteRange, deleteSelectionContent, handleContainerBeforeInput, SvelteSelection, NabuSelection).
- Media helpers reachable as actions (pickMedia, insertMediaUrl: nabu.exec('image:pick' | 'image:insert' | …)) and mediaFileHandler.
- Other internals: CommandRegistry, degradeUnknownBlocks, jsonToPasteBlocks, sanitizePasteBlocks, isTransformInput, markdownDestination, nextGraphemeLength, prevGraphemeLength, getPlaceholder, normalizePlaceholders, resolvePlaceholderText, reservedChords, tabDefaultFor.
- package.json exports: new ./ui and ./extend (with types and svelte conditions), . and ./vite unchanged. Sub-folder index.js barrels stay for internal use but no longer leak through the root.
- Docs: README (new "Entry points" section, UI imports from /ui, extension from /extend, regions example through region:create) and the guides updated; docs/guides/persistence.md no longer claims that loadSnapshot / saveSnapshot / deleteSnapshot are exported (they never were from the root).
Breaking: the slash menu is agnostic, DEFAULT_SLASH_COMMANDS removed (P-114)
- The slash menu no longer knows any block type: DEFAULT_SLASH_COMMANDS (15 hard-coded entries, shown whatever extensions were loaded, e.g. "Callout" in an editor without the callout extension, where it did nothing) is removed from @aionbuilders/nabu/ui. The menu lists only nabu.slashCommands, the entries contributed by the loaded extensions (Extension#slashCommands), plus the integrator's extras.
- Each built-in extension now declares its own entries, with the same titles, aliases and actions as before: Paragraph (Text), Heading (Heading 1-3), List (Bulleted / Numbered / To-do list), Code, Callout, Blockquote (Quote), Toggle, Divider, Image, Video, File; Regions' entries are in the structure group.
- New SlashCommand fields group ('text' | 'lists' | 'blocks' | 'media' | 'structure' or any string; a free group is listed after the known ones, no group = last) and order (number, inside the group). The menu sorts by group, then order, then declaration order, so the list no longer depends on the load order of the extensions. New exports of ui/slash-menu.svelte.js (internal): sortSlashCommands, SLASH_GROUP_ORDER.
- <SlashMenu commands> / new SlashMenuController(engine, commands) now mean EXTRA entries (default []), appended and sorted with the same rules; their when(engine, block) is honoured like the extensions'. Migration: commands={[...DEFAULT_SLASH_COMMANDS, mine]} becomes commands={[mine]}; to restyle or replace a built-in entry, provide your own extension (slashCommands) with that entry.
- No emoji in the library's entries any more: Callout !, Image ▢, Video ▷, File ⎘, To-do list ✓ (icon stays a plain string; themes and integrators can provide their own entries to change icons).
Breaking: block transform action code renamed code:block (P-115), generic region labels without a caret (P-116)
- Breaking: the block transform action code of CodeExtension is renamed code:block (nabu.exec('code:block')). code now only toggles the inline code mark (RichTextExtension, like bold / italic): before, exec('code') ran both. The slash entry "Code" runs code:block. Migration: exec('code') meaning the block becomes exec('code:block') (or exec('block:transform', { type: 'code' })).
- Several extensions may still declare the same action topic (all handlers run, by design); the rule is now documented in Extension#actions.
- nabu.commands.list() without a context (no caret) no longer reads Merge with “”: region:merge-previous / region:merge-next fall back to generic labels ("Merge with the previous region", "Merge the next region into this one"). New overridable region messages mergePreviousGeneric and mergeNextGeneric.
Removed - Zone, Context overlay, Variable (breaking)
- The Zone block is removed: ZoneBlock, ZoneExtension, Zones, Templates, Counters, the zone components and hooks, and the ::: shortcut. Zone nodes are no longer rendered or created; FullPreset no longer includes ZoneExtension.
- The Context overlay is removed: ContextExtension, ContextMenu, CONTEXT_COLLECT_EVENT, setMenuContext, getMenuContext, collect (it only served zones). FullPreset no longer includes ContextExtension.
- The Variable stub is removed (Block#variables, Block#var()). docs/design/variables.md stays as a design reference only.
- Nobody used them. They will be replaced later by a generic Regions system based on root-level markers (ADR 016).
Public API and the /extend contract
Added - the /extend contract: a generous stable surface and an unstable door, nabu.internals (P-119)
- Wide access, clear promise. Every member a third-party block or extension reasonably needs is now tagged @public in its JSDoc, typed, documented in the new "Stability" section of docs/guides/extensions.md, and pinned by tests/unit/extend-contract.test.js (194 members): Nabu (54 instance members + Nabu.BREAK / Nabu.CONTINUE: tree, doc, children, blocks, blocksByType, registry, components, defaultBlockType, extensions, systems, transact, commit, isolateUndoStep, insert, delete, shortcut, undo, redo, focus, openTrailingCaret, resolveDocumentOffset, importJSON, importMarkdown, importFragment, importRemote, insertPasteBlock, createPasteBlock, exec, trigger, BREAK, CONTINUE, commands, slashCommands, serializers, serialize, isMarkActive, pendingCommand, pendingCommandOn, cancelPendingCommand, commandAnnouncement, selection, lang, spellcheck, tab, tabHint, placeholders, composing, composingBlock, debugging, warn, positions, isEmpty, lastVisibleBlock, internals), Block (53 + 9 statics: identity and tree accessors, the capability flags, retype, transformTo, insert, delete, split, absorbs, consume, acceptPaste, dragRange, resolveOffset, getDOMPoint, beforeinput, keydown, ascend, the tree walkers, serialize; create, load and the paste statics), TextBlock (14), MegaBlock (10), TextBehavior (21), the selection (anchorBlock, focusBlock, setCursor, sync... 23) and nabu.commands (list, get, has, run, offer, invoke, context, platform). No behaviour change.
- Typed optional statics on Block (left out by the typing pass): htmlRules, fromHTML, htmlSiblings, markdownRules, fromMarkdown, toMarkdown, toHtml. They are declared (default undefined, so a block that declares none is unaffected) with new typedefs HtmlRule, HtmlHelpers, MarkdownRule, MarkdownHelpers and SerializeContext, exported from @aionbuilders/nabu/extend as types. The rule / helpers parameters of Paragraph, Heading and ListItem now use them.
- nabu.internals (unstable). A getter on the editor returning one frozen object of references (functions bound, not copies) to the machinery the engine runs on and the stable contract does not cover: pulse (the action bus), commandConfirm, hooks, hookOwners, inputRules, pasteInterpreters, pasteFileHandlers, undoManager, content, fileConfig, storedMarks / toggleStoredMark / clearStoredMarks, noteTextChange, captureUndoSelection, sealUndoEnd, updateRoots, deleteNode, rearmTab, tabDescription, the event pipeline (dispatchEventToSelection, handleKeydown, handleBeforeinput, beforeinput, handleMousedown, handleComposition*), the clipboard pipeline (handleCopy, handleCut, handlePaste, insertFragment), the drag and drop pipeline (handleDrag*, handleDrop, dropAt, dropTarget) and selection (normalize, scheduleRepair, repairAfterRemoval, placements). Marked @unstable: it may change in any version. Nothing was moved; the same members stay where they were. The set of keys is pinned by the contract test.
- The commands guide examples that drove the key pipeline and the confirmation by hand now go through engine.internals. The "Gaps worth knowing" note of the extensions guide (the members "not yet a documented contract") is replaced by the Stability section.
Accessibility, read-only mode and UI
- Links open in place unless they leave the site. RichText puts target="_blank" rel="noopener noreferrer" only on external links (absolute http(s) URLs on another origin). Relative links, #anchors and same-origin links now open in the same tab. Visible change for hosts that relied on every link opening a new tab.
Accessibility, read-only mode and word segmentation (P-120, P-121, P-122 read-only part, P-123)
- Accessible name (P-120). <NabuEditor> takes label (aria-label) and labelledby (aria-labelledby) on the contenteditable root. Still no role on an editable root (it would flatten the headings inside).
- Focus indicator and contrast (P-120). The base theme draws a 2 px ring on .nabu-editor:focus-visible in the new --nb-focus-ring (--nb-focus-ring-width): #0b57d0 on a light page, #8ab4f8 on a dark one (3:1 or more), also the fallbacks without the theme, chosen by prefers-color-scheme. A theme that draws its own (Atelier) overrides it. The default link colour --nb-link was #4d9fff (2.7:1 on white): the base theme now sets #0b57d0 (6.4:1 on white) and #8ab4f8 (7.9:1 on #1e1e1e), and the component fallbacks follow the system scheme the same way. Visible change for hosts that relied on the old blue.
- Slash menu ARIA (P-121). role="listbox" named "Insert block", role="option" entries with aria-selected, a role="group" per slash group (nothing visible changes). Because focus stays in a contenteditable host, the root also carries aria-controls and aria-activedescendant while the menu is open, and a polite live region of the menu announces the active entry ("Heading 1, Large heading, 2 of 15"). New messages prop of <SlashMenu>: label, groups, option. The entries are real type="button" buttons now. The callout icon button has an aria-label; the code language select too.
- Read-only mode (P-122, owner decision). <NabuEditor readonly> (reactive, can be toggled live), new readonly option of the engine and new @public member nabu.readonly (pinned in the contract test; documented in "Stability"). The root becomes contenteditable="false" with role="document" (aria-readonly is not valid there); the Tab hint is dropped. The engine ignores beforeinput, keyboard shortcuts, paste, cut (it copies, removes nothing), drop and drag moves, IME composition, every command and extension action (undo and redo included), and the clicks that edit (to-do box, callout icon, code language select); the toolbar, slash menu and drag handle hide themselves. Copy, selection and links work. A toggle can still be opened and closed as view state that is never written to the document. The host's own calls (insert, importJSON, importMarkdown, importRemote, transact) are not restricted. See docs/recipes/read-only-viewer.md (the static HTML renderer is kept as option 2).
- Word counts by word segmentation (P-123). wordCount of text blocks (and everything that sums them: list items, toggles, regions) uses Intl.Segmenter (granularity: 'word', isWordLike) with nabu.lang as locale, so Chinese and Japanese count words instead of one per sentence; the whitespace split remains the fallback. Behaviour change for Latin text: punctuation and quotes standing alone («, », —) no longer count, and a hyphenated compound counts as several words. Plain Latin words skip the segmenter, and typing re-counts only the edited block.
Fixed - UI components: toolbar placement, slash menu visibility, drag handle alignment, contrast, a11y (P-77, P-82 to P-85)
- FloatingToolbar follows its selection and stays on screen (P-77). It repositions on scroll (the window and any scrollable ancestor, capture-phase listener) and on resize; sits above the selection, or flips BELOW it when there is no room above; is clamped horizontally inside the viewport (and the scroll container when it is wide enough); and is hidden while the selection is entirely scrolled out of its visible container / the viewport. "Room above" is measured against the part of the viewport where the selection is visible (the viewport intersected with the client box of every overflow ancestor), so a selection at the top edge of a scroll container placed under a sticky header gets the toolbar below it instead of under the header. For a header that is NOT part of a scroll container (a fixed bar over a window-scrolled page) set --nb-toolbar-inset-top: <px> on the toolbar or any ancestor: the toolbar never goes above that line and a selection hidden under it hides the toolbar. New shared helpers in ui/anchor.js: clipBounds, isOutside, placeAround (also used by the slash menu).
- SlashMenu hides when the caret line is scrolled out of its scroll container (P-83): it is no longer rendered floating over the page at a stale anchor; the pending slash command is kept and the menu comes back (re-placed) when the caret line is visible again. While hidden, the arrows / Enter / Tab are not captured (Escape still closes). The menu now places itself with the shared anchorRect / placeNear.
- DragHandle is centred on the block's first line (P-84) (first text line box; for dividers and other void blocks the block's top strip): headings, region seams and dividers now align; body text keeps the previous flush +2px. Very thin blocks (dividers, seams) show the handle when the pointer is within about 6px above / below them, visuals unchanged.
- Contrast defaults (P-82): placeholder 35% -> 60% of the text colour (4.9:1 light, 6.0:1 dark), region seam label 55% -> 62% (5.3:1 light), --nb-text-muted light 0.4 -> 0.5 alpha (drag handle glyph 3.98:1, non-text 3:1), new --nb-region-warning token in nabu-theme.css: #b45309 light, #f59e0b dark (hold hint 8.7:1 on a dark page; it was 3.7:1). Hosts that do not import nabu-theme.css keep the light #b45309 fallback.
- Accessibility (P-85): the floating toolbar is role="toolbar" (aria-label="Text formatting"), its buttons are type="button" with aria-label ("Bold", "Italic", ..., "Link") and aria-pressed reflecting the active state; the region seam label carries a title with the full text (truncated labels).
Added - Keyboard block moves, region drag moves the whole region (P-80, P-81)
- block:move-up / block:move-down (engine commands, keys Mod-Shift-ArrowUp / Mod-Shift-ArrowDown; nabu.exec('block:move-up', { block? })): the keyboard alternative to dragging (WCAG 2.5.7). The block holding the caret moves ONE step among its siblings in the same parent (the root, a container's children, the items of a list: an item moves with its sub-lists). A selection moves the whole contiguous run of its top-most blocks together (the level of the deepest container holding both ends; a container selected from its own text into a descendant moves as one block), forward or backward. At the edge of the parent nothing moves and the key is still consumed: leaving a container is a structural decision of its own (Tab / Shift-Tab for lists, drag for the rest). One undo step; the selection stays on the same characters (and Ctrl+Z gives back the one from before). The engine names no block type, so a region marker is a block like any other: a block moved across a marker changes region (how text moves between regions); region:move-up/-down stay the way to move a whole region. The keys override the browser's own "select to paragraph start" inside the editor.
- Dragging a region seam moves the WHOLE region (marker + content + sub-regions), one undo step, labels renumber (replaces "only the marker moves"). Generic hook: Block#dragRange() returns the blocks that travel together (default [this]; RegionBlock overrides it with the blocks of its region, the same range region:move uses). DragHandle knows no region: it moves dragRange() in order, dims all of it, refuses a drop inside the range, and a multi-block range dropped on a nested block lands at its own level (next to the ancestor that sits in the range's parent).
Several editors, selection and tree fixes
Fixed - several editors on one page no longer steal each other's selection
- Cause. The DOM selection is one object for the whole page, and every engine watched it from the document (selectionchange, keydown, keyup, mousedown). An engine found a caret in a block it did not know, judged it "dead" (isDead only asked for ANY [data-nabu-root], not its own) and moved it into its own text: a click in a live editor jumped into a read-only neighbour, and typing was impossible. Regions also tracked the focus of ANY editor, and its focusout listener wrote $state while Svelte was tearing an editor down (state_unsafe_mutation).
- Fix. The engine now knows its own root: Nabu.svelte hands the [data-nabu-root] element over with an attachment (nabu.internals.selection.attachRoot, cleared on unmount; no new @public member). Everything that reads the page-wide selection is scoped to it: normalize, repairAfterRemoval, sync (the model reads as "no selection of mine" for a selection elsewhere, except one in the editor's own floating chrome), the document listeners, undo capture, the slash menu keys and the confirmation blur. NabuSelection#scope() says whose business a selection is: 'mine' (an end is in my root, none in another editor), 'outside' (in no editor) or 'foreign' (an end in another editor). A selection that CROSSES roots (a drag from one editor to the next) is 'foreign' for both: neither engine rewrites, mirrors or records it. An editor nested in another editor's UI island is not "mine" for the outer one (closest('[data-nabu-root]') === root), and the markers of the island around it (data-nabu-ui, data-nabu-inert) are not markers of its content. Events that bubble from a nested editor to the outer root are ignored by the outer engine.
- Regions follows the focus of its own root only, and writes it from a microtask (safe during a teardown).
- Unchanged: onBeforeInput hooks run twice per keystroke when none answers (once in dispatchEventToSelection, once in TextBehavior#handleBeforeInput); documented by a test, not changed.
- Tests: tests/e2e/multi-editor.spec.ts (route /e2e/multi), tests/unit/selection-root-scope.test.js.
Fixed - Selection shifted when a mark was removed (P-78)
- Toggling a mark (Ctrl+B, the toolbar, mark:toggle, mark:apply|remove, link:set|remove, formatRemove) put the selection back using the DOM offset of the old selection point as a MODEL offset. Once the text is split in spans the two differ, so selecting "brave" in "Hello brave new world", bolding then un-bolding selected [5,6], and each further toggle shifted again. The selection is now kept as model points (block + text offset, anchor and focus apart, so the direction survives too) and put back on the re-rendered DOM (livePoints / placePoints in undo-selection.js); single- and multi-block, forward and backward.
Fixed - Stale element after moving a block from the root into a container (P-79)
- A block dragged (or moved with node.move) from the ROOT into a container (toggle body, list item sub-list…) stayed rendered at the root next to its new place (duplicated data-block-id, 4 DOM blocks for a 3-block tree). The tree subscription refreshed the new and the old parent, but a node leaving the root has no oldParent, so the root list was never refreshed. It is now, for any re-parenting (root <-> container, container <-> container), local, undo/redo or remote.
Performance
Performance: a keystroke in a 10,000-block document reaches the screen in one frame
- Cause. It was not the engine: a keystroke costs ~1 ms of script at any size and creates or removes no DOM node. The time (80 to 100 ms at 10,000 blocks) was Chromium laying out, painting and syncing the selection of the WHOLE contenteditable after the DOM write, work that grows with the number of elements on the page. Measured with a Chrome trace: Editor::SyncSelection (a forced layout plus the selection sync) ran 70 ms per key and Layout 15 to 30 ms, with 9 dirty objects out of 27,000.
- Fix. The browser is let skip what is off screen (content-visibility: auto). Per block that costs as much as it saves, so it is done on a few large boxes: a list of 500 or more sibling blocks (the editor root, a region body) is rendered in chunks of ~64 blocks, each in a div.nabu-chunk (blocks/BlockList.svelte, blocks/chunks.js); the cuts are chosen by a hash of the block id, not by position, so an edit touches one chunk, not every chunk after it. In a document of 800 blocks or more (data-nabu-large on the editor root) the body of every region is skipped off screen too. Smaller documents are rendered exactly as before (no wrapper, no attribute, no style); a browser without content-visibility: auto keeps the old markup.
- Numbers (Performance lab, headless Chromium with software rendering, production build, key to next paint, p50 / p95, before then after, measured 2026-10-28): 1,000 blocks 18.1 / 24.3 ms, then 8.9 / 13.8 ms; 10,000 blocks with regions 92 / 112 ms, then 13.0 / 18.6 ms. For scale, the 7-block home editor measures 8.9 / 19.2 ms on the same setup: at 10,000 blocks typing is now as fast as on a near-empty page. Engine work is ~3 ms at every size. Ctrl+Home / Ctrl+End (and with Shift) go through the model in documents of 800 blocks or more, so they reach the true start and end.
- What a host may notice. The height of a part that was never on screen is an estimate (--nb-block-estimate, 72px per block) until it has been shown once, so the scrollbar of a very long document is approximate at first. Block margins no longer collapse across the edge of two chunks (a little extra space there). --nb-paragraph-indent and the first-heading rule of the Atelier theme know about chunks; a paragraph that starts a chunk right after a heading keeps its indent. --nb-clip-margin (3rem) is how far something may hang out of a chunk or region body (the margin rail of a region) before it is clipped.
Regions (first marker-based lots, see the summary above)
Added - Regions, lot 3: markdown, HTML and clipboard forms (ADR 016 §3.6)
- Markdown (serialize('markdown'), clipboard text/plain, importMarkdown, markdown paste): a marker is TWO lines, a data comment and a readable heading, e.g. <!-- region:chapter {"status":"draft","title":"La forêt"} --> then ## Chapitre 2 — La forêt. The comment (type + fields as minimal JSON, keys sorted, < > & written as \uXXXX so a field can never contain -->) is the source of truth; richtext fields are inline-markdown strings in the JSON. The heading (level = the region level, 1-6; text = the computed label) is a readable echo: on import it is CONSUMED (never a heading block) and its text is ignored, so a heading edited by hand does not change the title. Only this exact construct is recognised (a narrow exception, like <details>): a heading without the comment stays a heading, any other comment and a comment with invalid JSON stay literal text, an unknown region type falls back to the default top-level type (fields kept). serialize -> importMarkdown -> serialize is a fixed point.
- HTML (clipboard text/html): a marker is <hN data-nabu-region="chapter" data-nabu-fields='{…}'>label</hN> (everything escaped). HTML paste turns ONLY an element carrying data-nabu-region into a marker (type validated, fields parsed as safe JSON, richtext links sanitized); a plain <h2> from the web stays a heading.
- Clipboard: the internal fragment (application/x-nabu+json) carries markers with their fields (richtext as Loro deltas) plus a non-data echo ({ label, level }) used to write the readable heading of text/plain / text/html. A marker pasted into a list item, toggle body or callout is hoisted to the root (root-only rule, lot 1).
- Richtext field links are sanitized (sanitizeUrl) when stored, whatever the path (paste, import, setField).
Added - Regions, lot 2: commands, protected merges, hold seam (ADR 016, ADR 017)
- Commands (all also actions: nabu.exec(id, data); data.region targets a region instead of the caret's): region:split (Mod-Enter: cuts the root text block at the caret and opens a region of the same type, the default top-level type in the preamble; coexists with todo:toggle, which keeps Mod-Enter in to-do items), region:merge-previous (hold Backspace / Shift-Backspace), region:merge-next (hold Delete), and keyless region:insert ({ type?, at?: 'after' | 'before' }), region:move ({ direction }, the region with its sub-regions) and region:move-up / region:move-down (what a keymap binds), region:promote / region:demote, region:next / region:prev / region:parent. Bind the keyless ones with keymap. One undo step each, Ctrl+Z restores the selection.
- Backspace / Delete never merge text through a marker (decides ADR 016 open question 1): at the start of the first paragraph of a region a quick Backspace does nothing, holding it (700 ms) removes the marker; Delete at the end of the last block before a marker is symmetric; held-key repeats stop at the boundary. A region at the start of the document has nothing to merge into (no hold). A selection spanning a marker deletes the marker with the text (one undo step). Behaviour change: the silent text merge through a marker of lot 1 is gone.
- Default seam rendering of the hold: a prompt above the label (left-aligned, no bubble / background / border, --nb-region-warning tint, amber by default) and the hairline draining with the progress; nothing shifts. Texts overridable with RegionsExtension({ messages }); the prompt is announced through the aria-live region.
- Context facts: region, previousRegion, nextRegion, atStartOfRegion, atEndOfRegion, inRootTextBlock.
- Engine: new generic hook onBeforeMerge(nabu, survivor, victim) (returning nabu.BREAK refuses the Backspace / Delete merge of two text blocks); the engine names no reason, Regions uses it to protect markers whatever started the key.
- createMarker(nabu, { type, index }) (no commit) next to insertRegion.
Added - Regions, lot 1 (ADR 016)
- New extension RegionsExtension(options) (factory, src/lib/blocks/region/; createRegionsExtension alias; included in FullPreset as RegionsExtension()). It structures one continuous text with markers: a void region block (RegionBlock; Loro data regionType + a fields map, title being a base field), rendered as a discreet seam (computed label + hairline) announced as role="heading" / aria-level, themable with --nb-region-* CSS variables. No editing UI.
- Types declared by the integrator: level, label template ({index}, {path}, {title}, {type}, {level}), numbering ('parent' default, 'document', 'none'), typed fields (text, richtext, number, boolean, enum, date; values validated on setField and on creation, unknown fields kept as-is). Without options: generic section-1 / section-2 / section-3 types.
- Reactive registry Regions.get(nabu) / nabu.regions (installed by the extension, the engine knows nothing about it): list, tree, preamble, at(block | selection), of(marker), next / prev / parentOf; each region has type, level, index, path, label, fields, parent, children, first / last, blocks / ownBlocks, wordCount / ownWordCount. One linear pass over the root blocks; typing does not recompute the structure.
- Root-only rule: a marker that ends up inside a container (list, toggle…) is moved to the root, right after the top-level container that holds it, by an onBeforeTransaction normalizer (same undo step as the operation that displaced it).
- insertRegion(nabu, { type, at, title, fields }) helper; marker.setField(), marker.setRegionType(), marker.richtext(name). JSON export / import round-trip exactly ({ type: 'region', props: { regionType, fields } }); the markdown form of a marker is empty for now (see lot 3 above).
- Not in this lot: keyboard actions (see lot 2 above), markdown / HTML / clipboard forms (lot 3 above).
- Dev route /e2e?seed=regions seeds a small document with markers.
Added - commands.offer and region:insert inside a region; Changed - region word counts exclude titles (P-117, P-118)
- New public nabu.commands.offer(id, ctx?) (P-117): opens the confirmation of a command from code, as if its key had been pressed where when holds. choose opens the chooser; invite waits for the command's first key again; hold waits for the user to press (and hold) the command's first key, the count starting at that press (it never completes by itself), and both lapse after the invite timeout. A command without confirm simply runs, like exec with when checked. Returns true when a confirmation opened or the command ran; false when nothing happened (unknown id with a warning, when false, no option, no key for a hold / invite, run returned false). The internal commandConfirm.offer (used by the region ::: paths) now handles the three strategies too.
- region:insert accepts at: 'inside' (P-118): creates a sub-region in the body of the target region, at the end by default or at index (clamped). The type must be strictly deeper than the parent (default: the shallowest declared deeper type); a region of the deepest level, an invalid or undeclared type, or no region at all gives a warning and creates nothing. One undo step, the caret stays (like before / after). insertNearRegion takes the same { at, index }.
- Breaking (behaviour): Region.wordCount / ownWordCount count BODY text only (P-118): no title at all, neither the region's own nor its sub-regions'. Before, sub-regions' titles were counted although the doc comment said otherwise. A region block's own wordCount is its body only as well (it used to include its title). Preamble.wordCount is unchanged (it holds no region). A document total is preamble.wordCount plus the wordCount of the ROOT regions (regions.tree); summing regions.list counts nested bodies twice.
Commands, keymap and confirmations
Added - Command confirmations: hold, invite, choose (ADR 017, lot 2)
- Commands with confirm now get their keys bound. hold (holdMs, default 700, or new Nabu({ commandConfirm: { holdMs } })): only a fresh press starts it, progress runs 0 -> 1 over animation frames, release / another key / blur / selection change cancels, completion runs once; the repeats of a key held before the context are swallowed at the context (nothing starts, the text is no longer erased). invite: first press asks, same chord again runs; Escape / another key / click / 5 s cancel. choose: automatic when several commands tie on a key (replaces "run the first and warn"), or explicit (confirm: { strategy: 'choose', options(ctx) }, run(nabu, ctx, option)); arrows + Enter, digits 1-9, click.
- New reactive nabu.pendingCommand ({ id, strategy, label, display, message, target?, progress, options?, selectedIndex }), nabu.cancelPendingCommand(), nabu.pendingCommandOn(target), nabu.commandAnnouncement. nabu.exec / commands.run still run a command directly. Controller: utils/command-confirm.svelte.js.
- Accessibility: NabuEditor renders a polite aria-live region ([data-nabu-live]) with the prompts and outcomes; the texts are overridable (commandConfirm.messages, per-command confirm.messages). The hold target element gets data-command-target and --nb-command-progress.
- New default components CommandInvite and CommandChooser (src/lib/ui/, exported), neutral, themable with --nb-command-*.
- Behaviour change: a real tie between commands now opens the chooser instead of running the first registered one (including an extension command on Mod-z against the engine's undo).
Added - Commands, keymap and key chords (ADR 017, lot 1)
- New commands field on Extension: { [id]: { label, keys?, when?, scope?, run(nabu, ctx, data?), confirm? } }. Every command is also an action of the bus (nabu.exec(id, data) keeps working). New commandContext(nabu, ctx) field lets an extension contribute facts to the context (ctx = { nabu, event, block, selection, isCollapsed, ancestors, inRoot, ...facts }); the engine names none.
- Key resolution in the engine (Nabu#handleKeydown, before the legacy onKeyDown hooks; ignored during IME composition): chord match + when; one command runs; several -> a command scoped to the caret's block type beats a generic one, a real tie runs the first registered and warns; a synchronous false from run falls through to the next command, the legacy hooks and the native behavior.
- Chords (utils/keys.js): parseChord, eventChordId, formatChord, detectPlatform; Mod = Cmd on macOS, Ctrl elsewhere; display as ⌘⇧Z / Ctrl+Shift+Z. Browser / OS chords (Mod-w/t/n/q, reload, Mod-Tab, Alt-F4…) are refused at registration, as is a key without modifier on a command without when; same chord + same scope warns. Commands with confirm are stored but their keys are not bound until the confirmation strategies (lot 2).
- keymap init option (new Nabu, createEditor, factories, createPersistedEditor): { 'mark:bold': ['Mod-Alt-b'], 'mark:italic': [] } replaces / disables the keys of a command. Also a platform init option ('mac' | 'other') for tests and SSR.
- nabu.commands: list(ctx?), get(id), run(id, data?), has(id); rows { id, label, keys, display, scope, extension, available, confirm }.
- Migrated shortcuts: undo / redo (engine commands), mark:bold|italic|underline|code|strikethrough (rich text), todo:toggle (Mod-Enter on to-do items, scoped to list items so a later "split region" on Mod-Enter can coexist). Behaviour is unchanged except that Mod is now platform-strict: on macOS only Cmd (not Ctrl) triggers these, elsewhere only Ctrl (not the Windows / Meta key). The rich-text onKeyDown hook now only swallows the browser's own Ctrl/Cmd+B / I / U when no command took the key.
Persistence, files and drag and drop
Files: behavior change (ADR 014, lot 1)
- Files are no longer stored inside the Loro document. The bytes of an image / video / file go to a separate asset store; the document only holds a reference and a small metadata index. Ref format: nabu-asset:sha256-<64 hex> (SHA-256 of the bytes), so the same file added twice (picker, paste, import) is stored ONCE and gets the same ref. The Loro map assetIndex holds hash -> { mime, name, size, url? } (never bytes; url is reserved for the host location, lot 2). Before, every upload stayed forever in the saved snapshot (Loro keeps history), even after the block was deleted.
- Stores (files/store.js, persistence/asset-store.js): MemoryAssetStore for createEditor() / new Nabu() (lost with the page, like the document); IndexedDBAssetStore for createPersistedEditor() (database nabu, object store assets, shared by all the documents of the origin). New option files.store to plug your own (has, get, put, delete, keys, optional stat). The IndexedDB store also keeps docRefs[docId] -> hashes, refreshed on every save (used by the later cleanup).
- IndexedDB schema 1 -> 2: the nabu database goes to version 2 and gains the assets and docRefs object stores; the upgrade only adds stores, existing snapshots are untouched. Open connections close themselves on a version change, so another tab never blocks the upgrade.
- Migration of beta documents: when a document is opened with bytes in its Loro assets map, each entry is hashed and written to the store, assetIndex is completed, the blocks referencing nabu-asset:<uuid> get their sha256 ref and the legacy entries are removed - in ONE silent commit (not an undo step). In the default full history the old bytes stay in the document's history (only an explicit compaction, a later lot, frees them).
- Display: Files#resolve(ref) builds an object URL from the store in the background ('' until loaded, reactive), else the index url (through files.resolve), else the media block is in a "Missing file" state (MediaBlock#isMissing, Files#state). Object URLs are revoked when no mounted block uses them any more (Files#hold, called by the Image / Video / File components; short grace period so a moving block keeps its URL).
- Export / clipboard still embed the bytes (so a copy or export stays autonomous), now keyed by hash; they are verified on import (a hash mismatch is treated as missing, with nabu.warn). nabu-asset:<uuid> refs and uuid-keyed assets of older exports / clipboards are still accepted (re-created, then given their hash ref). The bytes come from the store for files of at most files.maxInlineSize (read when the blocks load): a copy / serialize('json') right after opening a document may not carry a file whose bytes are not loaded yet.
- Files#persist(file) now always writes to the local store (and the index) first, then calls onBeforePersist if configured (host refs and status: 'pending' flows are unchanged for now). maxInlineSize no longer triggers an "inline in Loro" warning; it only limits what is embedded in a copy / export. insertFragment / importFragment keep their signatures; storage of pasted bytes is asynchronous (Files#whenIdle() resolves when done; importFragment awaits it, a host upload is not waited for). Files#stage hashes and stores a pasted file directly (no base64 round trip without a host hook).
- Incompatible for code that read the Loro assets map directly (undocumented). The beta's "self-contained document" is gone: a Loro snapshot alone no longer contains its files, the export / clipboard / store do.
- Dev dependency: fake-indexeddb (unit tests of the IndexedDB store and schema upgrade).
- TODO(ADR-014 lot 3), marked in the code: history modes / compaction / cleanup of unreferenced files, drag and drop (the lot 2 items of this list are done, see below).
Files: host mode, outbox, upload state (ADR 014, lot 2)
- Host mode (files.onBeforePersist) no longer swaps the ref. The bytes are ingested into the local store first, the block keeps its nabu-asset:sha256-… ref FOREVER (status pending), the upload runs in the background with { signal, onProgress }, and on success assetIndex[hash].url = <ref returned by the hook> and the block becomes ready in a silent commit (not an undo step). On failure: status error, local copy kept. Display resolves the local bytes first, then the index url (through files.resolve). Documents whose blocks hold plain host URLs keep working unchanged. Pasting (stage, adoptAssets, importRemote, beta clipboards) follows the same model: the hash ref is kept, the block is pending until the host has the file. Behaviour change for hosts: the block ref is no longer the one you return; read it from assetIndex[hash].url (the display layer does it for you). Files#persist(file) now resolves with the hash ref right after the local write and starts the upload in the background.
- The upload queue is driven by the document (new files/uploads.svelte.js): a pending hash-ref block with local bytes and no host url starts an upload (this is also how it resumes after a reload, and how an undo that brings a deleted block back restarts it); an upload whose last block is deleted is aborted through the AbortSignal. Only media ref/status changes (and a node deletion while an upload runs) trigger a pass: typing costs nothing.
- Outbox with resume: the pending uploads ({ hash, attempts, state }, per document) are kept next to the asset store, never inside the Loro doc: IndexedDBOutbox (persistence/outbox.js) for createPersistedEditor, MemoryOutbox otherwise, files.outbox to plug your own (list, put, delete, optional clear). IndexedDB schema 2 -> 3: new object store outbox (keyPath [docId, hash], index docId); the upgrade only adds the store, snapshots / assets / docRefs are untouched. On open, pending uploads for files the document references resume automatically; an upload that FAILED is not retried on its own (block.retry()); entries for files the document no longer references, already hosted or without a local copy are dropped. A document opened where neither the bytes nor a host url exist (another device, upload not finished) shows missing with the reason interrupted.
- Core APIs for the future UI (no UI added): block.progress (reactive, 0..1 while uploading, else null), block.retry(), block.cancel() (status error, reason cancelled), block.reason ({ code, message }), block.displayStatus; the same on Files (progress, retry, cancel, isUploading, checkSize). New status rejected. Typedefs FileStatus / FileReason (files/status.js); MediaBlock persists an optional reason next to status.
- files.maxFileSize (default none): a file over the limit is refused; a picked or pasted file gives a block with status rejected, no ref and a readable reason (code too-large), nothing is stored or uploaded; Files#ingest / persist throw FileRejectedError; a fragment whose embedded file is too big gives a rejected block; a remote image over the limit keeps its URL. A copy of a rejected block stays rejected.
- P-65: a copy / serialize('json') made right after opening used to ship a media block without its bytes when they were not in the in-memory cache yet. The embed cache is now preloaded at open for every indexed file up to maxInlineSize (Files#whenIdle() waits for it). When bytes are still missing (evicted, not loaded, or the total is over maxInlineSize), the fragment / export gets an entry { mime, name, size, omitted: true } (no data) instead of nothing, nabu.warn says so, and the receiver shows the block as missing with the reason not-embedded (unless its own store already has the file). Other missing causes now carry a reason too (unavailable, corrupt).
- files.onAssetReleased(ref) declared in the typedef (called by the cleanup of lot 3, see below).
- Tests: new tests/unit/asset-uploads.test.js; outbox + v2->v3 upgrade in asset-store.test.js; host-mode e2e in asset-storage.spec.ts; the e2e route gained ?hostFiles=fail (and reports progress / honours signal).
- TODO(ADR-014 lot 3), marked in the code: history modes / compaction / cleanup of unreferenced files (onAssetReleased), drag and drop.
Files - history modes, compaction, release of files (ADR 014, lot 3)
- createPersistedEditor({ history }): 'full' (DEFAULT, identical to before: nothing is ever compacted, trimmed or deleted; no garbage collection of this document's files) or { mode: 'compact', retain, onBeforeCompact }. retain: ms, '30d' / '12h' (units s m h d w, strict) or 0 (= keep no past history); required in compact mode. Compaction happens at OPEN (after load, beta migration and upload reconciliation), never mid-session. An invalid history / retain throws a TypeError.
- nabu.compact({ retain, onBeforeCompact }) (explicit, installed by createPersistedEditor) resolves with { compacted, reason?, error?, bytesBefore, bytesAfter, released, deleted, snapshot }; a refusal never throws. Implementation: Loro shallow snapshot (export({ mode: 'shallow-snapshot', frontiers })) at the retention point (longest prefix of changes older than now - retain; undated changes are never windowed away, only retain: 0 compacts them), checked by re-importing it, then saved; a fresh Nabu is built from it when compacting at open, and a live editor keeps saving shallow snapshots at the same version (its in-memory history is freed at the next open).
- Safeguards: never without opt-in; onBeforeCompact(fullSnapshot) awaited BEFORE anything is dropped, a throw / rejection aborts (archive-failed) with nothing lost; refuses with pending-uploads, unsaved-changes (also uncommitted operations), detached or busy; retention honored (versions after the retention point can still be checked out); the undo stack is cleared after a compaction.
- Timestamps: createPersistedEditor now enables doc.setRecordTimestamp(true) so a retention window can be honored.
- Release of files: after a compaction, files referenced neither by the current state nor by the kept history are removed from assetIndex and from the document's docRefs; bytes leave the shared IndexedDB store only when no document's docRefs lists them (a custom files.store without the docRefs API is never emptied). Host mode: files.onAssetReleased(url) is now called (awaited; errors swallowed + nabu.warn) for released hosted files, Nabu never deletes at the host. clearPersistence() releases all the document's files the same way (the files used to stay in the store). Legacy beta bytes still in a full history are purged by a retain: 0 compaction.
- Files gained hostUrls(), pendingUploads() and releaseIndex(); AssetStore documents the optional setDocRefs / getDocRefs / clearDocRefs / referencedHashes. The e2e route gained ?archive=1|fail (installs an onBeforeCompact recorder). Tests: tests/unit/history-compaction.test.js, e2e 13-15 in asset-storage.spec.ts. Docs: ADR 014 section 7, docs/guides/persistence.md.
- Grace period for releases (multi-tab safety): new option files.releaseGraceMs (default 10 minutes, 0 disables it). A compaction or clearPersistence() never deletes from the shared store a file whose record savedAt is newer than that, nor one with no savedAt; it is skipped (CompactResult.skipped), kept in the document's docRefs and reconsidered by the next compaction / clear. Adding a file that is already stored refreshes its savedAt (AssetStore#touch, IndexedDBAssetStore#savedAt / touch). A custom store must offer savedAt for its files to be deletable.
- The TODO(ADR-014 lot 3) markers are resolved (drag and drop is a UI lot).
Drag and drop (ADR 014, lot 4)
- The editor now owns every drop. dragenter / dragover / dragleave / drop on the editor are handled by Nabu: what is dropped is inserted at the model position under the pointer through the SAME pipeline as a paste (files become media blocks through pasteFileHandlers and Files, then the paste interpreters: internal fragment, HTML, markdown, plain text; verbatim into a code block), as ONE undo step. Before, the browser's insertFromDrop was cancelled by the beforeinput guard (no desync, but the drop did nothing, and a dropped file could be opened by the browser); now it works and the default is always cancelled.
- Position under the pointer: a text caret in a text block (caretPositionFromPoint / caretRangeFromPoint); before / after a block without text (image, divider…) by the pointer's half, through an empty text block that is replaced by or receives the dropped content (nothing stray is left); the editor padding and the gaps resolve to the nearest block (start / end of the document at the edges).
- Dragging a text selection inside the editor is a move: serialized like a copy, deleted with deleteRange, inserted at the target, one undo step; dropping it on itself does nothing; Ctrl (Windows / Linux) or Alt / Option (macOS) copies instead. Dragging out of the editor (or into another Nabu) is a copy. Dragging an image, a link or any other draggable element inside the editor is cancelled.
- nabu.dropTarget (reactive, { block, offset, side, kind: 'files' | 'content' | 'move' } | null) tells a UI where the drop would land; Nabu draws nothing. nabu.dropAt(dataTransfer, position, { source? }) and nabu.insertFragment(fragment, { at }) are the programmatic entry points. New utils/drop.js (agnostic helpers).
- The block drag handle (DragHandle) is unchanged: it uses pointer events, not HTML5 drag and drop.
- Tests: tests/unit/drop.test.js (helpers, dropAt, the dropTarget lifecycle), tests/e2e/drag-drop.spec.ts (real mouse drags for an external source and for moving the selection, synthetic drops for files).
Clipboard, paste, import / export and security
Security
- Fix (P-42, P0): the text/html written to the clipboard on copy was not safely escaped. escapeHtml ignored " / ' (a link href containing " injected attributes such as onmouseover), link hrefs were not scheme-checked (javascript:alert(1) was emitted as a live link), Dialogue fell back to <p>${rawText}</p>, and Image / Video / File toHtml concatenated ref / alt / name unescaped.
- New shared module utils/html.js (exported from utils): escapeHtml (& < > " '), escapeAttr, sanitizeUrl(url, { kind: 'link' | 'media' }) and sanitizePasteBlocks(). sanitizeUrl is an allow-list: links accept http, https, mailto, tel, relative URLs and #fragment; media accept http, https, blob:, the internal nabu-asset: scheme, relative URLs and data:image/* / data:video/*. Everything else (javascript:, vbscript:, data:text/html, file:…) is refused, including obfuscated forms (surrounding or embedded whitespace / control characters, mixed case, entity-encoded schemes such as javascript: or java	script:, percent-encoded separators).
- Output: deltaToHtml escapes every attribute and renders an unsafe link as its label only (no <a>); every block toHtml (paragraph, heading, blockquote, callout, code, toggle, list, image, video, file, new Dialogue.toHtml) escapes its text / attributes, sanitizes URLs and no longer interpolates unchecked level / variant values into markup; the generic handleCopy fallback for blocks without toHtml escapes the text.
- Model: link:set, link:toggle, mark:apply and TextBehavior.applyMark refuse an unsafe link href (no-op + nabu.warn). insertFragment() and importFragment() (hence HTML / markdown / application/x-nabu+json paste, importMarkdown(), importJSON()) drop unsafe link marks (the text stays) and empty unsafe ref / src props.
- Paste: the HTML interpreter drops javascript: / data: hrefs (text kept as plain text), never reads text from <object>, <embed>, <svg>, <math>, <template>…, and <img> / <video> / <a download> with an unsafe source no longer become media / file blocks; markdown  stays plain text.
- Render: RichText never renders an unsafe link mark as a live <a> (documents loaded from a snapshot or a remote peer are covered; safe links also get rel="noopener noreferrer"); MediaBlock.displaySrc only returns a media-safe URL, so Image / Video / File never put javascript: in a src / href.
Clipboard
- Fix (P-43, P-29): copying a block that has BOTH text and children (list item, toggle) lost content. A boundary hybrid block now serializes its own text slice (clipped to the selection) AND the children inside the selection, recursively clipped; a hybrid that is neither boundary nor fully selected keeps its own text (an item "a" between two selected items was emitted empty). Generic rule in MegaBlock.serializeForClipboard: own text first (unless the selection starts inside a child), then the children between the ones holding the selection start and end.
- Fix: structural props now travel with the copy: a to-do item keeps checked (it was copied unchecked), a toggle keeps collapsed (a collapsed toggle was copied open). New overridable MegaBlock#clipboardProps on ListItem and ToggleBlock.
- Fix (P-45): copying items 2-3 of an ordered list that starts at 3 now yields a list starting at 4 (the first copied item's number) instead of 3. New overridable MegaBlock#clipboardPropsFrom(firstChildIndex) (implemented by List).
- text/plain on copy (P-46): a selection inside ONE text block (paragraph, heading, list item, code…) is now its raw selected text, no markdown syntax (**, fences, # , - ); a selection over several blocks is still markdown. text/html and application/x-nabu+json are unchanged by this rule.
- Paste: a container pasted with its own text (list item, toggle) keeps that text, and a container that needs a parent (a list item pasted at root level) is wrapped like leaf blocks already were.
- Pasting into a list (P-49): "what a list item can hold is absorbed; everything else splits the list". With the caret in a list item, pasted paragraphs (internal fragment, markdown, HTML, multi-line plain text) become ITEMS of that list at the same level (to-do: unchecked), and the items of a pasted list join the current level: they adopt the destination list's type (a numbered list pasted into a bullet list becomes bullets; checked is kept only in a to-do list), their own nested sublists are kept as they are. A block an item cannot hold (heading, code, quote, callout, divider, image, video, file, toggle, dialogue…) SPLITS the list at the caret: the items before stay, the block goes right after them, the rest of the list follows below with continued numbering (ordered start), reusing liftItemTo; the pasted blocks that follow it are inserted as they are (a paragraph stays a paragraph) above the list tail. The first block still merges into the head of the item and the last one (when still inside the list) with its tail; the caret ends after the pasted content; one undo step. Before, the extra paragraphs and lists ended up as invalid children of the list that the normalizer hoisted by accident, and the tail of the item became a paragraph.
- New generic extension point Block#acceptPaste(blocks, { offset }): the block holding the caret may take over the insertion of a pasted fragment (return null to decline and get the default insertion). Nabu#insertPasteBlock and Nabu#createPasteBlock are now public (the paste toolbox such a hook builds on). The engine names no block type; the List extension implements the rules (blocks/list/paste.js). liftItemTo / retypeItem take an optional numbered flag (a pure placeholder does not consume a number of an ordered list).
- Behaviour change: a whole block pasted as the ONLY block (e.g. markdown # Title, an image) at a caret in a list item used to merge its text into the item (a heading) or be silently dropped (an image); it now splits the list as described above.
- Pasting into a toggle (P-50): with the caret in the BODY, everything pasted (paragraphs, lists, headings, code, images, nested toggles…) lands inside the body at the caret, a toggle pasted there is a nested toggle, and an empty body paragraph takes the type of a whole block pasted alone (new overridable Block#adoptsPastedTypes, true on the toggle; lists keep their structure rules). With the caret in the HEADER, several pasted blocks no longer escape after the toggle: the first text block completes the header at the caret, the others go to the TOP of the body (like Enter in the header), and the header text after the caret follows the last pasted block (merged into it when it is text, otherwise in a new paragraph). Pasting several blocks into a COLLAPSED header expands the toggle (the content would land hidden); a single text block stays an inline merge and keeps it collapsed. One undo step restores header, body and collapsed. Implemented through Block#acceptPaste (ToggleBlock) + the reusable pasteIntoHeader sub-thread helper (blocks/subthread.js).
- Multi-block paste in the middle of a block (P-50): the text after the caret now continues in a block of the CARET BLOCK'S kind (a heading Ti|tle pasted with [p1, p2] gives h(Tip1) ; h(p2tle), same for quote, dialogue, callout, with their props), like Enter would. The last pasted block merges with that tail only when it is text (cut at the edge of a partial copy, or a default text block); any other last block (heading, code, container…) is inserted as it is and the tail follows in a new block of the caret block's kind. Without a tail the last pasted block keeps its own type. Before, the tail took the type of the last pasted block (h(Tip1) ; p(p2tle), or a tail swallowed by a code block). A paragraph caret block is unchanged.
- Format versioning (P-52): the application/x-nabu+json fragments written on copy / cut carry version: 1 (CLIPBOARD_FORMAT_VERSION). The Nabu paste interpreter reads version 1 and fragments without a version (read as 1); an unknown version is declined with a nabu.warn and the paste falls back to the next format of the clipboard (text/html, text/plain) instead of being mis-parsed. Documented in docs/design/clipboard-format.md.
- Unknown block types (P-52): a fragment containing a type the receiving editor does not have registered (copy from a full editor into a minimal one) is degraded instead of being dropped: the block becomes the default text block with its text (and partial) kept, its children are kept as normal blocks right after it (recursively degraded; the children of a known container are degraded in place). Before, an unknown container with children disappeared with all its children, and the children of an unknown leaf-like block were created INSIDE a paragraph. New pure degradeUnknownBlocks (utils/extensions.js), applied by insertFragment and importFragment (hence importJSON), and by the public insertPasteBlock / createPasteBlock.
- Media across documents (P-51): an image / video / file uploaded in the default mode lives INSIDE the document (nabu-asset:<id> ref), so copying it into another Nabu document pasted a ref to an asset that did not exist there (broken media), and the copied text/html carried nabu-asset: srcs that mean nothing outside Nabu. Now:
- COPY: the bytes of every nabu-asset: media of the fragment are embedded in application/x-nabu+json as a top-level assets: { [id]: { mime, name, size, data: base64 } } map next to blocks (the blocks keep their ref; still version: 1, older readers ignore the field). Above files.maxInlineSize (total) nothing is embedded and nabu.warn says so.
- PASTE (insertFragment / importFragment): an asset that already exists in the target is reused (same document, or already pasted once, no duplicate); one carried by the fragment is re-created (without a host hook: stored under the same id, in the paste's own undo step; with files.onBeforePersist: sent through Files#persist, so the host uploads it, the block appears at once as pending with a local preview and gets the host's ref when done; the final swap is a silent commit, not an undo step, so Ctrl+Z / redo treat the paste as ONE step); no bytes: the block is kept with the new status: 'missing' and Image / Video / File render a "Missing file" state (data-nabu-missing). Never throws; a failing upload ends in status: 'error'.
- text/html / text/plain on copy never contain nabu-asset:: an image / video of at most 1 MB becomes a data: URL in the HTML, anything else (larger, non-image file) is a label (alt / file name); markdown gets the alt text / name only. As a consequence the Image / Video / File toHtml / toMarkdown of a block WITHOUT a source now emit the label (or nothing) instead of <img src=""> / <video src=""> / ![]().
- Hosted refs (URLs from onBeforePersist, plain http(s)) need nothing and paste unchanged.
- New Files#getAsset / embedAssets / externalize / adoptAssets (files/files.svelte.js) and pure helpers in files/assets.clipboard.js. Nabu#commit({ origin }) tags a transaction; an origin starting with nabu:silent is excluded from the UndoManager. Documented in docs/design/clipboard-format.md.
- Dev-only /e2e?hostFiles=1|gated installs an in-memory onBeforePersist for the E2E suite.
Paste / Files (external content, lot 2)
- Google Docs lists (P-56): the HTML of a Docs list carries its nesting as a LEVEL (<li aria-level>; one <ul> per level as siblings, or a <ul> nested straight in the <ul>), not as <ul> in <li>; it used to paste flat. The levels are now rebuilt as real nested lists: bullet / numbered per level from list-style-type (disc / circle / square / decimal / lower-alpha / upper-roman…), <ol start> kept, aria-checked items make a to-do list; a jump of several levels is clamped.
- Word lists (P-56): consecutive <p class=MsoListParagraph style="mso-list:l0 level2 lfo1"> become real nested lists (level from levelN, one list per lN lfoN identity, bullet vs numbered from the ignored marker: · o § - / 1. a) i.; the first number of a numbered list is kept). The marker is still never text.
- Tables (P-56): with no table block yet, each ROW of a pasted <table> is one paragraph, cells separated by a TAB, inline marks (and the style of the cell) kept, nested blocks / lists / tables flattened to text with spaces, colspan keeps columns aligned, rows without text dropped, <caption> first. Same rows as the text/plain TSV of Google Sheets / Excel, which the HTML path (priority 50) already wins over. Declared by the paragraph block as the LOWEST-priority html rule for table: a future table block declares its own rule at a higher priority and takes over unchanged.
- Web images (P-56): <img src> in external HTML is an image block whose ref is the sanitized original URL (nothing is downloaded), alt kept. An image inside a paragraph (also inside <a> / <span> / <b>, or a heading) splits it: text before, the image, text after; unsafe (javascript:, data:text/html) or missing sources (lazy placeholders) are dropped without splitting. An <img> without src is no longer an empty image block.
- New opt-in files.importRemote (default false): when true, the remote http(s) images of a pasted web page are fetched and stored through Files#persist (so onBeforePersist receives the downloaded File), with the optimistic flow (pending, the original URL is displayed meanwhile, the swap is a silent commit); a fetch that fails (network, CORS, HTTP error, not a media type) keeps the original URL. No credentials are sent. Documented in the README (Files section).
- Pasting FILES (P-55): clipboardData.files (or items of kind file) become blocks at the caret, in order, one undo step. New generic extension point Extension.pasteFileHandlers: [{ priority?, accepts(file, nabu), block(file, nabu) }] (PasteFileHandler): the first handler that accepts a file makes the block (type + props other than ref); the engine reads the bytes (Files#stage) and inserts one fragment through insertFragment (hence Files#adoptAssets: stored in the document in the default mode, or sent to onBeforePersist with a pending block and a local preview; the final ref swap stays outside undo). Image (image/*), Video (video/*) and File (anything else) implement it (mediaFileHandler); the engine names no block type. Files win over the text payloads of the same clipboard, except application/x-nabu+json (an internal copy wins) and a payload whose interpreter says it outranks files (PasteInterpreter.outranksFiles; the HTML one does for a <table>: Excel / Sheets also put a picture of the range on the clipboard).
- Fix (P-53): a block without text (image, video, file, divider…) pasted into a NON-empty text block inserted nothing (the inline path merged its empty text). It is now inserted: the caret block is split around it (head, pasted block, tail); at the very start of the text the blank head is not left behind; in an empty block it replaces it as before.
- Engine hooks of the HTML interpreter (generic, used by the rules above): fromHTML may return one block, several or none; static htmlSiblings(el, rule, helpers) lets a block absorb the following siblings of its element (4th argument of fromHTML); an html rule flagged embed: true is lifted out of the inline content that contains it; the fragment of an HTML paste is flagged external. ListItem.fromHTML reads aria-checked. Documented in docs/design/copy-paste.md (section 13).
Paste / Markdown (inline, P-48)
- Inline markdown parser rewritten (paste/markdown-inline.js, CommonMark / GFM level, used by markdown paste AND importMarkdown; parseMarkdownInline keeps its name): one scan + CommonMark's delimiter-stack algorithm instead of earliest-match regexes. Links: [t](url), [t](url "title") (title ignored), [t](<url with spaces>), parentheses in URLs, marks and nested brackets inside the text; reference links [t][ref] / [t][] / [t] resolved from [ref]: url "title" definitions anywhere in the pasted / imported markdown (case-insensitive, first definition wins; definition lines produce no block, fenced code is left alone); autolinks <https://…> / <mailto:…> / <[email protected]> and GFM bare URLs (https://…, www.…, trailing ?!.,:;*_~ and an unbalanced ) excluded). Backslash escapes (\* \_ \[ \# …), code spans of any backtick length (their content is never parsed), <u>…</u>, hard line breaks. Unsafe URLs (sanitizeUrl) keep the text and drop the link.
- Images: a line that is only an image is an image block (title ignored, ![alt][ref] too); an image inside a line splits the paragraph (text before, image block, text after), pasted or imported, through the existing split insertion (P-53). An unsafe image URL keeps the alt text. In headings, list items, quotes… (no block to split) an image is its alt text. A badge [](url) becomes the image block (its link is not kept: image blocks have no link).
- Emphasis follows the typing shortcuts: new shared behaviors/text/markdown-syntax.js (delimiterFlags, WORD, escaping helpers) used by the shortcuts, the parser and the exporter: no whitespace just inside the delimiters, _ never intraword, * may be.
- Import joins the lines of a paragraph with a space, except after a hard break (two trailing spaces or a backslash) where the block gets a \n. Paste is unchanged: one line = one block.
- deltaToMarkdown round-trips: a mark spanning several runs is one delimiter pair (**a *b* c**), whitespace at the edge of a span stays outside the delimiters, adjacent runs with the same href share one […](href) (destinations with spaces / unbalanced parentheses use <…>), code spans lengthen their fence, markdown-significant characters in text are escaped (*, `, [, ], \, a non-intraword _, ~~, <u…), a block start that would read as another block (# , > , - , 1. , -- , ---) is escaped, a line break is a hard break (\ + newline; a callout body keeps the plain newline, its rule re-reads its own lines; new deltaToMarkdown(delta, { rawBreaks })).
Paste / Markdown (block level, P-59)
- Block-level markdown rewritten for paste AND importMarkdown (CommonMark / GFM level). Every rule lives in its block class (static markdownRules / fromMarkdown); the interpreter loop (paste/markdown.paste.js) stays generic and now gives the rules helpers.interrupts(lines, k) (does line k start a block that ends a paragraph, declared by a rule with interruptsParagraph) and helpers.parseBlocks(lines, overrides) (parse lines as a document of their own); detect also receives (line, lines, i, helpers). Paste keeps "one non-blank line = one block" for plain paragraphs; import keeps CommonMark paragraph joining, and a fence, heading, quote, list, divider or table now interrupts the paragraph above it. Line endings are normalised (CRLF / CR), a BOM is stripped, the leading indent of a paragraph line is trimmed (4 spaces / a TAB are NOT code).
- Setext headings (Heading): Title + === is a level 1, Title + --- a level 2 (an underline of any length, - alone included). The rule sits just above the paragraph catch-all and below every block rule, so a list item / quote / dialogue / ATX heading / fence is never a heading text, and a --- after a blank line (nothing to underline) stays a divider. Paste: the text is the ONE line above; import: the whole paragraph above.
- Code fences (Code): ~~~ as well as backticks, fence length >= 3, an info string (the first word is the language, so c++, js {highlight}), the closing fence has the SAME character and at least the same length, an unclosed fence runs to the end, an indented fence strips its indent from the content, the content is verbatim (no inline or block parsing, blank lines kept). ```code``` on one line is an inline code span, not a fence. The link-reference extraction follows the same fence rules.
- Dividers (Divider): --- *** ___ and the spaced forms (* * *, - - -), up to 3 spaces of indent.
- Blockquotes (Blockquote): a quote is a text block, so nested levels (> > x, >> x) are FLATTENED into one quote (text kept); >text without a space works; a lazy continuation line (no >) belongs to the quote on import and, in a paste, after a hard break (what serialize writes). The quote content is parsed as a document: its text paragraphs become quotes (one per paragraph; the lines of a paragraph joined by a line break in a paste, a space on import) and a list / fenced code / heading / divider / table inside a quote becomes that block, outside any quote (the > is lost, no text is). > [!NOTE] callouts keep their higher-priority rule.
- Lists (List, new list/markdown.js): markers - * + 1. 1); nesting by relative indentation (2 spaces, 4 spaces or a TAB each give one level, mixed indentation tolerated); an ordered list starts at its first number; a change between bulleted and numbered at the same level starts a new list (superseded by lot 5e: a change of bullet character / delimiter now starts a new list too, see Import / export); tasks - [ ] - [x] * [X] ([] too); loose lists (blank lines between items) are one list; an indented line under an item (also after a blank line) is a continuation APPENDED to the item text with a line break (an item holds text, not blocks); so is an unindented line after a hard break and, on import, a lazy continuation line. A fenced code inside an item is kept verbatim, fences included, as text of the item. List.fromMarkdown returns several lists when the marker type changes at the root.
- GFM tables (Paragraph, paragraph/table.markdown.js): | a | b | + a delimiter row |---|:--:| (same width, with a pipe) gives ONE paragraph per row (header and body, the delimiter row dropped, alignment lost), cells separated by a TAB, inline markdown parsed in the cells, \| for a pipe in a cell, short rows padded; the table ends at a blank line, a line without a pipe or another block. Rows without a delimiter row are plain text. It is a low-priority rule (priority 1, just above the paragraph catch-all): a future Table block declares a higher one and takes over, like the HTML table rule.
- Footnotes ([^1], [^1]: text) and HTML (<div>, <!-- -->, <script>, inline tags; only <u> is a mark) are kept as LITERAL text: a <div> line is a paragraph with that text, nothing is dropped or rendered as markup.
- serialize('markdown') -> importMarkdown -> serialize('markdown') is idempotent for a document with every block type (headings 1-6, marks and links, quotes, nested mixed lists with start and to-dos, code with a language, divider, the five callout variants, dialogue, image, escaped block starts); (the two exceptions of this lot, a toggle and a fenced code inside a list item, are fixed in lot 5e, see Import / export).
Import / export (structural round trip, lot 5e, P-62)
Reference: docs/design/import-export.md (JSON schema with version, markdown mapping per block type with exact / lossy, the <details> exception, list marker alternation). Fidelity is tested on the MODEL tree (tests/unit/roundtrip-structure.test.js, tests/e2e/import-export.spec.ts).
- JSON serialize('json') -> importJSON now reproduces the exact tree for every block type. Fixed losses: the text of a code block (its string content was ignored by the importer, the block came back empty); the checked of a to-do item (written next to type, never read: now props.checked; the old shape is still read); the name of a media block (and its status: error / missing are kept). Behaviour change: a to-do item is exported as props: { checked } instead of a top-level checked.
- A pending media (bytes still being stored / uploaded, transient nabu-pending: ref) is no longer exported as if it were final: it is exported as status: "missing" with an empty ref and its label (JSON and markdown). New MediaBlock#exportProps.
- The nabu-asset: bytes travel with the JSON export (new top-level assets map, the clipboard format's, bounded by files.maxInlineSize) and are re-created by importJSON; without them the media imports as missing.
- importJSON refuses an export of an unknown version (nabu.warn, document untouched) like the clipboard does; the number 1, "1" and no version are read as the current one (JSON_FORMAT_VERSION, isSupportedJsonVersion in utils/extensions.js). Unknown block types degrade as in a paste (degradeUnknownBlocks, text kept, children kept). jsonToPasteBlocks accepts a string content and ignores malformed entries.
- Toggle (markdown): exported as <details> / <details open> + <summary>…</summary> + the body as markdown + </details> (the <details> is on its own line now, an empty body no longer leaves blank lines), and read back as a Toggle (import and paste): collapsed = not open, the summary parsed as inline markdown, the body parsed as blocks (nesting, lists, code…). Only this exact construct is recognised (no attribute, lower case, a <summary>, a closing </details>; the older one-line <details><summary>… too); all other HTML stays literal text (P-59), nothing of the markup reaches the model. A text that looks like <details> / </summary> is escaped (\<) on export so that it cannot close a toggle. New blocks/toggle/markdown.js. Before, a toggle came back as paragraphs (P-62).
- Behaviour change (P-30, markdown parsing): a change of bullet character (- * +) or ordered delimiter (. )) at the same level starts a NEW list (CommonMark), as a change between bulleted and numbered already did. Adjacent lists of the same type are merged by the normalizer, so - a\n* b is still one bullet list in a document; but - [ ] a\n* b is now a to-do list then a bullet list (it used to be one to-do list), and the interpreter output of a paste differs (tests/unit/list.fromMarkdown.test.js, markdown-blocks.test.js updated).
- The exporter alternates the bullet character (-, *, -…) and the ordered delimiter (., )) between adjacent lists of the same family at the same level (root, sublists of an item, toggle body, clipboard text), so a to-do list followed by a bullet list re-imports as two lists of the right types.
- Lists export: nested content is indented by the width of its parent's marker (3. a -> 3 spaces, 10. a -> 4; was always 2 per level, which CommonMark readers did not nest under a numbered item). Behaviour change in the exported text of sublists under numbered items.
- A fenced code inside a list item (item text, an item holds no blocks) is exported as an indented fence that re-imports as the same text (it used to come back escaped, \``js`); text a fence cannot carry exactly stays escaped (still round-trips). The list parser now skips a closed fence as a whole when measuring the list (a --- or - x inside it no longer ends the list), and keeps the TABs of the code.
- Code export: the fence is longer than any backtick run in the code (a code block that contains a fence round-trips), the language loses whitespace / backticks. Image / video / file markdown escapes the alt / label and wraps a ref with spaces or parentheses in <…>. An empty heading / quote / dialogue exports as nothing (## came back as the text "## ").
- Documented loss (markdown): empty paragraphs and empty heading / quote / dialogue are dropped; an image keeps ref + alt but not name / status; a video and a file come back as a paragraph with a link; a code span takes no other mark; whitespace at the edge of a mark moves outside it.
Paste
- Pasting a whole block (e.g. # Title) into an empty root-level block now adopts its type. Pasting into existing text, or into a nested block (list item…), never changes the block type.
- Plain-text paste: one non-blank line = one block (runs of blank lines are separators). importMarkdown() keeps CommonMark semantics (consecutive lines = one paragraph) via the new optional interpret(raw, nabu, { joinLines }) argument of paste interpreters.
- A paste is always its own undo step; the typing right after it doesn't merge into it.
- New block capability flag pastesAsPlainText (default false, true on Code): pasting inside such a block inserts the clipboard's text/plain verbatim (replacing the selection, one undo step) before any paste interpreter runs. No more blocks spilling out of a code block, # / blank lines / - kept as typed.
- External HTML paste (Google Docs, Word, Notion, Wikipedia, GitHub, Sheets, any web page), engine lot (P-56, P-57, P-47). Reproduced by the new corpus tests/fixtures/html/ (see its README):
- Inline styles have the last word over tags: font-weight: normal | 400 (or any weight below 600) cancels the bold of <b> / <strong>, font-style: normal the italic, text-decoration: none the underline / strikethrough; font-weight: bold | 600+, font-style: italic | oblique, text-decoration: underline | line-through are still detected. The style attribute is parsed directly, so mso-bidi-font-weight:normal never counts as font-weight. Before, <b style="font-weight:normal"> and <strong style="font-weight:400"> were bold (Google Docs' wrapper only escaped it because its block children are flattened; a copy of PART of a line fell through to plain text and lost its marks).
- Only the marks Nabu knows survive (bold, italic, underline, strikethrough, code, link); colors, fonts, sizes and backgrounds are ignored and a font size never makes a heading. The underline a link gets from its style (Docs, Word) is not a mark.
- Empty text blocks (<p></p>, <p><br></p>, <p> </p>, empty list items) are dropped; a paste made only of empties inserts nothing; <pre> / code and the internal application/x-nabu+json path are untouched.
- Whitespace (P-57): HTML whitespace collapses as in CSS (and is trimmed at line edges; white-space: pre | pre-wrap | break-spaces keeps spaces, as Docs' spans use), <br> is a line break (a trailing one is ignored). A U+00A0 between two visible characters is KEPT ("10 km", "Bonjour :"); next to another space or at the start / end of a block or line it becomes a regular space (then collapsed). U+202F, U+2007, U+2060, U+00AD, ZWJ and ZWNJ are never touched (emoji family sequences, Persian words).
- Google redirect links https://www.google.com/url?q=<target>&sa=D&… (and http://, google.com) are unwrapped to their target, then checked by sanitizeUrl; a missing, relative, malformed or unsafe target keeps the original link when it is safe.
- P-47: a container with a <script> / <style> / <noscript> child no longer loses its sibling text (<div><script>x</script>text</div> pastes "text"); loose text and inline elements between blocks become paragraphs; scripts and styles never contribute text. A wrapper matching a block rule (<details>, a callout) is now that block instead of being flattened. Content of structure without a block yet (table cells, definition lists) is kept as paragraphs rather than lost.
- Word junk produces nothing: conditional comments (<![if …]>, <!--[if …]-->), <o:p> and other Office namespaces, mso-* styles, the <!--StartFragment--> markers and the mso-list:Ignore bullet markers; display:none, visibility:hidden, [hidden] and mso-hide elements are skipped.
- HTML that only holds inline content (a part of a line copied from Docs or a browser) is read as ONE inline fragment (partial: 'both', one edge space kept) instead of being dropped for the plain-text fallback, so its marks survive.
- Block-level elements met inside inline content (<blockquote><p>a</p><p>b</p></blockquote>) now separate lines instead of gluing their text.
- Lists and tables keep their exact structure for the next lot; today a table cell is a paragraph and Docs' flat aria-level lists are flat.
Editing behaviour
Editor options: spellcheck and lang
- New init options (accepted by new Nabu(), createEditor / the factories and createPersistedEditor), both reactive at runtime (nabu.spellcheck, nabu.lang). spellcheck (default true) drives the root's spellcheck attribute; when false the root also gets data-gramm="false", data-gramm_editor="false" and data-enable-grammarly="false" (opt-out hints of the Grammarly extension). Code blocks always stay spellcheck="false". lang (BCP 47 tag, invalid -> ignored with nabu.warn) sets lang on the root for the browser's spellchecker / hyphenation / screen readers; unset = inherit the page language. See the README ("Spellcheck & language", including a privacy note about Chrome's enhanced spell check).
Multi-block deletion
- Fix (P-44, P0): cutting (Ctrl+X) a selection that crosses nested-list levels corrupted the document (ul[], Block / DOM out of sync, "Text is deleted" page errors, undo not restoring): deleteSelectionContent destroyed the whole root list when both boundaries lived in it. Backspace / typing over the same selection did not corrupt but REORDERED (x, a>(s1,s2,s3), z with x@1 -> s2@1 gave x2, z, s3).
- One implementation of "delete the selected range across blocks": new deleteRange(nabu, { startBlock, startOffset, endBlock, endOffset }) in container.utils.js, used by cut, Backspace / Delete (any delete* input type, word deletions included), typing over a selection, Enter over a selection and paste over a selection (handleContainerBeforeInput and deleteSelectionContent are now thin wrappers). Generic, no block type involved: head of the start block and tail of the end block are merged at the junction (the end block's children are adopted by it, or promoted after it); what lies strictly inside the range is destroyed; what follows the end point inside the partially-selected blocks of the end spine survives in document order, re-homed after the start block (requiredParent honoured); when the start block is an ancestor of the end block (selection from a hybrid's text into its body) the rest of the body stays in place. Survivors are moved out before their former ancestors are destroyed. Type-specific invariants are left to the normalizers. Result: x@1 -> s2@1 gives x2, s3, z.
- Fix: the caret after a multi-block cut landed at the START of the merged block; it now sits at the cut junction (it was set before the commit re-rendered the text). Backspace / typing / Enter over a range place it the same way, after the commit.
- Fix: a word deletion or soft line break over a multi-block selection used to delete the range and then report the input as unhandled; unsupported input types over a range no longer mutate anything.
- Fix: ListItem#split sliced the tail with the stale mirrored text length instead of the live one.
- ADR 008 updated: the spine logic now lives in deleteRange.
Markdown shortcuts
- Block shortcuts (# , - , 1. , [] , > , ! , -- , --- ) now keep the trigger in the undo history: one Ctrl+Z gives back exactly what was typed (# , caret after the space), a second one removes the typing. Implemented once in nabu.shortcut(block, trigger, transform) (trigger inserted as a text commit, transform as its own structural step). The code fence (```js + Enter) comes back as the fence line, without the Enter.
- Fix: blocks with transformsEnabled = false (Code, Callout, Toggle) are no longer transformed by markdown shortcuts (# , > , --- , :::…): the extension onBeforeInput hooks are now skipped for typing in such a block (deletion hooks still run), and nabu.shortcut() refuses on them. Typing # comment in a code block stays plain text.
- Undo/redo restore the caret into the block that exists afterwards: Loro re-creates a block deleted then undone under a new id, so the stored caret now also carries the block's tree path (fixes the lost caret / swallowed key after undoing - , 1. , [] ).
- Inline shortcuts follow CommonMark flanking rules: no whitespace just inside the delimiters (2 * 3 * 4, * not * stay literal), _ / __ never open or close intraword (snake_case_name), `a b` keeps its spaces. ***both*** typed char by char is bold+italic (the **…* rule no longer fires inside a *** run); ___both___ too.
- Typography: -- becomes an em dash only when the -- is not preceded by another - (abc --- stays).
- 3. creates an ordered list that starts at 3: new start prop on List (<ol start>, markdown 3. 4.…, markdown / HTML paste, JSON, clipboard). Omitted when 1, ignored for bullet / todo lists; an adjacent list merged into another keeps the first list's start; nested lists start at 1.
- Fix: an ordered list created right under a bullet list was destroyed together with its item by the adjacent-list merge (lists of different types are no longer merged).
Slash menu
- /divider behaves like --- : an empty block becomes the divider, otherwise the divider goes after the block (after the whole list inside a list); the caret lands in a new paragraph below. One shared insertDivider().
- The menu is positioned in viewport coordinates (it was off-screen on a scrolled page), flips above the caret when there is no room below, is clamped horizontally and follows the caret on scroll / resize.
- Filtering: titles match by substring, aliases by prefix (/ul no longer matches Divider through "rule").
Undo
- Structural operations (split/Enter, block merge, multi-block deletion, block type transforms incl. markdown shortcuts, list indent/unindent, cut) are their own undo step. Detected centrally at commit time from the pending Loro transaction; plain typing still merges per second.
- Undo / redo restore the SELECTION, not just a caret (ADR 015). Ctrl+Z gives back the selection as it was right BEFORE the undone action: bold on "important" then Ctrl+Z removes the bold and selects "important" again; a deleted selection comes back selected; after typing, the caret returns to where typing started; a paste undoes to the pre-paste selection. Multi-block selections (anchor and focus in different blocks) and backward selections keep their blocks, offsets and direction. Ctrl+Y / Ctrl+Shift+Z gives back where the action ENDED (after redoing typing, the caret is at the end of the retyped text; after redoing a bold, the text is selected), even if the caret was moved elsewhere in between. The restored selection is a real selection (typing replaces it) and is scrolled into view with block: 'nearest' (nothing moves when it is already visible).
- Collaborative-safe: the selection is stored with the step as Loro stable cursors (two per endpoint, one on the character before and one on the character after, so a character deleted by the very action being undone is no anchor lost), not as offsets. A concurrent remote edit shifts the restored selection; if the targeted text was deleted remotely the caret lands at the nearest position. A block recreated by the undo under a NEW id (Loro) is found again by its tree path, then by the nearest text block; nothing throws. A block shortcut (# , - …) still undoes to the typed trigger with the caret right after it.
- New src/lib/blocks/undo-selection.js (capture / resolve / restore / scroll). New public nabu.captureUndoSelection() (called by the selection bridge; a host that places selections by itself does not need it) and nabu.sealUndoEnd(). Tests: tests/unit/undo-selection.test.js (Loro level incl. a simulated remote edit between two synced documents), tests/e2e/undo-selection.spec.ts.
Editing
- Fix: the Delete key (forward deletion) did nothing except at the end of a block. "abc|def" + Delete stayed "abcdef" (the native mutation is prevented and the handler only knew the end-of-block merge). Delete now removes the NEXT user-perceived character (grapheme cluster via Intl.Segmenter: ZWJ emoji, flags, letter + combining accent, surrogate pairs go in ONE press), or the selection when there is one inside the block; one commit (successive presses merge into one undo step), caret kept at the same offset, no input rule triggered. Delete at the end of a block still merges the next one (or deletes a following divider); at the end of the last block it is a no-op. Works in code blocks, list items, headings, quotes. Tests: tests/unit/delete-forward.test.js, tests/e2e/typing.spec.ts ("Delete key").
- Fix (P-72): Backspace is grapheme-aware. A collapsed Backspace removed exactly one UTF-16 unit, leaving half a character after a ZWJ emoji (👨👩👧), a flag (🇫🇷), a surrogate pair (𝒳) or a letter + combining accent (e + U+0301). It now deletes the whole previous grapheme cluster in one press (prevGraphemeLength, mirror of nextGraphemeLength: Intl.Segmenter, surrogate-pair fallback; from inside a cluster it deletes back to the cluster's start). Block-start merges, de-transformations, undo merging and caret are unchanged. Arrow keys over such characters were checked: the browser moves by graphemes and the model offset never lands inside one.
- Fix (P-73): the native input types that were swallowed now work. The editor cancels every native mutation, so any beforeinput type it did not know reached the default: branch and did nothing. Handled now (single block in TextBehavior; a multi-block selection goes through deleteRange for deletions and replaces the range for insertReplacementText / insertFromYank):
- insertReplacementText (spellcheck / autocorrect): replaces getTargetRanges() (mapped to model offsets; the selection when absent) with the text/plain of dataTransfer or data, keeps the marks of the replaced run, one undo step, caret after the new text. The editor root is now spellcheck="true" (code blocks stay spellcheck="false").
- deleteSoftLineBackward / deleteHardLineBackward, deleteSoftLineForward / deleteHardLineForward, deleteEntireSoftLine, deleteContent: ranges computed from the model text (a line is a "\n"-delimited run). A selection inside the block is deleted whole; forward deletion at the end of a line or block does nothing; the entire-line deletion keeps the line breaks.
- insertTranspose (macOS Ctrl+T): swaps the two graphemes around the caret (the last two at the end of the text), marks travel with their characters, caret after; no-op at the start, with a selection or across a line break.
- insertFromYank: inserts the text as plain text (no input rule), replacing the selection.
- formatBold / formatItalic / formatUnderline / formatStrikeThrough toggle the mark through mark:toggle, formatRemove clears bold / italic / underline / strikethrough / code (links stay); formatSubscript / formatSuperscript are ignored (no such mark). Routed by the rich-text extension's new onBeforeInput hook (core stays agnostic); without that extension TextBehavior ignores them silently.
- insertFromPaste / insertFromDrop / deleteByCut / deleteByDrag stay with the paste / drop / cut handlers (a drag-move fires no beforeinput at all). Any other unknown type keeps the warning.
- Tests: tests/unit/native-input.test.js, tests/e2e/native-input.spec.ts (synthetic beforeinput events, Ctrl+Z after each).
- Fix: ONE Shift+Enter (or Enter in Code / Callout) at the end of a text block now shows the new line at once; it used to take two, because white-space: pre-wrap gives no line box to a trailing \n. RichText (paragraph, heading, quote, dialogue, callout, list item, toggle header) and the Code block render a filler <br data-nabu-filler> after a text ending with \n (ProseMirror's "trailing hack"). The filler is rendering only: no text (offsets, Range.toString, the model, clipboard, exports and word count are unchanged); a caret the browser parks beside it (ArrowDown onto the empty last line) is brought back to the end of the text by the selection normalizer; an IME composition on that line drops the nodes the browser adds next to the filler and restores it when canceled. Empty blocks still render their lone \n text node (placeholders unchanged).
- Fix: block create() with index = 0 (Paragraph, Code, Heading, Blockquote, Divider, Dialogue, Callout, Media, List item) appended the block at the end (index || undefined); it is now index ?? undefined. Enter in a toggle header puts the new paragraph first in the body.
- The model selection now re-syncs synchronously from the DOM at every input entry point (keydown, beforeinput, compositionstart, copy / cut / paste) via the new idempotent SvelteSelection.sync(). selectionchange is asynchronous, so a keystroke right after a caret move could act on the previous caret: lost first character after a click, Enter splitting at the old offset, wrong marks range.
- nabu.exec() (and insertFragment()) now re-sync the model selection from the DOM too, so a programmatic action fired right after a keyboard-only selection change (Shift+Arrow, Home/End, Ctrl+A) acts on the new selection instead of the previous one. nabu.focus() returns a promise that resolves once the selection is set (await nabu.focus(...) before exec / typing); it is asynchronous because it waits for Svelte to render pending blocks.
- Word deletion (Ctrl/Alt+Backspace, Ctrl/Alt+Delete) now uses the model-based rule in every browser: the deleted word takes its surrounding whitespace with it, no orphan or doubled space. Previously Chromium's getTargetRanges() bypassed the fix for issue #4.
- Fix (P-34): the caret never rests on a non-text position. Around a Toggle (arrow button, collapsed body), a Divider or an Image the browser parked the native caret on a wrapper / whitespace text node / the editor root, so the next keystroke was swallowed and arrows needed 2-3 presses. NabuSelection.normalize() (synchronous, idempotent) now moves any dead endpoint (inert block, element caret, text node outside a text block's own text) to the nearest text position in the direction of travel, skipping void and hidden blocks, in one key press. It runs at selection change, at keydown / keyup and at beforeinput (a keystroke is never swallowed by a dead caret).
- New block capability hidesChildren (default false; Toggle: collapsed): findForward / findBackward / findLastDescendant and caret navigation never enter a block's hidden children (Backspace after a collapsed toggle no longer merges into its hidden body).
- Fix (P-35): a document ending with a non-text block (divider, image, a container whose end is not a text position) now offers a caret after it. ArrowDown / ArrowRight out of the last text block, and a click in the empty area below the last block, append ONE empty default text block (one undo step) and put the caret in it; nothing is created on load. New nabu.openTrailingCaret() / nabu.lastVisibleBlock; the default block type comes from the extension flagged defaultBlock: true (Paragraph) into nabu.defaultBlockType.
Placeholders
- Placeholders are displayed again (their CSS was disabled). New placeholders editor option: false to disable, or { show: 'focus' | 'empty', text: { [type]: string | false | (block) => string | false } }. Default show: 'focus' (only the empty block holding the caret). Blocks declare their default text with static placeholder; color via --nb-text-placeholder. getPlaceholder() is deprecated.
- createPersistedEditor() now forwards all editor init options.
Lists
- List schema normalizer (list/normalize.js, run by the List extension's onBeforeTransaction, i.e. inside the transaction/undo step of the operation that broke the structure). Invariants enforced to a fixpoint, with moves only (ids and caret survive): no empty list; no list directly inside a list (unwrapped); only list items inside a list (a foreign block is hoisted out, the list split around it); an item has only lists as children (0..n of them, see P-28 below); adjacent lists of the same type merge.
- Tab / Shift+Tab keep the caret (or range) at the same offset on the moved item (the item's whole text used to end up selected, so the next keystroke replaced it). Indent / unindent now work on tree nodes in list/indent.js.
- Tab / Shift+Tab with a selection spanning several items indent / unindent every touched item (document order, relative structure kept, items that cannot move stay, selection restored) as ONE undo step, and the key is always claimed when the selection is in list items, so the focus no longer leaves the editor. Routed by the List extension's onKeyDown hook (the keydown of a multi-block selection resolves to the lowest common ancestor, not to an item). list:indent / list:unindent actions act on the whole selection too.
- Enter on an EMPTY list item steps back one level: a nested item is unindented like Shift+Tab (its following siblings become its children), a top-level item leaves the list as a paragraph (list split around it, removed when it was the only item). The exit paragraph used to be inserted inside the parent list item for nested items. The item is retyped in place (same node/id), so its own sub-items are kept as a list below instead of being destroyed.
- Backspace at offset 0 of a list item steps back one level (collapsed caret): a nested item is unindented like Shift+Tab; a top-level item alone becomes a paragraph (text, marks, caret kept), the list is split around it, its sub-items stay a list right after it. A second Backspace then merges the paragraph into the previous block as before. Selections, Backspace elsewhere in the item and Delete are unchanged. Implemented in list/step-back.js, triggered from the List extension's onBeforeInput hook.
- Ordered numbering continues across those splits: the tail list gets start = the number its first item had (1. a 2. b 3. c with b turned into a paragraph reads 1. a, b, 3. c), taking the original start into account.
- Tab on an item with children moves the whole subtree one level deeper with it (its children stay its children; they were hoisted to following siblings before). With several selected items, a child selected together with its moved parent is not moved a second time. Shift+Tab keeps the item's children too (followers are appended after them).
- Changing the type of ONE list item acts on that item only, in place (slash menu "Numbered list" / "Bulleted list" / "To-do list" / "Text" / "Heading N"…, block:transform, exec('paragraph'), exec('heading:1'), markdown shortcuts typed inside an item). To another list type the item (with its sub-items) goes into a new list between the part before and the part after (ul[a] ol[b] ul[c]; adjacent lists of the same type merge, "Numbered list" on a bullet item used to do nothing); the same type is a no-op; entering a to-do list sets checked: false, leaving one drops it. To any other block it leaves the list exactly like the top-level Backspace (list split, sub-items stay a list right after it, ordered tail keeps counting through start). Converting to another LIST type works at any nesting level, in place (see P-28 below); only a conversion of a NESTED item to a non-list block first unindents it to the top level (what repeated Shift+Tab does: followers become its children). Generic core hook: Block.retype(type, props) (mutations only; transformTo and block:transform use it), overridden by ListItem; new list:convert action ({ listType }) used by the slash menu, which no longer builds lists itself. New nabu.isolateUndoStep().
- A list item may hold SEVERAL consecutive sublists of DIFFERENT types (P-28): a > [ul[s1], ol[s2], ul[s3]], mirroring the root level. The normalizer no longer merges an item's extra sublists into the first one: only ADJACENT sublists of the same type merge (the first keeps its start). Converting a nested item to another list type (slash "Numbered list" / "To-do list" / "Bulleted list", list:convert) splits its sublist in place inside the same parent item (ordered "after" part keeps counting through start; no more promotion to the top level; one undo step), converting it back re-merges. Tab puts the item in the previous sibling's LAST sublist when it has the item's list type, else in a new sublist of that type after the sibling's sublists; Shift+Tab makes the followers children in a sublist of THEIR type after the item's own sublists (the parent's later sublists follow, to keep the document order). Step-back of a top-level item moves ALL its sublists out. Markdown (- a / - s1 / 1. s2 / - s3), HTML, JSON and clipboard round-trip them; ListItem.sublists (all) replaces the single sublist (kept as the first), sublistsOf(item) replaces sublistOf in list/indent.js.
- Ctrl/Cmd+Enter toggles to-do items: the item under the caret, or every to-do item of a multi-block selection (any unchecked: check all, else uncheck all). Caret unchanged, one undo step; no-op outside to-do items. The normalizer now also runs when a list's listType changes.
Blocks
- Behavior change: the markdown syntax of a dialogue line is -- text (two hyphens + space), for export AND import / paste, independent of the displayed dash. The exchange format no longer depends on a visual preference, and – / — (common list bullets in French text) are never read as dialogue: a line starting with – or — is a plain paragraph. Markdown exported by previous betas with — text is therefore NOT recognized as dialogue anymore (deliberate breaking change). New createDialogueExtension({ dash = '–' }) factory (DialogueExtension stays exported as the default instance): dash only configures the DISPLAYED marker, through the CSS custom property --nb-dialogue-dash (overridable by a host in CSS; default –). New markdownRules / fromMarkdown on Dialogue (priority over the list rule: -- x is never a list item, - x never a dialogue).
- Toggle sub-thread rules (P-40), written as small reusable helpers (blocks/subthread.js, tree operations only; a container Callout will reuse them as is):
- Enter on an EMPTY paragraph that is the LAST child of the body exits the sub-thread: the paragraph moves right after the toggle (caret in it); the toggle may keep an empty body (Enter in the header recreates a first child). Other empty children / other block types keep their usual Enter.
- Behavior change: Backspace at offset 0 of the header turns the toggle into a paragraph with the header text, its body children following it (same parent, in order, nothing lost, one undo step). It no longer merges the header into the previous block, and is no longer a no-op on the first block of the document.
- Behavior change: Enter at the end of a COLLAPSED header creates a paragraph AFTER the toggle (caret in it) and the toggle stays collapsed (it used to expand); Enter in the middle splits the header, the tail goes into that paragraph. An expanded toggle is unchanged (new first body child).
- Hidden content is never touched: Delete at the end of a collapsed header acts on the next visible block (no-op if none) and the children of a block merged into a collapsed header stay visible after the toggle instead of vanishing into the hidden body; Backspace at offset 0 of the block after a collapsed toggle merges into the header, not into the hidden body.
- Engine: Block#ascend lets a container's handler DECLINE an event (falsy result) so the registered hooks run (the toggle only handles the Enter that exits its body).
- Fix (P-37): a block-type-specific onSplit was never run, the generic Paragraph one (registered first) captured the Enter of every block type. Block#ascend now runs the hooks registered by the extension that owns the block's type before the generic ones (nabu.hookOwners, filled at extension registration; core stays agnostic). Enter in a non-empty dialogue creates a new dialogue line; Enter on an empty dialogue turns it into a paragraph.
- Heading (P-36): Enter in the middle splits into two headings of the same level; at the start of a non-empty heading an empty paragraph is inserted above (heading, level and caret kept); at the end a paragraph is added below (unchanged, also for an empty heading).
- Callout (P-38, interim): Backspace at offset 0 of a non-empty callout is a no-op (nothing merges, variant kept); an empty callout still becomes a paragraph. The callout is planned to become a container.
- Code (P-39): Backspace at offset 0 of a non-empty code block turns it into a paragraph with the same text (language dropped, caret stays at 0); a second Backspace merges normally.
- Divider (P-39): Delete at the end of the block right before a divider deletes the divider (symmetric with Backspace after it); text and caret kept, one undo step. It used to merge the next block across the divider.
- Generic schema rule in Nabu.commit(): a block whose class cannot hold children (not a MegaBlock) never keeps child nodes. Retyping a node into such a block (block:transform, markdown shortcuts, data.set('type')) or moving a node under one moves its children to right after it, in order, in the same transaction (nothing is lost; e.g. a list item with a sublist transformed to a paragraph no longer hides the sublist). The tree-subscription no longer throws when a retyped block has no updateChildren().
Tooling, packaging, docs and tests
Tooling - SvelteKit 3 / Vite 8 / Vitest 5 toolchain (no behaviour change)
- Dev toolchain bumped: svelte 5.51 -> 5.57.3, svelte-check 4.4 -> 4.7.7, @sveltejs/kit 2.52 -> 3.1.0, @sveltejs/package 2.5 -> 3.0.1, @sveltejs/vite-plugin-svelte 6.2 -> 7.3.1, vite 7.3 -> 8.3.5, vitest + @vitest/coverage-v8 3.2 -> 5.0.5, typescript 5.9 -> 6.0.3. The demo/e2e app now builds with @sveltejs/adapter-node (replaces the Kit-2-only svelte-adapter-bun; the unused @sveltejs/adapter-auto is dropped).
- Applied with sv migrate sveltekit-3: svelte.config.js folded into vite.config.js (the adapter is now an option of sveltekit()), jsconfig.json extends the generated $app/tsconfig, $app/environment -> $app/env, and the $lib alias became #lib (a package.json imports entry: #lib -> src/lib/index.js, #lib/* -> src/lib/*, resolved natively by Vite/Vitest, so vitest.config.js no longer needs an alias). The library code in src/lib keeps relative imports.
- vitest.config.js: svelte({ hot: false }) -> svelte({ compilerOptions: { hmr: false } }) (hot was removed in vite-plugin-svelte 7).
- Published output unchanged: same 311 files in dist/; the only difference is that 14 .d.ts files now write import("../../extend.js") instead of import("../../extend").
- Minimum Svelte actually required by src/lib: 5.20 ($props.id() in CommandChooser); peerDependencies.svelte is now ^5.20.0 (was ^5.0.0), and the README requirements say so.
Changed - @aionbuilders/nabu/vite no longer uses vite-plugin-top-level-await
- vite-plugin-top-level-await 1.6 requires rollup and esbuild, which it does not declare and Vite 8 (Rolldown) no longer installs, so Vite 8 consumers failed with Cannot find module 'rollup' / 'esbuild'. The nabu() helper now returns vite-plugin-wasm plus a tiny plugin that sets build.target: 'esnext' (unless the consumer already set build.target): Loro's WebAssembly is loaded with top-level await, supported natively by modern browsers. vite-plugin-top-level-await is removed from dependencies.
- README "Vite setup" updated (manual setup is now vite-plugin-wasm + build: { target: 'esnext' }, with the reason), as is the demo's pitch panel.
Docs - README rewritten, getting-started pages, tested examples (audit D-01 to D-06)
- README.md is now short and accurate (beta status, install, requirements, entry points, real preset contents); the long previous version is kept in docs/archive/README-pre-beta-long.md as source material for the guides. New pages: docs/getting-started/{installation,first-editor,presets}.md; every page has YAML frontmatter (title, description, section, order).
- Stale status files archived to docs/archive/ (PROJECT_CONTEXT.md, PROGRESS.md, PROGRESS_LISTS.md, prompt.md, e2e-plan.md); tests/README.md describes the real test layout.
- Documentation examples are tests: a block fenced as ```js test is run by tests/unit/docs-examples.test.js (public package names are aliased to the source entries in vitest.config.js); ```svelte blocks must compile.
Tests
- E2E suite now runs against a PRODUCTION build (T-09): playwright.config.js builds with VITE_NABU_E2E=1 (build:e2e) and serves it with vite preview on port 5180; the /e2e route is only reachable in dev or in such a build (a normal build still 404s). Removes the random beforeEach boot timeouts of the dev server (3 consecutive full runs green, ~2.5 min). NABU_E2E_DEV=1 / test:e2e:dev keeps the dev-server mode for debugging. tests/README.md updated (obsolete __nabu_debug flow removed, T-05).
- E2E harness: dedicated dev-only /e2e route (fresh editor per test, ?persist=<docId>, ?options=<json>), fixing a startup race that made ~35 tests time out. New specs: text-marks, shortcuts-slash, fast-input, list-start. 307/307 e2e, 390/390 unit.
0.1.0-beta.0
First beta. The library is feature-complete for the beta milestone: everything beyond the stability baseline is opt-in (a dedicated extension or a mounted UI component), so MinimalPreset stays paragraph-only. 287/287 unit tests, prepack + publint clean.
Stability & packaging
- Beta cut: version 0.1.0-beta.0; corrected repository.url to the git+https://….git form (publint suggestion).
- createEditor and the named factories now forward Nabu init options (files, snapshot, …) to the constructor, not just extensions.
- Replaced $app/environment (SvelteKit-only virtual module) with esm-env in context.svelte.js. The library now builds correctly for plain Svelte, Vitest, and any non-SvelteKit bundler.
- Removed leftover debug console.log statements in the focus() method (nabu.svelte.js) and the onBeforeTransaction hook (paragraph/index.js). Focus debug output is now routed through the gated this.warn(...) helper (only active when nabu.debugging is true).
- Removed stale files: GEMINI.md, src/lib/blocks/zone/ZoneEditor copy.svelte, and the trash/ directory.
Core refactoring
- New TextBlock base class (blocks/text-block.svelte.js): Paragraph, Heading, Dialogue (and the new Blockquote) now inherit the ~10 TextBehavior proxies instead of duplicating them.
- New declarative input rules system: extensions can register inputRules (pattern tested against the text before the cursor + handler). The -- → — substitution moved from TextBehavior into the new TypographyExtension.
- nabu.transact(mutateFn, cursorIntent?) centralizes the mutate → commit → setCursor dance. Migrated mark:*, block:transform and zone:set* actions.
- Incremental wordCount: every block exposes wordCount ($derived); containers sum their children. ZoneBlock no longer re-traverses its whole subtree on each keystroke.
- Capability flags on Block: splitsOnEnter, transformsEnabled, marksEnabled (all default true): declarative engine opt-outs so a block can shape core behavior without overriding handlers (Code disables inline transforms, void blocks disable Enter-splitting).
Native behavior audit
- Implemented deleteWordBackward / deleteWordForward / deleteSoftLineBackward / historyUndo / historyRedo inputTypes. Word deletion now removes the whitespace surrounding the word, which closes issue #4 (Alt+Delete orphan space).
- IME composition support (closes issue #6): compositionstart/update/end are now handled. During composition the browser mutates the DOM freely (insertCompositionText is no longer blocked); the composed text is synced into Loro at compositionend, with guards for canceled compositions and destroyed blocks.
New blocks & features
- Blockquote block (> shortcut, Backspace/Enter de-transformations, markdown/HTML paste rules).
- Divider block: first non-textual block (--- shortcut, <hr> rendering, markdown ---).
- Code block: multi-line monospace, ``` shortcut, stores plain text only (pluggable syntax highlighting deferred, see docs/adr/013).
- Callout block: note / tip / important / warning / caution variants, ! shortcut.
- Toggle block: collapsible (hybrid MegaBlock: own text + child blocks, collapsed state in metadata; Enter spawns a body child, Backspace de-transforms).
- Checklist / to-do lists (listType: 'todo') with interactive checkboxes, plus a reactive Todos registry (Todos.get(nabu)) that mirrors the Zones system for document-wide task tracking.
- Image / Video / File void media blocks on a shared MediaBlock base, with an optimistic local preview while the asset persists.
- Link mark: link:set / link:remove / link:toggle actions, <a> rendering, [text](href) markdown and <a href> HTML serialization, link parsing on HTML paste.
- Document import: nabu.importMarkdown(md), nabu.importJSON(json) and nabu.importFragment(fragment) replace the whole document through the paste pipeline.
- FloatingToolbar component (ui/FloatingToolbar.svelte) + nabu.isMarkActive(mark) reactive helper.
- Placeholders on empty blocks via data-placeholder + global CSS (paragraph, headings, dialogue, blockquote).
- Vite plugin preset: import { nabu } from '@aionbuilders/nabu/vite' registers vite-plugin-wasm + vite-plugin-top-level-await (both moved to dependencies).
- HTML paste rules now support a priority field (specific blocks win over generic fallbacks).
- File handling: by default an uploaded file is stored as a blob in a separate Loro assets map (the document stays the single source of truth); a files.onBeforePersist hook lets the host redirect uploads to its own backend; Files.get(nabu).resolve(ref) turns a stored ref into a display URL. Warns above a 2 MB inline threshold.
Editor UX
- Slash menu (ui/SlashMenu.svelte): opt-in command palette: type / to open, filter by typing, ↑/↓+Enter to run, Esc to close. Built-ins cover Text, Headings 1–3, bulleted / numbered / to-do lists, Code, Callout, Quote, Toggle, Divider, Image, Video, File; extend or override via the commands prop.
- Inline markdown shortcuts (MarkdownShortcutsExtension): **bold**, __bold__, *italic*, _italic_, `code`, ~~strike~~ transform as you type.
- Stored marks (ProseMirror-style active formatting): toggling a mark at a collapsed cursor (e.g. Ctrl+B) starts formatting for the next typed text; marks now use expand: 'none' so they no longer leak past their range; cleared on cursor navigation.
- Drag handles (ui/DragHandle.svelte): opt-in overlay: hover a block to reveal a ⠿ grip and drag to reorder. Pointer-events based, custom drop indicator, reorder by proximity via moveBefore / moveAfter on the Loro tree (single transaction → collaborative & undo-safe), wrapOrphan structural repair, and an anti-cycle guard.
alpha.10
Zone Word Count Logic Refactor
- Fixed a bug where word count indexes (Counter getIndex) would miscalculate when zones were deleted or reordered.
- Redesigned Counter scoping: Template.getCounter now correctly scopes counters by parentZoneId directly, isolating word counts natively without manual backtracking.
- Fixed a memory leak where Template subscriptions to the counter would duplicate because they were not properly unsubscribing on destroy.
- Ghost zones (zones being removed from the DOM but lingering in the block map) are now properly filtered out of index computations.
Dynamic Word Count Cap (maxTarget)
- ZoneEditor now automatically caps a zone's word count target to its parent's capacity.
- Implemented visual indicators for Max: {target} in the ZoneEditor UI.
Theme & UI Modularity
- Completely migrated Nabu's design system to semantic CSS variables via a new nabu-theme.css file.
- The user interface now uses var(--nb-*) tokens for all colors, borders, shadows, and blurs.
- Introduced a bulletproof OS-native theme fallback: auto-switches on @media (prefers-color-scheme: dark), and allows explicit developer overrides via data-nabu-theme="dark|light".
- Refactored all major UI components (Zone.svelte, ZoneEditor.svelte, TemplateEditor.svelte, Context.svelte, ZoneTemplatesList.svelte) to support this dynamic theming out-of-the-box.
alpha.9
Selection normalizer (breaking fix)
Complete rewrite of findNearestTextNode in selection.svelte.js. The previous implementation used a sub-walker starting from the beginning of the element, causing the caret to loop back to the start of a zone when trying to exit. The new logic uses anchorOffset as the search pivot, correctly determining where in the element the caret landed before deciding whether to look inside or escape past it.
- Arrow keys now correctly exit zones in both directions
- Shift+arrow into an inert element now corrects only the focus, preserving the anchor (non-collapsed selection)
- data-nabu-ui elements (zone title input, etc.) bypass normalization entirely
Zones: nested zones
zone:insert and zone:wrap now support zones inside zones.
- zone:insert detects the nearest zone ancestor and inserts inside it rather than always jumping to root level
- zone:wrap (single-block) no longer bails when wrapping inside a zone, and creates a nested zone at the correct position
- zone:wrap (multi-block) allows wrapping a selection of zones into a new zone
Zones: UI
- Breadcrumb (top): when a zone is nested, the title area shows the full ancestor chain: › Partie II › Chapitre 7
- Closing chevrons (bottom): replaces the plain bottom boundary line with ‹ Partie II ‹ Chapitre 7 right-aligned, indicating zone exit context
- Word count: now displayed on all zones, not only nested ones
BlockGap: inter-block gap system
New BlockGap.svelte component rendered after every block via Block.svelte. Hovering the gap between two blocks reveals a subtle + line. Clicking emits gap:click via nabu.exec with { parentId, insertIndex }. It is completely agnostic: any extension can handle it.
ParagraphExtension registers a gap:click handler that inserts a paragraph at the gap position and moves the cursor there. This solves the "two consecutive zones with no way to insert content between them" problem.
Exports
ZoneBlock, Zones, and ZoneExtension are now properly exported from the package root via a new src/lib/blocks/zone/index.js barrel.