---
title: Writer Studio (standalone writing editor) (part 2)
---

# Writer Studio (standalone writing editor) (part 2)

## What it is

This is part 2 of the [Writer Studio (standalone writing editor)](ai-writer.md) page. It covers the editor surface and the document list around it — the formatting toolbar, the plugin’s find bar, slash menu and template picker, the row-level document operations, the font, spacing, theme and palette controls, **Save as**, and the two data-model tables behind all of it.

## Where to find it

Writer Studio opens from the **Writer** virtual project in the sidebar, and the [parent page](ai-writer.md) covers how the feature is gated, which of its two surfaces you are looking at, and the first-run wizard. Within the writer view the formatting toolbar sits between the document title and the editor body, the document list occupies the left sidebar, and the theme, palette, font and spacing controls live under the **Appearance** gear icon in the document header.

## How it behaves

### Formatting toolbar

A Word-style **formatting toolbar** sits between the document title and the editor
body (`FormatToolbar.tsx`). Left to right: **Undo · Redo**, a **paragraph-style**
dropdown (Normal text / Heading 1–3), **Bold · Italic · Strikethrough · Code**,
**Bullet list · Numbered list · Quote**, and **Link · Divider · Clear formatting**.

- **Undo / Redo** are the leftmost two buttons; they reflect the Tiptap editor
  history state (`can().undo()` / `can().redo()`) and disable when no action is
  available. They are keyboard-equivalent to Ctrl+Z / Ctrl+Y.
- Buttons reflect the cursor's context — a toggle shows `aria-pressed` / the accent
  style when the selection is inside that mark or block; the dropdown shows the
  current block's style.
- **Link** opens a small inline box to type a URL (auto-prefixed with `https://`
  when no scheme is given; only `http`/`https`/`mailto` are accepted), with a
  **Remove** option on an existing link.
- **Clear formatting** runs `unsetAllMarks()` + `clearNodes()` (matches Word's
  "Clear All Formatting").

The toolbar is **markdown-clean**: every action maps to a command StarterKit already
serializes to markdown, so the stored format stays markdown (no HTML). There are
deliberately **no Underline, text-color, or alignment** buttons — markdown has no
standard representation for them, and adding them would require embedding HTML in the
stored content. Clicking a button, pressing the keyboard shortcut (Ctrl+B…), and
typing markdown all drive the same Tiptap commands.

### Find & replace (plugin)

Cmd/Ctrl+F opens an in-document **find bar** (`FindBar`) over the editor — query + replace inputs,
case-sensitive and whole-word toggles, an "N of M" count, and next / previous / Replace / Replace-all.
The key is intercepted (`preventDefault`) so the webview's own native find doesn't hijack it; Esc closes
and clears the search.

