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 | |||
| 24 | 24 | html, | |
| 25 | 25 | }; | |
| 26 | 26 | ||
| 27 | - | use crate::ui::render_markdown; | |
| 27 | + | use crate::ui::{ | |
| 28 | + | render_markdown, | |
| 29 | + | render_markdown_inline, | |
| 30 | + | }; | |
| 28 | 31 | ||
| 29 | 32 | pub struct Section { | |
| 30 | 33 | /// Heading level (1–6); 0 for the implicit preamble section. | |
| ⋯ 560 unchanged lines | |||
| 591 | 594 | pub csrf: &'a str, | |
| 592 | 595 | } | |
| 593 | 596 | ||
| 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 | + | ||
| 594 | 610 | /// Render a todo-md document as a kanban board (columns = task-bearing | |
| 595 | 611 | /// sections) with prose sections as a notes area below. `None` if the file | |
| 596 | 612 | /// contains no tasks at all — callers fall back to plain markdown. | |
| 597 | 613 | 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> { | |
| 598 | 633 | let sections = parse(text); | |
| 599 | 634 | if sections.iter().all(|s| s.tasks.is_empty()) { | |
| 600 | 635 | return None; | |
| 601 | 636 | } | |
| 637 | + | let cols: Vec<&Section> = sections.iter().filter(|s| !s.tasks.is_empty()).collect(); | |
| 602 | 638 | ||
| 603 | 639 | // 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. | |
| 605 | 642 | let mut notes = String::new(); | |
| 606 | - | for s in §ions { | |
| 643 | + | for s in sections.iter().filter(|_| preview.is_none()) { | |
| 607 | 644 | let prose_empty = s.prose.trim().is_empty(); | |
| 608 | 645 | if s.tasks.is_empty() && s.level > 0 && prose_empty { | |
| 609 | 646 | continue; // empty section: nothing to show either way | |
| ⋯ 9 unchanged lines | |||
| 619 | 656 | } | |
| 620 | 657 | } | |
| 621 | 658 | ||
| 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 | + | ||
| 622 | 665 | let move_url = actions.map(|a| { | |
| 623 | 666 | format!( | |
| 624 | 667 | "/{}/{}/move-task/{}/{}", | |
| ⋯ 4 unchanged lines | |||
| 629 | 672 | ) | |
| 630 | 673 | }); | |
| 631 | 674 | 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 | + | } | |
| 632 | 681 | div.kanban data-move-url=[move_url.as_deref()] | |
| 633 | 682 | 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 { | |
| 635 | 684 | 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)) } | |
| 645 | 689 | } | |
| 646 | 690 | } | |
| 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 | + | } | |
| 653 | 707 | } | |
| 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 | + | } | |
| 661 | 717 | } | |
| 662 | 718 | } | |
| 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" } | |
| 668 | 725 | } | |
| 669 | 726 | } | |
| 670 | 727 | } | |
| ⋯ 35 unchanged lines | |||
| 706 | 763 | } | |
| 707 | 764 | return null; | |
| 708 | 765 | } | |
| 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)')); | |
| 711 | 768 | var best = null, bestOffset = -Infinity; | |
| 712 | 769 | cards.forEach(function(c){ | |
| 713 | 770 | var box = c.getBoundingClientRect(); | |
| ⋯ 7 unchanged lines | |||
| 721 | 778 | e.preventDefault(); // stop the page scrolling mid-drag | |
| 722 | 779 | moved = true; | |
| 723 | 780 | 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); | |
| 727 | 785 | } | |
| 728 | 786 | function onUp(e){ | |
| 729 | 787 | if (!card || (e && e.pointerId !== pid)) return; | |
| ⋯ 6 unchanged lines | |||
| 736 | 794 | try { grip.releasePointerCapture(pid); } catch (_) {} | |
| 737 | 795 | if (!moved) return; // a tap or cancelled gesture — nothing to commit | |
| 738 | 796 | var col = c.closest('.col'); | |
| 739 | - | var cards = [].slice.call(col.querySelectorAll('.card')); | |
| 797 | + | var cards = [].slice.call(col.querySelectorAll('.task')); | |
| 740 | 798 | var body = new URLSearchParams(); | |
| 741 | 799 | body.set('csrf', csrf); | |
| 742 | 800 | body.set('expected_tip', tip); | |
| ⋯ 11 unchanged lines | |||
| 754 | 812 | }).catch(function(err){ console.error(err); location.reload(); }); | |
| 755 | 813 | } | |
| 756 | 814 | board.addEventListener('pointerdown', function(e){ | |
| 757 | - | var g = e.target.closest('.card-grip'); | |
| 815 | + | var g = e.target.closest('.task-bead'); | |
| 758 | 816 | if (!g) return; | |
| 759 | - | var c = g.closest('.card'); | |
| 817 | + | var c = g.closest('.task'); | |
| 760 | 818 | if (!c) return; | |
| 761 | 819 | e.preventDefault(); | |
| 762 | 820 | card = c; grip = g; pid = e.pointerId; moved = false; | |
| ⋯ 106 unchanged lines | |||
| 869 | 927 | assert!(board.contains("kanban")); | |
| 870 | 928 | assert!(board.contains("Now")); | |
| 871 | 929 | 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.) | |
| 874 | 932 | assert!(board.contains("Implement Navbar")); | |
| 875 | 933 | 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}"); | |
| 877 | 935 | assert!(board.contains("just prose"), "notes area kept: {board}"); | |
| 878 | 936 | assert!(render_board("# readme\n\nonly prose\n", None).is_none()); | |
| 879 | 937 | } | |
| 880 | 938 | ||
| 881 | 939 | #[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] | |
| 882 | 994 | fn add_task_appends_ticket_within_section_byte_exactly() { | |
| 883 | 995 | let out = add_task(DOC, "Now", "new ticket", "").unwrap(); | |
| 884 | 996 | // 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 | |||
| 195 | 195 | .btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); } | |
| 196 | 196 | .readme { margin-top:16px; } | |
| 197 | 197 | .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; } | |
| 203 | 205 | .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); } | |
| 233 | 240 | /* Repo secrets (docs/secrets.md): sealed values, plus the CI unlock banner. */ | |
| 234 | 241 | .secret-unlocked { background:var(--success-bg); color:var(--success); border-radius:6px; padding:8px 12px; font-size:13px; } | |
| 235 | 242 | .secret-warn { background:var(--warning-bg); color:var(--warning); border-radius:6px; padding:8px 12px; font-size:13px; } | |
| 236 | 243 | .secret-stale { margin-left:10px; font-size:12px; color:var(--warning); } | |
| 237 | 244 | #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; } | |
| 244 | 253 | .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); } | |
| 260 | 259 | .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; } | |
| 261 | 260 | .latest-commit + .box { border-radius:0 0 6px 6px; } | |
| 262 | 261 | .commit-list { list-style:none; padding:0; margin:0; } | |
| ⋯ 38 unchanged lines | |||
| 301 | 300 | details.rev-menu { display:inline-block; } | |
| 302 | 301 | details.rev-menu > summary .pill { cursor:pointer; } | |
| 303 | 302 | @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; } | |
| 306 | 308 | } | |
| 307 | 309 | "#; | |
| 308 | 310 | ||
| ⋯ 12 unchanged lines | |||
| 321 | 323 | Plus, | |
| 322 | 324 | Folder, | |
| 323 | 325 | File, | |
| 324 | - | Grip, | |
| 325 | 326 | } | |
| 326 | 327 | ||
| 327 | 328 | impl Icon { | |
| ⋯ 5 unchanged lines | |||
| 333 | 334 | Icon::Plus => "i-plus", | |
| 334 | 335 | Icon::Folder => "i-folder", | |
| 335 | 336 | Icon::File => "i-file", | |
| 336 | - | Icon::Grip => "i-grip", | |
| 337 | 337 | } | |
| 338 | 338 | } | |
| 339 | 339 | } | |
| ⋯ 897 unchanged lines | |||
| 1237 | 1237 | )) | |
| 1238 | 1238 | }); | |
| 1239 | 1239 | ||
| 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. | |
| 1241 | 1242 | let todo_board = entries | |
| 1242 | 1243 | .iter() | |
| 1243 | 1244 | .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md")) | |
| 1244 | 1245 | .and_then(|e| { | |
| 1245 | 1246 | 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 | + | ) | |
| 1248 | 1255 | }); | |
| 1249 | 1256 | ||
| 1250 | 1257 | Ok(layout( | |
| ⋯ 6 unchanged lines | |||
| 1257 | 1264 | " · " | |
| 1258 | 1265 | a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" } | |
| 1259 | 1266 | } | |
| 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) } | |
| 1270 | 1269 | } | |
| 1271 | 1270 | @if let Some(c) = &latest { | |
| 1272 | 1271 | div.latest-commit { | |
| ⋯ 23 unchanged lines | |||
| 1296 | 1295 | }, | |
| 1297 | 1296 | )) | |
| 1298 | 1297 | } | |
| 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 | - | "#; | |
| 1310 | 1298 | ||
| 1311 | 1299 | async fn tree_root( | |
| 1312 | 1300 | State(app): State<App>, | |
| ⋯ 793 unchanged lines | |||
| 2106 | 2094 | PreEscaped(out) | |
| 2107 | 2095 | } | |
| 2108 | 2096 | ||
| 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 | + | ||
| 2109 | 2126 | /// Render a language breakdown bar showing percentages of each detected language. | |
| 2110 | 2127 | /// Displays as a horizontal bar with each language's proportion. | |
| 2111 | 2128 | pub(crate) fn render_languages_bar(languages_json: &str) -> Option<Markup> { | |
| ⋯ 726 unchanged lines | |||