Rich text
A rich-text editor is not shipped by the framework — the native
<textarea>stays the baseline. Enhance it with a dedicated engine, themed with Actual tokens.
Use a <textarea> while editing text, and a hosted editing engine as soon as
editing means a document (formatting, lists, links, mentions). The
rich-text template enhances a textarea
with @lekoala/rich-text (Squire
underneath): the package generates the toolbar with its roving tabindex,
states, commands and lifecycle, while Actual maps its visual language onto the
package's --rt-* tokens. No adapter, no compatibility layer.
Related terms: rich text, richtext, WYSIWYG, HTML editor, formatted text, Squire, ProseMirror, Tiptap, text formatting, bold italic, mentions, slash commands.
Theme bridge
The template loads the package JS and CSS from a CDN (@0.2 caret range) and
declares one editor per field — Actual never builds toolbar buttons or calls
into the editing engine:
<div class="field actual-rich-text">
<label class="field-label" for="note">Team note</label>
<rich-text id="note-editor">
<textarea class="textarea" id="note" name="note" required placeholder="Write a note…"></textarea>
</rich-text>
<span class="field-help">Bold, italic, lists, quotes and links.</span>
</div>
editor.options = {
buttons, // Tabler icons as Nodes, never HTML strings
requestLink, // an Actual <dialog> replacing window.prompt()
suggestions, // static @people and /snippets providers
};
The bridge maps tokens; it does not restyle the editor's internals:
.actual-rich-text .rt-shell {
--rt-bg: var(--surface);
--rt-fg: var(--text);
--rt-border: var(--form-invalid-border, var(--control-border, var(--border)));
--rt-divider: var(--border);
--rt-focus: var(--form-invalid-border, var(--focus));
--rt-toolbar-bg: var(--surface-subtle);
--rt-button-pressed-bg: var(--state-selected);
--rt-link: var(--primary);
--rt-suggestion-bg: var(--surface-raised);
--rt-suggestion-shadow: var(--shadow-popout);
}
The card in the template documents the full token map of this recipe. The
suggestion popover is a child of .rt-shell, so it inherits the tokens like
the rest of the editor. --rt-border paints only the field frame, so the
invalid and hover hooks never reach the toolbar separators or the quote bar,
which follow --rt-divider.
Bridge contract
@lekoala/rich-text owns the editor UI semantics — progressive enhancement,
sanitization, toolbar behaviour and keyboard, links, @mentions, /
suggestions, form/reset/readonly/disabled integration, selection and undo
lifecycle. Actual owns presentation and composer chrome — tokens, visual
states, dialogs, attachments, send, Ctrl/Cmd+Enter.
Formatting buttons stay local to the editor instance. The stateless command
router never drives bold/italic: those depend on selection, schema and history,
which only the engine knows. Reserve command for the chrome around the
editor (attach, template, dictate, send).
A rich composer reuses .composer and .composer-actions for the outer
surface; the bridge only lifts the editor and toolbar into the composer grid
with display: contents and grid areas. composer.css itself never knows
about .rt-*. The plain-text composer template
stays the canonical pattern when editing means text, not a document.
People lookup and templates are application concerns: the demo wires static
arrays into the package's suggestion seam, and the composer buttons type
@// so the providers do the rest. Attachments stay composer-owned —
logged, never serialized into the HTML.
Storage and sanitization
The form submits limited HTML, not engine state: portable, readable,
editor-independent, reversible. Sanitization is mandatory inside the package
and not configurable; the client-side sanitizer is a UI boundary, not the
persistence security boundary — sanitize again on the server before storing or
rendering untrusted content. An editor holding only the empty block serializes
to "", so native required keeps working.
If the product later needs truly structural content (custom document nodes, annotations, collaboration), a schema-based engine becomes interesting — see the rich-text design note.
Baseline traps
The textarea is the value owner and the no-JS fallback, holding raw HTML. Two details keep it honest:
- Without the integration script the page remains a plain textarea — the link dialog wiring and the suggestion providers only exist once the package loads, and the composer falls back to plain-text send.
requiredcannot sit usefully on a hidden textarea: mirror validity witharia-invalidon submit (the package emitsrichtext:invalidand focuses the editor when it is the form's first invalid control), and let the bridge re-own the danger hook from there.