Matching runs in a **custom ProseMirror extension** (`editor/extensions/FindReplace.ts`, the
`InlineAutocomplete` pattern — permitted by I20's Wave-2b clarification): all matches are highlighted via
inline decorations (`.writer-find-match`; the current one also `.writer-find-current`), and it edits
PLAIN TEXT only so the document stays markdown-clean (I3). Match ranges come from the pure `findAllMatches`
(`lib/find-matches.ts` — exact substring, case / whole-word, non-overlapping) over `collectTextBlocks`
(the SAME text→position map the chat "apply edit" flow uses). Replace-all runs **last match → first** so
an earlier match's position never shifts under a later edit.

### Slash "/" menu (plugin)

Type **`/`** at a line start (or after whitespace) to open a floating **command menu** at the caret,
filtered by what you keep typing; up/down move, Enter runs, Esc dismisses. Commands: **Heading 1-3,
Bulleted / Numbered list, Quote, Divider** (pure StarterKit chains -> clean markdown, I3/I20), **From
template** (opens the `TemplatePicker`), and the AI commands **Continue writing** (via the existing
`aiAutocomplete`, stale-guarded), **Summarize**, **Explain**, and **Improve writing** (the last three
open the Assistant + send via `sendChat`). The AI commands reuse the existing cost-cap + humanized-error
flow; no new bridge.

Detection is a pure `matchSlashQuery` (`lib/slash-trigger.ts`) over the text before the caret, wrapped
in the `SlashMenu` extension (`editor/extensions/SlashMenu.ts`) that computes `{ active, query, from }`
PURELY in the plugin's `apply` (no dispatch-from-view) and SKIPS code blocks. The command list is
`lib/slash-commands.ts`; the floating UI + keyboard live in `components/SlashMenu.tsx` -- the keyboard
is a capture-phase listener mounted ONLY while the menu is open. On pick it `deleteRange`s exactly the
`/query` before running the command.

### Document templates (plugin)

Creating a new document in the plugin (the sidebar **+** or the empty-state create button) opens a
**template picker** (`TemplatePicker`, reusing the guest `Modal`) with a **Blank** option first,
then any **user-defined templates** (accent-colored border), then the built-in starter templates
(`lib/writer-templates.ts`) — the same set Writer Studio ships, kept identical by a drift check.
Picking one seeds structured markdown (and, where it helps, a writing-guidance seed) via the existing
`createDocument` → `setContent` → `updateGuidance` path (the same flow as a Drive import). The picker
ONLY ever seeds a fresh document — there is no "apply to the open document" path, so it can never
overwrite an existing draft, and **Blank** preserves the one-click create.

#### User-defined templates

Users can save any open document as a reusable template via the **BookmarkPlus** button in the
document header. The template captures the document's title, markdown content, and writing guidance
(goal, audience, tone). Templates are stored in the `writer_templates` table (see "Data model"
below) with soft-delete and are global per install (no `account_id`).

User templates appear ABOVE the built-in templates in the picker with an accent-colored border.
Each user template has a hover-visible delete button (Trash2 icon) that soft-deletes it. The picker
fetches user templates via `writerApi.templateList()` on every open.

Both the IPC handlers and the plugin bridge support full CRUD:

| Surface       | List                   | Get                   | Create                   | Delete                   |
| ------------- | ---------------------- | --------------------- | ------------------------ | ------------------------ |
| IPC (native)  | `WRITER_TEMPLATE_LIST` | `WRITER_TEMPLATE_GET` | `WRITER_TEMPLATE_CREATE` | `WRITER_TEMPLATE_DELETE` |
| Plugin bridge | `templateList`         | `templateGet`         | `templateCreate`         | `templateDelete`         |

Template operations do NOT emit `writer:documents-changed` pushes (templates are a separate entity
from documents).

### Document clone

The document row context menu includes a **Duplicate** action that atomically copies a
document. `cloneWriterDocument` reads the source row, creates a new document with a
"Copy of [title]" prefix, copies all metadata fields (goal, audience, tone, creativity,
folder), and duplicates the document's tags. The clone is a fully independent document
with its own id and timestamps. Cloning a nonexistent or deleted document returns
undefined (the handler shows a humanized error). The `WRITER_DOCUMENTS_CHANGED` push
fires after clone so the sidebar refreshes.

### Document tags

Documents can be tagged with freeform text labels for organization and filtering.
Tags are stored in a junction table `writer_document_tags` (created by migration
`20260903023357`):

| Column        | Type    | Notes                            |
| ------------- | ------- | -------------------------------- |
| `document_id` | TEXT FK | references `writer_documents.id` |
| `tag`         | TEXT    | the tag label (case-sensitive)   |
| `created_at`  | TEXT    | ISO timestamp                    |

Primary key is `(document_id, tag)`. Adding a duplicate tag is a no-op (`INSERT OR
IGNORE`). Tags are loaded alongside documents via `loadAllDocumentTags` (a single
query that batch-loads all tags grouped by document id, avoiding N+1).

In the plugin DocumentList, each row shows up to 3 tag chips with an overflow count.
A tag filter bar appears above the document list when any tags exist; clicking a tag
filters the list to documents carrying that tag. The row context menu includes a
"Tags" item that opens a tag management popover with an input to add new tags and
buttons to remove existing ones.

### Templates, tags, copies and export in Writer Studio (native)

Writer Studio has the same five document tools as the plugin, over the same tables and channels.
Contract: `writer-library-contract`.

- **Start from a template.** **New from template** in the create menu, or the **From template**
  item in the `/` menu, opens a picker: a **Blank** card, then your saved templates, then the
  built-in ones, with one search box over all three. Picking one creates the document, saves the
  template's text, then its writing guidance, and only then opens it. If the text cannot be saved
  the new document is removed; if only the guidance fails, the document is kept and you are told.
- **Save as template.** An item in the document's **File** menu. It stores the title, text and
  guidance as a snapshot; later edits do not reach it. An empty document is refused with a plain
  sentence. The template's name is the title cut to 200 characters. A saved template's delete
  button is always visible, not hover-only, and asks for confirmation.
- **Tags.** Each list row shows up to three tag chips, then **+N**. **Manage tags** in the row
  menu opens a small dialog: the tags as removable chips, a box where Enter adds one, and
  suggestions from every tag in use. Adding or removing a tag does not change the document's
  last-edited time, so it never reorders the list or makes the next autosave conflict.
- **Filter by tag.** A row of tag pills appears above the list once any document is tagged.
  Picking one narrows the whole list, folders included, and works together with a search.
  Picking it again clears it, and it clears itself if no document carries that tag any more.
- **Duplicate.** Makes a "Copy of" document with the text, guidance and tags, and opens it. The
  copy carries none of the original's share page, board card, knowledge-base note, project item
  or chat links.
- **Export from the list.** **Export as Markdown** and **Export as HTML** in the row menu use the
  same export as the File menu. A failure is reported in words; cancelling the save dialog is
  silent.
- **Translate.** A preset in the selection popup, beside the other AI edit presets, reviewed
  before it applies like any AI edit.

The four row actions work on one document at a time; with several selected they show as disabled,
with a tooltip saying why. None of these actions has a keyboard shortcut: the `/` menu is already
the keyboard path to templates, and the rest are one click away in a menu.

- **In the web client** (a phone or browser linked to the app) the link refuses templates, tag
  editing, copies and export, so those menu items and the `/` template command are hidden there
  rather than shown only to fail. Tags still show on each row, and the tag filter still works.
- **From the command line**, the same actions are routes on the local control server:
  `POST /writer/documents/:id/clone`, `POST /writer/documents/:id/tags` (body `{ tag }`),
  `DELETE /writer/documents/:id/tags/:tag`, `GET` and `POST /writer/templates`, and
  `DELETE /writer/templates/:id`. They use the same saving code as the app, so a document copied
  or tagged from the command line shows up in the open Writer. Copy and template create accept an
  `X-Client-Request-Id` header, so a retried request never makes a second copy.

### Multi-select and bulk operations

The plugin DocumentList supports multi-select: click the checkbox on any row to
toggle selection (checkboxes appear on hover, or always when items are selected).
A bulk toolbar appears above the list showing the selection count, with actions for
Select all, Delete selected, and Clear selection. Bulk delete calls the per-document
delete for each selected id and clears the selection.

### Inline document rename

Double-clicking a document title in the sidebar, or choosing "Rename" from the row
context menu, turns the title into an inline text input. Commit with Enter or blur,
cancel with Escape. The `cancelDocRenameRef` pattern prevents a blur-after-Escape
from committing the cancelled value (same pattern as folder rename).

### Font size and line spacing

The Appearance menu (gear icon in the document header) includes two new submenus:

- **Font size** — 7 options from 12px to 24px (default 16px). Stored as
  `writerFontSize` in `appearance-settings.ts`.
- **Line spacing** — 3 options: Compact (1.4), Normal (1.7, default), Relaxed (2.0).
  Stored as `writerLineSpacing` (`WriterLineSpacing` enum).

Both are applied to the editor via CSS custom properties (`--writer-font-size`,
`--writer-line-height`) set on the prose container's inline style. The plugin reads
them on mount via the `writer.getAppearanceSettings` bridge method. The CSS variables
have fallback defaults in `index.css` so the editor renders correctly before settings
load.

### Save as (local file export)

The File menu includes a **Save as** submenu with two options:

- **Markdown (.md)** — writes the raw markdown content to the chosen path.
- **HTML (.html)** — converts the markdown to HTML via `markdownToExportHtml` (the
  same converter used for Google Docs export) and writes it.

Both open a system save dialog (`dialog.showSaveDialog`) with a default filename
derived from the document title (sanitized via `sanitizeTitleForPath`). Empty
documents cannot be exported (the handler returns a humanized error). The same export
options are also available in the document row context menu in the sidebar.

### Editor theme and color palette

The Writer editor can use a **different light/dark theme** from the rest of the app,
and an optional **color palette** that restyles the writing surface. Both live under
the **Appearance** menu (gear icon) in the document header (`WriterAppearanceMenu`):

- **Theme** (Sun/Moon/SunMoon icon) — a menu item that cycles between `match-app` (default),
  `light`, and `dark`. When set to an explicit theme that differs from the app's
  current mode, the Writer root div gets a `.writer-light` or `.writer-dark` CSS class
  that overrides the `--color-surface-*-rgb` custom properties. All existing `surface-*`
  Tailwind utilities within the scope resolve to the overridden values automatically,
  so zero child components needed refactoring.
- **Color palette** (Palette icon) — a submenu offering 8 curated palettes
  (Parchment, Midnight Ink, Candlelight, Noir, Velvet, Frost, Sage, Rose) plus None.
  Each palette adds a `.writer-palette-{name}` class to the WRITER_PAGE element with
  custom gradients, borders, and shadows. Parchment and Candlelight include SVG
  `feTurbulence` noise texture and are light-only (skipped in dark mode). All other
  palettes have both light and dark variants.

- **Toolbar layout** (LayoutGrid icon) — a submenu offering 4 toolbar button styles:
  Icons + labels (icon + full text), Icons only (icon with rich tooltip, **default**),
  Compact (icon + shortened label), and All icons (everything icon-only, including
  File/Share/Send-to menus). The default is Icons only because it matches other Omniscio
  toolbars and Writer Studio is a breadcrumbed tool for experienced users.

Settings: `writerTheme` (`'match-app' | 'light' | 'dark'`, default `'match-app'`),
`writerPalette` (enum, default `'none'`), and `writerToolbarLayout`
(`'icons-text' | 'icons-only' | 'compact' | 'full-icon'`, default `'icons-only'`) in
`appearance-settings.ts`. The Appearance gear icon shows accent highlighting when
palette is set away from its default. The theme logic is in `WriterView.tsx`
(`writerThemeClass` memo, `paletteClass` memo); the CSS classes are in `globals.css`.

The pop-out Writer window (`WriterWindowApp`) renders the same `WriterView`, so both
settings work identically in the standalone window.

## For agents

### Data model — `writer_documents`

Created by migration
`src/main/db/migrations/20260603162951-create-writer-documents-table.ts`.

| Column                      | Type    | Notes                                                     |
| --------------------------- | ------- | --------------------------------------------------------- |
| `id`                        | TEXT PK | `randomUUID()`                                            |
| `title`                     | TEXT    | display name (≤ 255 chars, Zod-enforced)                  |
| `content`                   | TEXT    | canonical markdown body (stored as markdown, never HTML)  |
| `goal`                      | TEXT    | what the document is for (writing guidance for AI)        |
| `audience`                  | TEXT    | who the document is for (writing guidance for AI)         |
| `tone`                      | TEXT    | desired tone, e.g. warm, formal, punchy (guidance for AI) |
| `creativity`                | INTEGER | 0–100 creativity dial value                               |
| `created_at` / `updated_at` | TEXT    | ISO timestamps, bound in JS                               |
| `is_deleted`                | INTEGER | 0/1 soft-delete — hard-deleted by the retention sweep     |
| `folder_id`                 | TEXT    | FK to `writer_folders`; NULL = Unfiled                    |
| `pinned`                    | INTEGER | 0/1 pin within folder                                     |
| `share_token`               | TEXT    | Shares republish token (set on publish)                   |
| `share_url`                 | TEXT    | published Shares URL                                      |
| `mc_item_id`                | TEXT    | linked Mission Control item id                            |
| `linked_kms_note_id`        | TEXT    | linked KMS note id (vault export bridge)                  |
| `linked_kms_vault_id`       | TEXT    | linked KMS vault id                                       |
| `external_url`              | TEXT    | URL of the linked external document (e.g. Google Doc)     |
| `external_provider`         | TEXT    | provider name for the external link (e.g. `google-docs`)  |
| `external_id`               | TEXT    | provider-specific document id                             |
| `source_session_id`         | TEXT    | id of the Claude session this document was created from   |

**Documents are global per install** — the table has no `account_id` column. Every
document is visible regardless of which Anthropic account is currently active.

Every READ query filters `AND is_deleted = 0`. Delete flips `is_deleted = 1`; a
toast action can restore it (`is_deleted = 0`) within the standard Undo window.

**A deleted document is destroyed for good once the retention window passes.**
The daily retention tick calls `purgeDeletedWriterDocuments(db, retentionDays)`
(`src/main/db/queries-writer.ts`), which runs
`DELETE FROM writer_documents WHERE is_deleted = 1 AND updated_at < ?`. Every
version of the document goes with it
(`purgeDeletedWriterDocumentVersions`, the same rule on `created_at`), so a
deleted draft and its history become unrecoverable at the cutoff —
`dataRetentionDays`, 30 days by default. The Undo window is the only recovery
path; after it closes, do not treat a deleted document as still being in the
database.

### Data model — `writer_templates`

Created by migration
`src/main/db/migrations/20260903023722-create-writer-templates-table.ts`.

| Column                      | Type    | Notes                                                   |
| --------------------------- | ------- | ------------------------------------------------------- |
| `id`                        | TEXT PK | `randomUUID()`                                          |
| `label`                     | TEXT    | display name (Zod: trimmed, 1–200 chars)                |
| `description`               | TEXT    | optional description (Zod: max 500 chars, default `''`) |
| `title`                     | TEXT    | seeded document title (Zod: trimmed, 1–500 chars)       |
| `markdown`                  | TEXT    | seeded document content (Zod: 1–500k chars)             |
| `goal`                      | TEXT    | writing guidance goal (nullable)                        |
| `audience`                  | TEXT    | writing guidance audience (nullable)                    |
| `tone`                      | TEXT    | writing guidance tone (nullable)                        |
| `is_deleted`                | INTEGER | 0/1 soft-delete                                         |
| `created_at` / `updated_at` | TEXT    | ISO timestamps, bound in JS                             |

**Templates are global per install** — no `account_id` column. Same soft-delete
pattern as documents. Query layer: `src/main/db/queries-writer-templates.ts`.

### Content format

Content is always stored as **markdown**. The Tiptap editor uses the
`@tiptap/markdown` extension: `getMarkdown()` serialises on save;
`setContent(md, { contentType: 'markdown' })` hydrates on open. Never store or
persist HTML.

## Related

The surfaces that frame these controls — the document-list view modes, search, folders, version history and autosave — are on the [Writer Studio (standalone writing editor)](ai-writer.md) page. The AI capabilities that read this document and its guidance are in [part 3](ai-writer-part-3.md), sharing and integrations in [part 4](ai-writer-part-4.md), and the IPC reference in [part 5](ai-writer-part-5.md).
