diff --git a/CHANGELOG.md b/CHANGELOG.md
index f35e7a6..5dee1c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## 2026-10-01
+
+### Fixed
+- Checkbox not rendering when creating/editing notes [PR 11](https://github.com/thermcampos/tasknote/pull/11)
+
+---
+
## 2026-09-30
### Fixed
- URL rendering in a note.
+```
+# Docker images
+- ghcr.io/thermcampos/tasknote/client:app-v2026.10.01.20
+```
+
---
## 2026-09-29
@@ -19,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Email sending migrated from Mailgun (named templates) to Resend (HTML bodies from local templates). The `MAILGUN_APIKEY` environment variable was replaced by `RESEND_APIKEY`.
+```
+# Docker images
+- ghcr.io/thermcampos/tasknote/server:api-v2026.09.29.15
+```
+
---
## 2026-09-28
diff --git a/client/src/__test__/components/MarkdownEditor.test.tsx b/client/src/__test__/components/MarkdownEditor.test.tsx
index 8afda29..7399bd6 100644
--- a/client/src/__test__/components/MarkdownEditor.test.tsx
+++ b/client/src/__test__/components/MarkdownEditor.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { render, waitFor } from '@testing-library/react';
+import { fireEvent, render, waitFor } from '@testing-library/react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import MarkdownEditor from '../../components/MarkdownEditor';
@@ -50,6 +50,37 @@ describe('MarkdownEditor', () => {
});
});
+ it('should render task list items as checkboxes and toggle them', async () => {
+ const onChange = vi.fn();
+ const { container } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(container.querySelectorAll('li input[type="checkbox"]').length).toBe(2);
+ });
+
+ const checkboxes = container.querySelectorAll('li input[type="checkbox"]');
+ expect(checkboxes[0].checked).toBe(false);
+ expect(checkboxes[1].checked).toBe(true);
+ expect(checkboxes[0].closest('li')?.getAttribute('data-item-type')).toBe('task');
+
+ const plainItem = Array.from(container.querySelectorAll('li')).find(
+ li => li.textContent === 'plain item'
+ );
+ expect(plainItem?.querySelector('input[type="checkbox"]')).toBeNull();
+
+ fireEvent.click(checkboxes[0]);
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalledWith(expect.stringContaining('[x] open task'));
+ });
+ });
+
it('should mark the editor as empty when there is no content', async () => {
const { getByTestId } = render(
diff --git a/client/src/components/MarkdownEditor/index.tsx b/client/src/components/MarkdownEditor/index.tsx
index 76c4e53..fa64cab 100644
--- a/client/src/components/MarkdownEditor/index.tsx
+++ b/client/src/components/MarkdownEditor/index.tsx
@@ -6,6 +6,7 @@ import { gfm } from '@milkdown/kit/preset/gfm';
import { history } from '@milkdown/kit/plugin/history';
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener';
import { insertLinkInputRule } from './linkInputRule';
+import { taskListItemView } from './taskListItemView';
import '@milkdown/kit/prose/view/style/prosemirror.css';
import '@milkdown/kit/prose/tables/style/tables.css';
@@ -38,6 +39,7 @@ const MarkdownEditorInner = forwardRef ({
diff --git a/client/src/components/MarkdownEditor/taskListItemView.ts b/client/src/components/MarkdownEditor/taskListItemView.ts
new file mode 100644
index 0000000..3ee4959
--- /dev/null
+++ b/client/src/components/MarkdownEditor/taskListItemView.ts
@@ -0,0 +1,88 @@
+import { listItemSchema } from '@milkdown/kit/preset/commonmark';
+import type { Node } from '@milkdown/kit/prose/model';
+import type { EditorView, NodeView, ViewMutationRecord } from '@milkdown/kit/prose/view';
+import { $view } from '@milkdown/kit/utils';
+
+class TaskListItemView implements NodeView {
+ dom: HTMLLIElement;
+ contentDOM: HTMLDivElement;
+ private checkbox: HTMLInputElement | null = null;
+
+ constructor(
+ private node: Node,
+ private readonly view: EditorView,
+ private readonly getPos: () => number | undefined
+ ) {
+ this.dom = document.createElement('li');
+ this.contentDOM = document.createElement('div');
+ this.dom.appendChild(this.contentDOM);
+ this.render();
+ }
+
+ update(node: Node): boolean {
+ if (node.type !== this.node.type) return false;
+ this.node = node;
+ this.render();
+ return true;
+ }
+
+ ignoreMutation(mutation: ViewMutationRecord): boolean {
+ return this.checkbox !== null && mutation.target === this.checkbox;
+ }
+
+ destroy(): void {
+ this.checkbox?.removeEventListener('change', this.handleToggle);
+ }
+
+ private render(): void {
+ const { checked, label, listType, spread } = this.node.attrs;
+ this.dom.dataset.label = label;
+ this.dom.dataset.listType = listType;
+ this.dom.dataset.spread = String(spread);
+
+ if (checked === null || checked === undefined) {
+ delete this.dom.dataset.itemType;
+ delete this.dom.dataset.checked;
+ if (this.checkbox) {
+ this.checkbox.removeEventListener('change', this.handleToggle);
+ this.checkbox.remove();
+ this.checkbox = null;
+ }
+ return;
+ }
+
+ this.dom.dataset.itemType = 'task';
+ this.dom.dataset.checked = String(checked);
+
+ if (!this.checkbox) {
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.className = 'task-list-item-checkbox';
+ checkbox.setAttribute('contenteditable', 'false');
+ checkbox.setAttribute('aria-label', 'Toggle task');
+ checkbox.addEventListener('mousedown', event => event.preventDefault());
+ checkbox.addEventListener('change', this.handleToggle);
+ this.dom.insertBefore(checkbox, this.contentDOM);
+ this.checkbox = checkbox;
+ }
+ this.checkbox.checked = Boolean(checked);
+ }
+
+ private handleToggle = (event: Event): void => {
+ const pos = this.getPos();
+ if (pos === undefined) return;
+ const checked = (event.target as HTMLInputElement).checked;
+ this.view.dispatch(
+ this.view.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, checked })
+ );
+ };
+}
+
+/**
+ * Milkdown node view that renders GFM task list items (`- [ ]`/`- [x]`) with a
+ * clickable checkbox instead of a plain bullet. Toggling the checkbox updates
+ * the `checked` attribute so the serialized markdown stays in sync.
+ */
+export const taskListItemView = $view(listItemSchema.node, () => {
+ return (node, view, getPos) => new TaskListItemView(node, view, getPos);
+});
diff --git a/client/src/styles/custom.scss b/client/src/styles/custom.scss
index cddf9f0..447221a 100644
--- a/client/src/styles/custom.scss
+++ b/client/src/styles/custom.scss
@@ -317,9 +317,26 @@ a:hover, .btn-link:hover {
padding: 4px 8px;
}
-.milkdown-editor .ProseMirror li:has(> input[type='checkbox']) {
+.milkdown-editor .ProseMirror li[data-item-type='task'] {
list-style: none;
margin-left: -1.4rem;
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+}
+
+.milkdown-editor .ProseMirror li[data-item-type='task'] > input[type='checkbox'] {
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.milkdown-editor .ProseMirror li[data-item-type='task'] > div {
+ flex: 1;
+}
+
+.milkdown-editor .ProseMirror li[data-item-type='task'][data-checked='true'] > div {
+ text-decoration: line-through;
+ color: var(--bs-secondary-color);
}
.milkdown-editor .ProseMirror a {