web: task lines render as checkboxes where they sit in the note
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s

M304 step 5. The server already returns a derived `items` array, so the web kept
working across the last two commits — but it was rendering every list TWICE: once
as literal `- [ ] milk` bullets inside the body, and again as the separate
NoteChecklist block underneath. This is the commit that makes the body the only
place a checklist appears.

markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would
otherwise swallow the marker and leave the brackets showing, the same ordering
reason `code` is matched before emphasis in INLINE_RE. Each item carries its
ordinal across the WHOLE document, because that is what an item's id means
everywhere else now; counting per block would have made the second list's
checkboxes toggle the first list's items.

The card's preview clamp is why that ordinal is safe there: it only ever drops
lines from the end, so a visible item's index is the same whether or not the body
was truncated.

MarkdownText emits a toggle rather than reaching for the store. Ticking a box
rewrites a line of someone's note, and a renderer used in several places should not
be the thing deciding that is allowed — the card passes `toggleable` and wires it,
a read-only render does not and the boxes are inert. Not wrapped in a <label>
either: on a card the text is the note's own words and clicking it opens the note,
so only the box toggles.

In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at
the caret. That makes it the one toolbar action needing no persisted note to hang
anything off — ensureDraft is gone from it, and it works on an empty compose box
the moment it opens. Enter on a task line continues the list, and on an EMPTY one
clears the marker; without that second half a list would be impossible to get out
of. Indent and bullet are carried over rather than normalised, because continuing
someone's `*` list with a `-` is an edit they did not ask for.

NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the
repository seam the REST routes and Tauri commands both implement, not the old path.

CI cannot check any of this beyond types — there are no frontend tests, only
vue-tsc. It wants a real browser pass.
This commit is contained in:
2026-08-24 08:10:20 -04:00
parent fe1f72ae1b
commit 3cab054684
5 changed files with 144 additions and 114 deletions
+45 -1
View File
@@ -13,10 +13,22 @@ export interface InlineToken {
// Flat (non-discriminated) shape on purpose — keeps template type-checking simple.
export interface Block {
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre";
type: "p" | "h1" | "h2" | "h3" | "quote" | "ul" | "ol" | "pre" | "task";
inline?: InlineToken[];
items?: InlineToken[][];
value?: string;
/** `task` only: one entry per `items` entry, parallel by position. */
tasks?: TaskMeta[];
}
export interface TaskMeta {
/** This item's ordinal among ALL task lines in the body, in document order.
* That is the id the server and the native clients address an item by, so a
* checkbox can be toggled straight from it. Counted across blocks, not within
* one, and unaffected by the card truncating the body — the card only ever
* drops lines from the END. */
index: number;
checked: boolean;
}
// Order matters: code is matched before emphasis so its contents aren't re-parsed;
@@ -44,11 +56,24 @@ export function parseInline(text: string): InlineToken[] {
return tokens;
}
// A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304).
//
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`.
// The other two implementations do not take `+`, and one grammar in three places has
// to be one grammar; a `+ [ ] x` line renders as an ordinary bullet everywhere,
// which is at least consistent.
const TASK_RE = /^\s*[-*] +\[([ xX])\](?: +(.*))?$/;
export function parseMarkdown(text: string): Block[] {
const lines = (text ?? "").split("\n");
const blocks: Block[] = [];
let paragraph: string[] = [];
let i = 0;
// Runs across the whole document, not per block, because that is what the item's
// id means everywhere else.
let taskIndex = 0;
const flushPara = () => {
if (paragraph.length) {
@@ -96,6 +121,25 @@ export function parseMarkdown(text: string): Block[] {
continue;
}
// Task list, BEFORE the plain bullet below — which would otherwise swallow
// `- [ ] x` as an ordinary list item and leave the brackets showing. Same
// ordering reason as `code` being matched before emphasis in INLINE_RE.
if (TASK_RE.test(line)) {
flushPara();
const items: InlineToken[][] = [];
const tasks: TaskMeta[] = [];
while (i < lines.length) {
const m = TASK_RE.exec(lines[i]);
if (!m) break;
items.push(parseInline(m[2] ?? ""));
tasks.push({ index: taskIndex, checked: m[1] !== " " });
taskIndex++;
i++;
}
blocks.push({ type: "task", items, tasks });
continue;
}
// Unordered list: -, *, or + then a space.
if (/^\s*[-*+]\s+/.test(line)) {
flushPara();