Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ A free, open-source, accessible, dark-mode-first kanban board — a calm alterna

## Status

**Slice 1 — local single-user board (in progress).**
**Slice 1 — local single-user board (complete).**

Boards, lists, and cards work end to end — create, rename, delete, and reorder lists inside a board, add cards to a list, move a card within its list or to another list, label them, delete a card with a one-click undo, open a card into a focused task view with an Edit mode, keep a per-card checklist (add, rename, reorder, check off into a collapsible Done strip, delete with undo) with progress shown on the board's card chips, and tune it all in a small settings screen — saved to SQLite, accessible and dark-mode-first.

- **Done:** the board, list, card, label, and checklist backend (JSON APIs behind `IBoardRepository`, `IListRepository`, `ICardRepository`, `ILabelRepository`, and `IChecklistItemRepository` seams, EF Core + SQLite, 147 NUnit tests, localhost-only) and the vanilla-JS MVC frontend (board-view navigation, accessible list reordering, card chips with a focused task view, accessible card moving with up/down buttons and a move-to-list dropdown, an inline label picker with soft-tint chips, a per-card checklist with a Done strip and chip progress bars, an undo-first delete for cards and checklist items with a transient "Deleted · Undo" toast, a task-view Edit mode, a localStorage settings screen gating the card Done checkboxes and the Delete card button, screen-reader announcements, keyboard focus management).
- **Next:** mobile + accessibility polish (per-list Done strips and a single-list phone view).
- **Done:** the board, list, card, label, and checklist backend (JSON APIs behind `IBoardRepository`, `IListRepository`, `ICardRepository`, `ILabelRepository`, and `IChecklistItemRepository` seams, EF Core + SQLite, 147 NUnit tests, localhost-only) and the vanilla-JS MVC frontend (board-view navigation, accessible list reordering, card chips with a focused task view, accessible card moving with up/down buttons and a move-to-list dropdown, an inline label picker with soft-tint chips, a per-card checklist with a Done strip and chip progress bars, an undo-first delete for cards and checklist items with a transient "Deleted · Undo" toast, a task-view Edit mode, a localStorage settings screen gating the card Done checkboxes and the Delete card button, screen-reader announcements, keyboard focus management, per-list Done strips, and a mobile single-list switcher).
- **Next:** Slice 2 — sharing and multi-user accounts.

Design specs: [`docs/2026-06-15-wend-slice1-design.md`](docs/2026-06-15-wend-slice1-design.md), [`docs/2026-06-19-wend-lists-design.md`](docs/2026-06-19-wend-lists-design.md), [`docs/2026-06-22-wend-cards-design.md`](docs/2026-06-22-wend-cards-design.md), [`docs/2026-06-23-wend-labels-design.md`](docs/2026-06-23-wend-labels-design.md), [`docs/2026-06-24-wend-card-moving-design.md`](docs/2026-06-24-wend-card-moving-design.md), [`docs/2026-06-25-wend-done-design.md`](docs/2026-06-25-wend-done-design.md), [`docs/2026-07-07-wend-delete-undo-design.md`](docs/2026-07-07-wend-delete-undo-design.md), [`docs/2026-07-07-wend-checklist-design.md`](docs/2026-07-07-wend-checklist-design.md)
Design specs: [`docs/2026-06-15-wend-slice1-design.md`](docs/2026-06-15-wend-slice1-design.md), [`docs/2026-06-19-wend-lists-design.md`](docs/2026-06-19-wend-lists-design.md), [`docs/2026-06-22-wend-cards-design.md`](docs/2026-06-22-wend-cards-design.md), [`docs/2026-06-23-wend-labels-design.md`](docs/2026-06-23-wend-labels-design.md), [`docs/2026-06-24-wend-card-moving-design.md`](docs/2026-06-24-wend-card-moving-design.md), [`docs/2026-06-25-wend-done-design.md`](docs/2026-06-25-wend-done-design.md), [`docs/2026-07-07-wend-delete-undo-design.md`](docs/2026-07-07-wend-delete-undo-design.md), [`docs/2026-07-07-wend-checklist-design.md`](docs/2026-07-07-wend-checklist-design.md), [`docs/2026-07-08-wend-mobile-a11y-polish-design.md`](docs/2026-07-08-wend-mobile-a11y-polish-design.md)

