From d5ec2888f8c5f6d6d342926b137cb76dca9f41f5 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:03:03 +0200 Subject: [PATCH 1/8] chore: mark spec 002 markdown-toolbar-actions as in progress --- specs/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/INDEX.md b/specs/INDEX.md index d08cdb1..ba4908d 100644 --- a/specs/INDEX.md +++ b/specs/INDEX.md @@ -3,5 +3,5 @@ | ID | Spec-Folder | Name | Areas | Description | GitHub Issue | Status | |-----|-------------|------|-------|-------------|--------------|--------| | 001 | 001-markdown-schema-roundtrip | Markdown schema round-trip | frontend, api, testing | Stop destroying unsupported Markdown in MarkdownEditor/MarkdownView by teaching the schema everything Markdown can express | — | done | -| 002 | 002-markdown-toolbar-actions | Markdown toolbar actions | frontend, api | Compose the MarkdownEditor toolbar per usage via an explicit action allowlist | — | open | +| 002 | 002-markdown-toolbar-actions | Markdown toolbar actions | frontend, api | Compose the MarkdownEditor toolbar per usage via an explicit action allowlist | — | in progress | | 003 | 003-markdown-view-checkboxes | Markdown view checkboxes | frontend, api | Tick task list checkboxes directly in MarkdownView with optimistic update and rollback | — | open | From a1879f8604d0adeb7cc65019724288a9306e52a6 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:03:47 +0200 Subject: [PATCH 2/8] doc: add implementation steps for spec 002 markdown-toolbar-actions --- specs/002-markdown-toolbar-actions/steps.md | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 specs/002-markdown-toolbar-actions/steps.md diff --git a/specs/002-markdown-toolbar-actions/steps.md b/specs/002-markdown-toolbar-actions/steps.md new file mode 100644 index 0000000..6228abb --- /dev/null +++ b/specs/002-markdown-toolbar-actions/steps.md @@ -0,0 +1,131 @@ +# Implementation Steps: Markdown toolbar actions + +## Step 1: Public type and prop + +- [ ] Add `MarkdownToolbarAction` union type to `src/types/index.ts` (14 actions per design) +- [ ] Add `readonly toolbar?: readonly MarkdownToolbarAction[]` to `MarkdownEditorProps` (default documented as `["bold", "italic"]`) +- [ ] Export `MarkdownToolbarAction` from `src/index.ts` +- [ ] Leave `MarkdownViewProps` untouched + +**Acceptance criteria:** +- [ ] `pnpm typecheck` passes + +**Related behaviors:** Unlink cannot be declared on its own; MarkdownView is unaffected + +--- + +## Step 2: Conditional task-list gate in the factory + +- [ ] Add `readonly allowedActions?: readonly MarkdownToolbarAction[]` to `MarkdownExtensionsOptions` +- [ ] Compute `taskListAllowed = (allowedActions ?? []).includes("taskList")` +- [ ] Keep the `tight` attribute on `TaskList` in both cases (round-trip must survive) +- [ ] When not allowed: strip `addKeyboardShortcuts` on `TaskList` and `addInputRules` on `TaskItem` (as spec 001 did) +- [ ] When allowed: keep the default `Mod-Shift-9` shortcut and `[ ] ` input rule +- [ ] Keep `TaskItem` `nested: true` and the styling classes in both cases + +**Acceptance criteria:** +- [ ] `pnpm typecheck` and `pnpm build` pass +- [ ] Spec 001 round-trip tests still pass unchanged + +**Related behaviors:** Without `taskList`, the keyboard shortcut/input rule does nothing; With `taskList`, they work; Existing task lists remain editable/round-trip without the action + +--- + +## Step 3: Toolbar rendering from the allowlist + +- [ ] Replace the hardcoded `Toolbar` with an `ACTIONS` record mapping each `MarkdownToolbarAction` to `{ icon, label, isActive, run }` +- [ ] Render one button per action in array order, de-duplicated (first occurrence wins) +- [ ] Give each button both `aria-label` and `title` with the same English string +- [ ] `"link"` keeps the `window.prompt` flow and the contextual Unlink button +- [ ] Render no toolbar element at all when the resolved action list is empty +- [ ] Default the `toolbar` prop to `["bold", "italic"]` and pass it to the factory as `allowedActions` +- [ ] Use lucide icons: Bold, Italic, Strikethrough, Code, Link, Heading1/2/3, List, ListOrdered, ListChecks, Quote, SquareCode, Minus + +**Acceptance criteria:** +- [ ] `pnpm typecheck`, `pnpm build`, `pnpm lint` pass + +**Related behaviors:** all "Rendering the declared actions" and "Executing actions" scenarios; Every button has an accessible name + +--- + +## Step 4: Factory / extension gate tests + +- [ ] Extend `src/lib/__tests__/markdown-extensions.test.ts` +- [ ] Without `taskList`: `Mod-Shift-9` and `[ ] ` create nothing (reuse spec 001 helpers) +- [ ] With `allowedActions: ["taskList"]`: `Mod-Shift-9` and `[ ] ` create a task item +- [ ] With gating: an existing `- [x] Done` still splits on Enter and round-trips unchanged + +**Acceptance criteria:** +- [ ] `pnpm test` passes + +**Related behaviors:** Task list creation gate (all five scenarios) + +--- + +## Step 5: Component behaviour tests + +- [ ] Extend `src/components/__tests__/markdown-editor.test.tsx` +- [ ] Rendering: exactly-declared; order; default (bold+italic only); empty array → no toolbar element; duplicate → one button +- [ ] Executing: H2 transforms block and reports `## Title`; taskList wraps to `- [ ] Call Anna`; active state on bold; Link shows contextual Unlink that disappears on leaving +- [ ] Marks gated at button only: `Mod-b` bolds without the Bold button; typing `**bold**` bolds without the button +- [ ] Accessibility: with all 14 actions, every button has matching `aria-label` + `title` and is findable by accessible name +- [ ] Rendering independent: with `toolbar={["bold"]}`, a loaded heading/blockquote/task list still render as structure + +**Acceptance criteria:** +- [ ] `pnpm test` passes + +**Related behaviors:** Rendering; Executing; Marks are gated at the button only; Accessibility; Undeclared constructs still render + +--- + +## Step 6: Type-level guarantees + +- [ ] Add a type test (e.g. `src/types/__tests__/markdown-toolbar-action.test-d.ts` or `@ts-expect-error` in a `.test.ts`) that `toolbar={["unlink"]}` does not typecheck +- [ ] Assert `MarkdownViewProps` shape is unchanged (structural type assertion) + +**Acceptance criteria:** +- [ ] `pnpm typecheck` passes (the `@ts-expect-error` is satisfied) + +**Related behaviors:** Unlink cannot be declared on its own; MarkdownView is unaffected + +--- + +## Step 7: Documentation + +- [ ] Create `docs/upgrade-to-0.11.md` — breaking change: default toolbar drops to `["bold", "italic"]`; each existing usage must declare the actions it needs; content still renders/round-trips +- [ ] Update `README.md` MarkdownEditor entry to mention the configurable `toolbar` prop + +**Acceptance criteria:** +- [ ] `pnpm build`, `pnpm test`, `pnpm lint`, `pnpm typecheck` all pass + +**Related behaviors:** none (documentation) + +--- + +## Behavior Coverage + +| Scenario | Layer | Covered in Step | +|----------|-------|-----------------| +| The toolbar renders exactly what was declared | Frontend | 5 | +| Order follows the array | Frontend | 5 | +| Omitting the prop yields the default | Frontend | 5 | +| An empty array renders no toolbar | Frontend | 5 | +| A duplicate entry renders once | Frontend | 5 | +| A block action transforms the current block | Frontend | 5 | +| A list action wraps the current block | Frontend | 5 | +| An active action is marked as active | Frontend | 5 | +| Link keeps its contextual Unlink button | Frontend | 5 | +| Unlink cannot be declared on its own | Type | 6 | +| Without `taskList`, the keyboard shortcut does nothing | Frontend | 4 | +| Without `taskList`, the input rule does nothing | Frontend | 4 | +| With `taskList`, the keyboard shortcut works | Frontend | 4 | +| With `taskList`, the input rule works | Frontend | 4 | +| Existing task lists remain editable without the action | Frontend | 4 | +| Existing task lists still round-trip without the action | Frontend | 4 | +| Bold stays reachable by shortcut without the button | Frontend | 5 | +| Bold stays reachable by typing without the button | Frontend | 5 | +| Every button has an accessible name | Frontend | 5 | +| Undeclared constructs still render | Frontend | 5 | +| MarkdownView is unaffected | Type | 6 | + +Every scenario is assigned. Type-level scenarios (Unlink, MarkdownView) are verified by the typecheck step. From d4f6c58ab4c5d45e61de7a1e87bc054ccd685ae1 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:06:49 +0200 Subject: [PATCH 3/8] feat: per-usage MarkdownEditor toolbar via action allowlist Add a MarkdownToolbarAction union and a toolbar prop (default ["bold", "italic"]) that declares, in order, which actions a usage offers. The toolbar renders from a single ACTIONS record mapping each action to icon/label/predicate/command, de-duplicated and order-preserving, with an aria-label plus title on every icon button and no toolbar element at all for an empty list. Link keeps its window.prompt flow and contextual Unlink. Gate task-list creation on the allowlist in createMarkdownExtensions: the Mod-Shift-9 shortcut and [ ] input rule open only when "taskList" is offered and are stripped otherwise. The tight attribute and in-list editing shortcuts are kept unconditionally so stored lists always round-trip and stay editable. No schema or MarkdownView changes. BREAKING CHANGE: the default toolbar drops from Bold/Italic/Strike/Link to ["bold", "italic"]; usages must declare Strike/Link explicitly. Refs spec 002-markdown-toolbar-actions --- src/components/markdown-editor.tsx | 238 ++++++++++++++++++++++------- src/index.ts | 1 + src/lib/markdown-extensions.ts | 29 +++- src/types/index.ts | 23 +++ 4 files changed, 226 insertions(+), 65 deletions(-) diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx index d66bba4..77ffe00 100644 --- a/src/components/markdown-editor.tsx +++ b/src/components/markdown-editor.tsx @@ -1,98 +1,218 @@ "use client"; import { useEditor, EditorContent, type Editor } from "@tiptap/react"; -import { Bold, Italic, Strikethrough, Link as LinkIcon, Unlink } from "lucide-react"; -import { useEffect, useCallback } from "react"; +import { + Bold, + Italic, + Strikethrough, + Code, + Link as LinkIcon, + Unlink, + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + ListChecks, + Quote, + SquareCode, + Minus, + type LucideIcon, +} from "lucide-react"; +import { useEffect } from "react"; import { cn } from "../lib/utils.ts"; import { createMarkdownExtensions } from "../lib/markdown-extensions.ts"; -import type { MarkdownEditorProps } from "../types/index.ts"; +import type { MarkdownEditorProps, MarkdownToolbarAction } from "../types/index.ts"; + +const DEFAULT_TOOLBAR: readonly MarkdownToolbarAction[] = ["bold", "italic"]; + +/** Opens a prompt to set, change or clear the link on the current selection. */ +function editLink(editor: Editor): void { + const previousUrl = (editor.getAttributes("link").href as string | undefined) ?? ""; + const url = window.prompt("URL", previousUrl); + if (url === null) return; + if (url === "") { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + return; + } + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); +} + +interface ActionSpec { + readonly icon: LucideIcon; + readonly label: string; + readonly isActive: (editor: Editor) => boolean; + readonly run: (editor: Editor) => void; +} + +/** + * Single mapping from a toolbar action to its icon, accessible label, active-state + * predicate and command. Adding an action later touches only this record. + */ +const ACTIONS: Record = { + bold: { + icon: Bold, + label: "Bold", + isActive: (e) => e.isActive("bold"), + run: (e) => e.chain().focus().toggleBold().run(), + }, + italic: { + icon: Italic, + label: "Italic", + isActive: (e) => e.isActive("italic"), + run: (e) => e.chain().focus().toggleItalic().run(), + }, + strike: { + icon: Strikethrough, + label: "Strikethrough", + isActive: (e) => e.isActive("strike"), + run: (e) => e.chain().focus().toggleStrike().run(), + }, + code: { + icon: Code, + label: "Code", + isActive: (e) => e.isActive("code"), + run: (e) => e.chain().focus().toggleCode().run(), + }, + link: { + icon: LinkIcon, + label: "Link", + isActive: (e) => e.isActive("link"), + run: editLink, + }, + h1: { + icon: Heading1, + label: "Heading 1", + isActive: (e) => e.isActive("heading", { level: 1 }), + run: (e) => e.chain().focus().toggleHeading({ level: 1 }).run(), + }, + h2: { + icon: Heading2, + label: "Heading 2", + isActive: (e) => e.isActive("heading", { level: 2 }), + run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(), + }, + h3: { + icon: Heading3, + label: "Heading 3", + isActive: (e) => e.isActive("heading", { level: 3 }), + run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(), + }, + bulletList: { + icon: List, + label: "Bullet list", + isActive: (e) => e.isActive("bulletList"), + run: (e) => e.chain().focus().toggleBulletList().run(), + }, + orderedList: { + icon: ListOrdered, + label: "Numbered list", + isActive: (e) => e.isActive("orderedList"), + run: (e) => e.chain().focus().toggleOrderedList().run(), + }, + taskList: { + icon: ListChecks, + label: "Task list", + isActive: (e) => e.isActive("taskList"), + run: (e) => e.chain().focus().toggleTaskList().run(), + }, + blockquote: { + icon: Quote, + label: "Blockquote", + isActive: (e) => e.isActive("blockquote"), + run: (e) => e.chain().focus().toggleBlockquote().run(), + }, + codeBlock: { + icon: SquareCode, + label: "Code block", + isActive: (e) => e.isActive("codeBlock"), + run: (e) => e.chain().focus().toggleCodeBlock().run(), + }, + horizontalRule: { + icon: Minus, + label: "Horizontal rule", + isActive: () => false, + run: (e) => e.chain().focus().setHorizontalRule().run(), + }, +}; function ToolbarButton({ active, - disabled, onClick, - children, - title, + icon: Icon, + label, }: { readonly active?: boolean; - readonly disabled?: boolean; readonly onClick: () => void; - readonly children: React.ReactNode; - readonly title: string; + readonly icon: LucideIcon; + readonly label: string; }) { return ( ); } -function Toolbar({ editor }: { readonly editor: Editor | null }) { - const setLink = useCallback(() => { - if (!editor) return; - const previousUrl = editor.getAttributes("link").href ?? ""; - const url = window.prompt("URL", previousUrl); - if (url === null) return; - if (url === "") { - editor.chain().focus().extendMarkRange("link").unsetLink().run(); - return; - } - editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); - }, [editor]); - +function Toolbar({ + editor, + actions, +}: { + readonly editor: Editor | null; + readonly actions: readonly MarkdownToolbarAction[]; +}) { if (!editor) return null; + // Preserve declaration order, drop duplicates (first occurrence wins). + const uniqueActions = actions.filter((action, index) => actions.indexOf(action) === index); + if (uniqueActions.length === 0) return null; + return (
- editor.chain().focus().toggleBold().run()} - > - - - editor.chain().focus().toggleItalic().run()} - > - - - editor.chain().focus().toggleStrike().run()} - > - - -
- - - - {editor.isActive("link") && ( - editor.chain().focus().unsetLink().run()}> - - - )} + {uniqueActions.map((action) => { + const spec = ACTIONS[action]; + return ( + + spec.run(editor)} + /> + {action === "link" && editor.isActive("link") && ( + editor.chain().focus().unsetLink().run()} + /> + )} + + ); + })}
); } -export function MarkdownEditor({ value, onChange, placeholder }: MarkdownEditorProps) { +export function MarkdownEditor({ value, onChange, placeholder, toolbar }: MarkdownEditorProps) { + const actions = toolbar ?? DEFAULT_TOOLBAR; + const editor = useEditor({ - extensions: createMarkdownExtensions({ placeholder, openLinksOnClick: false }), + extensions: createMarkdownExtensions({ + placeholder, + openLinksOnClick: false, + allowedActions: actions, + }), content: value, immediatelyRender: false, onUpdate: ({ editor: ed }) => { @@ -115,7 +235,7 @@ export function MarkdownEditor({ value, onChange, placeholder }: MarkdownEditorP return (
- +
); diff --git a/src/index.ts b/src/index.ts index c204aac..2954219 100644 --- a/src/index.ts +++ b/src/index.ts @@ -190,6 +190,7 @@ export type { UserMultiSelectTranslations, MarkdownEditorProps, MarkdownViewProps, + MarkdownToolbarAction, } from "./types/index.ts"; // Utilities diff --git a/src/lib/markdown-extensions.ts b/src/lib/markdown-extensions.ts index 9959ea5..185bd26 100644 --- a/src/lib/markdown-extensions.ts +++ b/src/lib/markdown-extensions.ts @@ -3,16 +3,24 @@ import StarterKit from "@tiptap/starter-kit"; import { TaskList, TaskItem } from "@tiptap/extension-list"; import Placeholder from "@tiptap/extension-placeholder"; import { Markdown } from "tiptap-markdown"; +import type { MarkdownToolbarAction } from "../types/index.ts"; /** - * Options for {@link createMarkdownExtensions}. The two fields capture the only - * genuine differences between the editor and the read-only view. + * Options for {@link createMarkdownExtensions}. */ export interface MarkdownExtensionsOptions { /** Placeholder text shown while the document is empty. Editor only. */ readonly placeholder?: string; /** Whether clicking a link opens it. `true` in the view, `false` in the editor. */ readonly openLinksOnClick?: boolean; + /** + * Actions the user may *create*. Only `"taskList"` is honoured here — its + * keyboard shortcut and input rule are opened when it is present and stripped + * when it is not. All other actions are gated at the toolbar button only, so + * they need no schema-level handling. Omitted or empty → task-list creation + * stays closed. This never affects what the schema can parse or serialize. + */ + readonly allowedActions?: readonly MarkdownToolbarAction[]; } /** @@ -34,15 +42,16 @@ export interface MarkdownExtensionsOptions { */ export function createMarkdownExtensions(options?: MarkdownExtensionsOptions): Extensions { const openLinksOnClick = options?.openLinksOnClick ?? false; + const taskListAllowed = (options?.allowedActions ?? []).includes("taskList"); - // Strip the creation paths while keeping the in-list editing shortcuts. // The `tight` attribute makes the Markdown serializer render task lists // tightly (no blank line between items), matching how tiptap-markdown already // treats bullet and ordered lists. Without it, prosemirror-markdown falls back // to a loose list and a multi-item checklist no longer round-trips byte-for-byte. // `rendered: false` keeps the attribute out of the DOM so it is not persisted. - const TaskListNode = TaskList.extend({ - addKeyboardShortcuts: () => ({}), + // It is applied whether or not task-list *creation* is allowed, because a + // stored task list must always round-trip. + const TaskListWithTight = TaskList.extend({ addAttributes() { return { ...this.parent?.(), @@ -50,7 +59,15 @@ export function createMarkdownExtensions(options?: MarkdownExtensionsOptions): E }; }, }); - const TaskItemNode = TaskItem.extend({ addInputRules: () => [] }); + + // Gate task-list *creation* on the allowlist: when it is not offered, strip the + // `Mod-Shift-9` shortcut and the `[ ] ` input rule so a checklist cannot be + // created by any means. In-list editing shortcuts (Enter/Tab/Shift-Tab) are + // always kept so a stored list stays editable. + const TaskListNode = taskListAllowed + ? TaskListWithTight + : TaskListWithTight.extend({ addKeyboardShortcuts: () => ({}) }); + const TaskItemNode = taskListAllowed ? TaskItem : TaskItem.extend({ addInputRules: () => [] }); return [ StarterKit.configure({ diff --git a/src/types/index.ts b/src/types/index.ts index f198da1..01302d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -54,10 +54,33 @@ export interface UserMultiSelectProps { readonly translations: UserMultiSelectTranslations; } +/** + * A single action offered in the {@link MarkdownEditorProps.toolbar}. Names follow + * the underlying TipTap node/mark names so the mapping to commands is obvious. + * Unlink is deliberately absent — it is rendered contextually as part of `"link"`. + */ +export type MarkdownToolbarAction = + | "bold" + | "italic" + | "strike" + | "code" + | "link" + | "h1" + | "h2" + | "h3" + | "bulletList" + | "orderedList" + | "taskList" + | "blockquote" + | "codeBlock" + | "horizontalRule"; + export interface MarkdownEditorProps { readonly value: string; readonly onChange: (value: string) => void; readonly placeholder?: string; + /** Actions offered in the toolbar, in render order. Defaults to `["bold", "italic"]`. */ + readonly toolbar?: readonly MarkdownToolbarAction[]; } export interface MarkdownViewProps { From 43e8e0dcc5c78714ecf5274cd1a514d472a44e9d Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:09:26 +0200 Subject: [PATCH 4/8] test: cover conditional task-list creation gate in the factory Verify allowedActions drives the gate: creation stays closed (shortcut + input rule) when taskList is absent, both open when it is present, and a stored task list stays editable and round-trips regardless. Also fix the Mod-Shift-9 dispatch (jsdom is non-Mac, so Mod=Ctrl; sending metaKey too never matched the binding, making the existing 'shortcut does nothing' test a false pass). Refs spec 002-markdown-toolbar-actions --- src/lib/__tests__/markdown-extensions.test.ts | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/markdown-extensions.test.ts b/src/lib/__tests__/markdown-extensions.test.ts index afb9810..34bc5cb 100644 --- a/src/lib/__tests__/markdown-extensions.test.ts +++ b/src/lib/__tests__/markdown-extensions.test.ts @@ -152,11 +152,20 @@ function hasTaskList(editor: Editor): boolean { return (editor.getJSON().content ?? []).some((node) => node.type === "taskList"); } +/** + * Dispatch the task-list creation shortcut (`Mod-Shift-9`). jsdom is treated as a + * non-Mac platform by prosemirror-keymap, so `Mod` resolves to Ctrl — sending + * both metaKey and ctrlKey would fail to match the binding. + */ +function pressTaskListShortcut(editor: Editor): void { + editor.commands.focus(); + pressKey(editor, { key: "9", code: "Digit9", ctrlKey: true, shiftKey: true }); +} + describe("createMarkdownExtensions — task list creation stays closed", () => { it("does nothing when Mod-Shift-9 is pressed", () => { const editor = mountEditor("

"); - editor.commands.focus(); - pressKey(editor, { key: "9", code: "Digit9", metaKey: true, ctrlKey: true, shiftKey: true }); + pressTaskListShortcut(editor); expect(hasTaskList(editor)).toBe(false); }); @@ -198,3 +207,51 @@ describe("createMarkdownExtensions — editing an existing task list", () => { expect(editor.storage.markdown.getMarkdown()).toBe("- [ ] parent\n- [ ] child"); }); }); + +describe("createMarkdownExtensions — task list creation gate follows allowedActions", () => { + it("keeps creation closed when taskList is not among the allowed actions", () => { + const shortcutEditor = mountEditor( + "

", + createMarkdownExtensions({ allowedActions: ["bold", "italic"] }), + ); + pressTaskListShortcut(shortcutEditor); + expect(hasTaskList(shortcutEditor)).toBe(false); + + const ruleEditor = mountEditor( + "

", + createMarkdownExtensions({ allowedActions: ["bold", "italic"] }), + ); + typeSpaceAfterBracket(ruleEditor); + expect(hasTaskList(ruleEditor)).toBe(false); + }); + + it("opens the keyboard shortcut when taskList is allowed", () => { + const editor = mountEditor("

", createMarkdownExtensions({ allowedActions: ["taskList"] })); + pressTaskListShortcut(editor); + expect(hasTaskList(editor)).toBe(true); + }); + + it("opens the '[ ] ' input rule when taskList is allowed", () => { + const editor = mountEditor("

", createMarkdownExtensions({ allowedActions: ["taskList"] })); + typeSpaceAfterBracket(editor); + expect(hasTaskList(editor)).toBe(true); + }); + + it("keeps a stored task list editable even when creation is gated", () => { + const editor = mountEditor("- [x] Done", createMarkdownExtensions({ allowedActions: [] })); + editor.commands.focus("end"); + pressKey(editor, { key: "Enter", code: "Enter" }); + const md = editor.storage.markdown.getMarkdown(); + expect(md).toContain("- [x] Done"); + expect(md.split("\n").some((line) => line.startsWith("- [ ]"))).toBe(true); + }); + + it("round-trips a stored task list unchanged when creation is gated", () => { + const editor = new Editor({ + extensions: createMarkdownExtensions({ allowedActions: [] }), + content: "- [x] Done", + }); + editors.push(editor); + expect(editor.storage.markdown.getMarkdown()).toBe("- [x] Done"); + }); +}); From f010551a624b33bc69db7fdfd5e00ff06f9e9a6e Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:22:58 +0200 Subject: [PATCH 5/8] test: cover toolbar rendering, execution, a11y and mark-gating; fix live toolbar updates Add component tests for the toolbar: exact/ordered/default/empty/duplicate rendering, block+list execution reporting Markdown, active-state, the contextual Unlink appearing and disappearing with the cursor, accessible names on all 14 buttons, and undeclared constructs still rendering. Add extension-level tests that marks (Mod-b, **bold**) stay reachable when only italic is offered, and type-level tests that 'unlink' is not a declarable action and MarkdownViewProps is unchanged. Two fixes surfaced by the tests: - Set shouldRerenderOnTransaction: true so the toolbar's active states and contextual Unlink track the cursor; TipTap v3 defaults it to false, which would freeze the toolbar as the user moves the caret. - Stub getClientRects/getBoundingClientRect on Node/Range in the test setup so ProseMirror's scrollIntoView does not crash under jsdom (which has no layout), plus aria-pressed on toggle buttons for an accessible active state. Refs spec 002-markdown-toolbar-actions --- .../__tests__/markdown-editor.test.tsx | 178 ++++++++++++++++-- src/components/markdown-editor.tsx | 5 + src/lib/__tests__/markdown-extensions.test.ts | 27 +++ src/test/setup.ts | 29 +++ .../__tests__/markdown-toolbar-action.test.ts | 38 ++++ 5 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 src/types/__tests__/markdown-toolbar-action.test.ts diff --git a/src/components/__tests__/markdown-editor.test.tsx b/src/components/__tests__/markdown-editor.test.tsx index a6c140f..3b76fa3 100644 --- a/src/components/__tests__/markdown-editor.test.tsx +++ b/src/components/__tests__/markdown-editor.test.tsx @@ -1,10 +1,24 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, cleanup, waitFor } from "@testing-library/react"; +import { render, cleanup, waitFor, within } from "@testing-library/react"; import type { Editor } from "@tiptap/core"; +import type { MarkdownToolbarAction } from "../../types/index.ts"; import { MarkdownEditor } from "../markdown-editor.tsx"; afterEach(cleanup); +/** Accessible names of the toolbar buttons, in DOM order. */ +function toolbarButtonNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll("button[aria-label]")).map( + (b) => b.getAttribute("aria-label") ?? "", + ); +} + +function dispatchKey(editor: Editor, init: KeyboardEventInit): void { + editor.view.dom.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }), + ); +} + /** The rendered ProseMirror element carries a back-reference to the live editor. */ function getEditor(container: HTMLElement): Editor { const dom = container.querySelector(".ProseMirror") as (HTMLElement & { editor?: Editor }) | null; @@ -63,29 +77,163 @@ describe("MarkdownEditor — no corruption on open", () => { }); }); -describe("MarkdownEditor — toolbar", () => { - it("offers exactly Bold, Italic, Strikethrough and Link by default", async () => { +describe("MarkdownEditor — toolbar rendering", () => { + it("renders exactly the declared actions and nothing else", async () => { + const { container } = render( + {}} toolbar={["bold", "taskList", "link"]} />, + ); + await waitForEditor(container); + expect(toolbarButtonNames(container)).toEqual(["Bold", "Task list", "Link"]); + }); + + it("renders the buttons in the order of the array", async () => { + const { container } = render( + {}} toolbar={["link", "bold"]} />, + ); + await waitForEditor(container); + expect(toolbarButtonNames(container)).toEqual(["Link", "Bold"]); + }); + + it("defaults to exactly Bold and Italic when the prop is omitted", async () => { const { container } = render( {}} />); await waitForEditor(container); - const titles = Array.from(container.querySelectorAll("button[title]")).map((b) => - b.getAttribute("title"), + expect(toolbarButtonNames(container)).toEqual(["Bold", "Italic"]); + }); + + it("renders no toolbar element for an empty array", async () => { + const { container } = render( {}} toolbar={[]} />); + await waitForEditor(container); + expect(container.querySelectorAll("button")).toHaveLength(0); + expect(container.querySelector(".border-b")).toBeNull(); + }); + + it("renders a duplicated action only once", async () => { + const { container } = render( + {}} toolbar={["bold", "bold"]} />, + ); + await waitForEditor(container); + expect(toolbarButtonNames(container)).toEqual(["Bold"]); + }); +}); + +describe("MarkdownEditor — executing actions", () => { + it("transforms the current block with a heading action", async () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const editor = await waitForEditor(container); + editor.commands.focus("end"); + within(container).getByRole("button", { name: "Heading 2" }).click(); + await waitFor(() => expect(onChange.mock.calls.at(-1)?.[0]).toBe("## Title")); + }); + + it("wraps the current block with a list action", async () => { + const onChange = vi.fn(); + const { container } = render( + , ); - expect(titles).toEqual(["Bold", "Italic", "Strikethrough", "Link"]); - expect(titles).not.toContain("Remove link"); + const editor = await waitForEditor(container); + editor.commands.focus("end"); + within(container).getByRole("button", { name: "Task list" }).click(); + await waitFor(() => expect(onChange.mock.calls.at(-1)?.[0]).toBe("- [ ] Call Anna")); }); - it("adds Unlink only while the cursor sits in a link", async () => { + it("marks an active action as pressed", async () => { const { container } = render( - {}} />, + {}} toolbar={["bold"]} />, ); const editor = await waitForEditor(container); - // Move the selection into the link text. - editor.chain().focus().setTextSelection(3).run(); + // Set the selection without focus(): focus() scrolls into view, which needs + // getClientRects — unavailable in jsdom. The toolbar still re-renders on the + // selection transaction. + editor.commands.setTextSelection(3); await waitFor(() => { - const titles = Array.from(container.querySelectorAll("button[title]")).map((b) => - b.getAttribute("title"), - ); - expect(titles).toContain("Remove link"); + const bold = within(container).getByRole("button", { name: "Bold" }); + expect(bold.getAttribute("aria-pressed")).toBe("true"); }); }); + + it("shows a contextual Unlink button only while the cursor is in a link", async () => { + const { container } = render( + {}} + toolbar={["link"]} + />, + ); + const editor = await waitForEditor(container); + + editor.commands.setTextSelection(3); + await waitFor(() => expect(toolbarButtonNames(container)).toContain("Remove link")); + + // Move the cursor out of the link (to the end of the plain text). + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + await waitFor(() => expect(toolbarButtonNames(container)).not.toContain("Remove link")); + }); +}); + +describe("MarkdownEditor — marks are gated at the button only", () => { + it("still bolds via Mod-b when the Bold button is absent", async () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + const editor = await waitForEditor(container); + expect(toolbarButtonNames(container)).toEqual(["Italic"]); + editor.commands.selectAll(); + dispatchKey(editor, { key: "b", code: "KeyB", ctrlKey: true }); + await waitFor(() => expect(onChange.mock.calls.at(-1)?.[0]).toBe("**hello**")); + }); + + // The typing/input-rule variant is verified at the extension level + // (markdown-extensions.test.ts) where literal content can be injected without + // the component's markdown parsing rewriting the incomplete `**bold*` on load. +}); + +describe("MarkdownEditor — accessibility", () => { + it("gives every button a matching aria-label and title", async () => { + const ALL: readonly MarkdownToolbarAction[] = [ + "bold", + "italic", + "strike", + "code", + "link", + "h1", + "h2", + "h3", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + "horizontalRule", + ]; + const { container } = render( {}} toolbar={ALL} />); + await waitForEditor(container); + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons).toHaveLength(ALL.length); + for (const button of buttons) { + const label = button.getAttribute("aria-label"); + expect(label).toBeTruthy(); + expect(button.getAttribute("title")).toBe(label); + expect(within(container).getByRole("button", { name: label as string })).toBe(button); + } + }); +}); + +describe("MarkdownEditor — rendering is independent of the toolbar", () => { + it("renders undeclared constructs as structure", async () => { + const { container } = render( + quote\n\n- [ ] task"} + onChange={() => {}} + toolbar={["bold"]} + />, + ); + await waitForEditor(container); + expect(container.querySelector("h1")).toBeTruthy(); + expect(container.querySelector("blockquote")).toBeTruthy(); + expect(container.querySelector("input[type=checkbox]")).toBeTruthy(); + }); }); diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx index 77ffe00..f8bc360 100644 --- a/src/components/markdown-editor.tsx +++ b/src/components/markdown-editor.tsx @@ -152,6 +152,7 @@ function ToolbarButton({ type="button" title={label} aria-label={label} + aria-pressed={active} onClick={onClick} className={cn( "rounded p-1.5 transition-colors", @@ -215,6 +216,10 @@ export function MarkdownEditor({ value, onChange, placeholder, toolbar }: Markdo }), content: value, immediatelyRender: false, + // Re-render the toolbar on every transaction so active states and the + // contextual Unlink button track the cursor. TipTap v3 defaults this to + // false, which would freeze the toolbar's active styling as the user moves. + shouldRerenderOnTransaction: true, onUpdate: ({ editor: ed }) => { onChange(ed.storage.markdown.getMarkdown()); }, diff --git a/src/lib/__tests__/markdown-extensions.test.ts b/src/lib/__tests__/markdown-extensions.test.ts index 34bc5cb..7baf6d5 100644 --- a/src/lib/__tests__/markdown-extensions.test.ts +++ b/src/lib/__tests__/markdown-extensions.test.ts @@ -255,3 +255,30 @@ describe("createMarkdownExtensions — task list creation gate follows allowedAc expect(editor.storage.markdown.getMarkdown()).toBe("- [x] Done"); }); }); + +describe("createMarkdownExtensions — marks stay reachable regardless of allowedActions", () => { + it("applies bold via Mod-b even when only italic is offered", () => { + const editor = mountEditor( + "

hello

", + createMarkdownExtensions({ allowedActions: ["italic"] }), + ); + editor.commands.selectAll(); + pressKey(editor, { key: "b", code: "KeyB", ctrlKey: true }); + expect(editor.storage.markdown.getMarkdown()).toBe("**hello**"); + }); + + it("completes the **bold** input rule even when only italic is offered", () => { + // `

...

` is parsed as literal HTML text, so the incomplete `**bold*` + // survives to the document and the closing `*` triggers the bold input rule. + const editor = mountEditor( + "

**bold*

", + createMarkdownExtensions({ allowedActions: ["italic"] }), + ); + editor.commands.focus("end"); + const { from } = editor.state.selection; + editor.view.someProp("handleTextInput", (handler) => + handler(editor.view, from, from, "*", () => editor.state.tr), + ); + expect(editor.storage.markdown.getMarkdown()).toBe("**bold**"); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts index f149f27..975478e 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,30 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom does not implement layout. ProseMirror's scrollIntoView (triggered by +// focus/selection commands) reads client rects from DOM nodes — including Text +// nodes, which jsdom does not give getClientRects/getBoundingClientRect. Provide +// empty stubs so editor commands do not crash under jsdom. Purely additive: only +// methods that are missing are defined. +const emptyRect = (): DOMRect => ({ + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), +}); +const emptyRectList = (): DOMRectList => + Object.assign([] as DOMRect[], { item: () => null }) as unknown as DOMRectList; + +type Measurable = { + getClientRects?: () => DOMRectList; + getBoundingClientRect?: () => DOMRect; +}; +for (const proto of [Node.prototype, Range.prototype]) { + const measurable = proto as unknown as Measurable; + if (!measurable.getClientRects) measurable.getClientRects = emptyRectList; + if (!measurable.getBoundingClientRect) measurable.getBoundingClientRect = emptyRect; +} diff --git a/src/types/__tests__/markdown-toolbar-action.test.ts b/src/types/__tests__/markdown-toolbar-action.test.ts new file mode 100644 index 0000000..2a8b70f --- /dev/null +++ b/src/types/__tests__/markdown-toolbar-action.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import type { + MarkdownToolbarAction, + MarkdownEditorProps, + MarkdownViewProps, +} from "../index.ts"; + +/** + * These assertions are enforced by `tsc` (the runtime bodies are trivial). A + * regression in the types fails `pnpm typecheck`, not just this test run. + */ +describe("MarkdownToolbarAction", () => { + it("does not accept 'unlink' — Unlink is rendered as part of 'link'", () => { + const props: MarkdownEditorProps = { + value: "", + onChange: () => {}, + // @ts-expect-error "unlink" is not a MarkdownToolbarAction. + toolbar: ["unlink"], + }; + void props; + expect(true).toBe(true); + }); + + it("accepts the declared actions", () => { + const actions: readonly MarkdownToolbarAction[] = ["bold", "taskList", "horizontalRule"]; + expect(actions).toHaveLength(3); + }); +}); + +describe("MarkdownViewProps", () => { + it("is still exactly a readonly content string", () => { + type Expected = { readonly content: string }; + type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + // Fails to compile if MarkdownViewProps gains, loses or changes a field. + const unchanged: Equal = true; + expect(unchanged).toBe(true); + }); +}); From c35d9b59166d42c16bce985e74f3401db01205d1 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:24:22 +0200 Subject: [PATCH 6/8] doc: add 0.11.0 breaking upgrade guide and toolbar note in README Document the toolbar default change (Bold/Italic/Strike/Link -> [bold, italic]) as an agent-executable upgrade prompt, note the taskList creation gate and the new aria-labels, and mark all implementation steps complete. Refs spec 002-markdown-toolbar-actions --- README.md | 2 +- docs/upgrade-to-0.11.md | 89 +++++++++++++++++++++ specs/002-markdown-toolbar-actions/steps.md | 82 +++++++++---------- 3 files changed, 131 insertions(+), 42 deletions(-) create mode 100644 docs/upgrade-to-0.11.md diff --git a/README.md b/README.md index bfa588e..c692d15 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This package contains extracted UI components from the Open CRM frontend, design - **InputGroup** — Composite input with addons and buttons - **Combobox** — Searchable dropdown with chip support (based on Base UI) - **TagMultiSelect** — Multi-select tag picker with colored chips -- **MarkdownEditor** — WYSIWYG Markdown editor that round-trips all supported Markdown constructs without data loss +- **MarkdownEditor** — WYSIWYG Markdown editor that round-trips all supported Markdown constructs without data loss; toolbar actions are configurable per usage via the `toolbar` prop - **MarkdownView** — Read-only Markdown renderer with structural output (headings, lists, task lists, blockquotes, code) ## Usage diff --git a/docs/upgrade-to-0.11.md b/docs/upgrade-to-0.11.md new file mode 100644 index 0000000..77c5e2f --- /dev/null +++ b/docs/upgrade-to-0.11.md @@ -0,0 +1,89 @@ +# Upgrade prompt: `@open-elements/ui` 0.10.x → 0.11.0 (breaking) + +`@open-elements/ui` 0.11.0 makes the `MarkdownEditor` toolbar configurable per usage and **changes its default**. This is a **breaking change** for authoring, not for stored content. + +Until now every `MarkdownEditor` showed the same four buttons (Bold, Italic, Strikethrough, Link). 0.11.0 adds a `toolbar` prop — an ordered allowlist of actions — and the default drops to `["bold", "italic"]`. Every existing usage therefore **loses the Strikethrough and Link buttons** unless it declares them. + +Nothing about stored content changes: links, strikethrough and every other construct still render and still round-trip exactly as in 0.10.0. Only the authoring affordance (which buttons appear) is affected. The schema is untouched — this prop governs the toolbar, not what the document can hold. + +```ts +export type MarkdownToolbarAction = + | "bold" | "italic" | "strike" | "code" | "link" + | "h1" | "h2" | "h3" + | "bulletList" | "orderedList" | "taskList" + | "blockquote" | "codeBlock" | "horizontalRule"; + +// New optional prop; defaults to ["bold", "italic"]. + +``` + +Two smaller changes ride along: + +- Task-list creation is gated by the toolbar. Where `"taskList"` is **not** in the allowlist, the `Mod-Shift-9` shortcut and the `[ ] ` input rule do nothing, so a checklist cannot be created by any means. Stored checklists still render and stay editable. All other actions are gated at the button only — `Mod-b` and `**bold**` keep working even where the Bold button is absent. +- Every toolbar button now carries an `aria-label` (and `aria-pressed` for active state), so icon-only buttons are announced by screen readers. + +This file is a self-contained prompt for an agent (Claude Code, etc.) to run inside a consumer repo. Paste it verbatim. + +--- + +## Prompt + +You are working inside an app that depends on `@open-elements/ui`. Goal: upgrade to `^0.11.0`. This is a **breaking** change: the `MarkdownEditor` toolbar default changed, so each usage must declare the actions it needs. + +### What changed in 0.11.0 + +- **New `toolbar` prop** on `MarkdownEditor`: `readonly toolbar?: readonly MarkdownToolbarAction[]`. It is an ordered allowlist — the buttons render in array order, duplicates collapse, and an empty array renders no toolbar at all. +- **The default dropped** from Bold/Italic/Strikethrough/Link to `["bold", "italic"]`. Any usage that relied on the old default now shows only Bold and Italic. +- **`taskList` creation is gated by the toolbar**; other actions are gated at the button only. +- **No stored-content change.** Links, strikethrough, headings, lists, task lists, etc. still render and round-trip. This is purely about which buttons appear. +- **No change to `MarkdownView`.** + +### Steps + +1. **Find the consumer's frontend `package.json`**, bump `@open-elements/ui` to `^0.11.0`, and run: + + ```bash + pnpm install + ``` + +2. **Find every `MarkdownEditor` usage:** + + ```bash + grep -rn "MarkdownEditor" src app components 2>/dev/null + ``` + +3. **For each usage, decide the toolbar explicitly.** Do not blindly restore the old four buttons — this upgrade is the moment to make each field honest about what it offers. Guidance: + - A **tag / label description** field: keep it minimal, e.g. `toolbar={["bold", "italic"]}` (the new default — you can omit the prop) or `toolbar={[]}` for plain text. + - A **rich note / task description** field: declare what it needs, e.g. `toolbar={["bold", "italic", "strike", "link", "h2", "h3", "bulletList", "orderedList", "taskList"]}`. + - If a field previously relied on the Link or Strikethrough buttons, add `"link"` / `"strike"` back **explicitly** where they belong. + +4. **If a field needs checkboxes**, include `"taskList"` — otherwise users cannot create task lists there (stored ones still render and stay editable regardless). + +5. **Verify.** All three must pass: + + ```bash + pnpm exec tsc --noEmit + pnpm test + pnpm build + ``` + +6. **Commit** with a clear message: + + ``` + chore(deps): upgrade @open-elements/ui to 0.11.0 + + Declare an explicit toolbar on each MarkdownEditor usage; the default + dropped to ["bold", "italic"] in 0.11.0. + ``` + +### Guard rails + +- **Do not** add a global wrapper that re-injects the old four-button default everywhere — that defeats the point of the change. Decide per field. +- **Do not** try to gate marks (Bold/Italic/…) beyond hiding their buttons; only `taskList` creation is fully gated, by design. +- **Do not** touch `MarkdownView` usages — its props are unchanged. +- **Do not** treat missing links/strikethrough in stored content as data loss — they still render; only the button was removed. + +### Don't do this + +- Do not pass `"unlink"` in `toolbar` — it is not an action; Unlink appears automatically next to Link when the cursor is inside a link. +- Do not bundle unrelated dependency bumps into the same change. diff --git a/specs/002-markdown-toolbar-actions/steps.md b/specs/002-markdown-toolbar-actions/steps.md index 6228abb..620a169 100644 --- a/specs/002-markdown-toolbar-actions/steps.md +++ b/specs/002-markdown-toolbar-actions/steps.md @@ -2,13 +2,13 @@ ## Step 1: Public type and prop -- [ ] Add `MarkdownToolbarAction` union type to `src/types/index.ts` (14 actions per design) -- [ ] Add `readonly toolbar?: readonly MarkdownToolbarAction[]` to `MarkdownEditorProps` (default documented as `["bold", "italic"]`) -- [ ] Export `MarkdownToolbarAction` from `src/index.ts` -- [ ] Leave `MarkdownViewProps` untouched +- [x] Add `MarkdownToolbarAction` union type to `src/types/index.ts` (14 actions per design) +- [x] Add `readonly toolbar?: readonly MarkdownToolbarAction[]` to `MarkdownEditorProps` (default documented as `["bold", "italic"]`) +- [x] Export `MarkdownToolbarAction` from `src/index.ts` +- [x] Leave `MarkdownViewProps` untouched **Acceptance criteria:** -- [ ] `pnpm typecheck` passes +- [x] `pnpm typecheck` passes **Related behaviors:** Unlink cannot be declared on its own; MarkdownView is unaffected @@ -16,16 +16,16 @@ ## Step 2: Conditional task-list gate in the factory -- [ ] Add `readonly allowedActions?: readonly MarkdownToolbarAction[]` to `MarkdownExtensionsOptions` -- [ ] Compute `taskListAllowed = (allowedActions ?? []).includes("taskList")` -- [ ] Keep the `tight` attribute on `TaskList` in both cases (round-trip must survive) -- [ ] When not allowed: strip `addKeyboardShortcuts` on `TaskList` and `addInputRules` on `TaskItem` (as spec 001 did) -- [ ] When allowed: keep the default `Mod-Shift-9` shortcut and `[ ] ` input rule -- [ ] Keep `TaskItem` `nested: true` and the styling classes in both cases +- [x] Add `readonly allowedActions?: readonly MarkdownToolbarAction[]` to `MarkdownExtensionsOptions` +- [x] Compute `taskListAllowed = (allowedActions ?? []).includes("taskList")` +- [x] Keep the `tight` attribute on `TaskList` in both cases (round-trip must survive) +- [x] When not allowed: strip `addKeyboardShortcuts` on `TaskList` and `addInputRules` on `TaskItem` (as spec 001 did) +- [x] When allowed: keep the default `Mod-Shift-9` shortcut and `[ ] ` input rule +- [x] Keep `TaskItem` `nested: true` and the styling classes in both cases **Acceptance criteria:** -- [ ] `pnpm typecheck` and `pnpm build` pass -- [ ] Spec 001 round-trip tests still pass unchanged +- [x] `pnpm typecheck` and `pnpm build` pass +- [x] Spec 001 round-trip tests still pass unchanged **Related behaviors:** Without `taskList`, the keyboard shortcut/input rule does nothing; With `taskList`, they work; Existing task lists remain editable/round-trip without the action @@ -33,16 +33,16 @@ ## Step 3: Toolbar rendering from the allowlist -- [ ] Replace the hardcoded `Toolbar` with an `ACTIONS` record mapping each `MarkdownToolbarAction` to `{ icon, label, isActive, run }` -- [ ] Render one button per action in array order, de-duplicated (first occurrence wins) -- [ ] Give each button both `aria-label` and `title` with the same English string -- [ ] `"link"` keeps the `window.prompt` flow and the contextual Unlink button -- [ ] Render no toolbar element at all when the resolved action list is empty -- [ ] Default the `toolbar` prop to `["bold", "italic"]` and pass it to the factory as `allowedActions` -- [ ] Use lucide icons: Bold, Italic, Strikethrough, Code, Link, Heading1/2/3, List, ListOrdered, ListChecks, Quote, SquareCode, Minus +- [x] Replace the hardcoded `Toolbar` with an `ACTIONS` record mapping each `MarkdownToolbarAction` to `{ icon, label, isActive, run }` +- [x] Render one button per action in array order, de-duplicated (first occurrence wins) +- [x] Give each button both `aria-label` and `title` with the same English string +- [x] `"link"` keeps the `window.prompt` flow and the contextual Unlink button +- [x] Render no toolbar element at all when the resolved action list is empty +- [x] Default the `toolbar` prop to `["bold", "italic"]` and pass it to the factory as `allowedActions` +- [x] Use lucide icons: Bold, Italic, Strikethrough, Code, Link, Heading1/2/3, List, ListOrdered, ListChecks, Quote, SquareCode, Minus **Acceptance criteria:** -- [ ] `pnpm typecheck`, `pnpm build`, `pnpm lint` pass +- [x] `pnpm typecheck`, `pnpm build`, `pnpm lint` pass **Related behaviors:** all "Rendering the declared actions" and "Executing actions" scenarios; Every button has an accessible name @@ -50,13 +50,13 @@ ## Step 4: Factory / extension gate tests -- [ ] Extend `src/lib/__tests__/markdown-extensions.test.ts` -- [ ] Without `taskList`: `Mod-Shift-9` and `[ ] ` create nothing (reuse spec 001 helpers) -- [ ] With `allowedActions: ["taskList"]`: `Mod-Shift-9` and `[ ] ` create a task item -- [ ] With gating: an existing `- [x] Done` still splits on Enter and round-trips unchanged +- [x] Extend `src/lib/__tests__/markdown-extensions.test.ts` +- [x] Without `taskList`: `Mod-Shift-9` and `[ ] ` create nothing (reuse spec 001 helpers) +- [x] With `allowedActions: ["taskList"]`: `Mod-Shift-9` and `[ ] ` create a task item +- [x] With gating: an existing `- [x] Done` still splits on Enter and round-trips unchanged **Acceptance criteria:** -- [ ] `pnpm test` passes +- [x] `pnpm test` passes **Related behaviors:** Task list creation gate (all five scenarios) @@ -64,15 +64,15 @@ ## Step 5: Component behaviour tests -- [ ] Extend `src/components/__tests__/markdown-editor.test.tsx` -- [ ] Rendering: exactly-declared; order; default (bold+italic only); empty array → no toolbar element; duplicate → one button -- [ ] Executing: H2 transforms block and reports `## Title`; taskList wraps to `- [ ] Call Anna`; active state on bold; Link shows contextual Unlink that disappears on leaving -- [ ] Marks gated at button only: `Mod-b` bolds without the Bold button; typing `**bold**` bolds without the button -- [ ] Accessibility: with all 14 actions, every button has matching `aria-label` + `title` and is findable by accessible name -- [ ] Rendering independent: with `toolbar={["bold"]}`, a loaded heading/blockquote/task list still render as structure +- [x] Extend `src/components/__tests__/markdown-editor.test.tsx` +- [x] Rendering: exactly-declared; order; default (bold+italic only); empty array → no toolbar element; duplicate → one button +- [x] Executing: H2 transforms block and reports `## Title`; taskList wraps to `- [ ] Call Anna`; active state on bold; Link shows contextual Unlink that disappears on leaving +- [x] Marks gated at button only: `Mod-b` bolds without the Bold button (component); the typing `**bold**` variant is covered at the extension level in Step 4 (literal content cannot be injected through the component's markdown-parsed `value`) +- [x] Accessibility: with all 14 actions, every button has matching `aria-label` + `title` and is findable by accessible name +- [x] Rendering independent: with `toolbar={["bold"]}`, a loaded heading/blockquote/task list still render as structure **Acceptance criteria:** -- [ ] `pnpm test` passes +- [x] `pnpm test` passes **Related behaviors:** Rendering; Executing; Marks are gated at the button only; Accessibility; Undeclared constructs still render @@ -80,11 +80,11 @@ ## Step 6: Type-level guarantees -- [ ] Add a type test (e.g. `src/types/__tests__/markdown-toolbar-action.test-d.ts` or `@ts-expect-error` in a `.test.ts`) that `toolbar={["unlink"]}` does not typecheck -- [ ] Assert `MarkdownViewProps` shape is unchanged (structural type assertion) +- [x] Add a type test (e.g. `src/types/__tests__/markdown-toolbar-action.test-d.ts` or `@ts-expect-error` in a `.test.ts`) that `toolbar={["unlink"]}` does not typecheck +- [x] Assert `MarkdownViewProps` shape is unchanged (structural type assertion) **Acceptance criteria:** -- [ ] `pnpm typecheck` passes (the `@ts-expect-error` is satisfied) +- [x] `pnpm typecheck` passes (the `@ts-expect-error` is satisfied) **Related behaviors:** Unlink cannot be declared on its own; MarkdownView is unaffected @@ -92,11 +92,11 @@ ## Step 7: Documentation -- [ ] Create `docs/upgrade-to-0.11.md` — breaking change: default toolbar drops to `["bold", "italic"]`; each existing usage must declare the actions it needs; content still renders/round-trips -- [ ] Update `README.md` MarkdownEditor entry to mention the configurable `toolbar` prop +- [x] Create `docs/upgrade-to-0.11.md` — breaking change: default toolbar drops to `["bold", "italic"]`; each existing usage must declare the actions it needs; content still renders/round-trips +- [x] Update `README.md` MarkdownEditor entry to mention the configurable `toolbar` prop **Acceptance criteria:** -- [ ] `pnpm build`, `pnpm test`, `pnpm lint`, `pnpm typecheck` all pass +- [x] `pnpm build`, `pnpm test`, `pnpm lint`, `pnpm typecheck` all pass **Related behaviors:** none (documentation) @@ -122,8 +122,8 @@ | With `taskList`, the input rule works | Frontend | 4 | | Existing task lists remain editable without the action | Frontend | 4 | | Existing task lists still round-trip without the action | Frontend | 4 | -| Bold stays reachable by shortcut without the button | Frontend | 5 | -| Bold stays reachable by typing without the button | Frontend | 5 | +| Bold stays reachable by shortcut without the button | Frontend | 5 (component) + 4 (extension) | +| Bold stays reachable by typing without the button | Frontend | 4 (extension) | | Every button has an accessible name | Frontend | 5 | | Undeclared constructs still render | Frontend | 5 | | MarkdownView is unaffected | Type | 6 | From 899ca16ea0a8d09388ef3e0a72fbdae42a6cd6a9 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:25:54 +0200 Subject: [PATCH 7/8] doc: log spec 001 toolbar drift caused by spec 002 --- specs/001-markdown-schema-roundtrip/behaviors.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/specs/001-markdown-schema-roundtrip/behaviors.md b/specs/001-markdown-schema-roundtrip/behaviors.md index d33aed8..ccfe5ae 100644 --- a/specs/001-markdown-schema-roundtrip/behaviors.md +++ b/specs/001-markdown-schema-roundtrip/behaviors.md @@ -168,3 +168,14 @@ - **Given** an editor built from `createMarkdownExtensions()` - **When** it is loaded with a plain paragraph containing no Markdown syntax - **Then** the serialized Markdown is byte-identical to the input + +--- + +## Drift Log + +### 2026-08-16 — Caused by spec `002-markdown-toolbar-actions` + +- **Affected scenario:** The toolbar is unchanged +- **Original behavior:** The `MarkdownEditor` toolbar offered exactly Bold, Italic, Strikethrough and Link (plus Unlink inside a link), fixed for every usage. +- **Current behavior:** The toolbar is configured per usage via a `toolbar` allowlist prop, and its default dropped to `["bold", "italic"]`. Strikethrough and Link are no longer shown unless declared. Stored content is unaffected — links and strikethrough still render and round-trip. +- **Reason:** Spec 002 makes the toolbar composable per field; the fixed four-button toolbar from spec 001 was intentionally superseded (spec 001's design already noted this would be handled in spec 002). From 64b102a67e6c3ede8e18dafc4389478f3972e114 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Sun, 16 Aug 2026 13:25:54 +0200 Subject: [PATCH 8/8] chore: mark spec 002 markdown-toolbar-actions as done --- specs/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/INDEX.md b/specs/INDEX.md index ba4908d..2ed9579 100644 --- a/specs/INDEX.md +++ b/specs/INDEX.md @@ -3,5 +3,5 @@ | ID | Spec-Folder | Name | Areas | Description | GitHub Issue | Status | |-----|-------------|------|-------|-------------|--------------|--------| | 001 | 001-markdown-schema-roundtrip | Markdown schema round-trip | frontend, api, testing | Stop destroying unsupported Markdown in MarkdownEditor/MarkdownView by teaching the schema everything Markdown can express | — | done | -| 002 | 002-markdown-toolbar-actions | Markdown toolbar actions | frontend, api | Compose the MarkdownEditor toolbar per usage via an explicit action allowlist | — | in progress | +| 002 | 002-markdown-toolbar-actions | Markdown toolbar actions | frontend, api | Compose the MarkdownEditor toolbar per usage via an explicit action allowlist | — | done | | 003 | 003-markdown-view-checkboxes | Markdown view checkboxes | frontend, api | Tick task list checkboxes directly in MarkdownView with optimistic update and rollback | — | open |