diff --git a/README.md b/README.md index d1c0562..0fa6be3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Wend.Api/wwwroot/css/app.css b/Wend.Api/wwwroot/css/app.css index d317326..15bb40f 100644 --- a/Wend.Api/wwwroot/css/app.css +++ b/Wend.Api/wwwroot/css/app.css @@ -38,6 +38,13 @@ 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; @@ -45,6 +52,8 @@ 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 { @@ -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; @@ -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; } diff --git a/Wend.Api/wwwroot/js/board/controller.js b/Wend.Api/wwwroot/js/board/controller.js index b587cb6..2cd8180 100644 --- a/Wend.Api/wwwroot/js/board/controller.js +++ b/Wend.Api/wwwroot/js/board/controller.js @@ -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 { @@ -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); @@ -112,6 +123,7 @@ export function createBoardController(model, view, announce, { onBack, onOpenCar model.subscribe((board) => { lists = board.lists; + boardId = board.id; view.render(board); }); } diff --git a/Wend.Api/wwwroot/js/board/view.js b/Wend.Api/wwwroot/js/board/view.js index 0f92e17..83c29b7 100644 --- a/Wend.Api/wwwroot/js/board/view.js +++ b/Wend.Api/wwwroot/js/board/view.js @@ -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; @@ -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(); @@ -77,8 +84,25 @@ export function createBoardView(root) { `; }) .join(""); + const doneCards = (l.cards ?? []).filter((c) => c.completedAt); + const doneOpen = ui.doneOpenLists.has(l.id); + const doneStrip = doneCards.length ? ` +
+ + ${doneOpen ? `` : ""} +
` : ""; return ` -
  • +
  • ${escapeHtml(l.title)}

    ${ui.renamingId === l.id ? `
    @@ -96,6 +120,7 @@ export function createBoardView(root) { aria-label="Delete list: ${escapeHtml(l.title)}">Delete `} + ${doneStrip} @@ -105,33 +130,13 @@ export function createBoardView(root) { .join("") : `
  • No lists yet — add one above.
  • `; - // 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 ? "" : ` -
    - - ${ui.doneOpen ? `
    ${doneGroups - .map((g) => ` -
    -

    ${escapeHtml(g.title)}

    -
      ${g.cards - .map((c) => ` -
    • - -
    • `) - .join("")}
    -
    `) - .join("")}
    ` : ""} -
    `; + const switcher = lists.length ? ` +
    + + +
    ` : ""; root.innerHTML = `
    @@ -141,8 +146,8 @@ export function createBoardView(root) { + ${switcher}
      ${items}
    - ${doneArea}
    `; } @@ -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 + else focusHeading(); // never strand focus on } function focusListAction(id, preferred) { @@ -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)); @@ -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); @@ -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 }; } diff --git a/Wend.Api/wwwroot/js/prefs.js b/Wend.Api/wwwroot/js/prefs.js index c819c9c..939fa55 100644 --- a/Wend.Api/wwwroot/js/prefs.js +++ b/Wend.Api/wwwroot/js/prefs.js @@ -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)); +} diff --git a/docs/2026-07-08-wend-mobile-a11y-polish-design.md b/docs/2026-07-08-wend-mobile-a11y-polish-design.md index fe1a550..d3abf56 100644 --- a/docs/2026-07-08-wend-mobile-a11y-polish-design.md +++ b/docs/2026-07-08-wend-mobile-a11y-polish-design.md @@ -1,7 +1,7 @@ # Wend — Plan 8: mobile + a11y polish design spec - **Date:** 2026-07-08 -- **Status:** Draft — stress-tested 2026-07-08 (8 fixes folded); pending review (Malin & Henry) +- **Status:** Phase 1 (a11y) built & merged 2026-07-08 (PR #25). Phase 2 (mobile) design brainstormed & **locked with Malin 2026-07-08** — the six open questions below are decided; the global Done area is retired (revises Slice-1 "Done = both"), pending **Henry's review**. - **Owners:** Malin & Henry - **Builds on:** [Slice 1 design](2026-06-15-wend-slice1-design.md) — the final Slice-1 increment - **Build mode:** **Claude solo** (per the 2026-07-07 delegation split — big plans go to Claude; the spec, review, and keyboard/screen-reader acceptance stay shared with Henry) @@ -34,10 +34,11 @@ The a11y findings are ready to build now. The mobile layout still wants the shar - **F9** Picker `aria-haspopup="true"` (menu semantics for a checkbox group); Settings hints not linked via `aria-describedby`; `announce()`'s rAF drops messages in hidden/throttled tabs. - **F10** Settings toggle drops keyboard focus on re-render (same family as F1). -**In — mobile layout (design pending — shared brainstorm):** +**In — mobile layout (decided 2026-07-08 — brainstormed & locked with Malin):** -- Single-list switcher on narrow screens (one list at a time + an accessible way to move between them). +- Single-list switcher on narrow screens — native `` ("Viewing: To Do ▾") — robust, native mobile UX, matches Wend's native-controls-first ethos. Mobile-first: the single-list + switcher is the *baseline*; the horizontal columns layer up unchanged at `min-width: 768px`. -- A **per-list Done strip**: each list gains a collapsible `Done (n)` disclosure of *its* completed cards, mirroring the checklist Done strip and the global Done area. -- Sits on top of **F6** — making each list a labelled region/heading is exactly what the switcher targets, so F6 lands first and the switcher builds on it. +1. **Switcher control → native `` at the top of the board on narrow screens ("List: To Do ▾"). Matches Wend's native-first ethos (already used for "Move to…"), gives a large self-announcing touch target, and scales to any number of lists with zero custom ARIA. *Rejected:* an APG `role="tablist"` (roving tabindex + arrow keys + `aria-selected`/`aria-controls`; wraps badly on a narrow screen). Hidden at `min-width: 768px`, where all columns show. -**Open questions for the brainstorm:** +2. **Done area → per-list strip only; the global Done area is retired.** Each list gains a collapsible `Done (n)` strip of *its* completed cards, mirroring the checklist Done strip ([checklist.js:39](../Wend.Api/wwwroot/js/card/checklist.js:39)) — same collapse/label/contrast pattern; un-check returns the card to the active list. **This revises the Slice-1 "Done = both" decision** ([Slice 1 design](2026-06-15-wend-slice1-design.md)): with per-list strips, the global grouped-by-list area would render every done card a *second* time. Per-list-only = one card, one place, identical on mobile and desktop, consistent with the checklist strip already shipped. Removes the global `doneArea` ([board/view.js:108](../Wend.Api/wwwroot/js/board/view.js:108)) + its `ui.doneOpen`/`focusDoneToggle` (Phase-1 F1's refocus lesson carries to the per-list toggles) and the now-dead `.done-area`/`.done-group`/`.done-row-*` CSS ([app.css:300](../Wend.Api/wwwroot/css/app.css:300)). *Henry to confirm in review — the one Slice-1 decision Phase 2 changes.* -1. **Switcher control:** native `` announces itself; a tabs pattern needs an explicit `aria-live` or focus move. +3. **Selected-list persistence → remembered per board.** The selected list is stored per board in `localStorage` (validated on read, in the spirit of [`prefs.js`](../Wend.Api/wwwroot/js/prefs.js)) so tapping a card and coming back returns to the same list instead of resetting to the first — the fresh-mount house pattern otherwise loses the user's place on every card round-trip. + +4. **Swipe gestures → no.** Keyboard/SR-first; the `` also speaks the option itself. (A tabs pattern would have needed explicit `aria-controls`/live wiring — another point for `` and shows only the selected `.list-card`; `min-width: 768px` hides the `` (mobile baseline; hidden at `min-width: 768px`), selected list remembered per board in `localStorage` (validated; stale → first). +9. Acceptance + README status (Slice 1 complete) + backlog note (mobile shipped). Branch `feature/mobile-a11y-polish` → PR → review + keyboard/SR acceptance → merge. Single-author (Claude as Malin) → squash-merge is clean (the merge-not-squash rule only bites when Henry has commits on the branch). diff --git a/docs/backlog.md b/docs/backlog.md index f92a53c..5b56e0d 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -42,3 +42,11 @@ Things we've consciously chosen to do *later*, each with the reason and the trig soft-delete (`DeletedAt` + query filter) with a "Deleted · Undo" toast that restores in place, sharing the cards' toast primitive and retention behaviour. - **Originally decided:** 2026-07-07 (Malin & Henry, Plan 7 acceptance). + +### Dev static-file caching — a no-cache header for local development + +- **Now:** `UseStaticFiles` sends no `Cache-Control`, so a dev browser can serve stale JS/CSS from earlier `127.0.0.1:5174` sessions and a normal reload won't revalidate ES-module imports — two "bugs" in the 2026-07-08 a11y sweep were cache ghosts. Workaround: hard-reload / disable cache before every browser check. +- **Later:** a dev-only middleware that sets `Cache-Control: no-cache` on static files (Development environment only), so reloads always revalidate. +- **Why deferred:** out of Plan 8's frontend-only scope; the hard-reload habit is a working stopgap and this touches server startup config. +- **Revisit when:** the next housekeeping pass, or if stale-cache ghosts keep biting acceptance runs. +- **Decided:** 2026-07-08 (Malin, Plan 8). diff --git a/docs/plans/2026-07-08-slice1-mobile-a11y-polish.md b/docs/plans/2026-07-08-slice1-mobile-a11y-polish.md index 482fe64..4458501 100644 --- a/docs/plans/2026-07-08-slice1-mobile-a11y-polish.md +++ b/docs/plans/2026-07-08-slice1-mobile-a11y-polish.md @@ -134,20 +134,201 @@ Replace the two `prompt()` calls with the card/checklist inline-rename pattern. --- -## Phase 2 — mobile layout (after the shared brainstorm) +## Phase 2 — mobile layout (decided 2026-07-08 — locked with Malin) -Design-pending — do **not** start these until the switcher decisions in the spec's "Mobile layout" open questions are settled with Henry. +Decisions in the spec's **Mobile layout §**: native ` + ${escapeHtml(c.title)} + +
  • `) + .join("")}` : ""} + ` : ""; + ``` + (No `card-done-toggle` class on the strip checkbox — the classless `.done-item-row input[type="checkbox"]` rule already sizes it to 24px, matching the checklist strip and avoiding the old `.done-row .card-done-toggle` margin fix.) +- **view.js — remove the global area.** Delete the `doneGroups` / `doneCount` / `doneArea` block and the `${doneArea}` in the shell; the shell now ends at ``. +- **view.js — per-list focus helper + click branch.** Replace `focusDoneToggle` and its `toggle-done-section` handler: + ```js + function focusListDoneToggle(listId) { + const t = root.querySelector(`.list-card[data-list-id="${listId}"] .done-toggle`); + if (t) t.focus(); + else focusHeading(); // never strand focus on + } + ``` + ```js + 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; + } + ``` + Swap `focusDoneToggle` → `focusListDoneToggle` in the returned object. +- **controller.js — retarget the done-toggle focus.** `toggleDone` calls `view.focusDoneToggle()` on complete; find the card's list and focus that list's strip toggle instead: + ```js + toggleDone: async (cardId, completed) => { + 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}.`); + if (list) view.focusListDoneToggle(list.id); else view.focusHeading(); + } else { + announce(`Restored: ${title}.`); + view.focusCard(cardId); + } + } catch { + announce("Couldn't update the card — please try again."); + } + }, + ``` +- **app.css — delete the dead global-area rules** ([app.css:300](../Wend.Api/wwwroot/css/app.css:300)): `.done-area`, `.done-body`, `.done-group-title`, `.done-list`, `.done-row-label`, `.done-row-title`, `.done-row .card-done-toggle`. **Keep `.done-toggle`** (shared by the checklist strip and now the per-list board strip). Update the section comment to say `.done-toggle` is the shared strip disclosure. + +**Verify (browser):** mark an active card done → it leaves the list's active cards and appears under that list's "✓ Done (n)" strip; `document.activeElement` is that list's `.done-toggle` (not `BODY`). Expand → the title is struck through and composites ≥ 4.5:1 against `#0D1117`; the checkbox measures ≥ 24px. Un-check → the card returns to active; un-checking the last done card removes the strip and focus lands on the card. No global Done area at the board bottom. + +**Commit:** `Plan 8 Task 7 — per-list Done strips; retire the global Done area` + +### Task 8 — Single-list switcher + remembered selection + +**Files:** `js/prefs.js`, `js/board/view.js`, `js/board/controller.js`, `css/app.css` + +Narrow screens (`< 768px`) show one list at a time behind a native ` + ${lists.map((l) => ``).join("")} + + ` : ""; + ``` +- **view.js — handle the change + keep focus.** In the existing `change` listener (after the `toggle-done` check, before `card-move-to`): + ```js + 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); + } + ``` +- **controller.js — persist + announce.** Add `setSelectedListId` to the prefs import, capture the board id, add the handler: + ```js + // top of createBoardController: + let boardId = null; + // model.subscribe: + model.subscribe((board) => { lists = board.lists; boardId = board.id; view.render(board); }); + // new handler: + 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"}.`); + }, + ``` +- **app.css — switcher styles (mobile-first).** Before the `@media (min-width: 768px)` block at [app.css:41](../Wend.Api/wwwroot/css/app.css:41): + ```css + /* 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; } + ``` + Inside that `@media (min-width: 768px)` block add: + ```css + .list-switcher-row { display: none; } + .list-columns > .list-card { display: flex; } /* every column visible on desktop */ + ``` + +**Verify (browser):** at 375px only the current list shows with the `