Build plans: [`docs/plans/2026-06-16-slice1-foundation-boards.md`](docs/plans/2026-06-16-slice1-foundation-boards.md), [`docs/plans/2026-06-19-slice1-lists.md`](docs/plans/2026-06-19-slice1-lists.md), [`docs/plans/2026-06-22-slice1-cards.md`](docs/plans/2026-06-22-slice1-cards.md), [`docs/plans/2026-06-23-slice1-labels.md`](docs/plans/2026-06-23-slice1-labels.md), [`docs/plans/2026-06-24-slice1-card-moving.md`](docs/plans/2026-06-24-slice1-card-moving.md), [`docs/plans/2026-06-25-slice1-done.md`](docs/plans/2026-06-25-slice1-done.md), [`docs/plans/2026-07-07-slice1-delete-undo.md`](docs/plans/2026-07-07-slice1-delete-undo.md), [`docs/plans/2026-07-07-slice1-checklist.md`](docs/plans/2026-07-07-slice1-checklist.md)
Build plans: [`docs/plans/2026-06-16-slice1-foundation-boards.md`](docs/plans/2026-06-16-slice1-foundation-boards.md), [`docs/plans/2026-06-19-slice1-lists.md`](docs/plans/2026-06-19-slice1-lists.md), [`docs/plans/2026-06-22-slice1-cards.md`](docs/plans/2026-06-22-slice1-cards.md), [`docs/plans/2026-06-23-slice1-labels.md`](docs/plans/2026-06-23-slice1-labels.md), [`docs/plans/2026-06-24-slice1-card-moving.md`](docs/plans/2026-06-24-slice1-card-moving.md), [`docs/plans/2026-06-25-slice1-done.md`](docs/plans/2026-06-25-slice1-done.md), [`docs/plans/2026-07-07-slice1-delete-undo.md`](docs/plans/2026-07-07-slice1-delete-undo.md), [`docs/plans/2026-07-07-slice1-checklist.md`](docs/plans/2026-07-07-slice1-checklist.md), [`docs/plans/2026-07-08-slice1-mobile-a11y-polish.md`](docs/plans/2026-07-08-slice1-mobile-a11y-polish.md)

## Stack

Expand Down
25 changes: 10 additions & 15 deletions Wend.Api/wwwroot/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,22 @@
flex-wrap: wrap;
}

/* Mobile single-list switcher: view one list at a time; all columns show at ≥768px. */
.list-switcher-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
.list-switcher-label { font-weight: 600; }
.list-switcher { flex: 1; min-height: 44px; max-width: 100%; }
.list-columns > .list-card { display: none; }
.list-columns > .list-card.is-current { display: flex; }

@media (min-width: 768px) {
.list-columns {
flex-direction: row;
align-items: flex-start;
overflow-x: auto;
}
.list-card { flex: 0 0 16rem; }
.list-switcher-row { display: none; }
.list-columns > .list-card { display: flex; } /* every column visible on desktop */
}

.app-header {
Expand Down Expand Up @@ -296,8 +305,7 @@
.card-done { margin: 0 0 1rem; }
.card-done-label { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 44px; cursor: pointer; }

/* Global Done area below the lists. */
.done-area { margin-top: 1.5rem; }
/* Shared strip disclosure — used by the per-list board Done strips and the checklist Done strip. */
.done-toggle {
width: 100%;
text-align: left;
Expand All @@ -308,19 +316,6 @@
border-color: color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.5rem;
}
.done-body { margin-top: 0.6rem; display: flex; flex-direction: column; gap: 0.75rem; }
.done-group-title { margin: 0 0 0.3rem; font-size: 0.85rem; opacity: 0.75; }
.done-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; }
.done-row-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
cursor: pointer;
opacity: 0.7;
}
.done-row-title { text-decoration: line-through; }
.done-row .card-done-toggle { margin-top: 0; }

