anvilsign in

collin/anvil · eaa68dd7

Lay the TODO board out as a ledger instead of a wall of cards

Collin Richards · 2026-08-18 15:17 UTC · eaa68dd7cfbf3b4bac42de41eada0d6efd0d8d75 · parent a064a2b0 · browse files

modifiedcrates/anvil-web/assets/icons.svg+1 −1
1-<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="display:none" aria-hidden="true"><symbol id="i-clipboard" viewBox="0 0 16 16"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></symbol><symbol id="i-pencil" viewBox="0 0 16 16"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.225-1.69-1.69-6.857 6.857-.563 1.965 1.965-.563 6.857-6.857-.001-.001Z"/></symbol><symbol id="i-plus" viewBox="0 0 16 16"><path d="M7.25 7.25V2h1.5v5.25H14v1.5H8.75V14h-1.5V8.75H2v-1.5h5.25Z"/></symbol><symbol id="i-folder" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></symbol><symbol id="i-file" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></symbol><symbol id="i-grip" viewBox="0 0 16 16"><circle cx="6" cy="4" r="1.3"/><circle cx="10" cy="4" r="1.3"/><circle cx="6" cy="8" r="1.3"/><circle cx="10" cy="8" r="1.3"/><circle cx="6" cy="12" r="1.3"/><circle cx="10" cy="12" r="1.3"/></symbol></svg>
1+<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="display:none" aria-hidden="true"><symbol id="i-clipboard" viewBox="0 0 16 16"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></symbol><symbol id="i-pencil" viewBox="0 0 16 16"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.225-1.69-1.69-6.857 6.857-.563 1.965 1.965-.563 6.857-6.857-.001-.001Z"/></symbol><symbol id="i-plus" viewBox="0 0 16 16"><path d="M7.25 7.25V2h1.5v5.25H14v1.5H8.75V14h-1.5V8.75H2v-1.5h5.25Z"/></symbol><symbol id="i-folder" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></symbol><symbol id="i-file" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></symbol></svg>
modifiedcrates/anvil-web/src/todomd.rs+154 −42
⋯ 23 unchanged lines
2424 html,
2525 };
2626
27-use crate::ui::render_markdown;
27+use crate::ui::{
28+ render_markdown,
29+ render_markdown_inline,
30+};
2831
2932 pub struct Section {
3033 /// Heading level (1–6); 0 for the implicit preamble section.
⋯ 560 unchanged lines
591594 pub csrf: &'a str,
592595 }
593596
597+/// The repo-home teaser: where the board's head link and its "+N more"
598+/// overflow point, and the file name to show.
599+pub struct Preview<'a> {
600+ /// The `TODO.md` blob view — the full board.
601+ pub href: &'a str,
602+ /// File name as written in the tree, e.g. `TODO.md`.
603+ pub name: &'a str,
604+}
605+
606+/// Tasks a column shows before the rest is deferred to the file view. Capping
607+/// by task rather than by height means the teaser always ends on a whole task.
608+const PREVIEW_TASKS: usize = 5;
609+
594610 /// Render a todo-md document as a kanban board (columns = task-bearing
595611 /// sections) with prose sections as a notes area below. `None` if the file
596612 /// contains no tasks at all — callers fall back to plain markdown.
597613 pub fn render_board(text: &str, actions: Option<&BoardActions>) -> Option<Markup> {
614+ board(text, actions, None)
615+}
616+
617+/// The same board as a repo-home teaser: columns capped, notes left to the
618+/// file view, and never editable (the blob view owns the actions).
619+pub fn render_board_preview(text: &str, preview: &Preview) -> Option<Markup> {
620+ board(text, None, Some(preview))
621+}
622+
623+/// A column's tally: open tasks, or the total for a section marked done.
624+fn count_label(s: &Section) -> String {
625+ if s.done {
626+ format!("{} done", s.tasks.len())
627+ } else {
628+ format!("{} open", s.tasks.iter().filter(|t| !t.done).count())
629+ }
630+}
631+
632+fn board(text: &str, actions: Option<&BoardActions>, preview: Option<&Preview>) -> Option<Markup> {
598633 let sections = parse(text);
599634 if sections.iter().all(|s| s.tasks.is_empty()) {
600635 return None;
601636 }
637+ let cols: Vec<&Section> = sections.iter().filter(|s| !s.tasks.is_empty()).collect();
602638
603639 // Prose-only sections (and stray preamble/column prose) become one
604- // markdown notes blob, headings preserved.
640+ // markdown notes blob, headings preserved. The teaser skips it: the file
641+ // view it links to is one click away and carries the whole document.
605642 let mut notes = String::new();
606- for s in &sections {
643+ for s in sections.iter().filter(|_| preview.is_none()) {
607644 let prose_empty = s.prose.trim().is_empty();
608645 if s.tasks.is_empty() && s.level > 0 && prose_empty {
609646 continue; // empty section: nothing to show either way
⋯ 9 unchanged lines
619656 }
620657 }
621658
659+ // A one-column teaser would stack two labels for the same thing — the file
660+ // name over the column heading ("TODO.md" / "TODO"). Fold the count into
661+ // the head link instead and drop the heading.
662+ let lone = preview.is_some() && cols.len() == 1;
663+ let cap = preview.map_or(usize::MAX, |_| PREVIEW_TASKS);
664+
622665 let move_url = actions.map(|a| {
623666 format!(
624667 "/{}/{}/move-task/{}/{}",
⋯ 4 unchanged lines
629672 )
630673 });
631674 Some(html! {
675+ @if let Some(p) = preview {
676+ p.todo-board-head {
677+ a href=(p.href) { (p.name) }
678+ @if lone { span.count { (count_label(cols[0])) } }
679+ }
680+ }
632681 div.kanban data-move-url=[move_url.as_deref()]
633682 data-tip=[actions.map(|a| a.tip)] data-csrf=[actions.map(|a| a.csrf)] {
634- @for s in sections.iter().filter(|s| !s.tasks.is_empty()) {
683+ @for s in &cols {
635684 div.col data-section=[actions.map(|_| s.title.as_str())] {
636- h3 {
637- @if s.title.is_empty() { "Tasks" } @else { (s.title) }
638- span.count {
639- @if s.done {
640- ({ s.tasks.len().to_string() }) " done"
641- } @else {
642- ({ s.tasks.iter().filter(|t| !t.done).count().to_string() })
643- " open"
644- }
685+ @if !lone {
686+ h3 {
687+ @if s.title.is_empty() { "Tasks" } @else { (s.title) }
688+ span.count { (count_label(s)) }
645689 }
646690 }
647- @for t in &s.tasks {
648- div.card.done[t.done]
649- data-title=[actions.map(|_| t.title.as_str())] {
650- @if let Some(a) = actions {
651- span.card-grip title="Drag to reorder" aria-hidden="true" {
652- (crate::ui::icon(crate::ui::Icon::Grip))
691+ ol.tasks {
692+ @for t in s.tasks.iter().take(cap) {
693+ li.task.done[t.done]
694+ data-title=[actions.map(|_| t.title.as_str())] {
695+ // The bead reads the task's state and, on an
696+ // editable board, is also what you grab to
697+ // reorder it — one mark on the rail per task.
698+ span.task-bead title=[actions.map(|_| "Drag to reorder")]
699+ aria-hidden="true" {}
700+ @if t.details.trim().is_empty() {
701+ div.task-title { (render_markdown_inline(&t.title)) }
702+ } @else {
703+ details {
704+ summary.task-title { (render_markdown_inline(&t.title)) }
705+ div.task-body { (render_markdown(&t.details)) }
706+ }
653707 }
654- form.card-del method="post"
655- action=(format!("/{}/{}/delete-task/{}/{}", a.owner, a.repo, crate::ui::enc_ref(a.rev), a.path)) {
656- (crate::ui::csrf_input(a.csrf))
657- input type="hidden" name="expected_tip" value=(a.tip);
658- input type="hidden" name="section" value=(s.title);
659- input type="hidden" name="title" value=(t.title);
660- button.card-del-btn type="submit" title="Delete task" aria-label="Delete task" { "×" }
708+ @if let Some(a) = actions {
709+ form.task-del method="post"
710+ action=(format!("/{}/{}/delete-task/{}/{}", a.owner, a.repo, crate::ui::enc_ref(a.rev), a.path)) {
711+ (crate::ui::csrf_input(a.csrf))
712+ input type="hidden" name="expected_tip" value=(a.tip);
713+ input type="hidden" name="section" value=(s.title);
714+ input type="hidden" name="title" value=(t.title);
715+ button.task-del-btn type="submit" title="Delete task" aria-label="Delete task" { "×" }
716+ }
661717 }
662718 }
663- div.title { (render_markdown(&t.title)) }
664- @if !t.details.trim().is_empty() {
665- details {
666- summary { "details" }
667- div.card-details { (render_markdown(&t.details)) }
719+ }
720+ @if let Some(p) = preview {
721+ @let hidden = s.tasks.len().saturating_sub(cap);
722+ @if hidden > 0 {
723+ li.task-more {
724+ a href=(p.href) { "+" (hidden) " more" }
668725 }
669726 }
670727 }
⋯ 35 unchanged lines
706763 }
707764 return null;
708765 }
709- function afterCard(col, y){
710- var cards = [].slice.call(col.querySelectorAll('.card:not(.dragging)'));
766+ function afterCard(list, y){
767+ var cards = [].slice.call(list.querySelectorAll('.task:not(.dragging)'));
711768 var best = null, bestOffset = -Infinity;
712769 cards.forEach(function(c){
713770 var box = c.getBoundingClientRect();
⋯ 7 unchanged lines
721778 e.preventDefault(); // stop the page scrolling mid-drag
722779 moved = true;
723780 var col = colAt(e.clientX, e.clientY) || card.closest('.col');
724- var after = afterCard(col, e.clientY);
725- if (after == null) col.appendChild(card);
726- else col.insertBefore(card, after);
781+ var list = col.querySelector('.tasks'); // tasks live on the column's rail
782+ var after = afterCard(list, e.clientY);
783+ if (after == null) list.appendChild(card);
784+ else list.insertBefore(card, after);
727785 }
728786 function onUp(e){
729787 if (!card || (e && e.pointerId !== pid)) return;
⋯ 6 unchanged lines
736794 try { grip.releasePointerCapture(pid); } catch (_) {}
737795 if (!moved) return; // a tap or cancelled gesture — nothing to commit
738796 var col = c.closest('.col');
739- var cards = [].slice.call(col.querySelectorAll('.card'));
797+ var cards = [].slice.call(col.querySelectorAll('.task'));
740798 var body = new URLSearchParams();
741799 body.set('csrf', csrf);
742800 body.set('expected_tip', tip);
⋯ 11 unchanged lines
754812 }).catch(function(err){ console.error(err); location.reload(); });
755813 }
756814 board.addEventListener('pointerdown', function(e){
757- var g = e.target.closest('.card-grip');
815+ var g = e.target.closest('.task-bead');
758816 if (!g) return;
759- var c = g.closest('.card');
817+ var c = g.closest('.task');
760818 if (!c) return;
761819 e.preventDefault();
762820 card = c; grip = g; pid = e.pointerId; moved = false;
⋯ 106 unchanged lines
869927 assert!(board.contains("kanban"));
870928 assert!(board.contains("Now"));
871929 assert!(board.contains("first task"));
872- // Tickets render as ordinary cards — no checkbox input, no special
873- // card class. (The DOC text mentions "checkbox", so check elements.)
930+ // Tickets render as ordinary tasks — no checkbox input, no special
931+ // class. (The DOC text mentions "checkbox", so check elements.)
874932 assert!(board.contains("Implement Navbar"));
875933 assert!(!board.contains("<input"), "no checkbox clutter: {board}");
876- assert!(!board.contains("ticket\""), "no ticket card class: {board}");
934+ assert!(!board.contains("ticket\""), "no ticket task class: {board}");
877935 assert!(board.contains("just prose"), "notes area kept: {board}");
878936 assert!(render_board("# readme\n\nonly prose\n", None).is_none());
879937 }
880938
881939 #[test]
940+ fn preview_caps_columns_and_links_the_rest() {
941+ let mut doc = String::from("# Now\n\n");
942+ for i in 1..=8 {
943+ doc.push_str(&format!("- [ ] task {i}\n"));
944+ }
945+ doc.push_str("\n# Notes\n\nsome prose\n");
946+ let p = Preview {
947+ href: "/o/r/blob/master/TODO.md",
948+ name: "TODO.md",
949+ };
950+ let out = render_board_preview(&doc, &p)
951+ .expect("has tasks")
952+ .into_string();
953+ assert!(out.contains("task 5"), "shows the first five: {out}");
954+ assert!(!out.contains("task 6"), "and no more: {out}");
955+ assert!(out.contains("+3 more"), "defers the rest: {out}");
956+ assert!(out.contains(p.href), "and links the file: {out}");
957+ // One column: its heading would only repeat the file name, so the
958+ // tally rides on the head link instead.
959+ assert!(!out.contains("<h3"), "no lone column heading: {out}");
960+ assert!(out.contains("8 open"), "tally on the head: {out}");
961+ assert!(!out.contains("some prose"), "notes stay in the file: {out}");
962+ }
963+
964+ #[test]
965+ fn preview_keeps_headings_when_there_are_several_columns() {
966+ let p = Preview {
967+ href: "/o/r/blob/master/TODO.md",
968+ name: "TODO.md",
969+ };
970+ let out = render_board_preview(DOC, &p)
971+ .expect("has tasks")
972+ .into_string();
973+ assert!(out.contains("<h3"), "columns keep their headings: {out}");
974+ assert!(
975+ !out.contains("more</a>"),
976+ "short columns need no overflow: {out}"
977+ );
978+ }
979+
980+ #[test]
981+ fn numbered_ticket_title_stays_a_title() {
982+ let out = render_board("# Now\n\n## 1. Undo across a hand boundary\n\nbody\n", None)
983+ .expect("has tasks")
984+ .into_string();
985+ assert!(
986+ out.contains(r#"<summary class="task-title">1. Undo across a hand boundary</summary>"#),
987+ "title is inline, not a one-item list: {out}"
988+ );
989+ // The only list on the board is the column's own task rail.
990+ assert_eq!(out.matches("<ol").count(), 1, "{out}");
991+ }
992+
993+ #[test]
882994 fn add_task_appends_ticket_within_section_byte_exactly() {
883995 let out = add_task(DOC, "Now", "new ticket", "").unwrap();
884996 // Lands as a nested heading after the section's last non-blank line,
⋯ 176 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+100 −83
⋯ 194 unchanged lines
195195 .btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
196196 .readme { margin-top:16px; }
197197 .readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
198-/* Kanban: cards are the only boxes. Columns are headers + whitespace, no
199- nested frames. */
200-.kanban { display:flex; gap:20px; align-items:flex-start; overflow-x:auto; padding:4px 2px 8px; }
201-.kanban .col { flex:1 1 0; min-width:240px; }
202-.kanban .col h3 { margin:0 0 12px; padding:0 2px 8px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); display:flex; align-items:baseline; gap:8px; border-bottom:1px solid var(--border); }
198+/* Todo board: a ledger, not a card wall. Each column is a hairline rail with
199+ one bead per task — hollow while open, filled once done — and the bead is
200+ also the drag handle, so a task carries exactly one mark. Titles are their
201+ own disclosure: the details open underneath, no separate control. */
202+.kanban { display:flex; gap:32px; align-items:flex-start; overflow-x:auto; padding:2px 2px 4px; }
203+.kanban .col { flex:1 1 0; min-width:0; max-width:640px; }
204+.kanban .col h3 { margin:0 0 6px; padding-bottom:6px; border-bottom:1px solid var(--border); font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); display:flex; align-items:baseline; gap:8px; }
203205 .kanban .col h3 .count { font-weight:400; letter-spacing:0; text-transform:none; font-size:12px; margin-left:auto; }
204-.kanban .card { position:relative; background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); }
205-.kanban .card-del { position:absolute; top:3px; right:4px; margin:0; }
206-.kanban .card-del-btn { border:0; background:none; color:var(--muted); cursor:pointer; font-size:16px; line-height:1; padding:1px 5px; border-radius:4px; opacity:0; transition:opacity .1s,background .1s; }
207-.kanban .card:hover .card-del-btn, .card-del-btn:focus { opacity:1; }
208-.kanban .card-del-btn:hover { color:var(--error); background:var(--code-bg); }
209-.kanban .card .title { padding-right:14px; }
210-.kanban .card:has(.card-grip) { padding-left:28px; }
211-.kanban .card-grip { position:absolute; left:2px; top:5px; color:var(--muted); cursor:grab; touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; line-height:0; padding:4px 5px; border-radius:4px; }
212-/* The touch must land on the grip (touch-action:none), not the icon inside it,
213- or the browser claims the gesture for scrolling and never drags. */
214-.kanban .card-grip svg { pointer-events:none; }
215-.kanban .card-grip:hover { color:var(--fg); background:var(--code-bg); }
216-.kanban .card.dragging { opacity:.4; pointer-events:none; }
217-.kanban .card.dragging .card-grip { pointer-events:auto; cursor:grabbing; }
218-.kanban .card .title p { margin:0; font-weight:500; }
219-.kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
220-.kanban .card details { margin-top:7px; }
221-.kanban .card summary { cursor:pointer; font-size:11px; font-weight:500; letter-spacing:.03em; text-transform:uppercase; color:var(--muted); list-style:none; display:inline-flex; align-items:center; gap:5px; user-select:none; }
222-.kanban .card summary:hover { color:var(--accent); }
223-.kanban .card summary::-webkit-details-marker { display:none; }
224-.kanban .card summary::before { content:"\25B8"; font-size:9px; transition:transform .15s ease; }
225-.kanban .card details[open] summary { margin-bottom:5px; }
226-.kanban .card details[open] summary::before { transform:rotate(90deg); }
227-.kanban .card .card-details { font-size:13px; color:var(--fg); line-height:1.5; }
228-.kanban .card .card-details p { margin:0 0 6px; }
229-.kanban .card .card-details ul { margin:4px 0; padding-left:16px; }
230-.kanban .card .card-details img { max-width:100%; height:auto; border-radius:4px; margin:2px 0; }
231-.kanban .card .card-details > :last-child { margin-bottom:0; }
232-.kanban .card .title img { max-width:100%; height:auto; border-radius:4px; }
206+.kanban .tasks { list-style:none; margin:0; padding:0; border-left:1px solid var(--border); }
207+.kanban .task { position:relative; padding:4px 30px 4px 16px; border-radius:0 5px 5px 0; }
208+.kanban .task:hover { background:var(--code-bg); }
209+.kanban .task-bead { position:absolute; left:-10px; top:3px; width:20px; height:20px; }
210+.kanban .task-bead::before { content:""; position:absolute; left:6px; top:6px; width:8px; height:8px; border-radius:50%; background:var(--bg); box-shadow:inset 0 0 0 1.5px var(--muted); }
211+.kanban .task.done .task-bead::before { background:var(--success); box-shadow:none; }
212+/* Editable board: the bead is the grip. touch-action:none must sit on the
213+ element the finger lands on, or the browser claims the gesture for scroll. */
214+.kanban[data-move-url] .task-bead { cursor:grab; touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
215+.kanban[data-move-url] .task-bead:hover::before { box-shadow:inset 0 0 0 1.5px var(--accent); }
216+.kanban .task.dragging { opacity:.4; pointer-events:none; }
217+.kanban .task.dragging .task-bead { pointer-events:auto; cursor:grabbing; }
218+.kanban .task-title { font-size:13.5px; line-height:1.45; font-weight:500; color:var(--fg); }
219+.kanban .task.done > .task-title, .kanban .task.done > details > .task-title { color:var(--muted); font-weight:400; }
220+.kanban .task-title code { font-size:12px; }
221+.kanban .task-title img { max-width:100%; height:auto; border-radius:4px; }
222+.kanban summary.task-title { cursor:pointer; list-style:none; display:flex; align-items:baseline; gap:6px; }
223+.kanban summary.task-title::-webkit-details-marker { display:none; }
224+.kanban summary.task-title::after { content:"\25B8"; font-size:11px; line-height:1; color:var(--muted); transition:transform .15s ease; }
225+.kanban summary.task-title:hover::after { color:var(--accent); }
226+.kanban details[open] > summary.task-title::after { transform:rotate(90deg); }
227+.kanban summary.task-title:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:3px; }
228+.kanban .task-body { font-size:13px; color:var(--muted); line-height:1.55; max-width:72ch; padding:3px 0 5px; }
229+.kanban .task-body p { margin:0 0 6px; }
230+.kanban .task-body ul { margin:4px 0; padding-left:16px; }
231+.kanban .task-body img { max-width:100%; height:auto; border-radius:4px; margin:2px 0; }
232+.kanban .task-body > :last-child { margin-bottom:0; }
233+.kanban .task-del { position:absolute; top:1px; right:2px; margin:0; }
234+.kanban .task-del-btn { border:0; background:none; color:var(--muted); cursor:pointer; font-size:16px; line-height:1; padding:1px 5px; border-radius:4px; opacity:0; transition:opacity .1s,background .1s; }
235+.kanban .task:hover .task-del-btn, .task-del-btn:focus { opacity:1; }
236+.kanban .task-del-btn:hover { color:var(--error); background:var(--code-bg); }
237+.kanban .task-more { padding:5px 0 0 16px; font-size:12px; }
238+.kanban .task-more a { color:var(--muted); }
239+.kanban .task-more a:hover { color:var(--accent); }
233240 /* Repo secrets (docs/secrets.md): sealed values, plus the CI unlock banner. */
234241 .secret-unlocked { background:var(--success-bg); color:var(--success); border-radius:6px; padding:8px 12px; font-size:13px; }
235242 .secret-warn { background:var(--warning-bg); color:var(--warning); border-radius:6px; padding:8px 12px; font-size:13px; }
236243 .secret-stale { margin-left:10px; font-size:12px; color:var(--warning); }
237244 #secrets-form textarea { width:100%; font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
238-.todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; }
239-/* Repo home: the board leads the page, clipped to a fixed-height teaser that
240- expands in place. A checkbox drives it, not <details>, because a closed
241- <details> hides its content outright — there'd be no preview to clip.
242- `.fits` is set by script when the board is short enough to need neither. */
243-.todo-preview { margin:4px 0 16px; }
245+/* The board's head line: the file it comes from, and — when the file has a
246+ single column, so a column heading would only repeat it — that column's
247+ tally on the same line. */
248+.todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; display:flex; align-items:baseline; gap:10px; }
249+.todo-board-head .count { font-weight:400; font-size:12px; color:var(--muted); }
250+/* Repo home: the board leads the page as a teaser. Columns are capped by task
251+ count in the renderer, so the clip always lands between tasks. */
252+.todo-preview { margin:4px 0 18px; }
244253 .todo-preview .todo-board-head { margin-top:0; }
245-.todo-expand { position:absolute; width:1px; height:1px; opacity:0; }
246-.todo-preview-body { position:relative; max-height:300px; overflow:hidden; }
247-.todo-preview-body::after { content:""; position:absolute; left:0; right:0; bottom:0; height:80px; background:linear-gradient(to bottom, transparent, var(--bg)); pointer-events:none; }
248-.todo-expand:checked ~ .todo-preview-body { max-height:none; }
249-.todo-expand:checked ~ .todo-preview-body::after { display:none; }
250-.todo-more { display:inline-block; cursor:pointer; font-size:12px; color:var(--muted); padding:2px 0 6px; user-select:none; }
251-.todo-more:hover { color:var(--accent); }
252-.todo-more::after { content:"Show more \25BE"; }
253-.todo-expand:checked ~ .todo-more::after { content:"Show less \25B4"; }
254-.todo-expand:focus-visible ~ .todo-more { outline:2px solid var(--accent); outline-offset:2px; border-radius:3px; }
255-.todo-preview.fits .todo-preview-body { max-height:none; }
256-.todo-preview.fits .todo-preview-body::after { display:none; }
257-.todo-preview.fits .todo-more { display:none; }
258-.todo-notes { margin:8px 2px; }
259-.todo-notes > summary { cursor:pointer; font-size:13px; color:var(--muted); }
254+/* The prose left over after the board, under a heading in the same key as a
255+ column head. */
256+.todo-notes { margin:18px 2px 8px; }
257+.todo-notes > summary { cursor:pointer; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); }
258+.todo-notes > summary:hover { color:var(--fg); }
260259 .latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; }
261260 .latest-commit + .box { border-radius:0 0 6px 6px; }
262261 .commit-list { list-style:none; padding:0; margin:0; }
⋯ 38 unchanged lines
301300 details.rev-menu { display:inline-block; }
302301 details.rev-menu > summary .pill { cursor:pointer; }
303302 @media (max-width:720px) {
304- .kanban { flex-direction:column; gap:14px; overflow-x:visible; }
305- .kanban .col { min-width:0; width:100%; }
303+ .kanban { flex-direction:column; gap:18px; overflow-x:visible; }
304+ .kanban .col { min-width:0; width:100%; max-width:none; }
305+}
306+@media (prefers-reduced-motion: reduce) {
307+ .kanban summary.task-title::after { transition:none; }
306308 }
307309 "#;
308310
⋯ 12 unchanged lines
321323 Plus,
322324 Folder,
323325 File,
324- Grip,
325326 }
326327
327328 impl Icon {
⋯ 5 unchanged lines
333334 Icon::Plus => "i-plus",
334335 Icon::Folder => "i-folder",
335336 Icon::File => "i-file",
336- Icon::Grip => "i-grip",
337337 }
338338 }
339339 }
⋯ 897 unchanged lines
12371237 ))
12381238 });
12391239
1240- // A root TODO.md with tasks renders as a kanban board below the README.
1240+ // A root TODO.md with tasks leads the page as a capped board teaser; the
1241+ // file view holds the whole thing.
12411242 let todo_board = entries
12421243 .iter()
12431244 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md"))
12441245 .and_then(|e| {
12451246 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
1246- let board = todomd::render_board(&String::from_utf8_lossy(&bytes), None)?;
1247- Some((board, e.name.clone()))
1247+ let href = format!("/{owner}/{repo}/blob/{}/{}", enc_ref(&rev), e.name);
1248+ todomd::render_board_preview(
1249+ &String::from_utf8_lossy(&bytes),
1250+ &todomd::Preview {
1251+ href: &href,
1252+ name: &e.name,
1253+ },
1254+ )
12481255 });
12491256
12501257 Ok(layout(
⋯ 6 unchanged lines
12571264 " · "
12581265 a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" }
12591266 }
1260- @if let Some((board, name)) = &todo_board {
1261- section.todo-preview {
1262- p.todo-board-head {
1263- a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1264- }
1265- input.todo-expand #todo-expand type="checkbox";
1266- div.todo-preview-body { (board) }
1267- label.todo-more for="todo-expand" {}
1268- }
1269- script { (maud::PreEscaped(TODO_PREVIEW_JS)) }
1267+ @if let Some(board) = &todo_board {
1268+ section.todo-preview { (board) }
12701269 }
12711270 @if let Some(c) = &latest {
12721271 div.latest-commit {
⋯ 23 unchanged lines
12961295 },
12971296 ))
12981297 }
1299-
1300-/// Drops the clip (and its "show more" toggle) when the preview isn't actually
1301-/// taller than the cap, so a short board doesn't get a pointless teaser.
1302-const TODO_PREVIEW_JS: &str = r#"
1303-(function(){
1304- var d = document.querySelector('.todo-preview');
1305- if (!d) return;
1306- var body = d.querySelector('.todo-preview-body');
1307- if (body.scrollHeight <= body.clientHeight + 8) d.classList.add('fits');
1308-})();
1309-"#;
13101298
13111299 async fn tree_root(
13121300 State(app): State<App>,
⋯ 793 unchanged lines
21062094 PreEscaped(out)
21072095 }
21082096
2097+/// Render one line of markdown as *inline* content — no block wrapper.
2098+///
2099+/// Task titles are single lines that still want code spans, links and
2100+/// emphasis, but a title that opens like a list marker (`1. Undo across a
2101+/// hand boundary`, straight off a `## 1. …` heading) would otherwise become a
2102+/// one-item `<ol>`, indented and numbered by the browser instead of read as a
2103+/// title. Escaping the marker keeps the author's numbering as literal text;
2104+/// unwrapping the lone paragraph keeps the result inline.
2105+pub(crate) fn render_markdown_inline(text: &str) -> Markup {
2106+ let t = text.trim();
2107+ let digits = t.chars().take_while(char::is_ascii_digit).count();
2108+ let escaped = match t.as_bytes() {
2109+ // "1. title" / "1) title" — escape the punctuation that makes it a list.
2110+ [b'0'..=b'9', ..] if matches!(t.as_bytes().get(digits), Some(b'.' | b')')) => {
2111+ format!("{}\\{}", &t[..digits], &t[digits..])
2112+ }
2113+ // "- title" / "* title" / "+ title"
2114+ [c @ (b'-' | b'*' | b'+'), b' ', ..] => format!("\\{}{}", *c as char, &t[1..]),
2115+ _ => t.to_string(),
2116+ };
2117+ let html = render_markdown(&escaped).into_string();
2118+ let trimmed = html.trim();
2119+ let inner = trimmed
2120+ .strip_prefix("<p>")
2121+ .and_then(|r| r.strip_suffix("</p>"))
2122+ .unwrap_or(trimmed);
2123+ PreEscaped(inner.to_string())
2124+}
2125+
21092126 /// Render a language breakdown bar showing percentages of each detected language.
21102127 /// Displays as a horizontal bar with each language's proportion.
21112128 pub(crate) fn render_languages_bar(languages_json: &str) -> Option<Markup> {
⋯ 726 unchanged lines