body wrapper, so the
+ // parent item must stay a block, not flex, or the nested
shares the
+ // parent's row). If remark-gfm ever wrapped the body, this fails loudly.
+ const parent = topItems[0]!;
+ const nested = parent.querySelector(":scope > ul.contains-task-list");
+ expect(nested).not.toBeNull();
+
+ const childItems = nested!.querySelectorAll(":scope > li.task-list-item");
+ expect(childItems).toHaveLength(2);
+ const childBoxes = nested!.querySelectorAll(
+ 'input[type="checkbox"]',
+ );
+ expect(childBoxes[0]!.checked).toBe(true);
+ expect(childBoxes[1]!.checked).toBe(false);
+ });
+});
+
describe("ReadonlyContent highlight Markdown", () => {
// `==text==` is lowered to a raw by highlightToHtml; rehype-raw turns
// it into an element and the sanitize schema must whitelist or it gets
diff --git a/packages/views/editor/styles/prose.css b/packages/views/editor/styles/prose.css
index c57181ec28..ba9e96a716 100644
--- a/packages/views/editor/styles/prose.css
+++ b/packages/views/editor/styles/prose.css
@@ -149,6 +149,109 @@
list-style-type: lower-roman;
}
+/*
+ * Task lists (checkboxes) — `- [ ]` / `- [x]`.
+ *
+ * The editor and readonly views emit DIFFERENT DOM, so they are styled
+ * separately rather than sharing a flex rule:
+ *
+ * - Editor (Tiptap NodeView):
+ * ul[data-type="taskList"] > li[data-checked]
+ * > label(contenteditable) > input + span
+ * > div(content) > p, [nested ul]
+ * The item body is wrapped in a , so the row is a flex layout and a
+ * nested task list inside that
still stacks below the text.
+ *
+ * - Readonly (remark-gfm):
+ * ul.contains-task-list > li.task-list-item
+ * > input[disabled] + text [ + nested ul ]
+ * There is NO body wrapper — a nested
is a direct sibling of the
+ * checkbox and text. Flex here would pull the nested list onto the same
+ * row, so the item stays a block and the checkbox is positioned inline.
+ */
+
+/* Shared: checkbox appearance. */
+.rich-text-editor input[type="checkbox"] {
+ width: 0.95rem;
+ height: 0.95rem;
+ accent-color: var(--brand);
+ cursor: pointer;
+}
+
+/* --- Editor (Tiptap) --- */
+.rich-text-editor ul[data-type="taskList"] {
+ list-style: none;
+ padding-inline: 0;
+ margin: 0.5rem 0;
+}
+
+/* Child combinators keep these rules off nested plain bullet/ordered lists. */
+.rich-text-editor ul[data-type="taskList"] > li {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 0;
+}
+
+/* Checkbox column. The top margin nudges the box onto the first text line
+ (line-height 1.625). */
+.rich-text-editor ul[data-type="taskList"] > li > label {
+ flex: 0 0 auto;
+ margin: 0.28rem 0 0;
+ user-select: none;
+}
+
+/* Content column fills the remaining width and may wrap/scroll. */
+.rich-text-editor ul[data-type="taskList"] > li > div {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.rich-text-editor ul[data-type="taskList"] > li > div > p {
+ margin: 0;
+}
+
+/* Completed editor item: muted text (no strikethrough), matching Linear. The
+ NodeView keeps `data-checked` on the - (but not data-type). */
+.rich-text-editor ul[data-type="taskList"] > li[data-checked="true"] > div {
+ color: var(--muted-foreground);
+}
+
+/* --- Readonly (remark-gfm) --- */
+.rich-text-editor ul.contains-task-list {
+ list-style: none;
+ padding-inline: 0;
+ margin: 0.5rem 0;
+}
+
+.rich-text-editor li.task-list-item {
+ padding-inline: 0;
+}
+
+/* Inline checkbox on the first text line; a nested
stays block and drops
+ below the item instead of sharing the row. */
+.rich-text-editor li.task-list-item > input[type="checkbox"] {
+ margin: 0 0.5rem 0 0;
+ vertical-align: -0.1em;
+}
+
+/* Indent nested readonly task lists under their parent item. */
+.rich-text-editor li.task-list-item .contains-task-list {
+ padding-inline-start: 1.45rem;
+ margin: 0.25rem 0;
+}
+
+/* Completed readonly item: muted text, matched via :has(input:checked). The
+ reset keeps a completed parent from bleeding its muted color into the
+ (independently evaluated) nested items below it. */
+.rich-text-editor li.task-list-item:has(> input:checked) {
+ color: var(--muted-foreground);
+}
+
+.rich-text-editor li.task-list-item:has(> input:checked) .contains-task-list {
+ color: var(--foreground);
+}
+
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;
diff --git a/packages/views/locales/en/editor.json b/packages/views/locales/en/editor.json
index c6ab0d0f38..bddfea8b6d 100644
--- a/packages/views/locales/en/editor.json
+++ b/packages/views/locales/en/editor.json
@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "Bullet List",
- "ordered_list": "Ordered List"
+ "ordered_list": "Ordered List",
+ "task_list": "Task List"
},
"sub_issue": {
"tooltip": "Create sub-issue from selection",
diff --git a/packages/views/locales/ja/editor.json b/packages/views/locales/ja/editor.json
index 950f44cadb..29fab8b953 100644
--- a/packages/views/locales/ja/editor.json
+++ b/packages/views/locales/ja/editor.json
@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "箇条書きリスト",
- "ordered_list": "番号付きリスト"
+ "ordered_list": "番号付きリスト",
+ "task_list": "タスクリスト"
},
"sub_issue": {
"tooltip": "選択範囲からサブイシューを作成",
diff --git a/packages/views/locales/ko/editor.json b/packages/views/locales/ko/editor.json
index e2e608f736..4fe1238848 100644
--- a/packages/views/locales/ko/editor.json
+++ b/packages/views/locales/ko/editor.json
@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "글머리 기호 목록",
- "ordered_list": "번호 목록"
+ "ordered_list": "번호 목록",
+ "task_list": "체크리스트"
},
"sub_issue": {
"tooltip": "선택 영역으로 하위 이슈 만들기",
diff --git a/packages/views/locales/zh-Hans/editor.json b/packages/views/locales/zh-Hans/editor.json
index 6672a72f3d..2a6c0d7106 100644
--- a/packages/views/locales/zh-Hans/editor.json
+++ b/packages/views/locales/zh-Hans/editor.json
@@ -18,7 +18,8 @@
},
"list_dropdown": {
"bullet_list": "无序列表",
- "ordered_list": "有序列表"
+ "ordered_list": "有序列表",
+ "task_list": "任务列表"
},
"sub_issue": {
"tooltip": "用选中内容创建子 issue",