/* ---- Undo toast (uses the design-system .toast-region / .toast component) ---- */
.toast { align-items: center; }
Expand Down
16 changes: 14 additions & 2 deletions Wend.Api/wwwroot/js/board/controller.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { setSelectedListId } from "../prefs.js";

// Wires the board view to the model: announces results, manages focus, confirms list deletes,
// turns move-left/right into a target position, and forwards card actions (reorder + move-to-list).
// onBack() returns to the overview; onOpenCard(cardId) opens a card's task view.
export function createBoardController(model, view, announce, { onBack, onOpenCard } = {}) {
let lists = [];
let boardId = null;

view.bindActions({
back: () => onBack?.(),
openCard: (cardId) => onOpenCard?.(cardId),
selectList: (listId) => {
const list = lists.find((l) => l.id === listId);
if (!list) return;
setSelectedListId(boardId, listId);
const count = (list.cards ?? []).filter((c) => !c.completedAt).length;
announce(`Showing ${list.title}, ${count} ${count === 1 ? "card" : "cards"}.`);
},
create: async (title) => {
if (!title) return;
try {
Expand Down Expand Up @@ -63,13 +73,14 @@ export function createBoardController(model, view, announce, { onBack, onOpenCar
}
},
toggleDone: async (cardId, completed) => {
const card = lists.flatMap((l) => l.cards ?? []).find((c) => c.id === cardId);
const list = lists.find((l) => (l.cards ?? []).some((c) => c.id === cardId));
const card = list?.cards?.find((c) => c.id === cardId);
const title = card ? card.title : "the card";
try {
await model.setCardDone(cardId, completed);
if (completed) {
announce(`Marked done: ${title}.`);
view.focusDoneToggle();
if (list) view.focusListDoneToggle(list.id); else view.focusHeading();
} else {
announce(`Restored: ${title}.`);
view.focusCard(cardId);
Expand Down Expand Up @@ -112,6 +123,7 @@ export function createBoardController(model, view, announce, { onBack, onOpenCar

model.subscribe((board) => {
lists = board.lists;
boardId = board.id;
view.render(board);
});
}
96 changes: 57 additions & 39 deletions Wend.Api/wwwroot/js/board/view.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { escapeHtml } from "../escape.js";
import { getPrefs } from "../prefs.js";
import { getPrefs, getSelectedListId } from "../prefs.js";

// Renders one board's view: back link, title, add-list form, each list with its move/rename/delete
// controls, its ACTIVE cards (a leading done checkbox + label chips + move controls) and an add-card
// form, then a collapsible global Done area (done cards grouped by list, each with an un-check).
// "Done" is a render grouping on completedAt; the collapse state lives here (ui.doneOpen).
// controls, its ACTIVE cards (a leading done checkbox + label chips + move controls), a collapsible
// per-list Done strip (that list's completed cards, each with an un-check), and an add-card form.
// "Done" is a render grouping on completedAt; per-list collapse state lives here (ui.doneOpenLists).
export function createBoardView(root) {
let lastBoard = null;
const ui = { doneOpen: false, renamingId: null };
const ui = { doneOpenLists: new Set(), renamingId: null, selectedListId: null };

function render(board) {
lastBoard = board;
Expand All @@ -17,6 +17,13 @@ export function createBoardView(root) {
function paint() {
const board = lastBoard;
const lists = board.lists;
// Resolve the mobile switcher's current list: keep a valid selection, else the remembered
// one (validated), else the first list. Self-heals when the selected list is deleted.
const validIds = new Set(lists.map((l) => l.id));
if (!validIds.has(ui.selectedListId)) {
const remembered = getSelectedListId(board.id);
ui.selectedListId = validIds.has(remembered) ? remembered : (lists[0]?.id ?? null);
}
const labelsById = new Map((board.labels ?? []).map((l) => [l.id, l]));
const prefs = getPrefs();

Expand Down Expand Up @@ -77,8 +84,25 @@ export function createBoardView(root) {
</li>`;
})
.join("");
const doneCards = (l.cards ?? []).filter((c) => c.completedAt);
const doneOpen = ui.doneOpenLists.has(l.id);
const doneStrip = doneCards.length ? `
<div class="done-strip">
<button type="button" class="done-toggle" data-action="toggle-list-done" data-list-id="${l.id}"
aria-expanded="${doneOpen ? "true" : "false"}">✓ Done (${doneCards.length})</button>
${doneOpen ? `<ul class="done-items">${doneCards
.map((c) => `
<li class="done-item-row" data-card-id="${c.id}">
<label class="checklist-done-label">
<input type="checkbox" data-action="toggle-done" data-card-id="${c.id}" checked
aria-label="Mark not done: ${escapeHtml(c.title)}" />
<span class="done-item-text">${escapeHtml(c.title)}</span>
</label>
</li>`)
.join("")}</ul>` : ""}
</div>` : "";
return `
<li class="list-card" data-list-id="${l.id}" role="group" aria-labelledby="list-${l.id}-title">
<li class="list-card${l.id === ui.selectedListId ? " is-current" : ""}" data-list-id="${l.id}" role="group" aria-labelledby="list-${l.id}-title">
<h3 id="list-${l.id}-title" class="list-title">${escapeHtml(l.title)}</h3>
${ui.renamingId === l.id
? `<form class="rename-form" data-action="save-list-rename" data-id="${l.id}">
Expand All @@ -96,6 +120,7 @@ export function createBoardView(root) {
aria-label="Delete list: ${escapeHtml(l.title)}">Delete</button>
</div>`}
<ul class="card-list">${cards}</ul>
${doneStrip}
<form class="card-form" data-action="create-card" data-list-id="${l.id}">
<input name="title" aria-label="Add a card to ${escapeHtml(l.title)}" placeholder="Add a card…" required />
<button type="submit">Add</button>
Expand All @@ -105,33 +130,13 @@ export function createBoardView(root) {
.join("")
: `<li class="empty">No lists yet — add one above.</li>`;

// Done area: done cards grouped under their list. Hidden entirely when nothing is done.
const doneGroups = lists
.map((l) => ({ title: l.title, cards: (l.cards ?? []).filter((c) => c.completedAt) }))
.filter((g) => g.cards.length);
const doneCount = doneGroups.reduce((n, g) => n + g.cards.length, 0);
const doneArea = doneCount === 0 ? "" : `
<section class="done-area" aria-label="Done">
<button class="done-toggle" data-action="toggle-done-section" aria-expanded="${ui.doneOpen ? "true" : "false"}">
✓ Done (${doneCount})
</button>
${ui.doneOpen ? `<div class="done-body">${doneGroups
.map((g) => `
<div class="done-group">
<h3 class="done-group-title">${escapeHtml(g.title)}</h3>
<ul class="done-list">${g.cards
.map((c) => `
<li class="done-row" data-card-id="${c.id}">
<label class="done-row-label">
<input type="checkbox" class="card-done-toggle" data-action="toggle-done" data-card-id="${c.id}" checked
aria-label="Mark not done: ${escapeHtml(c.title)}" />
<span class="done-row-title">${escapeHtml(c.title)}</span>
</label>
</li>`)
.join("")}</ul>
</div>`)
.join("")}</div>` : ""}
</section>`;
const switcher = lists.length ? `
<div class="list-switcher-row">
<label class="list-switcher-label" for="list-switcher">List</label>
<select class="list-switcher" id="list-switcher" data-action="switch-list">
${lists.map((l) => `<option value="${l.id}"${l.id === ui.selectedListId ? " selected" : ""}>${escapeHtml(l.title)}</option>`).join("")}
</select>
</div>` : "";

root.innerHTML = `
<div class="board-view">
Expand All @@ -141,8 +146,8 @@ export function createBoardView(root) {
<input name="title" aria-label="New list name" placeholder="New list…" required />
<button type="submit">Add list</button>
</form>
${switcher}
<ul class="list-columns">${items}</ul>
${doneArea}
</div>`;
}

Expand All @@ -158,10 +163,10 @@ export function createBoardView(root) {
function focusCard(cardId) {
root.querySelector(`.card-chip[data-card-id="${cardId}"]`)?.focus();
}
function focusDoneToggle() {
const t = root.querySelector(".done-toggle");
function focusListDoneToggle(listId) {
const t = root.querySelector(`.list-card[data-list-id="${listId}"] .done-toggle`);
if (t) t.focus();
else focusHeading(); // fallback so a vanished toggle can't strand focus on <body>
else focusHeading(); // never strand focus on <body>
}

function focusListAction(id, preferred) {
Expand Down Expand Up @@ -233,7 +238,13 @@ export function createBoardView(root) {
const btn = e.target.closest("button[data-action]");
if (!btn || btn.dataset.action === "create" || btn.dataset.action === "create-card") return;
const action = btn.dataset.action;
if (action === "toggle-done-section") { ui.doneOpen = !ui.doneOpen; paint(); focusDoneToggle(); return; }
if (action === "toggle-list-done") {
const lid = Number(btn.dataset.listId);
ui.doneOpenLists.has(lid) ? ui.doneOpenLists.delete(lid) : ui.doneOpenLists.add(lid);
paint();
focusListDoneToggle(lid);
return;
}
if (action === "back") return handlers.back();
if (action === "open-card") return handlers.openCard(Number(btn.dataset.cardId));
if (action === "card-up") return handlers.cardUp(Number(btn.dataset.cardId));
Expand All @@ -247,6 +258,13 @@ export function createBoardView(root) {
root.addEventListener("change", (e) => {
const done = e.target.closest('input[data-action="toggle-done"]');
if (done) return handlers.toggleDone(Number(done.dataset.cardId), done.checked);
const sw = e.target.closest('select[data-action="switch-list"]');
if (sw) {
ui.selectedListId = Number(sw.value);
paint();
root.querySelector(".list-switcher")?.focus(); // repaint re-creates the select — refocus it
return handlers.selectList(ui.selectedListId);
}
const sel = e.target.closest('select[data-action="card-move-to"]');
if (!sel) return;
const listId = Number(sel.value);
Expand All @@ -258,5 +276,5 @@ export function createBoardView(root) {
});
}

return { render, focusHeading, focusNewListInput, focusNewCardInput, focusCard, focusDoneToggle, focusListAction, focusCardAction, focusListRenameTrigger, bindActions };
return { render, focusHeading, focusNewListInput, focusNewCardInput, focusCard, focusListDoneToggle, focusListAction, focusCardAction, focusListRenameTrigger, bindActions };
}
28 changes: 28 additions & 0 deletions Wend.Api/wwwroot/js/prefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,31 @@ export function setPref(key, value) {
if (!(key in prefs)) return; // unknown key → ignore
localStorage.setItem(KEY, JSON.stringify({ ...prefs, [key]: value === true }));
}

// Remembered "currently viewed" list per board (mobile single-list switcher). Stored as a
// { boardId: listId } map under its own key. Reads validate to a number; anything else → null,
// so the caller falls back to the first list.
const SELECTION_KEY = "wend.board.selection";

export function getSelectedListId(boardId) {
let map = null;
try {
map = JSON.parse(localStorage.getItem(SELECTION_KEY) ?? "null");
} catch {
// corrupted value → treat as no selection
}
const v = map && typeof map === "object" ? map[boardId] : null;
return typeof v === "number" ? v : null;
}

export function setSelectedListId(boardId, listId) {
let map = null;
try {
map = JSON.parse(localStorage.getItem(SELECTION_KEY) ?? "null");
} catch {
// corrupted value → start fresh
}
if (!map || typeof map !== "object") map = {};
map[boardId] = listId;
localStorage.setItem(SELECTION_KEY, JSON.stringify(map));
}
Loading