From ac53bcebd3f6689803a33e6d9db9df60299e788b Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 18:40:33 +0200 Subject: [PATCH 01/21] docs: plan the Ignite v3 Focus surface --- .../2026-08-13-ignite-v3-focus-surface.md | 2452 +++++++++++++++++ 1 file changed, 2452 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-ignite-v3-focus-surface.md diff --git a/docs/superpowers/plans/2026-08-13-ignite-v3-focus-surface.md b/docs/superpowers/plans/2026-08-13-ignite-v3-focus-surface.md new file mode 100644 index 0000000..e3eea85 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-ignite-v3-focus-surface.md @@ -0,0 +1,2452 @@ +# Ignite v3 — Plan 3: The Focus surface + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Merge Today and Focus into one landing surface with four tabs — Today, Tomorrow, Starred, Focus — so the notepad capture writes into is somewhere you can actually look, and the dates Plan 2 made real finally have a surface that shows them. + +**Architecture:** The landing route is renamed `today` → `focus` and `#area/focus` redirects onto it, so the Focus area stops being a second door to the same tasks. `src/views/today.js` is renamed to `src/views/focus.js` and grows a tab strip: view-owned tab state, four mutually exclusive panels, and the §3.3 lifecycle (close menu → commit rename → render → focus the tab). All bucketing moves into one new pure function, `groupTasksForFocus`, which adds a Tomorrow bucket and a notepad bucket to the existing cascade. Rows gain an area badge, and at ≥768px the badge and time become fixed-width columns that line up across every group. + +**Tech Stack:** Vanilla JS (MVC), Vite, Vitest, Biome. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-08-11-ignite-v3-focus-design.md` — decisions D1 and D3, §3, §7, §8, and the §9 function table. + +**Status:** stress-tested 2026-08-13, 13 findings folded in — no criticals, four important. The four worth knowing before you read a step and think it looks over-built: the `#area/focus` redirect uses `replaceState` because assigning `location.hash` builds a back-button trap; the capture toast snapshots its preconditions before its `await` because reading them after makes it announce the wrong destination; `doRender` re-asserts tab focus because the 60-second tick otherwise throws it to ``; and `pendingFocusTaskId` has a fallback because rescheduling a task off the current tab is now the ordinary case, not an edge one. + +## Global Constraints + +- **Mobile-first CSS.** Baseline styles target the phone; layer up with `@media (min-width: 768px)`. No `max-width` queries. +- **`design-system/` is vendored and read-only.** Never hand-edit. All project CSS goes in `main.css` at the repo root (NOT `src/styles/main.css`). +- **Escape every user-authored string** through `escapeHtml` before interpolating. The area badge is a new interpolation site for a user-authored area name. +- **≥44×44 px** for every new control: each tab and the File button. **At 375px the tab strip scrolls horizontally — the targets do not shrink** (§8.1). §8.1 also lists the capture bar's destination chip; **it is deliberately excluded** — the chip is a non-interactive ``, not a control, and sizing it to 44px would unbalance the capture bar. One of the three recorded Plan 1 corrections the spec still carries. +- **`renderPageHeader` in `controller.js` stays the only `

` emitter in the app.** Views render into `#main-root`, a later sibling of `#page-header`, so they cannot own it. +- **Never put `overflow` on `.group__list` or `.group`.** The task ⋯ menu injects inside the `
  • `; any clipping ancestor cuts it off. (`overflow-x` on the *tab strip* is fine — no menu lives there.) +- **`.task` stays a flex row.** `.task__title` keeps `flex: 1; min-width: 0`, and `.task--editing`'s padding must keep tracking `.task`'s. Do not convert the row to grid. +- **Tab counts and area badges use real token colours, never `opacity`** (§8.2). v2 shipped zero contrast violations; hold the line. +- **Any tab transition is gated on `prefers-reduced-motion`.** +- **`utils/` never imports from `model/`.** Focus-area membership reaches the pure functions as a list of section ids the controller resolves. +- **Line endings are LF**, pinned by `.gitattributes`. Do not resolve a Biome complaint by changing Biome. +- **No AI attribution in commit messages.** No `Co-Authored-By`, no "Generated with Claude Code". +- Run `npm run check` (Biome) before every commit. Zero warnings, zero `biome-ignore`. +- Green baseline before starting: **228 tests / 15 files**, Biome clean, build clean. +- **The suite grows, so no step asserts a fixed total.** Tasks 1–5 each add cases; Tasks 6–14 add none. Projection, for sanity only: Task 1 `+2` → 230, Task 2 `+5` → 235, Task 3 `+2` → 237, Task 4 `+6` → 243, Task 5 `+4` and a 16th file → **247 / 16**, and flat thereafter. **Read the real number off the runner every time and report that** — a projection is not evidence, and this project has shipped a plan whose arithmetic was wrong before. + +--- + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `src/controller.js` | modify | Route rename + `#area/focus` redirect; page header (heading, greeting, summary); capture toast; tab wiring | +| `src/utils/time.js` | modify | `groupTasksForFocus` (replaces `groupTasksForToday`), `pickNextTask` (new signature), `formatDayGreeting`, `summariseDay` | +| `src/utils/areas.js` | **create** | `areaForTask` — resolves the row's area badge | +| `src/views/focus.js` | **create** (git mv of `today.js`) | The Focus surface: tabs, panels, menus, rename, File | +| `src/views/today.js` | **delete** (via git mv) | — | +| `src/views/focus-tabs.js` | **create** | Pure template for the tab strip + the `TABS` list | +| `src/views/task.js` | modify | Area badge slot; row order reshuffled for column alignment | +| `src/views/toast.js` | modify | Generic action button (`onAction` / `actionLabel`) instead of a hardcoded Undo | +| `src/views/sidebar.js` | modify | Focus stops being a listed area; route name | +| `src/views/topbar.js` | modify | `go-today` → `go-focus` | +| `tests/unit/parse-hash.test.js` | modify | Landing route is `focus` | +| `tests/utils/time.test.js` | modify | New grouping, sort, hero, greeting and summary contracts | +| `tests/utils/areas.test.js` | **create** | `areaForTask` | +| `tests/utils/capture.test.js` | modify | Route-name copy in one fixture | +| `main.css` | modify | Tab strip, panels, badge/time columns, empty states, phone padding | +| `README.md` | modify | Describe the Focus surface | + +### Four deliberate deviations from the spec, and why + +**1. Tab state is view-owned, not controller-owned.** §3.3 says "controller-owned transient state, the same call as `drawerOpen`". It is owned by `focus.js` instead. Three reasons: all four §3.3 lifecycle steps (close the menu, commit the live rename, render, focus the tab button) act on state that already lives in the view — `openMenuTaskId`, `taskMenuMode`, `renamingTaskId`, `pendingRenameTaskValue` — and the controller cannot touch any of them; the spec's own rule "it always resets to Today on mount" comes for free from a closure that dies with the view, whereas a controller field would need an explicit reset in `mountMainView`; and `drawerOpen` is controller-owned because the drawer is *chrome*, outside every view, which the tabs are not. The controller still drives the tab where it needs to, through the `selectTab(tabId)` / `getActiveTab()` methods the view exposes (Task 13). + +**2. `formatDayGreeting` builds its string from constant arrays, not `toLocaleDateString`.** Today's `renderPageHeader` calls `state.now.toLocaleDateString(undefined, {...})`, which returns a different string per locale and is therefore untestable without pinning a locale. §9 requires `formatDayGreeting(now)` as a tested pure function and §3.1 fixes the format as `Tuesday 11 August`. Constant arrays give exactly that, deterministically, and match how `formatTimeLabel` and `formatOccurrenceLabel` already work in the same file. **Consequence: on a Norwegian-locale browser the greeting changes from "tirsdag 11. august" to "Tuesday 11 August".** The rest of the app is English-only ("Today", "Overdue", "Starred", "Next"), so this makes the surface consistent rather than half-localised — but it is a visible change and it is Malin's to reject. + +**3. `pickNextTask` changes signature to take the grouped buckets.** It currently takes the raw task list and scans *every* dated task, so it can promote something due next Friday into a hero labelled "Next" on the Today tab. It also treats an untimed task due today as overdue, because an untimed task is stored at local midnight and `dueAt > now` is false from 00:01 — the defect Plan 2 recorded and explicitly deferred to this plan. Taking `groups` fixes both: the candidates are exactly what the Today tab renders. Task 3 rewrites its four existing tests. + +**4. The toast's action button is renamed, not aliased.** §3.4 needs a toast with a **View** action. `toast.show` currently hardcodes the label "Undo" and names the handler `onUndo`. Passing a View action through a slot called `onUndo` would be a lie at seven call sites; adding a second alias would be worse. Task 6 renames the pair to `onAction` / `actionLabel` (defaulting to `"Undo"`) and the CSS class `.toast__undo` → `.toast__action`, and updates all five `onUndo:` call sites. The nine-variable `clearActive()` reset contract is preserved exactly — one variable is renamed, none added or removed. + +### Four gaps the spec does not cover + +**A task due later than tomorrow appears on no tab.** The §3.2 table has Today, Tomorrow, Starred and the notepad; a task due next Friday matches none of them, and the date-beats-star cascade means starring it does not rescue it either. It is still visible in its own area, so nothing is lost — but the landing surface cannot show you the week. **Not fixed here.** The answer is the Calendar tab, which §11 defers explicitly and which the tab strip this plan builds is the structure to hang. Recorded so it is a decision. + +**Filing a task out of the notepad leaves focus with nowhere to return.** `pick-move-target` sets `pendingFocusTaskId` on the moved task, with the comment "the task stays in Today". That is true on the dated tabs — a move between sections changes neither `dueAt` nor `starred` — and false on the notepad, where filing is precisely what removes the row from the tab. Task 11 branches: on the Focus tab, focus goes to the Focus **tab button**, which always exists, rather than to a ⋯ that is about to be detached. §5's prescribed `focusAfterFile(nextId)` drain is deliberately not built, matching Plan 1's decision to reuse the move picker wholesale. + +**`area.js`'s File button does not move focus into the picker it opens.** §4.2's focus contract says the picker opens with focus on the first section item; `area.js:494-502` opens the picker without setting `pendingMenuFocusTaskId`, so focus stays on a File button that the synchronous re-render has just detached. `focus.js` does it correctly (Task 11), which leaves the two surfaces inconsistent. **Not fixed here** — `area.js` is out of this plan's scope and the fix is one line whenever someone is in that file. Recorded as a follow-up. + +**The Starred tab's cross-area `order` sort stays arbitrary.** `order` is only meaningful within a section, so sorting a cross-area list by it produces an arbitrary sequence. §3.2 flags this as an inherited wart and leaves it; this plan carries it unchanged so it changes one thing at a time. + +### Considered and deliberately not fixed + +- **A tab switch during a rename whose model write rejects leaves the tab visually unchanged.** `selectTab` fires the rename commit and then does *not* render, so the write's own notify-render can render the new tab and consume the focus flag in one pass. Rendering here as well would focus the tab button and let the queued render detach it — focus to ``, the exact trap the cascade-focus drain exists to prevent, and the view cannot drain (only the controller can `await applyState()`). If the write rejects — the task was cascade-deleted mid-rename — no notify fires and the tab does not visibly change until the next render. This is identical in kind to the race `commitTaskRenameFromInput` already carries today, where a rejected write leaves `pendingFocusTaskId` unconsumed. Same trade, same reasoning, not made worse. +- **Tabs are not in the URL.** §13 decides this: the app must always open on Today, and tab history would fight that. +- **The area badge is `display: none` on phones, which strips it from the accessibility tree too.** The area is not conveyed by any other means on a phone row. Accepted: §7 makes dropping it a layout decision, and the alternative — a visually-hidden badge — adds a per-row string to every screen-reader row for information the sighted phone user does not get either. +- **Both `renderPageHeader` and the view call `groupTasksForFocus` on the same state.** Two O(n) passes per render instead of one. They are handed the identical `state` object, including the same `now`, so the summary counts and the tab counts agree by construction — which is the property worth having. Threading the groups through `state` would make the view depend on the controller having computed them. +- **The capture toast can in principle paint over an open capture picker.** The known latent trap: `#toast-root` is z-index 100 and beats a picker inside `#capture-root`'s z-index-60 stacking context. Unreachable here — the toast only fires on the Focus route, where `captureDestination` returns `{ kind: "focus" }` and the picker never opens. + +*The rest of this list came out of the 2026-08-13 stress test.* + +- **The tab count includes the "Next" hero; the group heading count excludes it.** Two numbers on one screen derived differently — the Today tab may read `5` above an Overdue group headed `1` and a Today group headed `3`. Pre-existing: `today.js` already filters the hero out before counting a group. Defensible, because a group's count counts the rows in that group. +- **Every tab shows a count even at zero.** "Tomorrow 0" is noise on an empty app, but §3.2 says each tab shows a count, and suppressing zeroes makes the strip's width jump as tasks come and go — worse on the 375px scrolling strip than the noise it removes. +- **Arrow-key activation commits a live rename.** Automatic activation means arrowing across the strip mid-rename commits it. That is §3.3's rule stated outright — commit, never discard — and it is what clicking anywhere else already does. +- **Extra Focus sections become unmanageable.** A second Focus section created before the merge keeps its tasks visible in the flat notepad (§12.4) and File moves them out, but the section itself can no longer be renamed, reordered or deleted, and lingers in IndexedDB. Cruft, not data loss. The move picker still offers it as a target, which is consistent. Belongs to the deferred rail rethink. +- **The toast's View button is not inside the live region.** A screen-reader user hears "Added to Focus" and is not told a View button exists; they have five seconds to Tab to it. Identical for Undo across all five existing toasts, and the toast's `aria-live` invariant deliberately keeps the button *outside* the live region so `update()` re-announces. Not a regression, and the Focus tab is reachable regardless — View is a shortcut, not the only route. +- **`·` as the summary separator.** Screen readers may skip it, so `1 overdue · 3 due today` can read as one run-on phrase. `formatTimeLabel` already ships `"Aug 21 · 09:00"`; a lone comma here would be the inconsistency. +- **Holding an arrow key cycles tabs and re-renders on every repeat.** `bindKeys` fires on keydown repeat. The icon picker has exactly this property and shipped. + +--- + +## Task 1: Rename the landing route to Focus + +**Files:** +- Modify: `src/controller.js` (`parseHash`, `currentRoute` init, `mountMainView`, `onHashChange`, `start`, both `onGoToday` callbacks) +- Modify: `src/views/sidebar.js` (header comment, `onGoToday` param, `go-today` action, `template`) +- Modify: `src/views/topbar.js` (header comment, `onGoToday` param, `go-today` action) +- Test: `tests/unit/parse-hash.test.js`, `tests/utils/capture.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: `parseHash(hash)` returns `{ name: "focus" }` for `""`, `"#"`, `"#today"`, `"#focus"` and every unknown route; `{ name: "area", id }` unchanged. The controller exposes no new API. `createSidebarView` / `createTopbarView` take `onGoFocus` where they took `onGoToday`. + +- [ ] **Step 1: Rewrite the failing route tests** + +Replace the whole body of `tests/unit/parse-hash.test.js` with: + +```javascript +import { describe, expect, it } from "vitest"; +import { parseHash } from "../../src/controller.js"; + +// parseHash maps the location hash to a route object. Two routes exist: +// { name: "focus" } — the landing surface, and the fallback for anything unknown +// { name: "area", id: "" } — an area page +// "#today" is kept as an accepted alias so pre-v3 bookmarks still resolve. +describe("parseHash", () => { + it("treats an empty hash as Focus", () => { + expect(parseHash("")).toEqual({ name: "focus" }); + }); + + it("treats a bare # as Focus", () => { + expect(parseHash("#")).toEqual({ name: "focus" }); + }); + + it("parses #focus as Focus", () => { + expect(parseHash("#focus")).toEqual({ name: "focus" }); + }); + + it("still parses the legacy #today as Focus", () => { + expect(parseHash("#today")).toEqual({ name: "focus" }); + }); + + it("parses a hash with no leading # as Focus", () => { + expect(parseHash("focus")).toEqual({ name: "focus" }); + }); + + it("parses #area/ into an area route", () => { + expect(parseHash("#area/abc123")).toEqual({ name: "area", id: "abc123" }); + }); + + it("keeps the full id after area/, including dashes", () => { + expect(parseHash("#area/focus-default")).toEqual({ + name: "area", + id: "focus-default", + }); + }); + + it("still parses #area/focus as an area route (the controller redirects it)", () => { + // parseHash stays pure — the redirect is a side effect and belongs to the + // controller, which owns FOCUS_ID. utils and pure parsers never import model/. + expect(parseHash("#area/focus")).toEqual({ name: "area", id: "focus" }); + }); + + it("falls back to Focus on an unknown route", () => { + expect(parseHash("#settings")).toEqual({ name: "focus" }); + }); + + it("falls back to Focus when area/ has no id", () => { + // /^area\/(.+)$/ requires at least one character after the slash. + expect(parseHash("#area/")).toEqual({ name: "focus" }); + }); + + it("treats null and undefined as Focus (hash may be unset)", () => { + expect(parseHash(null)).toEqual({ name: "focus" }); + expect(parseHash(undefined)).toEqual({ name: "focus" }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:run -- tests/unit/parse-hash.test.js +``` + +Expected: FAIL — several assertions report `{ name: "today" }` where `{ name: "focus" }` is expected. + +- [ ] **Step 3: Rename the route in `parseHash`** + +In `src/controller.js`, replace the `parseHash` body: + +```javascript +export function parseHash(hash) { + const raw = (hash || "").replace(/^#/, ""); + if (raw === "" || raw === "focus" || raw === "today") return { name: "focus" }; + const areaMatch = raw.match(/^area\/(.+)$/); + if (areaMatch) return { name: "area", id: areaMatch[1] }; + return { name: "focus" }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm run test:run -- tests/unit/parse-hash.test.js +``` + +Expected: PASS. + +- [ ] **Step 5: Update the controller's route handling** + +In `src/controller.js`: + +Change the initial route (grep `let currentRoute`): + +```javascript + let currentRoute = { name: "focus" }; +``` + +Add the redirect helper directly above `function mountMainView(route) {`: + +```javascript + // #area/focus is a dead duplicate of the landing route: Focus is no longer a + // listed area, it IS the landing surface. Redirect rather than render it, so + // neither a bookmark nor the back button can land on a second copy of the + // same tasks. + // + // replaceState, NOT an assignment to location.hash. Assigning PUSHES a + // history entry, so Back would land on #area/focus and be redirected straight + // forward again — a loop the user cannot walk out of. replaceState rewrites + // the URL in place, fires no hashchange, and therefore also mounts once + // instead of twice. + function routeFromHash() { + const route = parseHash(window.location.hash); + if (route.name === "area" && route.id === FOCUS_ID) { + window.history.replaceState(null, "", "#focus"); + return { name: "focus" }; + } + return route; + } +``` + +Change the route test in `mountMainView` (grep `if (route.name === "today")`): + +```javascript + if (route.name === "focus") { +``` + +In `onHashChange`, replace the `currentRoute` assignment (grep `currentRoute = parseHash`): + +```javascript + currentRoute = routeFromHash(); +``` + +In `start()`, replace the identical assignment (grep `currentRoute = parseHash`, the second occurrence) with: + +```javascript + currentRoute = routeFromHash(); +``` + +- [ ] **Step 6: Rename the go-home callback through the controller, topbar and sidebar** + +In `src/controller.js`, both callback objects (grep `onGoToday`) become: + +```javascript + onGoFocus: () => { + window.location.hash = "#focus"; + closeDrawer(); // same-hash tap of "Ignite" on #focus fires no hashchange + }, +``` + +In `src/views/topbar.js`: + +```javascript +// createTopbarView(rootEl, { onToggleDrawer, onGoFocus }) → { setExpanded(open), destroy() } +``` + +```javascript +export function createTopbarView(rootEl, { onToggleDrawer, onGoFocus }) { +``` + +```javascript + +``` + +```javascript + "go-focus": () => onGoFocus(), +``` + +In `src/views/sidebar.js`, update the header comment (grep `onGoToday` and `{ name: "today" }`): + +```javascript +// onToggleCollapse, onGoFocus, onOpenArea, +``` + +```javascript +// { name: "focus" } → wordmark gets aria-current="page" +``` + +the destructure (grep `onGoToday,`): + +```javascript + onGoFocus, +``` + +the action key (grep `"go-today"`): + +```javascript + "go-focus": () => onGoFocus(), +``` + +and in `template` (grep `const route = state.route`): + +```javascript + const route = state.route ?? { name: "focus" }; + const focusActive = route.name === "focus"; + const wordmarkAria = focusActive ? 'aria-current="page"' : ""; + const wordmarkActive = focusActive ? "is-active" : ""; +``` + +plus the button itself (grep `data-action="go-today"`): + +```javascript + +``` + +- [ ] **Step 7: Update the one stale route fixture** + +In `tests/utils/capture.test.js`, the fixture on line 11 names the old route. `captureDestination` only tests `route?.name !== "area"`, so this is copy, not behaviour: + +```javascript + expect(captureDestination({ name: "focus" }, [])).toEqual({ +``` + +- [ ] **Step 8: Run the full suite and Biome** + +```bash +npm run test:run && npm run check +``` + +Expected: all tests pass, Biome reports zero warnings. The suite grows in this task — the rewritten `parse-hash.test.js` has 11 cases where the old one had 9. + +- [ ] **Step 9: Commit** + +```bash +git add src/controller.js src/views/sidebar.js src/views/topbar.js tests/unit/parse-hash.test.js tests/utils/capture.test.js +git commit -m "refactor(route): rename the landing route to focus and redirect #area/focus" +``` + +--- + +## Task 2: `groupTasksForFocus` + +**Files:** +- Modify: `src/utils/time.js` (replace `groupTasksForToday`) +- Modify: `src/views/today.js` (the import and its one call site) +- Test: `tests/utils/time.test.js` + +**Interfaces:** +- Consumes: `sortByDueThenUntimed(tasks)` (already in the file). +- Produces: `groupTasksForFocus(tasks, now, focusSectionIds)` → `{ overdue, today, tomorrow, starred, notepad }`, all arrays of task objects. `focusSectionIds` is an array of section ids belonging to the Focus area; the controller resolves it, because `utils/` never imports `FOCUS_ID` from `model/`. `groupTasksForToday` is **removed** in this task. + +**Every commit on this branch must leave the build green**, so the caller moves over in the same commit that changes the function. `today.js` reads only `.overdue`, `.today` and `.starred`, which the new return value still carries, and it passes `[]` for `focusSectionIds` because it has no notepad to render yet — Task 10 supplies the real list. Do **not** leave `groupTasksForToday` behind as a bridge; two bucketing functions is exactly the drift the whole-branch review has caught twice. + +- [ ] **Step 1: Write the failing tests** + +In `tests/utils/time.test.js`, replace the entire `describe("groupTasksForToday", …)` block (it starts at line 73) with: + +```javascript +describe("groupTasksForFocus", () => { + it("partitions tasks into overdue, today, tomorrow, starred and the notepad", () => { + const tasks = [ + task({ id: "a", dueAt: "2026-04-27T09:00:00.000Z" }), // overdue (yesterday) + task({ id: "b", dueAt: "2026-04-28T18:00:00.000Z" }), // today + task({ id: "c", dueAt: "2026-04-29T09:00:00.000Z" }), // tomorrow + task({ id: "d", starred: true, dueAt: null }), // starred undated + task({ id: "e", dueAt: null, sectionId: "focus-default" }), // notepad + task({ id: "f", dueAt: null, sectionId: "work-1" }), // undated in an area: no tab + task({ id: "g", dueAt: "2026-05-10T09:00:00.000Z" }), // beyond tomorrow: no tab + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.overdue.map((t) => t.id)).toEqual(["a"]); + expect(result.today.map((t) => t.id)).toEqual(["b"]); + expect(result.tomorrow.map((t) => t.id)).toEqual(["c"]); + expect(result.starred.map((t) => t.id)).toEqual(["d"]); + expect(result.notepad.map((t) => t.id)).toEqual(["e"]); + }); + + it("excludes completed tasks from every group, including the notepad", () => { + const tasks = [ + task({ id: "a", completed: true, dueAt: "2026-04-27T09:00:00.000Z" }), + task({ id: "b", completed: true, dueAt: "2026-04-28T18:00:00.000Z" }), + task({ id: "c", completed: true, dueAt: "2026-04-29T09:00:00.000Z" }), + task({ id: "d", completed: true, starred: true }), + task({ id: "e", completed: true, sectionId: "focus-default" }), + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.overdue).toEqual([]); + expect(result.today).toEqual([]); + expect(result.tomorrow).toEqual([]); + expect(result.starred).toEqual([]); + expect(result.notepad).toEqual([]); + }); + + it("lets a date beat a star: a dated starred task never reaches Starred", () => { + const tasks = [ + task({ id: "a", starred: true, dueAt: "2026-04-28T18:00:00.000Z" }), + // Dated beyond tomorrow AND starred — the date still wins, so it lands + // on no tab at all rather than falling through to Starred. + task({ id: "b", starred: true, dueAt: "2026-05-10T09:00:00.000Z" }), + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.today.map((t) => t.id)).toEqual(["a"]); + expect(result.starred).toEqual([]); + }); + + it("lets a star beat the notepad: starring a note promotes it out", () => { + const tasks = [ + task({ id: "a", starred: true, dueAt: null, sectionId: "focus-default" }), + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.starred.map((t) => t.id)).toEqual(["a"]); + expect(result.notepad).toEqual([]); + }); + + it("keeps same-day past-due tasks in Today, not Overdue", () => { + const tasks = [task({ id: "a", dueAt: "2026-04-28T09:00:00.000Z" })]; + const result = groupTasksForFocus(tasks, NOW, []); + expect(result.overdue).toEqual([]); + expect(result.today.map((t) => t.id)).toEqual(["a"]); + }); + + it("sorts the dated groups by time and starred by order", () => { + const tasks = [ + task({ id: "b", dueAt: "2026-04-28T18:00:00.000Z", hasTime: true }), + task({ id: "a", dueAt: "2026-04-28T16:00:00.000Z", hasTime: true }), + task({ id: "d", dueAt: "2026-04-29T18:00:00.000Z", hasTime: true }), + task({ id: "c", dueAt: "2026-04-29T16:00:00.000Z", hasTime: true }), + task({ id: "z", starred: true, order: 2 }), + task({ id: "y", starred: true, order: 0 }), + ]; + const result = groupTasksForFocus(tasks, NOW, []); + expect(result.today.map((t) => t.id)).toEqual(["a", "b"]); + expect(result.tomorrow.map((t) => t.id)).toEqual(["c", "d"]); + expect(result.starred.map((t) => t.id)).toEqual(["y", "z"]); + }); + + it("sorts the notepad newest first, on createdAt", () => { + // The notepad is a capture stream, so the thing you just typed belongs on + // top. Deliberately NOT `order`: the M4 reorder invariants depend on + // `order` meaning position-within-a-section, and this is a cross-section + // view. Spec §12.2. + const tasks = [ + task({ id: "old", sectionId: "focus-default", createdAt: "2026-04-01T10:00:00.000Z" }), + task({ id: "new", sectionId: "focus-default", createdAt: "2026-04-28T10:00:00.000Z" }), + task({ id: "mid", sectionId: "focus-default", createdAt: "2026-04-14T10:00:00.000Z" }), + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.notepad.map((t) => t.id)).toEqual(["new", "mid", "old"]); + }); + + it("counts every Focus section into the notepad, not just focus-default", () => { + // §12.4: extra Focus sections created before the merge still show their + // tasks. Nothing is orphaned by the surface losing its section headings. + const tasks = [ + task({ id: "a", sectionId: "focus-default" }), + task({ id: "b", sectionId: "focus-extra" }), + ]; + const result = groupTasksForFocus(tasks, NOW, [ + "focus-default", + "focus-extra", + ]); + expect(result.notepad.map((t) => t.id).sort()).toEqual(["a", "b"]); + }); + + it("treats a missing focusSectionIds as an empty notepad rather than throwing", () => { + const tasks = [task({ id: "a", sectionId: "focus-default" })]; + expect(groupTasksForFocus(tasks, NOW).notepad).toEqual([]); + }); +}); +``` + +The `task()` helper at the top of the file already defaults `createdAt` to `"2026-04-28T08:00:00.000Z"` and `sectionId` to `"focus-default"`, so nothing needs adding — but **that `sectionId` default is why several fixtures above set it explicitly.** A task built with no overrides is already a Focus-area task, and the notepad assertions would pass for the wrong reason otherwise. + +**There is a SECOND block calling the old function** — `describe("groupTasksForToday — untimed ordering", …)`, further down the file, which Plan 2 added. Convert it rather than deleting it; it pins a contract this plan does not change: + +```javascript +describe("groupTasksForFocus — untimed ordering", () => { + it("sorts today's untimed tasks after its timed ones", () => { + const tasks = [ + task({ + id: "untimed", + dueAt: new Date("2026-04-28T00:00:00").toISOString(), + hasTime: false, + }), + task({ + id: "timed", + dueAt: new Date("2026-04-28T18:00:00").toISOString(), + hasTime: true, + }), + ]; + expect(groupTasksForFocus(tasks, NOW, []).today.map((t) => t.id)).toEqual([ + "timed", + "untimed", + ]); + }); +}); +``` + +Grep `groupTasksForToday` across `tests/` before moving on — after this task there must be zero hits anywhere in the repo. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:run -- tests/utils/time.test.js +``` + +Expected: FAIL with `groupTasksForFocus is not a function` (and the import at the top of the file still names `groupTasksForToday`). + +- [ ] **Step 3: Replace `groupTasksForToday` with `groupTasksForFocus`** + +In `src/utils/time.js`, replace the whole `groupTasksForToday` function with: + +```javascript +// The four Focus tabs, in one pass. Precedence is a cascade — a date beats a +// star, a star beats the notepad — so each task lands in at most one bucket and +// the tab counts sum to something meaningful. +// +// A dated task outside today/tomorrow deliberately falls off every tab rather +// than dropping through to Starred: the date is the strongest statement the +// user has made about it, and it is still visible in its own area. +// +// `focusSectionIds` arrives as a parameter because utils/ must never import +// FOCUS_ID from model/. The controller resolves it from the section list. +export function groupTasksForFocus(tasks, now, focusSectionIds) { + const startToday = startOfDay(now).getTime(); + const startTomorrow = startToday + ONE_DAY_MS; + const startDayAfter = startTomorrow + ONE_DAY_MS; + const inFocus = new Set(focusSectionIds ?? []); + + const overdue = []; + const today = []; + const tomorrow = []; + const starred = []; + const notepad = []; + + for (const t of tasks) { + if (t.completed) continue; + if (t.dueAt) { + const due = new Date(t.dueAt).getTime(); + if (due < startToday) overdue.push(t); + else if (due < startTomorrow) today.push(t); + else if (due < startDayAfter) tomorrow.push(t); + continue; + } + if (t.starred) { + starred.push(t); + continue; + } + if (inFocus.has(t.sectionId)) notepad.push(t); + } + + starred.sort((a, b) => a.order - b.order); + // Newest first: the notepad is a capture stream, so what you just typed goes + // on top. Sorting in the view keeps `order` semantics — and the M4 reorder + // invariants that rest on them — untouched. Spec §12.2. + notepad.sort((a, b) => + a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0, + ); + + return { + overdue: sortByDueThenUntimed(overdue), + today: sortByDueThenUntimed(today), + tomorrow: sortByDueThenUntimed(tomorrow), + starred, + notepad, + }; +} +``` + +- [ ] **Step 4: Update the test file's import** + +At the top of `tests/utils/time.test.js`, replace `groupTasksForToday,` in the import list with `groupTasksForFocus,` (keep the list alphabetical — Biome sorts it). + +- [ ] **Step 5: Move the one existing caller across** + +In `src/views/today.js`, change the import (grep `groupTasksForToday`): + +```javascript +import { groupTasksForFocus, pickNextTask } from "../utils/time.js"; +``` + +and its single call site inside `template`: + +```javascript + // Temporary []: this view has no notepad yet, and an empty focusSectionIds + // yields an empty notepad bucket, which nothing here reads. Task 10 replaces + // the whole template and supplies the real list. + const groups = groupTasksForFocus(state.tasks, state.now, []); +``` + +- [ ] **Step 6: Run the tests, Biome and the build** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean, build clean. The rendered Today view is byte-for-byte what it was — the three buckets it reads are unchanged. + +- [ ] **Step 7: Commit** + +```bash +git add src/utils/time.js src/views/today.js tests/utils/time.test.js +git commit -m "feat(focus): bucket tasks into the four Focus tabs" +``` + +--- + +## Task 3: `pickNextTask` over the grouped buckets + +**Files:** +- Modify: `src/utils/time.js` (`pickNextTask`) +- Modify: `src/views/today.js` (two adjacent lines in `template`) +- Test: `tests/utils/time.test.js` + +**Interfaces:** +- Consumes: the object `groupTasksForFocus` returns. +- Produces: `pickNextTask(groups, now)` → a task object or `null`. It reads only `groups.today` and `groups.overdue`. + +- [ ] **Step 1: Write the failing tests** + +In `tests/utils/time.test.js`, replace the entire `describe("pickNextTask", …)` block with: + +```javascript +describe("pickNextTask", () => { + // The hero reads the grouped buckets, not the raw list, so it can never + // promote something due next week into a card labelled "Next" on Today. + const groups = (over, todayList) => ({ + overdue: over, + today: todayList, + tomorrow: [], + starred: [], + notepad: [], + }); + + it("picks the earliest still-upcoming timed task today", () => { + const a = task({ id: "a", dueAt: "2026-04-28T18:00:00.000Z", hasTime: true }); + const b = task({ id: "b", dueAt: "2026-04-28T16:00:00.000Z", hasTime: true }); + expect(pickNextTask(groups([], [b, a]), NOW)?.id).toBe("b"); + }); + + it("skips a timed task that has already passed today", () => { + const past = task({ id: "past", dueAt: "2026-04-28T09:00:00.000Z", hasTime: true }); + const soon = task({ id: "soon", dueAt: "2026-04-28T18:00:00.000Z", hasTime: true }); + expect(pickNextTask(groups([], [past, soon]), NOW)?.id).toBe("soon"); + }); + + it("prefers an untimed task due today over anything overdue", () => { + // An untimed task is stored at local midnight, so `dueAt > now` is false + // from 00:01 onward. Reading the buckets instead of the raw dates is what + // stops that stored midnight from reading as "overdue" — the defect Plan 2 + // recorded and deferred to here. + const untimed = task({ id: "untimed", dueAt: "2026-04-28T00:00:00.000Z", hasTime: false }); + const old = task({ id: "old", dueAt: "2026-04-20T09:00:00.000Z", hasTime: true }); + expect(pickNextTask(groups([old], [untimed]), NOW)?.id).toBe("untimed"); + }); + + it("falls back to the first task due today when everything today has passed", () => { + const past = task({ id: "past", dueAt: "2026-04-28T09:00:00.000Z", hasTime: true }); + expect(pickNextTask(groups([], [past]), NOW)?.id).toBe("past"); + }); + + it("falls back to the oldest overdue task when nothing is due today", () => { + // `overdue` arrives day-ascending from sortByDueThenUntimed, so [0] is the + // thing that has been rotting longest. + const older = task({ id: "older", dueAt: "2026-04-20T09:00:00.000Z" }); + const newer = task({ id: "newer", dueAt: "2026-04-27T09:00:00.000Z" }); + expect(pickNextTask(groups([older, newer], []), NOW)?.id).toBe("older"); + }); + + it("never reaches into Starred, Tomorrow or the notepad", () => { + const result = pickNextTask( + { + overdue: [], + today: [], + tomorrow: [task({ id: "t" })], + starred: [task({ id: "s", starred: true })], + notepad: [task({ id: "n" })], + }, + NOW, + ); + expect(result).toBeNull(); + }); + + it("returns null for empty groups", () => { + expect(pickNextTask(groups([], []), NOW)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:run -- tests/utils/time.test.js +``` + +Expected: FAIL — the current `pickNextTask` calls `tasks.filter` on the groups object. + +- [ ] **Step 3: Rewrite `pickNextTask`** + +In `src/utils/time.js`, replace `pickNextTask` and delete the now-unused `byDueAtAsc` helper below it: + +```javascript +// The Today tab's "Next up" hero. Takes the grouped buckets rather than the raw +// task list for two reasons: it can then never promote something due next week +// into a card labelled "Next", and an untimed task due today stays a candidate +// instead of being mistaken for overdue (its stored midnight is behind `now` +// from 00:01 onward). +// +// Candidate order: the next timed thing still ahead of you today → anything +// untimed today ("sometime today" is still ahead of you) → the earliest thing +// today that has already passed → the oldest overdue item. `groups.today` and +// `groups.overdue` both arrive sorted, so first-match is the right pick. +export function pickNextTask(groups, now) { + const today = groups?.today ?? []; + const upcoming = today.find( + (t) => t.hasTime && new Date(t.dueAt).getTime() > now.getTime(), + ); + if (upcoming) return upcoming; + + const untimed = today.find((t) => !t.hasTime); + if (untimed) return untimed; + + return today[0] ?? groups?.overdue?.[0] ?? null; +} +``` + +- [ ] **Step 4: Move the caller across** + +In `src/views/today.js`'s `template`, the two lines that compute `next` and `groups` currently run in that order. Swap them, so the hero reads the buckets: + +```javascript + const groups = groupTasksForFocus(state.tasks, state.now, []); + const next = pickNextTask(groups, state.now); +``` + +- [ ] **Step 5: Run the tests, Biome and the build** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean (`byDueAtAsc` is gone, so `noUnusedVariables` stays quiet), build clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/utils/time.js src/views/today.js tests/utils/time.test.js +git commit -m "fix(focus): pick the Next hero from the Today bucket, not every dated task" +``` + +--- + +## Task 4: `formatDayGreeting` and `summariseDay` + +**Files:** +- Modify: `src/utils/time.js` +- Test: `tests/utils/time.test.js` + +**Interfaces:** +- Consumes: the object `groupTasksForFocus` returns. +- Produces: `formatDayGreeting(now)` → `"Tuesday 28 April"`. `summariseDay(groups)` → `{ overdue: number, dueToday: number }`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/utils/time.test.js`: + +```javascript +describe("formatDayGreeting", () => { + it("renders weekday, day-of-month and month in full", () => { + expect(formatDayGreeting(NOW)).toBe("Tuesday 28 April"); + }); + + it("does not pad a single-digit day", () => { + expect(formatDayGreeting(new Date("2026-04-05T12:00:00"))).toBe( + "Sunday 5 April", + ); + }); + + it("handles the last month of the year", () => { + expect(formatDayGreeting(new Date("2026-12-31T12:00:00"))).toBe( + "Thursday 31 December", + ); + }); +}); + +describe("summariseDay", () => { + it("counts overdue and due-today items", () => { + const result = summariseDay({ + overdue: [task({ id: "a" }), task({ id: "b" })], + today: [task({ id: "c" })], + tomorrow: [task({ id: "d" })], + starred: [task({ id: "e" })], + notepad: [task({ id: "f" })], + }); + expect(result).toEqual({ overdue: 2, dueToday: 1 }); + }); + + it("counts zeroes rather than omitting them", () => { + expect( + summariseDay({ + overdue: [], + today: [], + tomorrow: [], + starred: [], + notepad: [], + }), + ).toEqual({ overdue: 0, dueToday: 0 }); + }); + + it("survives a missing or partial groups object", () => { + expect(summariseDay()).toEqual({ overdue: 0, dueToday: 0 }); + }); +}); +``` + +Confirm `NOW` at the top of the file is `new Date("2026-04-28T12:00:00")` — a Tuesday. If it carries a different value, adjust the first expectation to match the day it actually names rather than changing `NOW`, which every other test depends on. + +Add `formatDayGreeting,` and `summariseDay,` to the import list at the top of the file. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm run test:run -- tests/utils/time.test.js +``` + +Expected: FAIL with `formatDayGreeting is not a function`. + +- [ ] **Step 3: Implement both functions** + +In `src/utils/time.js`, add the long-name constants next to the existing `SHORT_WEEKDAY` / `SHORT_MONTH` arrays at the top of the file: + +```javascript +const LONG_WEEKDAY = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const LONG_MONTH = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +``` + +and append both functions to the end of the file: + +```javascript +// "Tuesday 28 April" — the Focus page's greeting line. +// +// Built from constant arrays rather than toLocaleDateString on purpose: the +// locale-driven version returns a different string per machine, which makes it +// untestable without pinning a locale, and the rest of this surface ("Today", +// "Overdue", "Starred", "Next") is English regardless. Matches how +// formatTimeLabel and formatOccurrenceLabel already work in this file. +export function formatDayGreeting(now) { + return `${LONG_WEEKDAY[now.getDay()]} ${now.getDate()} ${LONG_MONTH[now.getMonth()]}`; +} + +// Counts for the page-header summary. Takes the grouped output, not the raw +// task list, so the summary can never disagree with what the Today tab renders +// — both are derived from one groupTasksForFocus call over one `state`. +export function summariseDay(groups) { + return { + overdue: groups?.overdue?.length ?? 0, + dueToday: groups?.today?.length ?? 0, + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm run test:run -- tests/utils/time.test.js && npm run check +``` + +Expected: PASS, Biome clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/time.js tests/utils/time.test.js +git commit -m "feat(focus): add the day greeting and the overdue/due-today summary" +``` + +--- + +## Task 5: `areaForTask` + +**Files:** +- Create: `src/utils/areas.js` +- Test: `tests/utils/areas.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: `areaForTask(task, sections, areas)` → an area object or `null`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/utils/areas.test.js`: + +```javascript +import { describe, expect, it } from "vitest"; +import { areaForTask } from "../../src/utils/areas.js"; + +const SECTIONS = [ + { id: "s1", areaId: "work" }, + { id: "s2", areaId: "home" }, + { id: "orphan", areaId: "deleted-area" }, +]; +const AREAS = [ + { id: "work", name: "Work" }, + { id: "home", name: "Home" }, +]; + +describe("areaForTask", () => { + it("resolves the area through the task's section", () => { + const result = areaForTask({ id: "t", sectionId: "s2" }, SECTIONS, AREAS); + expect(result?.name).toBe("Home"); + }); + + it("returns null when the section is missing", () => { + expect(areaForTask({ id: "t", sectionId: "gone" }, SECTIONS, AREAS)).toBeNull(); + }); + + it("returns null when the section points at a missing area", () => { + expect(areaForTask({ id: "t", sectionId: "orphan" }, SECTIONS, AREAS)).toBeNull(); + }); + + it("returns null for a missing task or missing lists", () => { + expect(areaForTask(null, SECTIONS, AREAS)).toBeNull(); + expect(areaForTask({ id: "t", sectionId: "s1" })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +npm run test:run -- tests/utils/areas.test.js +``` + +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Implement it** + +Create `src/utils/areas.js`: + +```javascript +// Pure area lookups for view code. No DOM, no model imports — the caller passes +// the full section and area lists it already has in `state`. + +// areaForTask(task, sections, areas) → Area | null +// +// Resolves the area a task lives in, for the row's area badge. Returns null +// rather than throwing when either link is missing: a task whose section was +// cascade-deleted mid-render is a real race, and a badge is not worth a throw +// inside a template. +export function areaForTask(task, sections, areas) { + if (!task) return null; + const section = (sections ?? []).find((s) => s.id === task.sectionId); + if (!section) return null; + return (areas ?? []).find((a) => a.id === section.areaId) ?? null; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +npm run test:run -- tests/utils/areas.test.js && npm run check +``` + +Expected: PASS, Biome clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/areas.js tests/utils/areas.test.js +git commit -m "feat(focus): resolve a task's area for the row badge" +``` + +--- + +## Task 6: A generic toast action button + +**Files:** +- Modify: `src/views/toast.js` +- Modify: `src/controller.js` (five `onUndo:` call sites) +- Modify: `main.css` (`.toast__undo` → `.toast__action`, two rules) + +**Interfaces:** +- Consumes: nothing. +- Produces: `toast.show({ message, onAction, actionLabel, onDismiss, durationMs, key })`. `actionLabel` defaults to `"Undo"`, so every existing toast is unchanged on screen. `onUndo` is **gone** — there is no alias. + +- [ ] **Step 1: Rename the action slot in `toast.js`** + +In `src/views/toast.js`, extend the header comment block after the `aria-live placement` paragraph: + +```javascript +// Action button: one per toast, labelled "Undo" unless the caller passes +// actionLabel. The label is user-visible copy, so it goes through escapeHtml +// like every other interpolated string — even though every current caller +// passes a literal. +``` + +Rename the closure variable (grep `activeUndoHandler`) in both its declaration and its reset inside `clearActive()`: + +```javascript + let activeActionHandler = null; +``` + +```javascript + activeActionHandler = null; +``` + +Replace the `show` signature and the two lines that build and wire the button: + +```javascript + function show({ + message, + onAction, + onDismiss, + durationMs: d, + key, + actionLabel, + } = {}) { +``` + +```javascript + rootEl.innerHTML = ` +
    + ${escapeHtml(message ?? "")} + +
    + `; + + const toastEl = rootEl.querySelector(".toast"); + const actionBtn = rootEl.querySelector(".toast__action"); + + activeActionHandler = () => { + clearActive(); + if (onAction) onAction(); + }; + actionBtn.addEventListener("click", activeActionHandler, { once: true }); +``` + +The nine-variable `clearActive()` contract is unchanged: one variable renamed, none added or removed. + +- [ ] **Step 2: Rename the five call sites in the controller** + +In `src/controller.js`, change `onUndo:` to `onAction:` at all five sites — grep `onUndo` to locate them, line numbers drift. They are, in file order: + +1. `handleToggleComplete` — the recurring-completion undo (`await tasks.update(id, snapshot)`) +2. `handleTaskDelete` — the delete-batch undo (`const batch = taskDeleteBatch; …`) +3. `handleMoveTaskToSection` — the move undo (`await tasks.update(taskId, { sectionId: fromSectionId, order: fromOrder })`) +4. `onDeleteSection` — the section-cascade undo (`await sections.restore(sectionSnapshot); …`) +5. `deleteAreaCascade` — the area-cascade undo (`await areas.restore(areaSnapshot); …`) + +Change only the key. Every handler body stays byte-identical, and none of them passes `actionLabel`, so all five keep the "Undo" label. + +- [ ] **Step 3: Rename the CSS class** + +In `main.css`, rename both selectors (grep `toast__undo`): + +```css +.toast__action { +``` + +```css +.toast__action:hover { +``` + +- [ ] **Step 4: Verify nothing still references the old names** + +```bash +grep -rn "onUndo\|toast__undo\|activeUndoHandler" src/ main.css tests/ +``` + +Expected: no output. + +- [ ] **Step 5: Run the suite, Biome and the build** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean, build clean. All five toasts still read "Undo" on screen; nothing user-visible changed in this task. + +- [ ] **Step 6: Commit** + +```bash +git add src/views/toast.js src/controller.js main.css +git commit -m "refactor(toast): let a toast carry any action, not only Undo" +``` + +--- + +## Task 7: The area badge in the task row + +**Files:** +- Modify: `src/views/task.js` + +**Interfaces:** +- Consumes: `areaForTask` output (the caller resolves the name and passes a string). +- Produces: `renderTaskRow(task, { now, isOpen, renaming, pendingRenameValue, showFile, areaName })`. `areaName` defaults to `null`, which renders no badge — so `area.js` and `section.js`, which do not pass it, are unaffected. + +- [ ] **Step 1: Add the badge and reorder the row** + +In `src/views/task.js`, extend the options comment block: + +```javascript +// areaName - the task's area name, or null. Renders the area badge +// that appears at >=768px on the dated tabs. Escaped — +// area names are user-authored and this is a new +// interpolation site. +``` + +Add `areaName = null,` to the destructured options next to `showFile = false,`, and build the badge above the `return`: + +```javascript + // The badge is a plain , not a link or a button: it says where the task + // lives, it does not navigate. main.css hides it below 768px. + const areaBadge = areaName + ? `${escapeHtml(areaName)}` + : ""; +``` + +Replace the returned markup so the fixed-width columns sit to the right of the flexible title: + +```javascript + return ` +
  • + + ${escapeHtml(task.title)} + ${recurring} + ${areaBadge} + ${timeLabel} + + ${fileBtn} + +
  • + `; +``` + +**The order is load-bearing, not taste.** `.task__title` is the only `flex: 1` child, so it absorbs every width difference between rows. Anything *conditional* must therefore sit immediately after it — that is `⟲`, which is present only on recurring tasks. Everything after that point has a fixed basis and is packed against the right edge, which is what makes the badge and time columns line up across every group (§7: "the alignment is the work"). Moving `⟲` back below the badge would shift the badge left by its width on recurring rows only. The star moves from before `⟲` to after the time for the same reason; it now sits beside the ⋯, which also groups the two per-row controls together. + +- [ ] **Step 2: Verify no caller broke** + +```bash +npm run test:run && npm run check +``` + +Expected: all tests pass, Biome clean. `area.js` and `section.js` pass no `areaName`, so their rows render exactly as before apart from the star's position. + +- [ ] **Step 3: Commit** + +```bash +git add src/views/task.js +git commit -m "feat(focus): give the task row an area badge slot" +``` + +--- + +## Task 8: The tab strip template + +**Files:** +- Create: `src/views/focus-tabs.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: `TABS` — a frozen-by-convention array of `{ id, label }` in display order: `today`, `tomorrow`, `starred`, `focus`. `renderTabStrip({ activeTab, counts })` → an HTML string; `counts` is an object keyed by tab id. + +- [ ] **Step 1: Write the template** + +Create `src/views/focus-tabs.js`: + +```javascript +// renderTabStrip({ activeTab, counts }) → string +// +// Pure template for the Focus surface's tab strip. ARIA APG tabs: a +// role="tablist" of role="tab" buttons with ROVING TABINDEX — exactly one tab +// stop for the whole strip, arrows traverse (focus.js wires the keys through +// utils/menu-keyboard.js). Four extra tab stops in a keyboard-first capture +// flow is the failure this avoids; it is the same treatment the icon picker +// already gets, for the same reason. +// +// aria-controls is set ONLY on the selected tab. Just one panel is in the DOM at +// a time, so putting aria-controls on the other three would point at ids that do +// not exist — the kind of "technically ARIA" attribute that axe flags and that +// already cost this project once (aria-expanded on the capture input). +// +// Nothing here is user-authored: labels and ids come from the TABS literal and +// counts are numbers, so there is no escaping to do. + +export const TABS = [ + { id: "today", label: "Today" }, + { id: "tomorrow", label: "Tomorrow" }, + { id: "starred", label: "Starred" }, + { id: "focus", label: "Focus" }, +]; + +export function renderTabStrip({ activeTab, counts }) { + const tabs = TABS.map((tab) => { + const selected = tab.id === activeTab; + const controls = selected ? ` aria-controls="focus-panel-${tab.id}"` : ""; + return ` + + `; + }).join(""); + + return ` +
    ${tabs}
    + `; +} +``` + +- [ ] **Step 2: Verify Biome accepts it** + +```bash +npm run check +``` + +Expected: zero warnings. The module has no importer yet — that is Task 10. + +- [ ] **Step 3: Commit** + +```bash +git add src/views/focus-tabs.js +git commit -m "feat(focus): add the tab strip template" +``` + +--- + +## Task 9: Rename `today.js` to `focus.js` + +**Files:** +- Rename: `src/views/today.js` → `src/views/focus.js` +- Modify: `src/controller.js` (the import and the one call site) + +**Interfaces:** +- Consumes: nothing. +- Produces: `createFocusView(rootEl, callbacks)` — the same object `createTodayView` returned (`render`, `focusTaskMenu`, `destroy`). + +Mechanical, on purpose: it lands as its own commit so Task 10's diff is readable instead of being buried under a whole-file move. Unlike Plan 2's decision to keep the dialog's internal `repeat-*` namespace, this identifier is *not* invisible — a file called `today.js` that renders the Focus surface and contains a Today tab inside it actively misleads the next reader. + +- [ ] **Step 1: Rename the file, preserving history** + +```bash +git mv src/views/today.js src/views/focus.js +``` + +- [ ] **Step 2: Rename the factory and its self-references** + +In `src/views/focus.js`: + +```javascript +// createFocusView(rootEl, { onToggleComplete, onToggleStar, onDelete }) +``` + +```javascript +export function createFocusView(rootEl, callbacks) { +``` + +and update the two internal helper names that spell out the old surface (grep `Today`): + +```javascript + function findOpenMenuInFocus(target) { +``` + +with its single call site inside `docKeyHandler`: + +```javascript + const menuEl = findOpenMenuInFocus(event.target); +``` + +- [ ] **Step 3: Update the controller** + +In `src/controller.js` (grep `createTodayView`): + +```javascript +import { createFocusView } from "./views/focus.js"; +``` + +```javascript + currentMainView = createFocusView(mainRoot, { +``` + +- [ ] **Step 4: Update the four comments in other files that point at the old filename** + +`src/utils/rename-input.js:2`, `src/views/area.js:197`, `src/views/capture.js:141` and `src/views/move-picker.js:6` each name `today.js` in prose. Replace `today.js` with `focus.js` in each — grep to find them; they are comments only. Line numbers drift, so locate by grep rather than by number. + +- [ ] **Step 5: Verify** + +```bash +grep -rn "today\.js\|createTodayView" src/ && echo "STALE REFERENCES ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 6: Commit** + +```bash +git add -A src/views src/controller.js src/utils/rename-input.js +git commit -m "refactor(focus): rename the Today view to the Focus view" +``` + +--- + +## Task 10: Tabs — state, lifecycle, keyboard, and the four panels + +**Files:** +- Modify: `src/views/focus.js` + +**Interfaces:** +- Consumes: `groupTasksForFocus`, `pickNextTask` (Tasks 2–3); `TABS`, `renderTabStrip` (Task 8). +- Produces: `createFocusView` returns `{ render, focusTaskMenu, selectTab, getActiveTab, destroy }`. `selectTab(tabId)` runs the full §3.3 lifecycle; `getActiveTab()` returns the current tab id. The view expects `state.route` to carry `{ name: "focus" }` and reads `state.sections` to resolve which sections belong to the Focus area — it receives them as a prop, computed in the template, so no model import is added. + +- [ ] **Step 1: Swap the imports** + +In `src/views/focus.js`, add the tab strip and `FOCUS_ID` (the `time.js` import is already correct from Tasks 2–3): + +```javascript +import { FOCUS_ID } from "../model/areas.js"; +import { renderTabStrip, TABS } from "./focus-tabs.js"; +``` + +Importing a model constant into a view is the established pattern — `sidebar.js` already imports `FOCUS_ID` for exactly this kind of identity test. The rule this plan holds is narrower: **`utils/` never imports from `model/`**, which is why `groupTasksForFocus` takes section ids as data instead. + +- [ ] **Step 2: Add the tab state** + +Below `let taskMenuMode = "actions";` add: + +```javascript + // Which tab is showing. VIEW-owned, not a model field and not controller + // state: every step of a tab switch acts on state that lives in this closure + // (the open menu, the live rename), and "always resets to Today on mount" + // then comes for free — leaving Focus for an area destroys the view, so + // coming back always lands here again. Spec §3.3. + let activeTab = "today"; + // After the next render, focus this tab's button. Consumed last in doRender, + // cleared unconditionally, reset in destroy() — same contract as every other + // pending-focus flag in this file. + let pendingFocusTab = null; +``` + +- [ ] **Step 3: Add `selectTab`** + +Add below `enterTaskRename`: + +```javascript + // Switching tab is a lifecycle event, not just a re-render: the panel is + // rewritten, which detaches everything inside it. In order (spec §3.3): + // 1. close any open task menu + // 2. resolve a live rename by COMMITTING it, matching Enter — never + // discard what the user typed + // 3. render the new tab + // 4. move focus to the newly-selected tab button + // + // Step 3 is deliberately conditional. When a rename commit fires, the model + // write's own notify-render is the render that must consume pendingFocusTab. + // Rendering here as well would focus the tab button and then let that queued + // render rewrite innerHTML underneath it, dropping focus to — the trap + // the cascade-focus drain exists to prevent, and a view cannot drain, only + // the controller can await applyState(). commitTaskRenameFromInput below + // renders in exactly one branch for exactly this reason. + const selectTab = (next) => { + if (!TABS.some((t) => t.id === next)) return; + + openMenuTaskId = null; + taskMenuMode = "actions"; + pendingMenuFocusTaskId = null; + // Never route focus back to a ⋯ button that is about to be detached. + pendingFocusTaskId = null; + + let renameCommitted = false; + if (renamingTaskId) { + const input = rootEl.querySelector(".task__rename-input"); + const value = (input?.value ?? "").trim(); + const id = renamingTaskId; + renamingTaskId = null; + pendingRenameTaskValue = null; + pendingRenameTaskSelect = false; + if (value) { + callbacks.onCommitTaskRename({ taskId: id, name: value }); + renameCommitted = true; + } + } + + activeTab = next; + pendingFocusTab = next; + if (!renameCommitted) doRender(); + }; +``` + +- [ ] **Step 4: Wire the click action** + +Add to the `bindActions` map, above `"toggle-complete"`: + +```javascript + "select-tab": (_event, actionEl) => { + const next = actionEl?.dataset?.tab; + if (next) selectTab(next); + }, +``` + +- [ ] **Step 5: Wire the arrow keys** + +Add to the `bindKeys(rootEl, …)` map, alongside `Enter` and `F2`: + +```javascript + ArrowRight: (event) => moveTabFocus(event, 1), + ArrowLeft: (event) => moveTabFocus(event, -1), + Home: (event) => moveTabFocus(event, 0, firstEnabledIndex), + End: (event) => moveTabFocus(event, 0, lastEnabledIndex), +``` + +and define the helper above `commitTaskRenameFromInput`: + +```javascript + // Roving-tabindex traversal for the tab strip, mirroring the icon picker. + // Guarded on the event target actually being a tab, so it never competes with + // the ⋯ menu's own Home/End handling (which is guarded on being inside an + // open menu) or with a caret moving inside a rename input. + // + // Activation is automatic: arrowing to a tab selects it, per the APG pattern + // for cheap panels. selectTab moves the focus, so nothing is needed here + // beyond choosing the target. + function moveTabFocus(event, direction, pick) { + if (!event.target.closest('[role="tab"]')) return; + const items = TABS.map(() => ({ disabled: false })); + const currentIndex = TABS.findIndex((t) => t.id === activeTab); + const nextIdx = pick + ? pick(items) + : nextEnabledIndex(items, currentIndex, direction); + if (nextIdx < 0) return; + event.preventDefault(); + selectTab(TABS[nextIdx].id); + } +``` + +`firstEnabledIndex`, `lastEnabledIndex` and `nextEnabledIndex` are already imported at the top of the file for the ⋯ menu. + +- [ ] **Step 6: Capture, then consume, the tab focus in `doRender`** + +Two edits in `doRender`, and the first one is the reason a keyboard user can park on this surface at all. + +**6a — capture.** Add directly above `isRendering = true;`, beside the existing `readRenameCaret` call, which is the same idea for the same reason: + +```javascript + // The 60s tick and every model notify rewrite this subtree with no user + // action behind them, detaching whatever held focus. Without this, focus + // parked on a tab drops to once a minute — and the tab strip is now + // the whole surface's navigation, so that is not a small loss. + // + // Only ever re-asserts focus that was ALREADY on a tab, and the guard means + // it never overrides a tab switch that has explicitly asked for focus. + if (!pendingFocusTab) { + pendingFocusTab = + document.activeElement?.closest?.(".focus-tab")?.dataset?.tab ?? null; + } +``` + +**6b — consume.** Add at the end of `doRender`, after the `pendingMenuFocusTaskId` block: + +```javascript + // Last flag consumed, cleared unconditionally. It is mutually exclusive + // with the two above — selectTab nulls both before setting this one. + if (pendingFocusTab) { + rootEl + .querySelector(`.focus-tab[data-tab="${CSS.escape(pendingFocusTab)}"]`) + ?.focus(); + pendingFocusTab = null; + } +``` + +**6c — give `pendingFocusTaskId` a fallback.** Replace the existing `pendingFocusTaskId` block a few lines above it: + +```javascript + // Post-render lookup: focus the task's ⋯ button by data-attribute. + // Captured element refs go stale across innerHTML rewrites, so we query + // the freshly-rendered DOM. + if (pendingFocusTaskId) { + const trigger = rootEl.querySelector( + `[data-id="${CSS.escape(pendingFocusTaskId)}"] .task__menu-btn`, + ); + // The task may have left this tab entirely between the flag being set + // and this render — rescheduling it to next week from the Schedule + // dialog is enough, and that is now the ordinary outcome rather than an + // edge case. Without the fallback, `?.focus()` silently no-ops and focus + // stays wherever the closed dialog left it: . + if (trigger) trigger.focus(); + else + rootEl + .querySelector(`.focus-tab[data-tab="${CSS.escape(activeTab)}"]`) + ?.focus(); + pendingFocusTaskId = null; + } +``` + +- [ ] **Step 7: Pass the tab state into the template and expose the two methods** + +Change the `doRender` template call to pass `activeTab`: + +```javascript + rootEl.innerHTML = template( + lastState, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + activeTab, + ); +``` + +and extend the returned object: + +```javascript + return { + render(state) { + lastState = state; + doRender(); + }, + focusTaskMenu(taskId) { + pendingFocusTaskId = taskId; + }, + selectTab, + getActiveTab: () => activeTab, + destroy() { +``` + +Add the two resets inside `destroy()`, beside the other flag resets: + +```javascript + activeTab = "today"; + pendingFocusTab = null; +``` + +- [ ] **Step 8: Rewrite the template around the tabs** + +Replace the whole `template(...)` function with: + +```javascript +function template( + state, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + activeTab, +) { + // Which sections belong to Focus. Resolved here, then passed as data, because + // utils/time.js must stay ignorant of FOCUS_ID. Every Focus section counts, + // not just focus-default — §12.4 keeps pre-merge extra sections from + // orphaning their tasks now that the surface has no section headings. + const focusSectionIds = state.sections + .filter((s) => s.areaId === FOCUS_ID) + .map((s) => s.id); + + const groups = groupTasksForFocus(state.tasks, state.now, focusSectionIds); + const counts = { + today: groups.overdue.length + groups.today.length, + tomorrow: groups.tomorrow.length, + starred: groups.starred.length, + focus: groups.notepad.length, + }; + + // >=1 section other than any task's own ⇒ a valid move target exists. + const hasMoveTargets = state.sections.length > 1; + + // Compute the picker only for the open task in picker mode. + let movePickerHtml = null; + if (openMenuTaskId && taskMenuMode === "picker") { + const openTask = state.tasks.find((t) => t.id === openMenuTaskId); + if (openTask) { + movePickerHtml = renderMovePicker({ + task: openTask, + areas: state.areas, + sections: state.sections, + }); + } + } + + const rowOpts = { + now: state.now, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + movePickerHtml, + hasMoveTargets, + }; + + return ` + ${renderTabStrip({ activeTab, counts })} + ${renderPanel(activeTab, groups, state, rowOpts)} + `; +} + +// One panel at a time — the other three are not in the DOM, which is what keeps +// aria-controls honest and stops four lists of rows from competing for ids. +function renderPanel(activeTab, groups, state, rowOpts) { + const body = panelBody(activeTab, groups, state, rowOpts); + return ` +
    ${body}
    + `; +} + +function panelBody(activeTab, groups, state, rowOpts) { + if (activeTab === "tomorrow") { + return renderGroup("Tomorrow", "group--tomorrow", groups.tomorrow, true, rowOpts); + } + if (activeTab === "starred") { + return renderGroup("Starred", "group--starred", groups.starred, false, rowOpts); + } + if (activeTab === "focus") { + return renderGroup("Focus", "group--notepad", groups.notepad, false, rowOpts); + } + + const next = pickNextTask(groups, state.now); + const visible = (list) => list.filter((t) => t.id !== next?.id); + return ` + ${next ? renderNextCard(next, rowOpts) : ""} + ${renderGroup("Overdue", "group--overdue", visible(groups.overdue), true, rowOpts)} + ${renderGroup("Today", "group--today", visible(groups.today), true, rowOpts)} + `; +} +``` + +- [ ] **Step 9: Collapse the row-rendering helpers onto `rowOpts`** + +The three render helpers currently take eight positional parameters each, which a fourth tab's worth of call sites would make unreadable. Replace `renderNextCard`, `renderGroup` and `renderTaskRowWithMenu` with: + +```javascript +function renderNextCard(task, rowOpts) { + return ` +
    +

    Next

    +
      + ${renderTaskRowWithMenu(task, rowOpts)} +
    +
    + `; +} + +function renderGroup(heading, modifierClass, tasks, showCount, rowOpts) { + if (tasks.length === 0) return ""; + // The count is a separate element, not part of the heading string: it is + // metadata, and styling it as such is what lets the heading itself read as a + // heading rather than a label. + const countHtml = showCount + ? `${tasks.length}` + : ""; + const rows = tasks.map((t) => renderTaskRowWithMenu(t, rowOpts)).join(""); + return ` +
    +

    ${heading}${countHtml}

    +
      ${rows}
    +
    + `; +} + +function renderTaskRowWithMenu(task, rowOpts) { + const { + now, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + movePickerHtml, + hasMoveTargets, + } = rowOpts; + + const isRenaming = renamingTaskId === task.id; + if (isRenaming) { + // Rename input replaces the row's children — no menu injection, + // no checkbox / star / ⋯. Mutually exclusive with menu state. + return renderTaskRow(task, { + now, + renaming: true, + pendingRenameValue: pendingRenameTaskValue, + }); + } + + const isOpen = openMenuTaskId === task.id; + const row = renderTaskRow(task, { now, isOpen }); + if (!isOpen) return row; + + // Picker face: replace the action menu with the pre-rendered picker. + // The menu injects inside the
  • as its last child (the
  • is + // position: relative so the absolute menu anchors to the row). + // + // The replacement is a FUNCTION, not a string. A string replacement treats + // `$&`, `$'` and "$`" as substitution patterns, and escapeHtml does not touch + // `$` — so an area or section named `$&` reaches here through the picker + // markup and expands to the matched `
  • `, injecting a stray closing tag. + // Not script execution (the match is always the literal ``), but real + // DOM corruption. A function replacement never interprets `$`. + if (taskMenuMode === "picker" && movePickerHtml) { + return row.replace("", () => `${movePickerHtml}`); + } + + // Actions face. Focus menu: [Rename, Move to…, Schedule…, Delete]. No Move + // up/down — every tab here is a sorted view, not a manual order. + const moveToItem = hasMoveTargets + ? `` + : ""; + // Function replacement here too — same `$&` reasoning as the picker face + // above. Nothing user-authored is in this string today, but the two call + // sites must not drift apart. + return row.replace( + "", + () => ``, + ); +} +``` + +The old `allEmpty` early return is gone — the tab strip must always render, and per-tab empty states arrive in Task 11. + +- [ ] **Step 10: Verify** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean, build clean. + +- [ ] **Step 11: Browser check — tabs, at two widths** + +Start the dev server and open `http://localhost:5173/ignite/#focus` (the bare root drops the hash — always include `/ignite/`). In **one** `javascript_tool` call per assertion set, so nothing is measured across a round trip: + +1. At **1280px**: assert `window.innerWidth`, then that `[role="tablist"]` exists with four `[role="tab"]`, exactly one with `aria-selected="true"` and `tabindex="0"`, and the other three `tabindex="-1"`. +2. Click each tab in turn (via a synthetic `MouseEvent` with `detail: 1` if a real click no-ops) and assert the panel's `id` changes to `focus-panel-` and `document.activeElement` is the newly-selected tab button — in the same call as the click. +3. Focus the selected tab, dispatch `ArrowRight`, and assert selection moved one tab right and focus followed. +4. At **375px** (`resize_window`): assert `window.innerWidth === 375`, then `getBoundingClientRect()` on every tab shows `height >= 44`, and `.focus-tabs` has `scrollWidth >= clientWidth` — the strip scrolls, the targets do not shrink. + +- [ ] **Step 12: Commit** + +```bash +git add src/views/focus.js +git commit -m "feat(focus): put Today, Tomorrow, Starred and the notepad on tabs" +``` + +--- + +## Task 11: Empty states, the area badge, and File on notepad rows + +**Files:** +- Modify: `src/views/focus.js` + +**Interfaces:** +- Consumes: `areaForTask` (Task 5); `renderTaskRow`'s `areaName` and `showFile` options (Task 7). +- Produces: no new exports. Every panel renders either rows or a message; notepad rows carry a File button; rows on the three dated tabs carry an area badge. + +- [ ] **Step 1: Import `areaForTask`** + +```javascript +import { areaForTask } from "../utils/areas.js"; +``` + +- [ ] **Step 2: Add the empty states** + +Replace `panelBody` with: + +```javascript +// Every tab needs an empty state. A blank surface with no message is +// indistinguishable from a broken render, and one of these four (Focus) is the +// very first thing a new user sees. +// +// The Focus copy deliberately does NOT say where the capture bar is. The bar is +// pinned to the BOTTOM on phones and sits at the top from 768px up (decision +// D9), so any directional word is wrong on one of the two — and wrong on the +// primary form factor if it says "above". +const EMPTY_STATE = { + today: "Nothing due today.", + tomorrow: "Nothing scheduled for tomorrow.", + starred: "Star a task to pull it into your day.", + focus: "Anything you capture lands here.", +}; + +// Returns { html, isEmpty }. The caller needs the flag, not a guess at it — see +// renderPanel in Step 3. +function panelBody(activeTab, groups, state, rowOpts) { + if (activeTab === "tomorrow") { + const html = renderGroup("Tomorrow", "group--tomorrow", groups.tomorrow, true, rowOpts); + return html ? { html, isEmpty: false } : renderEmpty("tomorrow"); + } + if (activeTab === "starred") { + const html = renderGroup("Starred", "group--starred", groups.starred, false, rowOpts); + return html ? { html, isEmpty: false } : renderEmpty("starred"); + } + if (activeTab === "focus") { + const html = renderGroup("Focus", "group--notepad", groups.notepad, false, rowOpts); + return html ? { html, isEmpty: false } : renderEmpty("focus"); + } + + const next = pickNextTask(groups, state.now); + if (!next) return renderEmpty("today"); + const visible = (list) => list.filter((t) => t.id !== next.id); + return { + html: ` + ${renderNextCard(next, rowOpts)} + ${renderGroup("Overdue", "group--overdue", visible(groups.overdue), true, rowOpts)} + ${renderGroup("Today", "group--today", visible(groups.today), true, rowOpts)} + `, + isEmpty: false, + }; +} + +function renderEmpty(tab) { + return { html: `

    ${EMPTY_STATE[tab]}

    `, isEmpty: true }; +} +``` + +`renderGroup` returns `""` for an empty list, so the ternary is the whole test. `pickNextTask` returns null only when both `overdue` and `today` are empty, so `!next` alone decides the Today tab's empty state — no second condition needed, and it is also what makes `next` non-null below. + +- [ ] **Step 3: Make the panel focusable only when it is empty** + +An empty panel contains nothing focusable, which strands a keyboard user arrowing off the tab strip. APG's rule is to give exactly that case `tabindex="0"`. Replace `renderPanel`: + +```javascript +function renderPanel(activeTab, groups, state, rowOpts) { + // isEmpty comes back as a flag rather than being sniffed out of the HTML. + // Testing `body.includes('class="empty"')` would work today — escapeHtml + // turns a `"` in a task title into `"`, so no row can forge it — but it + // couples this decision to a class name inside a string and breaks silently + // the first time someone adds a class or reorders an attribute. + const { html, isEmpty } = panelBody(activeTab, groups, state, rowOpts); + // A panel full of rows already holds checkboxes and buttons, so adding a tab + // stop would just be one more thing to Tab past. An EMPTY panel holds nothing + // at all, and without tabindex the message is unreachable from the keyboard. + const focusable = isEmpty ? ' tabindex="0"' : ""; + return ` +
    ${html}
    + `; +} +``` + +- [ ] **Step 4: Thread the badge and the File flag into the rows** + +In `template`, extend `rowOpts` with the two per-tab facts: + +```javascript + const rowOpts = { + now: state.now, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + movePickerHtml, + hasMoveTargets, + areas: state.areas, + sections: state.sections, + // The notepad is entirely Focus-area tasks, so a badge there would say + // "Focus" on every row. File is the notepad's own affordance: one tap to + // move a captured thought out, so it never rots for costing two levels of + // menu to file. Spec D7. + // + // File is gated on hasMoveTargets for the same reason "Move to…" is. With + // only the Focus area and its one section there is nowhere to file to, and + // an ungated button would render on every row purely to open a picker + // holding nothing but the disabled "No other sections" hint and Back. + showBadge: activeTab !== "focus", + showFile: activeTab === "focus" && hasMoveTargets, + }; +``` + +and consume them in `renderTaskRowWithMenu` — replace the destructure and the row call: + +```javascript + const { + now, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + movePickerHtml, + hasMoveTargets, + areas, + sections, + showBadge, + showFile, + } = rowOpts; +``` + +```javascript + const isOpen = openMenuTaskId === task.id; + const areaName = showBadge + ? (areaForTask(task, sections, areas)?.name ?? null) + : null; + const row = renderTaskRow(task, { now, isOpen, areaName, showFile }); + if (!isOpen) return row; +``` + +- [ ] **Step 5: Handle the File click** + +Add to the `bindActions` map, next to `"move-task-to"`: + +```javascript + // File is a shortcut, not a new mechanism: it opens this task's own ⋯ menu + // already switched to picker mode, so pick-move-target, the move undo + // toast and the picker's a11y all apply unchanged. + // + // stopPropagation is REQUIRED. The synchronous doRender below detaches + // this button, after which the document click handler would see a detached + // target as "outside" and close the menu it just opened — the same trap + // documented for move-task-to. + "file-task": (event, actionEl) => { + event.stopPropagation(); + const t = taskFromEvent(actionEl); + if (!t) return; + openMenuTaskId = t.id; + taskMenuMode = "picker"; + pendingMenuFocusTaskId = t.id; // §4.2: the picker opens on its first item + doRender(); + }, +``` + +- [ ] **Step 6: Route focus correctly when a notepad row is filed away** + +`pick-move-target` currently sets `pendingFocusTaskId` with the comment "the task stays in Today". That holds on the dated tabs — a move between sections changes neither `dueAt` nor `starred` — and is exactly false on the notepad, where filing is what removes the row. Replace the handler: + +```javascript + "pick-move-target": (_event, actionEl) => { + const t = taskFromEvent(actionEl); + const targetSectionId = actionEl?.dataset?.targetSectionId; + if (!t || !targetSectionId) return; + openMenuTaskId = null; + taskMenuMode = "actions"; // reset for next open + if (activeTab === "focus") { + // Filing is precisely what takes the row off this tab, so the ⋯ we + // would return to will not exist after the render. The Focus tab + // button always does. + pendingFocusTab = "focus"; + } else { + // A move changes neither dueAt nor starred, so the task stays on + // this tab; refocus its ⋯. + pendingFocusTaskId = t.id; + } + callbacks.onMoveTaskToSection({ taskId: t.id, targetSectionId }); + // No doRender() — the model-notify re-render consumes the focus flag. + // Toast is the only visible feedback here. + }, +``` + +- [ ] **Step 7: Verify** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean, build clean. + +- [ ] **Step 8: Browser check — all four empty states, badge, File** + +Seed IndexedDB directly rather than clicking through the UI: open `ignite`, read one existing row as a shape template, `put` variants with distinct ids, `location.reload()`. **Delete the seeds afterwards.** + +1. **Empty states.** With no tasks at all, visit each tab and assert the panel contains exactly the four `EMPTY_STATE` strings, and that the empty panel carries `tabindex="0"`. +2. **Badge.** Seed a task due today in a user area. At **1280px**, assert the row contains `.task__area-badge` with that area's name. At **375px**, assert `getComputedStyle(badge).display === "none"` — in the same call as the `window.innerWidth` assertion. +3. **File.** Seed an undated, unstarred task in `focus-default`. On the Focus tab, assert the row has a `.task__file` button whose `aria-label` names the task, click it, and assert in the same call that a `[role="menu"]` opened inside the `
  • ` and `document.activeElement` is its first `[role="menuitem"]`. +4. **Filing routes focus.** Pick a target section, then assert `document.activeElement` is `.focus-tab[data-tab="focus"]` and not `document.body`. Allow for the async write: click → `await` ~250 ms → assert, all inside one call. +5. **Badge absent on the notepad.** Assert a notepad row has no `.task__area-badge` even at 1280px. + +- [ ] **Step 9: Commit** + +```bash +git add src/views/focus.js +git commit -m "feat(focus): add empty states, area badges and one-tap filing" +``` + +--- + +## Task 12: The page header — heading, greeting, summary + +**Files:** +- Modify: `src/controller.js` (`renderPageHeader`, imports) + +**Interfaces:** +- Consumes: `groupTasksForFocus`, `formatDayGreeting`, `summariseDay` (Tasks 2 and 4); `FOCUS_ID` (already imported). +- Produces: no new exports. `#page-header` renders `

    Focus

    `, a greeting `

    ` and a summary `

    ` on the focus route; the area branch is untouched. + +- [ ] **Step 1: Extend the time imports** + +In `src/controller.js`, replace the `time.js` import: + +```javascript +import { + formatDayGreeting, + formatDueSummary, + formatOccurrenceLabel, + groupTasksForFocus, + summariseDay, +} from "./utils/time.js"; +``` + +- [ ] **Step 2: Rewrite the focus branch of `renderPageHeader`** + +Replace everything after the `if (currentRoute.name === "area") { … }` block: + +```javascript + // The

    says "Focus", not the date. A heading names WHERE YOU ARE, and + // a screen-reader user navigating by heading needs a landmark that is the + // same every day. The date is the greeting beneath it. Spec §3.1. + const focusSectionIds = state.sections + .filter((s) => s.areaId === FOCUS_ID) + .map((s) => s.id); + // Computed here as well as in the view. Both calls take the same `state`, + // including the same `now`, so the summary and the tab counts agree by + // construction — which is the property worth paying one extra O(n) pass + // for. Threading groups through `state` would make the view depend on the + // controller having computed them first. + const groups = groupTasksForFocus(state.tasks, state.now, focusSectionIds); + const { overdue, dueToday } = summariseDay(groups); + // The overdue count is emphasised, and omitted entirely at zero — "0 + // overdue" is a reassurance nobody asked for. The Overdue group keeps its + // own heading and count, so this line is never the only route to the + // number (§8). + const summary = [ + overdue > 0 + ? `${overdue} overdue` + : "", + `${dueToday} due today`, + ] + .filter(Boolean) + .join(" · "); + + // No escapeHtml on the greeting: formatDayGreeting composes it from two + // constant arrays and a number, with no user-authored input anywhere. + pageHeaderRoot.innerHTML = ` +

    Focus

    +

    ${formatDayGreeting(state.now)}

    +

    ${summary}

    + `; +``` + +- [ ] **Step 3: Verify** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean, build clean. + +- [ ] **Step 4: Browser check — the heading contract** + +In one `javascript_tool` call: assert `document.querySelectorAll("h1").length === 1`, that its text is exactly `Focus`, that `.page-header__greeting` matches `/^[A-Z][a-z]+ \d{1,2} [A-Z][a-z]+$/`, and that `.page-header__summary` reads `N due today` with no overdue clause when nothing is overdue. Then seed an overdue task, reload, and assert the summary now leads with a `` whose text ends in `overdue` **and** that the Overdue group heading still carries its own count. + +- [ ] **Step 5: Commit** + +```bash +git add src/controller.js +git commit -m "feat(focus): head the page with Focus, the day and the day's shape" +``` + +--- + +## Task 13: Capture feedback and the sidebar + +**Files:** +- Modify: `src/controller.js` (the capture callback, `mountMainView`, one new transient field) +- Modify: `src/views/sidebar.js` (`template`) + +**Interfaces:** +- Consumes: `selectTab` / `getActiveTab` (Task 10); `toast.show`'s `onAction` / `actionLabel` (Task 6). +- Produces: no new exports. Capture on the Focus route raises `Added to Focus` with a **View** action; the sidebar no longer lists Focus as an area. + +- [ ] **Step 1: Add the pending-tab field and consume it on mount** + +The View action can be clicked after the user has already navigated to an area — the toast lives five seconds and survives a route change, because `#toast-root` is a body child. At that point `currentMainView` is the *area* view, which has no `selectTab`, so `?.` swallows the call and the button does nothing at all. A dead button is worse than no button. + +In `src/controller.js`, declare the field beside the other transient UI state (grep `let repeatEditorTaskId`): + +```javascript + let pendingTabSelection = null; // transient UI state — NOT a model field +``` + +and consume it at the end of `mountMainView`'s focus branch, immediately before its `return;`: + +```javascript + // Something asked for a specific tab on the next Focus view — today + // only the capture toast's View action, fired after a route change. + // selectTab before the first render(state) is safe: doRender early- + // returns on a null lastState, activeTab is already set, and the + // applyState below renders the right tab. + if (pendingTabSelection) { + currentMainView.selectTab(pendingTabSelection); + pendingTabSelection = null; + } + return; +``` + +- [ ] **Step 2: Give capture its feedback** + +In `src/controller.js`, replace the `capture = createCaptureView(...)` call in `start()`: + +```javascript + capture = createCaptureView(captureRoot, { + // No `starred` — a star means "I chose this for today", and capture + // setting it on everything made the signal worthless. Spec D2. + onSubmit: async (title, sectionId) => { + // SNAPSHOT BOTH BEFORE THE WRITE. Each of these can change while the + // IndexedDB write is in flight — one sidebar click during the await is + // enough — and reading them afterwards makes the toast announce + // "Added to Focus" for a task that went into an area. Same trap as + // wasDrawerOpen in deleteAreaCascade, and the lesson recorded there + // is that a race like this usually has more than one late read. + const wasFocusRoute = currentRoute.name === "focus"; + const tabBefore = currentMainView?.getActiveTab?.(); + + await tasks.create({ sectionId, title }); + + // Capture on Focus writes into the notepad, which is usually not the + // tab on screen — so say where it went and offer one tap to look. + // Skipped when the notepad WAS on screen: the row is right there, and + // a toast about something visible is noise. Spec §3.4. + if (!wasFocusRoute || tabBefore === "focus") return; + toast.show({ + message: "Added to Focus", + actionLabel: "View", + durationMs: MOVE_TOAST_MS, + onAction: () => { + // The user may have left Focus since the toast appeared. Route + // there first and let mountMainView apply the tab; the direct + // call covers the ordinary case where we never left. + if (currentRoute.name !== "focus") { + pendingTabSelection = "focus"; + window.location.hash = "#focus"; + return; + } + currentMainView?.selectTab?.("focus"); + }, + }); + }, + focusSectionId: FOCUS_DEFAULT_SECTION_ID, + }); +``` + +The capture view awaits `onSubmit` and clears the input only on success, so the toast appearing before the clear is the intended order. This toast cannot collide with the capture picker: it fires only on the Focus route, where `captureDestination` returns `{ kind: "focus" }` and no picker ever opens. + +- [ ] **Step 3: Stop listing Focus in the sidebar** + +In `src/views/sidebar.js`'s `template`, replace the area-list construction: + +```javascript + const sorted = state.areas.slice().sort((a, b) => a.order - b.order); + // Focus is no longer a listed area — it IS the landing surface, reached by + // the wordmark above. Listing it as well would be a second door onto the same + // tasks, and its rename and icon controls belong to a surface that no longer + // exists here. Spec D1. + const userAreas = sorted.filter((a) => a.id !== FOCUS_ID); + const firstUserAreaId = userAreas[0]?.id ?? null; + const lastUserAreaId = userAreas[userAreas.length - 1]?.id ?? null; + const items = userAreas + .map((area) => + renderAreaRow(area, state, route, { + canMoveUp: area.id !== firstUserAreaId, + canMoveDown: area.id !== lastUserAreaId, + isUndeletable: false, + openAreaMenuId, + renamingAreaId, + pendingRenameValue, + }), + ) + .join(""); +``` + +`FOCUS_ID` stays imported — it is what the filter tests. `isUndeletable` stays in `renderAreaRow`'s options: the only area it ever guarded is now unlisted, but the parameter is part of that function's contract and `renderAreaMenu` reads it. + +- [ ] **Step 4: Verify** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean, build clean. + +- [ ] **Step 5: Browser check — capture round trip and the sidebar** + +1. On `#focus`, Today tab: type into the capture input and submit with `form.requestSubmit()` (**the CDP pane's Return produces `event.key === ""` and fires no native submit** — a real Enter has never driven this path; that manual phone pass is still outstanding). In the same call, `await` ~250 ms, then assert the toast reads `Added to Focus` and its action button reads `View`. +2. Click **View** and assert the Focus tab is selected, the panel id is `focus-panel-focus`, and the new task's title is in the panel. +3. Capture again while already on the Focus tab and assert **no** toast appears. +4. **View after a route change.** Capture on the Focus tab strip's Today tab, then — inside the same call, before the 5 s window closes — navigate to an area, `await` a tick, click View, `await` ~250 ms, and assert the route is `#focus`, the panel id is `focus-panel-focus`, and the Focus tab carries `aria-selected="true"`. This is the `pendingTabSelection` path; without it the button silently does nothing. +5. **The back button is not a trap.** Navigate to `#area/focus` directly, assert the URL settles on `#focus` and the surface renders, then go Back and assert you land on whatever preceded it — **not** back on `#focus`. `replaceState` is what makes this pass; an assignment to `location.hash` would loop. +6. Assert the sidebar's `.sidebar__area-row` list contains no row with `data-area-id="focus"`, and that the remaining rows' Move up / Move down are enabled correctly at the ends — the first user area must now offer no Move up. +7. Delete the tasks you seeded. + +- [ ] **Step 6: Commit** + +```bash +git add src/controller.js src/views/sidebar.js +git commit -m "feat(focus): confirm captures with a View action and unlist Focus from the rail" +``` + +--- + +## Task 14: Layout, and the README + +**Files:** +- Modify: `main.css` +- Modify: `README.md` + +**Interfaces:** +- Consumes: the class names Tasks 7, 8, 10 and 11 introduced: `.focus-tabs`, `.focus-tab`, `.focus-tab__count`, `.focus-panel`, `.task__area-badge`, `.group--tomorrow`, `.group--notepad`, `.page-header__greeting`, `.page-header__summary`, `.page-header__overdue`. +- Produces: no new API. + +- [ ] **Step 1: Style the tab strip and panel** + +In `main.css`, insert immediately **before** the `/* --- NEXT card --- */` comment (so these base rules precede the state overrides further down and specificity stays ascending — appending to the end trips Biome's `noDescendingSpecificity`): + +```css +/* --- Focus tabs --- */ +.focus-tabs { + display: flex; + gap: var(--space-1); + margin: 0 0 var(--space-4); + border-bottom: 1px solid var(--border); + /* Four 44px targets plus their counts do not fit 375px. The strip scrolls; + the targets do NOT shrink (spec §8.1). + Deliberately NOT `scrollbar-width: none`: at 375px the fourth tab sits off + screen, and the scrollbar is the only cue that it is there at all. Mobile + browsers auto-hide overlay scrollbars anyway, and the strip does not + overflow on desktop, so hiding it buys nothing and costs the affordance. */ + overflow-x: auto; +} +.focus-tab { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 44px; + padding: 0 var(--space-3); + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + font: inherit; + font-size: var(--text-sm); + cursor: pointer; +} +.focus-tab:hover { + color: var(--text); +} +.focus-tab.is-active { + color: var(--text); + border-bottom-color: var(--accent); +} +/* A real token colour, never opacity: opacity compounds against whatever is + behind it, so a value that passes contrast on one surface fails on another. + Spec §8.2. `--text-sm` matches `.group__count`, which is the same idea in the + same palette — `--text-xs` (12px) would make this the smallest text anywhere + in the app, below even the 13.6px time label. */ +.focus-tab__count { + color: var(--text-faint); + font-size: var(--text-sm); +} +.focus-tab.is-active .focus-tab__count { + color: var(--text-muted); +} +.focus-panel:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +``` + +There is no tab transition to gate: selection is a border-colour and colour swap with no `transition` property, so `prefers-reduced-motion` has nothing to suppress. **Do not add one** — it would then need the gate. + +- [ ] **Step 2: Give the badge and time real columns at ≥768px** + +Add the badge's base rule directly after the existing `.task__time-label, .task__recurring` rule: + +```css +/* Phone: the row is title-first, so the badge drops out entirely (spec §7). + display:none takes it out of the accessibility tree too — accepted, because + the area is not conveyed to the sighted phone user either. */ +.task__area-badge { + display: none; + color: var(--text-muted); + font-size: var(--text-sm); +} +/* Fixed bases so the columns line up across every group. `.task__title` is the + only flex:1 child, which is what absorbs the conditional ⟲ glyph; every OTHER + child must refuse to shrink or the alignment drifts row to row. + `.task__check` and `.task__recurring` are in this list on purpose: both + default to `flex: 0 1 auto`, so under pressure they compress by a fraction and + every column to their right moves with them. A partially-pinned row is the + version that looks right on seeded data and wrong on a real list, and §7 calls + this alignment the work. */ +.task__check, +.task__recurring, +.task__star, +.task__menu-btn { + flex: 0 0 auto; +} +``` + +and add a `@media (min-width: 768px)` block immediately after it: + +```css +@media (min-width: 768px) { + .task__area-badge { + display: block; + flex: 0 0 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .task__time-label { + flex: 0 0 6rem; + text-align: right; + } +} +``` + +- [ ] **Step 3: Tighten the phone page padding and style the header lines** + +Change the phone value of `--main-padding` (grep `--main-padding: var(--space-5)`) — the ≥768px override to `2rem` is untouched: + +```css + --main-padding: var(--space-4); +``` + +`--space-5` is `1.5rem` and `--space-4` is `1rem`, so the phone gains 1rem of row width on each side. + +**Know the blast radius before you change it — four rules read this variable, not one.** `#main`'s padding (`main.css:111`, the intended target) and its `padding-block-end: calc(var(--main-padding) + var(--capture-h))` (`:1112`), plus **`.area`** (`:579`, the area shell's own padding *inside* `#main`) and the **mobile capture bar**'s horizontal padding (`:1104`). That sharing is deliberate and worth keeping: it is what holds the capture bar's left edge in line with the rows above it, and the area view tightens by the same amount for the same reason. Do **not** hard-code a replacement value at any of the four sites, and do not touch `--capture-h`. The ≥768px override to `2rem` is untouched, so nothing changes on desktop. + +Add the header lines after the existing `.page-header__date` rule (and delete `.page-header__date`, whose element no longer renders): + +```css +.page-header__greeting { + margin: var(--space-1) 0 0; + color: var(--text-muted); + font-size: var(--text-sm); +} +.page-header__summary { + margin: var(--space-1) 0 0; + color: var(--text-muted); + font-size: var(--text-sm); +} +.page-header__overdue { + color: var(--color-overdue); + font-weight: 600; +} +``` + +`--color-overdue` is the token `.group--overdue .group__heading` already uses, so the emphasis matches the group it points at. + +- [ ] **Step 4: Update the README** + +Three edits in `README.md`. Keep the existing tone — plain, no marketing, no badges. + +Replace the **Today view** bullet (line 35) with two bullets, leaving the rest of the list in place: + +```markdown +- **Focus** — the landing surface, on four tabs: Today (what's overdue and what's due, led by what's next), Tomorrow, Starred, and Focus itself — the notepad every capture lands in until you file it +- **One-tap filing** — a note moves out of the notepad into any section without going through a menu +``` + +The existing **Tasks** bullet (line 38) already ends "tasks in Focus file into any section in one tap", which the new bullet now says better — trim that clause from the Tasks bullet so the two do not repeat each other. + +Update the screenshot alt text (line 9), which still describes the old surface: + +```markdown +![Ignite in dark mode — the Focus view showing the tab strip, a Next card and a Today group, with the area sidebar and theme control on the left](docs/desktop_preview.png) +``` + +Leave the **Status** line (line 7) and the two "228 tests" mentions alone unless the suite total actually changed — read the count off the test runner's output at Step 5 and update all three occurrences to whatever it really is. Do not carry the number forward from this plan. `docs/desktop_preview.png` itself is now out of date; **retaking it is Malin's call**, not this task's — flag it rather than replacing the file. + +- [ ] **Step 5: Verify** + +```bash +npm run test:run && npm run check && npm run build +``` + +Expected: all tests pass, Biome clean (watch specifically for `noDescendingSpecificity`), build clean. + +- [ ] **Step 6: Browser check — the alignment, at both widths** + +This is the check the project has skipped twice, at a cost of two Criticals. Measure geometry, do not read computed styles, and assert the viewport width in the same call. + +1. At **1280px**, seed at least six tasks across Overdue and Today, mixing recurring with non-recurring and long titles with short. Then, in one call: assert `window.innerWidth === 1280`, collect `getBoundingClientRect().left` for every `.task__area-badge` and every `.task__time-label` across **both** groups, and assert each set has exactly one distinct value (allow a sub-pixel epsilon). A recurring row must not shift its neighbours' columns. +2. Same call: assert no `.task__title` overflows its row — `scrollWidth <= clientWidth + 1` after ellipsis. +3. At **375px**: assert `window.innerWidth === 375`, that every `.task__area-badge` computes `display: none`, that `.task__time-label` is still visible (the *column* drops on phones, not the time — losing it would undo Plan 2 on the surface that exists to show it), and that no `.task` row's `getBoundingClientRect().right` exceeds `window.innerWidth`. +4. Same call at 375px: assert the capture bar still clears the last row — the last `.task`'s `bottom` is above `#capture-root`'s `top`. +5. **The area view at 375px**, because `--main-padding` reaches it too. Navigate to a user area and assert no `.section` or `.task` row's `right` exceeds `window.innerWidth`, and that the capture bar's left edge still lines up with the rows above it (`#capture-root`'s content-box `left` equals a `.task`'s `left` within a pixel). The tightened padding nests — `.area` sits inside `#main` and both read the variable — so this is where a wrong value shows up, not on Focus. +6. Toggle to light theme and re-assert the tab strip and count colours render (a screenshot is fine here; a DOM read is better evidence for the geometry above). + +- [ ] **Step 7: Commit** + +```bash +git add main.css README.md +git commit -m "feat(focus): lay out the tab strip and the badge and time columns" +``` + +--- + +## After the last task + +- [ ] **Whole-branch review.** Per-task review is task-scoped by construction and has missed a Critical on each of the last two runs — the v2 icon picker crushing the rename row, and v3 Plan 1's two contradictory contracts for one promise. Budget an opus review over the entire diff, told explicitly to look for cross-task contract drift and for anything that only breaks at a width nobody measured. +- [ ] **An axe pass over the Focus surface, on every tab.** Scope axe to `#main` — `page-has-heading-one` and `landmark-one-main` fire on the inert background whenever a dialog is open, and that is a known artifact, not a defect. The tab strip is a new ARIA composite and has never been checked; so is the empty panel's `tabindex`. **The schedule-dialog axe pass Plan 2 left owed is still owed** and is worth folding into the same session. +- [ ] **Verify the counts before reporting them.** Read the test count off the runner's output, not off this plan. + +## Verification checklist from the spec + +§9 requires browser verification of each of these. Tasks 10, 11, 13 and 14 cover them; tick them off as a set before calling the plan done. + +- [ ] Capture on Focus lands in the notepad, unstarred, with the toast and a working View +- [ ] Capture in a multi-section area still opens the picker; in a single-section area it still writes directly +- [ ] The inline add run still keeps its row focused across several entries +- [ ] Filing out of Focus works and focus lands on the Focus tab, never `` +- [ ] Tab switching by keyboard: arrows traverse, exactly one tab stop +- [ ] Switching tab **mid-rename** commits the rename, focus lands on the new tab, nothing reaches `` +- [ ] Switching tab with a ⋯ menu open closes the menu and lands focus on the new tab +- [ ] Dismissing the capture picker by route change and by outside click leaves the typed text +- [ ] Escape out of the capture picker: text intact, focus on the input +- [ ] All four empty states +- [ ] Tab strip at 375px: targets ≥44px, strip scrolls +- [ ] ≥768px column alignment across every group From 12323bc8ef11c1b21097a55a1e663e9624eac2c9 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 18:47:55 +0200 Subject: [PATCH 02/21] refactor(route): rename the landing route to focus and redirect #area/focus --- src/controller.js | 42 +++++++++++++++++++++++++--------- src/views/sidebar.js | 18 +++++++-------- src/views/topbar.js | 10 ++++---- tests/unit/parse-hash.test.js | 43 ++++++++++++++++++++++------------- tests/utils/capture.test.js | 2 +- 5 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/controller.js b/src/controller.js index 34818d3..2e62cfd 100644 --- a/src/controller.js +++ b/src/controller.js @@ -35,10 +35,11 @@ const COMPLETE_TOAST_MS = 5_000; export function parseHash(hash) { const raw = (hash || "").replace(/^#/, ""); - if (raw === "" || raw === "today") return { name: "today" }; + if (raw === "" || raw === "focus" || raw === "today") + return { name: "focus" }; const areaMatch = raw.match(/^area\/(.+)$/); if (areaMatch) return { name: "area", id: areaMatch[1] }; - return { name: "today" }; + return { name: "focus" }; } export function createController({ models, els }) { @@ -60,7 +61,7 @@ export function createController({ models, els }) { let capture = null; let toast = null; let currentMainView = null; - let currentRoute = { name: "today" }; + let currentRoute = { name: "focus" }; let tickHandle = null; let unsubs = []; let taskDeleteBatch = null; // null | { tasks: Array } @@ -292,11 +293,30 @@ export function createController({ models, els }) { }); } + // #area/focus is a dead duplicate of the landing route: Focus is no longer a + // listed area, it IS the landing surface. Redirect rather than render it, so + // neither a bookmark nor the back button can land on a second copy of the + // same tasks. + // + // replaceState, NOT an assignment to location.hash. Assigning PUSHES a + // history entry, so Back would land on #area/focus and be redirected straight + // forward again — a loop the user cannot walk out of. replaceState rewrites + // the URL in place, fires no hashchange, and therefore also mounts once + // instead of twice. + function routeFromHash() { + const route = parseHash(window.location.hash); + if (route.name === "area" && route.id === FOCUS_ID) { + window.history.replaceState(null, "", "#focus"); + return { name: "focus" }; + } + return route; + } + function mountMainView(route) { currentMainView?.destroy(); currentMainView = null; - if (route.name === "today") { + if (route.name === "focus") { currentMainView = createTodayView(mainRoot, { onToggleComplete: handleToggleComplete, onToggleStar: (id, currentStarred) => @@ -720,7 +740,7 @@ export function createController({ models, els }) { closeDrawer(); // close on ALL route changes incl. browser back/forward closeRecurrenceEditor({ rerender: false }); // route change closes the dialog capture?.closePicker(); - currentRoute = parseHash(window.location.hash); + currentRoute = routeFromHash(); mountMainView(currentRoute); applyState(); } @@ -736,9 +756,9 @@ export function createController({ models, els }) { topbar = createTopbarView(topbarRoot, { onToggleDrawer: () => (drawerOpen ? closeDrawer() : openDrawer()), - onGoToday: () => { - window.location.hash = "#today"; - closeDrawer(); // same-hash tap of "Ignite" on #today fires no hashchange + onGoFocus: () => { + window.location.hash = "#focus"; + closeDrawer(); // same-hash tap of "Ignite" on #focus fires no hashchange }, }); @@ -749,8 +769,8 @@ export function createController({ models, els }) { !(current.sidebarCollapsed ?? false), ); }, - onGoToday: () => { - window.location.hash = "#today"; + onGoFocus: () => { + window.location.hash = "#focus"; closeDrawer(); }, onOpenArea: (id) => { @@ -778,7 +798,7 @@ export function createController({ models, els }) { settings.subscribe(applyState), ); - currentRoute = parseHash(window.location.hash); + currentRoute = routeFromHash(); mountMainView(currentRoute); applyState(); diff --git a/src/views/sidebar.js b/src/views/sidebar.js index edc2ef2..24cf0b0 100644 --- a/src/views/sidebar.js +++ b/src/views/sidebar.js @@ -1,12 +1,12 @@ // createSidebarView(rootEl, { -// onToggleCollapse, onGoToday, onOpenArea, +// onToggleCollapse, onGoFocus, onOpenArea, // onAddArea, onCommitAreaRename, onMoveAreaUp, onMoveAreaDown, onDeleteArea, // onCloseDrawer, onCycleTheme, onPickAreaIcon, // }) → { render(state), enterRename(areaId), destroy() } // // state expected: { areas, sections, tasks, settings, route, now, themeChoice, theme } // route: -// { name: "today" } → wordmark gets aria-current="page" +// { name: "focus" } → wordmark gets aria-current="page" // { name: "area", id: "..." } → matching area row gets aria-current="page" // // Closure state (all reset to initial values in destroy()): @@ -41,7 +41,7 @@ export function createSidebarView( rootEl, { onToggleCollapse, - onGoToday, + onGoFocus, onOpenArea, onAddArea, onCommitAreaRename, @@ -182,7 +182,7 @@ export function createSidebarView( const unbindClick = bindActions(rootEl, { "toggle-sidebar": () => onToggleCollapse(), - "go-today": () => onGoToday(), + "go-focus": () => onGoFocus(), "open-area": (_event, actionEl) => { const id = actionEl.dataset.id; @@ -475,10 +475,10 @@ function template( state, { openAreaMenuId, renamingAreaId, pendingRenameValue }, ) { - const route = state.route ?? { name: "today" }; - const todayActive = route.name === "today"; - const wordmarkAria = todayActive ? 'aria-current="page"' : ""; - const wordmarkActive = todayActive ? "is-active" : ""; + const route = state.route ?? { name: "focus" }; + const focusActive = route.name === "focus"; + const wordmarkAria = focusActive ? 'aria-current="page"' : ""; + const wordmarkActive = focusActive ? "is-active" : ""; const sorted = state.areas.slice().sort((a, b) => a.order - b.order); // Focus is pinned to the top; only user areas reorder among themselves. @@ -503,7 +503,7 @@ function template( return ` + data-action="go-focus" ${wordmarkAria}>Ignite - + `; const menuBtn = rootEl.querySelector(".topbar__menu"); const unbindClick = bindActions(rootEl, { "toggle-drawer": () => onToggleDrawer(), - "go-today": () => onGoToday(), + "go-focus": () => onGoFocus(), }); return { diff --git a/tests/unit/parse-hash.test.js b/tests/unit/parse-hash.test.js index d0b1364..7cadd39 100644 --- a/tests/unit/parse-hash.test.js +++ b/tests/unit/parse-hash.test.js @@ -2,23 +2,28 @@ import { describe, expect, it } from "vitest"; import { parseHash } from "../../src/controller.js"; // parseHash maps the location hash to a route object. Two routes exist: -// { name: "today" } — the default, and the fallback for anything unknown +// { name: "focus" } — the landing surface, and the fallback for anything unknown // { name: "area", id: "" } — an area page +// "#today" is kept as an accepted alias so pre-v3 bookmarks still resolve. describe("parseHash", () => { - it("treats an empty hash as Today", () => { - expect(parseHash("")).toEqual({ name: "today" }); + it("treats an empty hash as Focus", () => { + expect(parseHash("")).toEqual({ name: "focus" }); }); - it("treats a bare # as Today", () => { - expect(parseHash("#")).toEqual({ name: "today" }); + it("treats a bare # as Focus", () => { + expect(parseHash("#")).toEqual({ name: "focus" }); }); - it("parses #today as Today", () => { - expect(parseHash("#today")).toEqual({ name: "today" }); + it("parses #focus as Focus", () => { + expect(parseHash("#focus")).toEqual({ name: "focus" }); }); - it("parses a hash with no leading # as Today", () => { - expect(parseHash("today")).toEqual({ name: "today" }); + it("still parses the legacy #today as Focus", () => { + expect(parseHash("#today")).toEqual({ name: "focus" }); + }); + + it("parses a hash with no leading # as Focus", () => { + expect(parseHash("focus")).toEqual({ name: "focus" }); }); it("parses #area/ into an area route", () => { @@ -32,17 +37,23 @@ describe("parseHash", () => { }); }); - it("falls back to Today on an unknown route", () => { - expect(parseHash("#settings")).toEqual({ name: "today" }); + it("still parses #area/focus as an area route (the controller redirects it)", () => { + // parseHash stays pure — the redirect is a side effect and belongs to the + // controller, which owns FOCUS_ID. utils and pure parsers never import model/. + expect(parseHash("#area/focus")).toEqual({ name: "area", id: "focus" }); + }); + + it("falls back to Focus on an unknown route", () => { + expect(parseHash("#settings")).toEqual({ name: "focus" }); }); - it("falls back to Today when area/ has no id", () => { + it("falls back to Focus when area/ has no id", () => { // /^area\/(.+)$/ requires at least one character after the slash. - expect(parseHash("#area/")).toEqual({ name: "today" }); + expect(parseHash("#area/")).toEqual({ name: "focus" }); }); - it("treats null and undefined as Today (hash may be unset)", () => { - expect(parseHash(null)).toEqual({ name: "today" }); - expect(parseHash(undefined)).toEqual({ name: "today" }); + it("treats null and undefined as Focus (hash may be unset)", () => { + expect(parseHash(null)).toEqual({ name: "focus" }); + expect(parseHash(undefined)).toEqual({ name: "focus" }); }); }); diff --git a/tests/utils/capture.test.js b/tests/utils/capture.test.js index dd038ad..7b67518 100644 --- a/tests/utils/capture.test.js +++ b/tests/utils/capture.test.js @@ -8,7 +8,7 @@ const sec = (id, areaId, order, name = id) => ({ id, areaId, order, name }); describe("captureDestination", () => { it("routes the Focus/landing route to the notepad", () => { - expect(captureDestination({ name: "today" }, [])).toEqual({ + expect(captureDestination({ name: "focus" }, [])).toEqual({ kind: "focus", }); }); From 777527374c495c106b3a189c8c563b4a5e61d690 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 18:53:32 +0200 Subject: [PATCH 03/21] feat(focus): bucket tasks into the four Focus tabs --- src/utils/time.js | 31 ++++++++++- src/views/today.js | 7 ++- tests/utils/time.test.js | 112 ++++++++++++++++++++++++++++++++------- 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/src/utils/time.js b/src/utils/time.js index 26f910f..85f13bf 100644 --- a/src/utils/time.js +++ b/src/utils/time.js @@ -98,13 +98,27 @@ export function formatDueSummary(dueAtIso, hasTime, now, format = "24h") { return `${day} at ${formatHM(new Date(dueAtIso), format)}`; } -export function groupTasksForToday(tasks, now) { +// The four Focus tabs, in one pass. Precedence is a cascade — a date beats a +// star, a star beats the notepad — so each task lands in at most one bucket and +// the tab counts sum to something meaningful. +// +// A dated task outside today/tomorrow deliberately falls off every tab rather +// than dropping through to Starred: the date is the strongest statement the +// user has made about it, and it is still visible in its own area. +// +// `focusSectionIds` arrives as a parameter because utils/ must never import +// FOCUS_ID from model/. The controller resolves it from the section list. +export function groupTasksForFocus(tasks, now, focusSectionIds) { const startToday = startOfDay(now).getTime(); const startTomorrow = startToday + ONE_DAY_MS; + const startDayAfter = startTomorrow + ONE_DAY_MS; + const inFocus = new Set(focusSectionIds ?? []); const overdue = []; const today = []; + const tomorrow = []; const starred = []; + const notepad = []; for (const t of tasks) { if (t.completed) continue; @@ -112,17 +126,30 @@ export function groupTasksForToday(tasks, now) { const due = new Date(t.dueAt).getTime(); if (due < startToday) overdue.push(t); else if (due < startTomorrow) today.push(t); - } else if (t.starred) { + else if (due < startDayAfter) tomorrow.push(t); + continue; + } + if (t.starred) { starred.push(t); + continue; } + if (inFocus.has(t.sectionId)) notepad.push(t); } starred.sort((a, b) => a.order - b.order); + // Newest first: the notepad is a capture stream, so what you just typed goes + // on top. Sorting in the view keeps `order` semantics — and the M4 reorder + // invariants that rest on them — untouched. Spec §12.2. + notepad.sort((a, b) => + a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0, + ); return { overdue: sortByDueThenUntimed(overdue), today: sortByDueThenUntimed(today), + tomorrow: sortByDueThenUntimed(tomorrow), starred, + notepad, }; } diff --git a/src/views/today.js b/src/views/today.js index bbb8099..4fe5c4d 100644 --- a/src/views/today.js +++ b/src/views/today.js @@ -11,7 +11,7 @@ import { nextEnabledIndex, } from "../utils/menu-keyboard.js"; import { attachRenameInput, readRenameCaret } from "../utils/rename-input.js"; -import { groupTasksForToday, pickNextTask } from "../utils/time.js"; +import { groupTasksForFocus, pickNextTask } from "../utils/time.js"; import { renderMovePicker } from "./move-picker.js"; import { renderTaskRow } from "./task.js"; @@ -369,7 +369,10 @@ function template( taskMenuMode, ) { const next = pickNextTask(state.tasks, state.now); - const groups = groupTasksForToday(state.tasks, state.now); + // Temporary []: this view has no notepad yet, and an empty focusSectionIds + // yields an empty notepad bucket, which nothing here reads. Task 10 replaces + // the whole template and supplies the real list. + const groups = groupTasksForFocus(state.tasks, state.now, []); const visible = (list) => list.filter((t) => t.id !== next?.id); const overdue = visible(groups.overdue); diff --git a/tests/utils/time.test.js b/tests/utils/time.test.js index 2910fa0..93b3380 100644 --- a/tests/utils/time.test.js +++ b/tests/utils/time.test.js @@ -3,7 +3,7 @@ import { formatDueSummary, formatOccurrenceLabel, formatTimeLabel, - groupTasksForToday, + groupTasksForFocus, pickNextTask, sortByDueThenUntimed, } from "../../src/utils/time.js"; @@ -70,52 +70,128 @@ describe("formatTimeLabel", () => { }); }); -describe("groupTasksForToday", () => { - it("partitions tasks into overdue, today, and starred", () => { +describe("groupTasksForFocus", () => { + it("partitions tasks into overdue, today, tomorrow, starred and the notepad", () => { const tasks = [ task({ id: "a", dueAt: "2026-04-27T09:00:00.000Z" }), // overdue (yesterday) task({ id: "b", dueAt: "2026-04-28T18:00:00.000Z" }), // today - task({ id: "c", starred: true, dueAt: null }), // starred undated - task({ id: "d", dueAt: "2026-05-10T09:00:00.000Z" }), // future, ignored + task({ id: "c", dueAt: "2026-04-29T09:00:00.000Z" }), // tomorrow + task({ id: "d", starred: true, dueAt: null }), // starred undated + task({ id: "e", dueAt: null, sectionId: "focus-default" }), // notepad + task({ id: "f", dueAt: null, sectionId: "work-1" }), // undated in an area: no tab + task({ id: "g", dueAt: "2026-05-10T09:00:00.000Z" }), // beyond tomorrow: no tab ]; - const result = groupTasksForToday(tasks, NOW); + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); expect(result.overdue.map((t) => t.id)).toEqual(["a"]); expect(result.today.map((t) => t.id)).toEqual(["b"]); - expect(result.starred.map((t) => t.id)).toEqual(["c"]); + expect(result.tomorrow.map((t) => t.id)).toEqual(["c"]); + expect(result.starred.map((t) => t.id)).toEqual(["d"]); + expect(result.notepad.map((t) => t.id)).toEqual(["e"]); }); - it("excludes completed tasks from every group", () => { + it("excludes completed tasks from every group, including the notepad", () => { const tasks = [ task({ id: "a", completed: true, dueAt: "2026-04-27T09:00:00.000Z" }), task({ id: "b", completed: true, dueAt: "2026-04-28T18:00:00.000Z" }), - task({ id: "c", completed: true, starred: true }), + task({ id: "c", completed: true, dueAt: "2026-04-29T09:00:00.000Z" }), + task({ id: "d", completed: true, starred: true }), + task({ id: "e", completed: true, sectionId: "focus-default" }), ]; - const result = groupTasksForToday(tasks, NOW); + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); expect(result.overdue).toEqual([]); expect(result.today).toEqual([]); + expect(result.tomorrow).toEqual([]); expect(result.starred).toEqual([]); + expect(result.notepad).toEqual([]); }); - it("keeps same-day past-due tasks in Today (with 'was' label), not Overdue", () => { + it("lets a date beat a star: a dated starred task never reaches Starred", () => { + const tasks = [ + task({ id: "a", starred: true, dueAt: "2026-04-28T18:00:00.000Z" }), + // Dated beyond tomorrow AND starred — the date still wins, so it lands + // on no tab at all rather than falling through to Starred. + task({ id: "b", starred: true, dueAt: "2026-05-10T09:00:00.000Z" }), + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.today.map((t) => t.id)).toEqual(["a"]); + expect(result.starred).toEqual([]); + }); + + it("lets a star beat the notepad: starring a note promotes it out", () => { + const tasks = [ + task({ id: "a", starred: true, dueAt: null, sectionId: "focus-default" }), + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.starred.map((t) => t.id)).toEqual(["a"]); + expect(result.notepad).toEqual([]); + }); + + it("keeps same-day past-due tasks in Today, not Overdue", () => { const tasks = [task({ id: "a", dueAt: "2026-04-28T09:00:00.000Z" })]; - const result = groupTasksForToday(tasks, NOW); + const result = groupTasksForFocus(tasks, NOW, []); expect(result.overdue).toEqual([]); expect(result.today.map((t) => t.id)).toEqual(["a"]); }); - it("sorts today by dueAt ascending and starred by order ascending", () => { - // Both dated tasks are TIMED: sorting by clock time is only meaningful - // for tasks that carry one. Untimed peers sort by createdAt instead. + it("sorts the dated groups by time and starred by order", () => { const tasks = [ task({ id: "b", dueAt: "2026-04-28T18:00:00.000Z", hasTime: true }), task({ id: "a", dueAt: "2026-04-28T16:00:00.000Z", hasTime: true }), + task({ id: "d", dueAt: "2026-04-29T18:00:00.000Z", hasTime: true }), + task({ id: "c", dueAt: "2026-04-29T16:00:00.000Z", hasTime: true }), task({ id: "z", starred: true, order: 2 }), task({ id: "y", starred: true, order: 0 }), ]; - const result = groupTasksForToday(tasks, NOW); + const result = groupTasksForFocus(tasks, NOW, []); expect(result.today.map((t) => t.id)).toEqual(["a", "b"]); + expect(result.tomorrow.map((t) => t.id)).toEqual(["c", "d"]); expect(result.starred.map((t) => t.id)).toEqual(["y", "z"]); }); + + it("sorts the notepad newest first, on createdAt", () => { + // The notepad is a capture stream, so the thing you just typed belongs on + // top. Deliberately NOT `order`: the M4 reorder invariants depend on + // `order` meaning position-within-a-section, and this is a cross-section + // view. Spec §12.2. + const tasks = [ + task({ + id: "old", + sectionId: "focus-default", + createdAt: "2026-04-01T10:00:00.000Z", + }), + task({ + id: "new", + sectionId: "focus-default", + createdAt: "2026-04-28T10:00:00.000Z", + }), + task({ + id: "mid", + sectionId: "focus-default", + createdAt: "2026-04-14T10:00:00.000Z", + }), + ]; + const result = groupTasksForFocus(tasks, NOW, ["focus-default"]); + expect(result.notepad.map((t) => t.id)).toEqual(["new", "mid", "old"]); + }); + + it("counts every Focus section into the notepad, not just focus-default", () => { + // §12.4: extra Focus sections created before the merge still show their + // tasks. Nothing is orphaned by the surface losing its section headings. + const tasks = [ + task({ id: "a", sectionId: "focus-default" }), + task({ id: "b", sectionId: "focus-extra" }), + ]; + const result = groupTasksForFocus(tasks, NOW, [ + "focus-default", + "focus-extra", + ]); + expect(result.notepad.map((t) => t.id).sort()).toEqual(["a", "b"]); + }); + + it("treats a missing focusSectionIds as an empty notepad rather than throwing", () => { + const tasks = [task({ id: "a", sectionId: "focus-default" })]; + expect(groupTasksForFocus(tasks, NOW).notepad).toEqual([]); + }); }); describe("pickNextTask", () => { @@ -265,7 +341,7 @@ describe("sortByDueThenUntimed", () => { }); }); -describe("groupTasksForToday — untimed ordering", () => { +describe("groupTasksForFocus — untimed ordering", () => { it("sorts today's untimed tasks after its timed ones", () => { const tasks = [ task({ @@ -279,7 +355,7 @@ describe("groupTasksForToday — untimed ordering", () => { hasTime: true, }), ]; - expect(groupTasksForToday(tasks, NOW).today.map((t) => t.id)).toEqual([ + expect(groupTasksForFocus(tasks, NOW, []).today.map((t) => t.id)).toEqual([ "timed", "untimed", ]); From 6c4bf0ba3e1b741e9bcb42aa802447e5c9e25840 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 18:58:22 +0200 Subject: [PATCH 04/21] fix(focus): pick the Next hero from the Today bucket, not every dated task --- src/utils/time.js | 34 +++++------ src/views/today.js | 2 +- tests/utils/time.test.js | 119 ++++++++++++++++++++++++++++----------- 3 files changed, 105 insertions(+), 50 deletions(-) diff --git a/src/utils/time.js b/src/utils/time.js index 85f13bf..c8c9ca1 100644 --- a/src/utils/time.js +++ b/src/utils/time.js @@ -185,25 +185,25 @@ function byDueThenUntimed(a, b) { return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0; } -export function pickNextTask(tasks, now) { - const active = tasks.filter((t) => !t.completed); - const dated = active.filter((t) => t.dueAt).sort(byDueAtAsc); - const upcoming = dated.find( - (t) => new Date(t.dueAt).getTime() > now.getTime(), +// The Today tab's "Next up" hero. Takes the grouped buckets rather than the raw +// task list for two reasons: it can then never promote something due next week +// into a card labelled "Next", and an untimed task due today stays a candidate +// instead of being mistaken for overdue (its stored midnight is behind `now` +// from 00:01 onward). +// +// Candidate order: the next timed thing still ahead of you today → anything +// untimed today ("sometime today" is still ahead of you) → the earliest thing +// today that has already passed → the oldest overdue item. `groups.today` and +// `groups.overdue` both arrive sorted, so first-match is the right pick. +export function pickNextTask(groups, now) { + const today = groups?.today ?? []; + const upcoming = today.find( + (t) => t.hasTime && new Date(t.dueAt).getTime() > now.getTime(), ); if (upcoming) return upcoming; - const overdue = dated.find( - (t) => new Date(t.dueAt).getTime() <= now.getTime(), - ); - if (overdue) return overdue; - - const starred = active - .filter((t) => t.starred && !t.dueAt) - .sort((a, b) => a.order - b.order); - return starred[0] ?? null; -} + const untimed = today.find((t) => !t.hasTime); + if (untimed) return untimed; -function byDueAtAsc(a, b) { - return new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime(); + return today[0] ?? groups?.overdue?.[0] ?? null; } diff --git a/src/views/today.js b/src/views/today.js index 4fe5c4d..70185a7 100644 --- a/src/views/today.js +++ b/src/views/today.js @@ -368,11 +368,11 @@ function template( pendingRenameTaskValue, taskMenuMode, ) { - const next = pickNextTask(state.tasks, state.now); // Temporary []: this view has no notepad yet, and an empty focusSectionIds // yields an empty notepad bucket, which nothing here reads. Task 10 replaces // the whole template and supplies the real list. const groups = groupTasksForFocus(state.tasks, state.now, []); + const next = pickNextTask(groups, state.now); const visible = (list) => list.filter((t) => t.id !== next?.id); const overdue = visible(groups.overdue); diff --git a/tests/utils/time.test.js b/tests/utils/time.test.js index 93b3380..3ee06af 100644 --- a/tests/utils/time.test.js +++ b/tests/utils/time.test.js @@ -195,40 +195,95 @@ describe("groupTasksForFocus", () => { }); describe("pickNextTask", () => { - it("picks the earliest upcoming time-dated task", () => { - const tasks = [ - task({ id: "a", dueAt: "2026-04-28T18:00:00.000Z" }), - task({ id: "b", dueAt: "2026-04-28T16:00:00.000Z" }), - ]; - expect(pickNextTask(tasks, NOW)?.id).toBe("b"); - }); - - it("falls back to oldest overdue when nothing is upcoming", () => { - const tasks = [ - task({ id: "a", dueAt: "2026-04-26T09:00:00.000Z" }), - task({ id: "b", dueAt: "2026-04-27T09:00:00.000Z" }), - ]; - expect(pickNextTask(tasks, NOW)?.id).toBe("a"); - }); - - it("falls back to first starred undated when nothing is dated", () => { - const tasks = [ - task({ id: "a", starred: true, order: 2 }), - task({ id: "b", starred: true, order: 0 }), - ]; - expect(pickNextTask(tasks, NOW)?.id).toBe("b"); - }); - - it("returns null on empty input", () => { - expect(pickNextTask([], NOW)).toBeNull(); + // The hero reads the grouped buckets, not the raw list, so it can never + // promote something due next week into a card labelled "Next" on Today. + const groups = (over, todayList) => ({ + overdue: over, + today: todayList, + tomorrow: [], + starred: [], + notepad: [], + }); + + it("picks the earliest still-upcoming timed task today", () => { + const a = task({ + id: "a", + dueAt: "2026-04-28T18:00:00.000Z", + hasTime: true, + }); + const b = task({ + id: "b", + dueAt: "2026-04-28T16:00:00.000Z", + hasTime: true, + }); + expect(pickNextTask(groups([], [b, a]), NOW)?.id).toBe("b"); + }); + + it("skips a timed task that has already passed today", () => { + const past = task({ + id: "past", + dueAt: "2026-04-28T09:00:00.000Z", + hasTime: true, + }); + const soon = task({ + id: "soon", + dueAt: "2026-04-28T18:00:00.000Z", + hasTime: true, + }); + expect(pickNextTask(groups([], [past, soon]), NOW)?.id).toBe("soon"); + }); + + it("prefers an untimed task due today over anything overdue", () => { + // An untimed task is stored at local midnight, so `dueAt > now` is false + // from 00:01 onward. Reading the buckets instead of the raw dates is what + // stops that stored midnight from reading as "overdue" — the defect Plan 2 + // recorded and deferred to here. + const untimed = task({ + id: "untimed", + dueAt: "2026-04-28T00:00:00.000Z", + hasTime: false, + }); + const old = task({ + id: "old", + dueAt: "2026-04-20T09:00:00.000Z", + hasTime: true, + }); + expect(pickNextTask(groups([old], [untimed]), NOW)?.id).toBe("untimed"); + }); + + it("falls back to the first task due today when everything today has passed", () => { + const past = task({ + id: "past", + dueAt: "2026-04-28T09:00:00.000Z", + hasTime: true, + }); + expect(pickNextTask(groups([], [past]), NOW)?.id).toBe("past"); + }); + + it("falls back to the oldest overdue task when nothing is due today", () => { + // `overdue` arrives day-ascending from sortByDueThenUntimed, so [0] is the + // thing that has been rotting longest. + const older = task({ id: "older", dueAt: "2026-04-20T09:00:00.000Z" }); + const newer = task({ id: "newer", dueAt: "2026-04-27T09:00:00.000Z" }); + expect(pickNextTask(groups([older, newer], []), NOW)?.id).toBe("older"); + }); + + it("never reaches into Starred, Tomorrow or the notepad", () => { + const result = pickNextTask( + { + overdue: [], + today: [], + tomorrow: [task({ id: "t" })], + starred: [task({ id: "s", starred: true })], + notepad: [task({ id: "n" })], + }, + NOW, + ); + expect(result).toBeNull(); }); - it("ignores completed tasks", () => { - const tasks = [ - task({ id: "a", completed: true, dueAt: "2026-04-28T18:00:00.000Z" }), - task({ id: "b", starred: true, order: 0 }), - ]; - expect(pickNextTask(tasks, NOW)?.id).toBe("b"); + it("returns null for empty groups", () => { + expect(pickNextTask(groups([], []), NOW)).toBeNull(); }); }); From 699fc7b1d282671b79ffd26e793511836b183976 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 19:02:53 +0200 Subject: [PATCH 05/21] feat(focus): add the day greeting and the overdue/due-today summary --- src/utils/time.js | 44 ++++++++++++++++++++++++++++++++++++ tests/utils/time.test.js | 49 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/utils/time.js b/src/utils/time.js index c8c9ca1..71c38a7 100644 --- a/src/utils/time.js +++ b/src/utils/time.js @@ -18,6 +18,29 @@ const SHORT_MONTH = [ "Nov", "Dec", ]; +const LONG_WEEKDAY = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const LONG_MONTH = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; function startOfDay(date) { const d = new Date(date); @@ -207,3 +230,24 @@ export function pickNextTask(groups, now) { return today[0] ?? groups?.overdue?.[0] ?? null; } + +// "Tuesday 28 April" — the Focus page's greeting line. +// +// Built from constant arrays rather than toLocaleDateString on purpose: the +// locale-driven version returns a different string per machine, which makes it +// untestable without pinning a locale, and the rest of this surface ("Today", +// "Overdue", "Starred", "Next") is English regardless. Matches how +// formatTimeLabel and formatOccurrenceLabel already work in this file. +export function formatDayGreeting(now) { + return `${LONG_WEEKDAY[now.getDay()]} ${now.getDate()} ${LONG_MONTH[now.getMonth()]}`; +} + +// Counts for the page-header summary. Takes the grouped output, not the raw +// task list, so the summary can never disagree with what the Today tab renders +// — both are derived from one groupTasksForFocus call over one `state`. +export function summariseDay(groups) { + return { + overdue: groups?.overdue?.length ?? 0, + dueToday: groups?.today?.length ?? 0, + }; +} diff --git a/tests/utils/time.test.js b/tests/utils/time.test.js index 3ee06af..aff4b8d 100644 --- a/tests/utils/time.test.js +++ b/tests/utils/time.test.js @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { + formatDayGreeting, formatDueSummary, formatOccurrenceLabel, formatTimeLabel, groupTasksForFocus, pickNextTask, sortByDueThenUntimed, + summariseDay, } from "../../src/utils/time.js"; const NOW = new Date("2026-04-28T14:00:00"); @@ -459,3 +461,50 @@ describe("formatDueSummary", () => { ); }); }); + +describe("formatDayGreeting", () => { + it("renders weekday, day-of-month and month in full", () => { + expect(formatDayGreeting(NOW)).toBe("Tuesday 28 April"); + }); + + it("does not pad a single-digit day", () => { + expect(formatDayGreeting(new Date("2026-04-05T12:00:00"))).toBe( + "Sunday 5 April", + ); + }); + + it("handles the last month of the year", () => { + expect(formatDayGreeting(new Date("2026-12-31T12:00:00"))).toBe( + "Thursday 31 December", + ); + }); +}); + +describe("summariseDay", () => { + it("counts overdue and due-today items", () => { + const result = summariseDay({ + overdue: [task({ id: "a" }), task({ id: "b" })], + today: [task({ id: "c" })], + tomorrow: [task({ id: "d" })], + starred: [task({ id: "e" })], + notepad: [task({ id: "f" })], + }); + expect(result).toEqual({ overdue: 2, dueToday: 1 }); + }); + + it("counts zeroes rather than omitting them", () => { + expect( + summariseDay({ + overdue: [], + today: [], + tomorrow: [], + starred: [], + notepad: [], + }), + ).toEqual({ overdue: 0, dueToday: 0 }); + }); + + it("survives a missing or partial groups object", () => { + expect(summariseDay()).toEqual({ overdue: 0, dueToday: 0 }); + }); +}); From 696ddc7fef2a41723d72d71362e63d0e4ca2249d Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 19:06:37 +0200 Subject: [PATCH 06/21] feat(focus): resolve a task's area for the row badge --- src/utils/areas.js | 15 +++++++++++++++ tests/utils/areas.test.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/utils/areas.js create mode 100644 tests/utils/areas.test.js diff --git a/src/utils/areas.js b/src/utils/areas.js new file mode 100644 index 0000000..154eaa7 --- /dev/null +++ b/src/utils/areas.js @@ -0,0 +1,15 @@ +// Pure area lookups for view code. No DOM, no model imports — the caller passes +// the full section and area lists it already has in `state`. + +// areaForTask(task, sections, areas) → Area | null +// +// Resolves the area a task lives in, for the row's area badge. Returns null +// rather than throwing when either link is missing: a task whose section was +// cascade-deleted mid-render is a real race, and a badge is not worth a throw +// inside a template. +export function areaForTask(task, sections, areas) { + if (!task) return null; + const section = (sections ?? []).find((s) => s.id === task.sectionId); + if (!section) return null; + return (areas ?? []).find((a) => a.id === section.areaId) ?? null; +} diff --git a/tests/utils/areas.test.js b/tests/utils/areas.test.js new file mode 100644 index 0000000..b44fb47 --- /dev/null +++ b/tests/utils/areas.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { areaForTask } from "../../src/utils/areas.js"; + +const SECTIONS = [ + { id: "s1", areaId: "work" }, + { id: "s2", areaId: "home" }, + { id: "orphan", areaId: "deleted-area" }, +]; +const AREAS = [ + { id: "work", name: "Work" }, + { id: "home", name: "Home" }, +]; + +describe("areaForTask", () => { + it("resolves the area through the task's section", () => { + const result = areaForTask({ id: "t", sectionId: "s2" }, SECTIONS, AREAS); + expect(result?.name).toBe("Home"); + }); + + it("returns null when the section is missing", () => { + expect( + areaForTask({ id: "t", sectionId: "gone" }, SECTIONS, AREAS), + ).toBeNull(); + }); + + it("returns null when the section points at a missing area", () => { + expect( + areaForTask({ id: "t", sectionId: "orphan" }, SECTIONS, AREAS), + ).toBeNull(); + }); + + it("returns null for a missing task or missing lists", () => { + expect(areaForTask(null, SECTIONS, AREAS)).toBeNull(); + expect(areaForTask({ id: "t", sectionId: "s1" })).toBeNull(); + }); +}); From 16a52e2686c13ac0e144b843a70fd6f998226bfe Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 19:20:56 +0200 Subject: [PATCH 07/21] refactor(toast): let a toast carry any action, not only Undo --- main.css | 4 ++-- src/controller.js | 10 +++++----- src/views/toast.js | 28 ++++++++++++++++++++-------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/main.css b/main.css index f39bb41..2456472 100644 --- a/main.css +++ b/main.css @@ -543,7 +543,7 @@ body { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); pointer-events: auto; } -.toast__undo { +.toast__action { background: none; border: none; color: var(--color-accent); @@ -551,7 +551,7 @@ body { font: inherit; padding: 0.2rem 0.4rem; } -.toast__undo:hover { +.toast__action:hover { text-decoration: underline; } diff --git a/src/controller.js b/src/controller.js index 2e62cfd..9e20b97 100644 --- a/src/controller.js +++ b/src/controller.js @@ -200,7 +200,7 @@ export function createController({ models, els }) { toast.show({ message: `Done · next ${formatOccurrenceLabel(updated.dueAt, new Date())}`, durationMs: COMPLETE_TOAST_MS, - onUndo: async () => { + onAction: async () => { try { await tasks.update(id, snapshot); // restore date, stamp, count } catch (err) { @@ -229,7 +229,7 @@ export function createController({ models, els }) { message: formatTaskDeleteMessage(1), key: TASK_DELETE_BATCH_KEY, durationMs: 5000, - onUndo: () => { + onAction: () => { const batch = taskDeleteBatch; taskDeleteBatch = null; for (const t of [...batch.tasks].reverse()) { @@ -277,7 +277,7 @@ export function createController({ models, els }) { toast.show({ message: `Moved to ${label}`, durationMs: MOVE_TOAST_MS, - onUndo: async () => { + onAction: async () => { // Exact restore — the move touched only this task (append left // peers untouched; the source kept the gap this task vacated). try { @@ -445,7 +445,7 @@ export function createController({ models, els }) { toast.show({ message: cascadeMessage(sectionSnapshot.name, taskSnapshots.length), durationMs: CASCADE_TOAST_MS, - onUndo: async () => { + onAction: async () => { await sections.restore(sectionSnapshot); await tasks.restoreMany(taskSnapshots); }, @@ -551,7 +551,7 @@ export function createController({ models, els }) { taskSnapshots.length, ), durationMs: CASCADE_TOAST_MS, - onUndo: async () => { + onAction: async () => { // Reverse-cascade restore (parents before children); same // empty-layer guard as the delete above. await areas.restore(areaSnapshot); diff --git a/src/views/toast.js b/src/views/toast.js index 0927064..781d14e 100644 --- a/src/views/toast.js +++ b/src/views/toast.js @@ -18,6 +18,11 @@ // outside the live region), update() textContent changes still announce // to screen readers. (Per W3C: focus INSIDE a live region suppresses // change announcements in some SRs.) +// +// Action button: one per toast, labelled "Undo" unless the caller passes +// actionLabel. The label is user-visible copy, so it goes through escapeHtml +// like every other interpolated string — even though every current caller +// passes a literal. import { escapeHtml } from "../utils/dom.js"; @@ -34,7 +39,7 @@ export function createToastView(rootEl) { let elapsedAtPause = 0; let durationMs = null; let activeKey = null; - let activeUndoHandler = null; + let activeActionHandler = null; let activeOnDismiss = null; let isHovered = false; let isFocused = false; @@ -46,7 +51,7 @@ export function createToastView(rootEl) { elapsedAtPause = 0; durationMs = null; activeKey = null; - activeUndoHandler = null; + activeActionHandler = null; activeOnDismiss = null; isHovered = false; isFocused = false; @@ -94,7 +99,14 @@ export function createToastView(rootEl) { }); } - function show({ message, onUndo, onDismiss, durationMs: d, key } = {}) { + function show({ + message, + onAction, + onDismiss, + durationMs: d, + key, + actionLabel, + } = {}) { // Fire prior toast's onDismiss before replacing (so its closure state // is committed — e.g. taskDeleteBatch in controller clears). const priorOnDismiss = activeOnDismiss; @@ -109,18 +121,18 @@ export function createToastView(rootEl) { rootEl.innerHTML = `
    ${escapeHtml(message ?? "")} - +
    `; const toastEl = rootEl.querySelector(".toast"); - const undoBtn = rootEl.querySelector(".toast__undo"); + const actionBtn = rootEl.querySelector(".toast__action"); - activeUndoHandler = () => { + activeActionHandler = () => { clearActive(); - if (onUndo) onUndo(); + if (onAction) onAction(); }; - undoBtn.addEventListener("click", activeUndoHandler, { once: true }); + actionBtn.addEventListener("click", activeActionHandler, { once: true }); attachInteractionListeners(toastEl); From 1edf8e2c90102ba3aa29f8fbe0ea025742f9ca30 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 19:28:37 +0200 Subject: [PATCH 08/21] feat(focus): give the task row an area badge slot --- src/views/task.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/views/task.js b/src/views/task.js index 19b8f13..5cf5a0f 100644 --- a/src/views/task.js +++ b/src/views/task.js @@ -14,6 +14,10 @@ // or null. Falls back to task.title when null. // MUST be ??, not || — a typed "" renders empty + // placeholder. +// areaName - the task's area name, or null. Renders the area badge +// that appears at >=768px on the dated tabs. Escaped — +// area names are user-authored and this is a new +// interpolation site. import { escapeHtml } from "../utils/dom.js"; import { describeRecurrence } from "../utils/text.js"; @@ -27,6 +31,7 @@ export function renderTaskRow( renaming = false, pendingRenameValue = null, showFile = false, + areaName = null, } = { now: new Date() }, ) { if (renaming) return renderRenameRow(task, pendingRenameValue); @@ -55,16 +60,22 @@ export function renderTaskRow( aria-haspopup="menu" aria-label="File ${escapeHtml(task.title)}">File` : ""; + // The badge is a plain , not a link or a button: it says where the task + // lives, it does not navigate. main.css hides it below 768px. + const areaBadge = areaName + ? `${escapeHtml(areaName)}` + : ""; return `
  • ${escapeHtml(task.title)} - ${recurring} + ${areaBadge} ${timeLabel} + ${fileBtn} + `; + }).join(""); + + return ` +
    ${tabs}
    + `; +} From db001d31c48762550dc723fd0d3a215d349ea378 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 19:37:10 +0200 Subject: [PATCH 10/21] refactor(focus): rename the Today view to the Focus view --- src/controller.js | 4 ++-- src/utils/rename-input.js | 2 +- src/views/area.js | 2 +- src/views/capture.js | 2 +- src/views/{today.js => focus.js} | 10 +++++----- src/views/move-picker.js | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) rename src/views/{today.js => focus.js} (98%) diff --git a/src/controller.js b/src/controller.js index 9e20b97..96776e0 100644 --- a/src/controller.js +++ b/src/controller.js @@ -18,10 +18,10 @@ import { import { formatDueSummary, formatOccurrenceLabel } from "./utils/time.js"; import { createAreaView } from "./views/area.js"; import { createCaptureView } from "./views/capture.js"; +import { createFocusView } from "./views/focus.js"; import { createRecurrenceDialog } from "./views/recurrence-dialog.js"; import { createSidebarView } from "./views/sidebar.js"; import { createToastView, TASK_DELETE_BATCH_KEY } from "./views/toast.js"; -import { createTodayView } from "./views/today.js"; import { createTopbarView } from "./views/topbar.js"; const TICK_MS = 60_000; @@ -317,7 +317,7 @@ export function createController({ models, els }) { currentMainView = null; if (route.name === "focus") { - currentMainView = createTodayView(mainRoot, { + currentMainView = createFocusView(mainRoot, { onToggleComplete: handleToggleComplete, onToggleStar: (id, currentStarred) => tasks.update(id, { starred: !currentStarred }), diff --git a/src/utils/rename-input.js b/src/utils/rename-input.js index 8274a72..7027352 100644 --- a/src/utils/rename-input.js +++ b/src/utils/rename-input.js @@ -1,5 +1,5 @@ // Shared wiring for an inline-rename that is recreated on every -// innerHTML rewrite. area.js, today.js and sidebar.js each carried a verbatim +// innerHTML rewrite. area.js, focus.js and sidebar.js each carried a verbatim // copy of this — it's the trickiest focus code in the app, so it lives once here. // // The VIEW still owns its rename STATE (which id is editing, the pending text, diff --git a/src/views/area.js b/src/views/area.js index 9ca3ebb..a8eedc0 100644 --- a/src/views/area.js +++ b/src/views/area.js @@ -194,7 +194,7 @@ export function createAreaView(rootEl, { areaId, callbacks }) { // Esc lives on document, not rootEl. After doRender() rewrites innerHTML // the previously-focused element is detached and focus drops to , // which is outside rootEl. A keydown on body bubbles up to document only — - // it never visits rootEl. Matches the today.js pattern. + // it never visits rootEl. Matches the focus.js pattern. function findOpenMenuInArea(target) { if (openMenuId) { const menu = rootEl.querySelector( diff --git a/src/views/capture.js b/src/views/capture.js index fcb7d71..68d64f4 100644 --- a/src/views/capture.js +++ b/src/views/capture.js @@ -138,7 +138,7 @@ export function createCaptureView(rootEl, { onSubmit, focusSectionId }) { // // Below Escape: ARIA APG menu navigation for the picker, guarded on // pickerOpen AND the event target being inside the picker — same double - // guard as the ⋯-menu handlers in today.js / area.js (area.js:216-217), + // guard as the ⋯-menu handlers in focus.js / area.js (area.js:216-217), // extending this single rootEl listener rather than adding a second one. // The second half matters here specifically: clicking back into the // input does NOT close the picker (the outside-click handler returns diff --git a/src/views/today.js b/src/views/focus.js similarity index 98% rename from src/views/today.js rename to src/views/focus.js index 70185a7..51d5ae9 100644 --- a/src/views/today.js +++ b/src/views/focus.js @@ -1,4 +1,4 @@ -// createTodayView(rootEl, { onToggleComplete, onToggleStar, onDelete }) +// createFocusView(rootEl, { onToggleComplete, onToggleStar, onDelete }) // → { render(state), destroy() } // // state expected: { tasks, sections, areas, settings, now } @@ -15,7 +15,7 @@ import { groupTasksForFocus, pickNextTask } from "../utils/time.js"; import { renderMovePicker } from "./move-picker.js"; import { renderTaskRow } from "./task.js"; -export function createTodayView(rootEl, callbacks) { +export function createFocusView(rootEl, callbacks) { let lastState = null; let openMenuTaskId = null; let pendingFocusTaskId = null; @@ -75,7 +75,7 @@ export function createTodayView(rootEl, callbacks) { closeMenu(false); }; - function findOpenMenuInToday(target) { + function findOpenMenuInFocus(target) { if (!openMenuTaskId) return null; const menu = rootEl.querySelector( `[data-id="${CSS.escape(openMenuTaskId)}"] [role="menu"]`, @@ -93,7 +93,7 @@ export function createTodayView(rootEl, callbacks) { return; } - const menuEl = findOpenMenuInToday(event.target); + const menuEl = findOpenMenuInFocus(event.target); if (!menuEl) return; const menuItems = Array.from(menuEl.querySelectorAll('[role="menuitem"]')); @@ -184,7 +184,7 @@ export function createTodayView(rootEl, callbacks) { event.stopPropagation(); const t = taskFromEvent(actionEl); if (!t) return; - openMenuTaskId = null; // today.js names it openMenuTaskId + openMenuTaskId = null; // focus.js names it openMenuTaskId taskMenuMode = "actions"; doRender(); callbacks.onOpenRepeatEditor?.(t.id); diff --git a/src/views/move-picker.js b/src/views/move-picker.js index c75d866..0759b72 100644 --- a/src/views/move-picker.js +++ b/src/views/move-picker.js @@ -3,7 +3,7 @@ // Pure template. Renders the move-target picker as a sub-face of the task // ⋯ menu: every section in every area EXCEPT the task's current section, // grouped by area, with a "← Back" row LAST. Shared by area.js (threaded -// through section.js) and today.js. +// through section.js) and focus.js. // // The returned markup is itself role="menu", so it reuses the views' // existing menu machinery (findOpenMenu*, arrow-key nav, isRendering blur From 364ee526ccbc55186b645bcf599ccb67626e4978 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 19:50:54 +0200 Subject: [PATCH 11/21] feat(focus): put Today, Tomorrow, Starred and the notepad on tabs --- src/views/focus.js | 304 +++++++++++++++++++++++++++++++++------------ 1 file changed, 227 insertions(+), 77 deletions(-) diff --git a/src/views/focus.js b/src/views/focus.js index 51d5ae9..89c474d 100644 --- a/src/views/focus.js +++ b/src/views/focus.js @@ -4,6 +4,7 @@ // state expected: { tasks, sections, areas, settings, now } // onDelete receives the full task object so the controller can restore it. +import { FOCUS_ID } from "../model/areas.js"; import { bindActions, bindKeys } from "../utils/dom.js"; import { firstEnabledIndex, @@ -12,6 +13,7 @@ import { } from "../utils/menu-keyboard.js"; import { attachRenameInput, readRenameCaret } from "../utils/rename-input.js"; import { groupTasksForFocus, pickNextTask } from "../utils/time.js"; +import { renderTabStrip, TABS } from "./focus-tabs.js"; import { renderMovePicker } from "./move-picker.js"; import { renderTaskRow } from "./task.js"; @@ -37,6 +39,16 @@ export function createFocusView(rootEl, callbacks) { // Sub-face of the open task menu: 'actions' (default) | 'picker'. // RESET to 'actions' on every open-menu; destroy resets it too. let taskMenuMode = "actions"; + // Which tab is showing. VIEW-owned, not a model field and not controller + // state: every step of a tab switch acts on state that lives in this closure + // (the open menu, the live rename), and "always resets to Today on mount" + // then comes for free — leaving Focus for an area destroys the view, so + // coming back always lands here again. Spec §3.3. + let activeTab = "today"; + // After the next render, focus this tab's button. Consumed last in doRender, + // cleared unconditionally, reset in destroy() — same contract as every other + // pending-focus flag in this file. + let pendingFocusTab = null; // returnFocus=false on click-outside dismiss: focus follows the click // (e.g. into the capture input), not back to the ⋯. Esc / menu-action / @@ -69,6 +81,49 @@ export function createFocusView(rootEl, callbacks) { doRender(); }; + // Switching tab is a lifecycle event, not just a re-render: the panel is + // rewritten, which detaches everything inside it. In order (spec §3.3): + // 1. close any open task menu + // 2. resolve a live rename by COMMITTING it, matching Enter — never + // discard what the user typed + // 3. render the new tab + // 4. move focus to the newly-selected tab button + // + // Step 3 is deliberately conditional. When a rename commit fires, the model + // write's own notify-render is the render that must consume pendingFocusTab. + // Rendering here as well would focus the tab button and then let that queued + // render rewrite innerHTML underneath it, dropping focus to — the trap + // the cascade-focus drain exists to prevent, and a view cannot drain, only + // the controller can await applyState(). commitTaskRenameFromInput below + // renders in exactly one branch for exactly this reason. + const selectTab = (next) => { + if (!TABS.some((t) => t.id === next)) return; + + openMenuTaskId = null; + taskMenuMode = "actions"; + pendingMenuFocusTaskId = null; + // Never route focus back to a ⋯ button that is about to be detached. + pendingFocusTaskId = null; + + let renameCommitted = false; + if (renamingTaskId) { + const input = rootEl.querySelector(".task__rename-input"); + const value = (input?.value ?? "").trim(); + const id = renamingTaskId; + renamingTaskId = null; + pendingRenameTaskValue = null; + pendingRenameTaskSelect = false; + if (value) { + callbacks.onCommitTaskRename({ taskId: id, name: value }); + renameCommitted = true; + } + } + + activeTab = next; + pendingFocusTab = next; + if (!renameCommitted) doRender(); + }; + const docClickHandler = (event) => { if (!openMenuTaskId) return; if (rootEl.contains(event.target)) return; @@ -145,6 +200,10 @@ export function createFocusView(rootEl, callbacks) { rootEl.addEventListener("dblclick", dblclickHandler); const unbind = bindActions(rootEl, { + "select-tab": (_event, actionEl) => { + const next = actionEl?.dataset?.tab; + if (next) selectTab(next); + }, "toggle-complete": (_event, actionEl) => { const t = taskFromEvent(actionEl); if (t) callbacks.onToggleComplete(t.id); @@ -244,8 +303,32 @@ export function createFocusView(rootEl, callbacks) { const t = taskFromEvent(event.target); if (t) enterTaskRename(t); }, + ArrowRight: (event) => moveTabFocus(event, 1), + ArrowLeft: (event) => moveTabFocus(event, -1), + Home: (event) => moveTabFocus(event, 0, firstEnabledIndex), + End: (event) => moveTabFocus(event, 0, lastEnabledIndex), }); + // Roving-tabindex traversal for the tab strip, mirroring the icon picker. + // Guarded on the event target actually being a tab, so it never competes with + // the ⋯ menu's own Home/End handling (which is guarded on being inside an + // open menu) or with a caret moving inside a rename input. + // + // Activation is automatic: arrowing to a tab selects it, per the APG pattern + // for cheap panels. selectTab moves the focus, so nothing is needed here + // beyond choosing the target. + function moveTabFocus(event, direction, pick) { + if (!event.target.closest('[role="tab"]')) return; + const items = TABS.map(() => ({ disabled: false })); + const currentIndex = TABS.findIndex((t) => t.id === activeTab); + const nextIdx = pick + ? pick(items) + : nextEnabledIndex(items, currentIndex, direction); + if (nextIdx < 0) return; + event.preventDefault(); + selectTab(TABS[nextIdx].id); + } + function commitTaskRenameFromInput(inputEl) { const id = inputEl?.dataset?.taskId ?? renamingTaskId; if (!id) return; @@ -269,6 +352,18 @@ export function createFocusView(rootEl, callbacks) { // helper re-focuses and restores it after re-render. See utils/rename-input.js. const taskCaret = readRenameCaret(rootEl, ".task__rename-input"); + // The 60s tick and every model notify rewrite this subtree with no user + // action behind them, detaching whatever held focus. Without this, focus + // parked on a tab drops to once a minute — and the tab strip is now + // the whole surface's navigation, so that is not a small loss. + // + // Only ever re-asserts focus that was ALREADY on a tab, and the guard means + // it never overrides a tab switch that has explicitly asked for focus. + if (!pendingFocusTab) { + pendingFocusTab = + document.activeElement?.closest?.(".focus-tab")?.dataset?.tab ?? null; + } + isRendering = true; try { rootEl.innerHTML = template( @@ -277,6 +372,7 @@ export function createFocusView(rootEl, callbacks) { renamingTaskId, pendingRenameTaskValue, taskMenuMode, + activeTab, ); } finally { // try/finally so a defensive template throw can't strand @@ -300,13 +396,22 @@ export function createFocusView(rootEl, callbacks) { if (attached) pendingRenameTaskSelect = false; // Post-render lookup: focus the task's ⋯ button by data-attribute. - // Captured element refs go stale across innerHTML rewrites, so we - // query the freshly-rendered DOM. Mirrors area.js's pendingFocusTaskId. + // Captured element refs go stale across innerHTML rewrites, so we query + // the freshly-rendered DOM. if (pendingFocusTaskId) { const trigger = rootEl.querySelector( `[data-id="${CSS.escape(pendingFocusTaskId)}"] .task__menu-btn`, ); - trigger?.focus(); + // The task may have left this tab entirely between the flag being set + // and this render — rescheduling it to next week from the Schedule + // dialog is enough, and that is now the ordinary outcome rather than an + // edge case. Without the fallback, `?.focus()` silently no-ops and focus + // stays wherever the closed dialog left it: . + if (trigger) trigger.focus(); + else + rootEl + .querySelector(`.focus-tab[data-tab="${CSS.escape(activeTab)}"]`) + ?.focus(); pendingFocusTaskId = null; } @@ -321,6 +426,15 @@ export function createFocusView(rootEl, callbacks) { firstItem?.focus(); pendingMenuFocusTaskId = null; } + + // Last flag consumed, cleared unconditionally. It is mutually exclusive + // with the two above — selectTab nulls both before setting this one. + if (pendingFocusTab) { + rootEl + .querySelector(`.focus-tab[data-tab="${CSS.escape(pendingFocusTab)}"]`) + ?.focus(); + pendingFocusTab = null; + } } return { @@ -331,6 +445,8 @@ export function createFocusView(rootEl, callbacks) { focusTaskMenu(taskId) { pendingFocusTaskId = taskId; }, + selectTab, + getActiveTab: () => activeTab, destroy() { // Destroy-commit: if a task rename is in flight and the input has // a non-empty trimmed value, commit it BEFORE listener unbinding so @@ -357,6 +473,8 @@ export function createFocusView(rootEl, callbacks) { pendingRenameTaskSelect = false; isRendering = false; taskMenuMode = "actions"; + activeTab = "today"; + pendingFocusTab = null; }, }; } @@ -367,26 +485,25 @@ function template( renamingTaskId, pendingRenameTaskValue, taskMenuMode, + activeTab, ) { - // Temporary []: this view has no notepad yet, and an empty focusSectionIds - // yields an empty notepad bucket, which nothing here reads. Task 10 replaces - // the whole template and supplies the real list. - const groups = groupTasksForFocus(state.tasks, state.now, []); - const next = pickNextTask(groups, state.now); - const visible = (list) => list.filter((t) => t.id !== next?.id); - - const overdue = visible(groups.overdue); - const today = visible(groups.today); - const starred = visible(groups.starred); - - const allEmpty = - !next && overdue.length === 0 && today.length === 0 && starred.length === 0; - - if (allEmpty) { - return `

    You're clear. Nice.

    `; - } + // Which sections belong to Focus. Resolved here, then passed as data, because + // utils/time.js must stay ignorant of FOCUS_ID. Every Focus section counts, + // not just focus-default — §12.4 keeps pre-merge extra sections from + // orphaning their tasks now that the surface has no section headings. + const focusSectionIds = state.sections + .filter((s) => s.areaId === FOCUS_ID) + .map((s) => s.id); + + const groups = groupTasksForFocus(state.tasks, state.now, focusSectionIds); + const counts = { + today: groups.overdue.length + groups.today.length, + tomorrow: groups.tomorrow.length, + starred: groups.starred.length, + focus: groups.notepad.length, + }; - // ≥1 section other than any task's own ⇒ a valid move target exists. + // >=1 section other than any task's own ⇒ a valid move target exists. const hasMoveTargets = state.sections.length > 1; // Compute the picker only for the open task in picker mode. @@ -402,47 +519,82 @@ function template( } } + const rowOpts = { + now: state.now, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + movePickerHtml, + hasMoveTargets, + }; + return ` - ${next ? renderNextCard(next, state.now, openMenuTaskId, renamingTaskId, pendingRenameTaskValue, taskMenuMode, movePickerHtml, hasMoveTargets) : ""} - ${renderGroup("Overdue", "group--overdue", overdue, state.now, openMenuTaskId, true, renamingTaskId, pendingRenameTaskValue, taskMenuMode, movePickerHtml, hasMoveTargets)} - ${renderGroup("Today", "group--today", today, state.now, openMenuTaskId, true, renamingTaskId, pendingRenameTaskValue, taskMenuMode, movePickerHtml, hasMoveTargets)} - ${renderGroup("Starred", "group--starred", starred, state.now, openMenuTaskId, false, renamingTaskId, pendingRenameTaskValue, taskMenuMode, movePickerHtml, hasMoveTargets)} + ${renderTabStrip({ activeTab, counts })} + ${renderPanel(activeTab, groups, state, rowOpts)} `; } -function renderNextCard( - task, - now, - openMenuTaskId, - renamingTaskId, - pendingRenameTaskValue, - taskMenuMode, - movePickerHtml, - hasMoveTargets, -) { +// One panel at a time — the other three are not in the DOM, which is what keeps +// aria-controls honest and stops four lists of rows from competing for ids. +function renderPanel(activeTab, groups, state, rowOpts) { + const body = panelBody(activeTab, groups, state, rowOpts); + return ` +
    ${body}
    + `; +} + +function panelBody(activeTab, groups, state, rowOpts) { + if (activeTab === "tomorrow") { + return renderGroup( + "Tomorrow", + "group--tomorrow", + groups.tomorrow, + true, + rowOpts, + ); + } + if (activeTab === "starred") { + return renderGroup( + "Starred", + "group--starred", + groups.starred, + false, + rowOpts, + ); + } + if (activeTab === "focus") { + return renderGroup( + "Focus", + "group--notepad", + groups.notepad, + false, + rowOpts, + ); + } + + const next = pickNextTask(groups, state.now); + const visible = (list) => list.filter((t) => t.id !== next?.id); + return ` + ${next ? renderNextCard(next, rowOpts) : ""} + ${renderGroup("Overdue", "group--overdue", visible(groups.overdue), true, rowOpts)} + ${renderGroup("Today", "group--today", visible(groups.today), true, rowOpts)} + `; +} + +function renderNextCard(task, rowOpts) { return `

    Next

      - ${renderTaskRowWithMenu(task, now, openMenuTaskId, renamingTaskId, pendingRenameTaskValue, taskMenuMode, movePickerHtml, hasMoveTargets)} + ${renderTaskRowWithMenu(task, rowOpts)}
    `; } -function renderGroup( - heading, - modifierClass, - tasks, - now, - openMenuTaskId, - showCount, - renamingTaskId, - pendingRenameTaskValue, - taskMenuMode, - movePickerHtml, - hasMoveTargets, -) { +function renderGroup(heading, modifierClass, tasks, showCount, rowOpts) { if (tasks.length === 0) return ""; // The count is a separate element, not part of the heading string: it is // metadata, and styling it as such is what lets the heading itself read as a @@ -450,20 +602,7 @@ function renderGroup( const countHtml = showCount ? `${tasks.length}` : ""; - const rows = tasks - .map((t) => - renderTaskRowWithMenu( - t, - now, - openMenuTaskId, - renamingTaskId, - pendingRenameTaskValue, - taskMenuMode, - movePickerHtml, - hasMoveTargets, - ), - ) - .join(""); + const rows = tasks.map((t) => renderTaskRowWithMenu(t, rowOpts)).join(""); return `

    ${heading}${countHtml}

    @@ -472,16 +611,17 @@ function renderGroup( `; } -function renderTaskRowWithMenu( - task, - now, - openMenuTaskId, - renamingTaskId, - pendingRenameTaskValue, - taskMenuMode, - movePickerHtml, - hasMoveTargets, -) { +function renderTaskRowWithMenu(task, rowOpts) { + const { + now, + openMenuTaskId, + renamingTaskId, + pendingRenameTaskValue, + taskMenuMode, + movePickerHtml, + hasMoveTargets, + } = rowOpts; + const isRenaming = renamingTaskId === task.id; if (isRenaming) { // Rename input replaces the row's children — no menu injection, @@ -500,18 +640,28 @@ function renderTaskRowWithMenu( // Picker face: replace the action menu with the pre-rendered picker. // The menu injects inside the
  • as its last child (the
  • is // position: relative so the absolute menu anchors to the row). + // + // The replacement is a FUNCTION, not a string. A string replacement treats + // `$&`, `$'` and "$`" as substitution patterns, and escapeHtml does not touch + // `$` — so an area or section named `$&` reaches here through the picker + // markup and expands to the matched `
  • `, injecting a stray closing tag. + // Not script execution (the match is always the literal `
  • `), but real + // DOM corruption. A function replacement never interprets `$`. if (taskMenuMode === "picker" && movePickerHtml) { - return row.replace("", `${movePickerHtml}`); + return row.replace("", () => `${movePickerHtml}`); } - // Actions face. Today menu: [Rename, Move to…, Delete]. No Move up/down — - // today is a sorted view, not a manual order. + // Actions face. Focus menu: [Rename, Move to…, Schedule…, Delete]. No Move + // up/down — every tab here is a sorted view, not a manual order. const moveToItem = hasMoveTargets ? `` : ""; + // Function replacement here too — same `$&` reasoning as the picker face + // above. Nothing user-authored is in this string today, but the two call + // sites must not drift apart. return row.replace( "", - `

    `; return; } - const date = state.now.toLocaleDateString(undefined, { - weekday: "long", - day: "numeric", - month: "long", - }); + // The

    says "Focus", not the date. A heading names WHERE YOU ARE, and + // a screen-reader user navigating by heading needs a landmark that is the + // same every day. The date is the greeting beneath it. Spec §3.1. + const focusSectionIds = state.sections + .filter((s) => s.areaId === FOCUS_ID) + .map((s) => s.id); + // Computed here as well as in the view. Both calls take the same `state`, + // including the same `now`, so the summary and the tab counts agree by + // construction — which is the property worth paying one extra O(n) pass + // for. Threading groups through `state` would make the view depend on the + // controller having computed them first. + const groups = groupTasksForFocus(state.tasks, state.now, focusSectionIds); + const { overdue, dueToday } = summariseDay(groups); + // The overdue count is emphasised, and omitted entirely at zero — "0 + // overdue" is a reassurance nobody asked for. The Overdue group keeps its + // own heading and count, so this line is never the only route to the + // number (§8). + const summary = [ + overdue > 0 + ? `${overdue} overdue` + : "", + `${dueToday} due today`, + ] + .filter(Boolean) + .join(" · "); + + // No escapeHtml on the greeting: formatDayGreeting composes it from two + // constant arrays and a number, with no user-authored input anywhere. pageHeaderRoot.innerHTML = ` -

    Today

    -

    ${date}

    +

    Focus

    +

    ${formatDayGreeting(state.now)}

    +

    ${summary}

    `; } From bcffe4b00e02caad59d78dc30a84f0cc76c181e9 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 20:27:29 +0200 Subject: [PATCH 15/21] feat(focus): confirm captures with a View action and unlist Focus from the rail --- src/controller.js | 45 +++++++++++++++++++++++++++++++++++++++++++- src/views/sidebar.js | 24 +++++++++++------------ 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/controller.js b/src/controller.js index 605f425..8d32a7e 100644 --- a/src/controller.js +++ b/src/controller.js @@ -76,6 +76,7 @@ export function createController({ models, els }) { let drawerMqHandler = null; let recurrenceDialog = null; let repeatEditorTaskId = null; // transient UI state — NOT a model field + let pendingTabSelection = null; // transient UI state — NOT a model field const completing = new Set(); // task ids mid-completion (re-entry guard) let currentChoice = null; // "system" | "dark" | "light" — mirrors the model let currentTheme = null; // resolved "dark" | "light" — what is on the document @@ -367,6 +368,15 @@ export function createController({ models, els }) { onMoveTaskToSection: handleMoveTaskToSection, onOpenRepeatEditor: openRecurrenceEditor, }); + // Something asked for a specific tab on the next Focus view — today + // only the capture toast's View action, fired after a route change. + // selectTab before the first render(state) is safe: doRender early- + // returns on a null lastState, activeTab is already set, and the + // applyState below renders the right tab. + if (pendingTabSelection) { + currentMainView.selectTab(pendingTabSelection); + pendingTabSelection = null; + } return; } @@ -817,7 +827,40 @@ export function createController({ models, els }) { capture = createCaptureView(captureRoot, { // No `starred` — a star means "I chose this for today", and capture // setting it on everything made the signal worthless. Spec D2. - onSubmit: (title, sectionId) => tasks.create({ sectionId, title }), + onSubmit: async (title, sectionId) => { + // SNAPSHOT BOTH BEFORE THE WRITE. Each of these can change while the + // IndexedDB write is in flight — one sidebar click during the await is + // enough — and reading them afterwards makes the toast announce + // "Added to Focus" for a task that went into an area. Same trap as + // wasDrawerOpen in deleteAreaCascade, and the lesson recorded there + // is that a race like this usually has more than one late read. + const wasFocusRoute = currentRoute.name === "focus"; + const tabBefore = currentMainView?.getActiveTab?.(); + + await tasks.create({ sectionId, title }); + + // Capture on Focus writes into the notepad, which is usually not the + // tab on screen — so say where it went and offer one tap to look. + // Skipped when the notepad WAS on screen: the row is right there, and + // a toast about something visible is noise. Spec §3.4. + if (!wasFocusRoute || tabBefore === "focus") return; + toast.show({ + message: "Added to Focus", + actionLabel: "View", + durationMs: MOVE_TOAST_MS, + onAction: () => { + // The user may have left Focus since the toast appeared. Route + // there first and let mountMainView apply the tab; the direct + // call covers the ordinary case where we never left. + if (currentRoute.name !== "focus") { + pendingTabSelection = "focus"; + window.location.hash = "#focus"; + return; + } + currentMainView?.selectTab?.("focus"); + }, + }); + }, focusSectionId: FOCUS_DEFAULT_SECTION_ID, }); diff --git a/src/views/sidebar.js b/src/views/sidebar.js index 24cf0b0..e6d1f3d 100644 --- a/src/views/sidebar.js +++ b/src/views/sidebar.js @@ -481,24 +481,24 @@ function template( const wordmarkActive = focusActive ? "is-active" : ""; const sorted = state.areas.slice().sort((a, b) => a.order - b.order); - // Focus is pinned to the top; only user areas reorder among themselves. + // Focus is no longer a listed area — it IS the landing surface, reached by + // the wordmark above. Listing it as well would be a second door onto the same + // tasks, and its rename and icon controls belong to a surface that no longer + // exists here. Spec D1. const userAreas = sorted.filter((a) => a.id !== FOCUS_ID); const firstUserAreaId = userAreas[0]?.id ?? null; const lastUserAreaId = userAreas[userAreas.length - 1]?.id ?? null; - const items = sorted - .map((area) => { - const isFocus = area.id === FOCUS_ID; - return renderAreaRow(area, state, route, { - // Focus is pinned (no moves). A user area can move up unless it's - // directly below Focus, and down unless it's the last row. - canMoveUp: !isFocus && area.id !== firstUserAreaId, - canMoveDown: !isFocus && area.id !== lastUserAreaId, - isUndeletable: isFocus, + const items = userAreas + .map((area) => + renderAreaRow(area, state, route, { + canMoveUp: area.id !== firstUserAreaId, + canMoveDown: area.id !== lastUserAreaId, + isUndeletable: false, openAreaMenuId, renamingAreaId, pendingRenameValue, - }); - }) + }), + ) .join(""); return ` From 0e3c4d8f64273b1cec5de2c0173cddb764f77d91 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 20:36:46 +0200 Subject: [PATCH 16/21] fix(focus): clear the pending tab selection on every mount --- src/controller.js | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/controller.js b/src/controller.js index 8d32a7e..62356b8 100644 --- a/src/controller.js +++ b/src/controller.js @@ -347,6 +347,14 @@ export function createController({ models, els }) { currentMainView?.destroy(); currentMainView = null; + // Read and clear in one place, before either branch. Every pending-focus + // flag in this codebase is consumed once and cleared unconditionally; this + // one used to be cleared only on the focus path, so a hashchange that + // routed to an area in between would leave it set and force-select the + // Focus tab on some later, unrelated mount. + const wantedTab = pendingTabSelection; + pendingTabSelection = null; + if (route.name === "focus") { currentMainView = createFocusView(mainRoot, { onToggleComplete: handleToggleComplete, @@ -368,15 +376,12 @@ export function createController({ models, els }) { onMoveTaskToSection: handleMoveTaskToSection, onOpenRepeatEditor: openRecurrenceEditor, }); - // Something asked for a specific tab on the next Focus view — today - // only the capture toast's View action, fired after a route change. - // selectTab before the first render(state) is safe: doRender early- - // returns on a null lastState, activeTab is already set, and the - // applyState below renders the right tab. - if (pendingTabSelection) { - currentMainView.selectTab(pendingTabSelection); - pendingTabSelection = null; - } + // Something asked for a specific tab on this mount — today only the + // capture toast's View action, fired after a route change. selectTab + // before the first render(state) is safe: doRender early-returns on a + // null lastState, activeTab is already set, and the applyState that + // follows renders the right tab. + if (wantedTab) currentMainView.selectTab(wantedTab); return; } From 33ca03650f5fb3dcb6b30c0aa6d157e3e1e22ff4 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Thu, 13 Aug 2026 21:04:03 +0200 Subject: [PATCH 17/21] feat(focus): lay out the tab strip and the badge and time columns --- README.md | 11 +++--- main.css | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4c41519..ca96fcc 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ An ADHD-friendly task app — open source, local-first, zero bloat. -**Status:** v0.4.0 — 228 tests passing. **Live:** [malinfossum.github.io/ignite](https://malinfossum.github.io/ignite/) +**Status:** v0.4.0 — 247 tests passing. **Live:** [malinfossum.github.io/ignite](https://malinfossum.github.io/ignite/) -![Ignite in dark mode — the Today view showing a Next card and a Starred group, with the area sidebar and theme control on the left](docs/desktop_preview.png) +![Ignite in dark mode — the Focus view showing the tab strip, a Next card and a Today group, with the area sidebar and theme control on the left](docs/desktop_preview.png) --- @@ -32,10 +32,11 @@ Built to replace a cluttered subscription task app with something minimal, free, ## Features - **Quick capture** — type and go; the bar shows where the task will land, and asks which section when an area has more than one -- **Today view** — what's next, with relative time labels that tick live +- **Focus** — the landing surface, on four tabs: Today (what's overdue and what's due, led by what's next), Tomorrow, Starred, and Focus itself — the notepad every capture lands in until you file it +- **One-tap filing** — a note moves out of the notepad into any section without going through a menu - **Areas & sections** — organize with a two-level hierarchy; create, rename, reorder, and delete - **Add in place** — every section has its own add row, so a run of tasks goes in without leaving the keyboard -- **Tasks** — complete, star, reorder, rename in place, and move between sections; tasks in Focus file into any section in one tap +- **Tasks** — complete, star, reorder, rename in place, and move between sections - **Scheduling** — give any task a date, and a time of day if it needs one; a task with no time reads as due that day rather than at midnight, and sorts after the day's timed tasks - **Recurring tasks** — repeat daily, weekly, monthly, or yearly (every N); completing one advances it to the next date, keeping its time of day, and records when you last did it - **Undo everything** — deletes (including a whole area and its contents) are undoable from the toast @@ -53,7 +54,7 @@ Vanilla HTML, CSS, and JavaScript — no frameworks. Strict MVC with a `subscrib - **Build:** Vite - **Persistence:** IndexedDB (hand-rolled wrapper) - **Offline:** hand-rolled service worker + web app manifest -- **Test:** Vitest + fake-indexeddb (228 tests) +- **Test:** Vitest + fake-indexeddb (247 tests) - **Format / lint:** Biome - **Deploy:** GitHub Pages via GitHub Actions diff --git a/main.css b/main.css index 2456472..e48ea9f 100644 --- a/main.css +++ b/main.css @@ -37,7 +37,7 @@ --sidebar-width: 240px; --sidebar-rail-width: 48px; --main-max-width: var(--reading-width); - --main-padding: var(--space-5); + --main-padding: var(--space-4); --radius: var(--radius-sm); } @@ -289,6 +289,59 @@ body { position: static; } +/* --- Focus tabs --- */ +.focus-tabs { + display: flex; + gap: var(--space-1); + margin: 0 0 var(--space-4); + border-bottom: 1px solid var(--border); + /* Four 44px targets plus their counts do not fit 375px. The strip scrolls; + the targets do NOT shrink (spec §8.1). + Deliberately NOT `scrollbar-width: none`: at 375px the fourth tab sits off + screen, and the scrollbar is the only cue that it is there at all. Mobile + browsers auto-hide overlay scrollbars anyway, and the strip does not + overflow on desktop, so hiding it buys nothing and costs the affordance. */ + overflow-x: auto; +} +.focus-tab { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 44px; + padding: 0 var(--space-3); + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + font: inherit; + font-size: var(--text-sm); + cursor: pointer; +} +.focus-tab:hover { + color: var(--text); +} +.focus-tab.is-active { + color: var(--text); + border-bottom-color: var(--accent); +} +/* A real token colour, never opacity: opacity compounds against whatever is + behind it, so a value that passes contrast on one surface fails on another. + Spec §8.2. `--text-sm` matches `.group__count`, which is the same idea in the + same palette — `--text-xs` (12px) would make this the smallest text anywhere + in the app, below even the 13.6px time label. */ +.focus-tab__count { + color: var(--text-faint); + font-size: var(--text-sm); +} +.focus-tab.is-active .focus-tab__count { + color: var(--text-muted); +} +.focus-panel:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* --- NEXT card --- */ .next-card { margin: 0 0 var(--space-5); @@ -345,7 +398,7 @@ body { border-top: 1px solid var(--border-soft); } /* Round the end rows individually. Do NOT reach for `overflow: hidden` here — the - task ⋯ menu injects INSIDE the
  • as its last child (see today.js), so a + task ⋯ menu injects INSIDE the
  • as its last child (see focus.js), so a clipping ancestor cuts the menu off. It bites worst on the last row of a group, which is exactly where a menu most needs to overflow. */ .group__list > .task:first-child { @@ -411,6 +464,41 @@ body { color: var(--color-text-muted); font-size: 0.85rem; } +/* Phone: the row is title-first, so the badge drops out entirely (spec §7). + display:none takes it out of the accessibility tree too — accepted, because + the area is not conveyed to the sighted phone user either. */ +.task__area-badge { + display: none; + color: var(--text-muted); + font-size: var(--text-sm); +} +/* Fixed bases so the columns line up across every group. `.task__title` is the + only flex:1 child, which is what absorbs the conditional ⟲ glyph; every OTHER + child must refuse to shrink or the alignment drifts row to row. + `.task__check` and `.task__recurring` are in this list on purpose: both + default to `flex: 0 1 auto`, so under pressure they compress by a fraction and + every column to their right moves with them. A partially-pinned row is the + version that looks right on seeded data and wrong on a real list, and §7 calls + this alignment the work. */ +.task__check, +.task__recurring, +.task__star, +.task__menu-btn { + flex: 0 0 auto; +} +@media (min-width: 768px) { + .task__area-badge { + display: block; + flex: 0 0 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .task__time-label { + flex: 0 0 6rem; + text-align: right; + } +} .task__file { flex: 0 0 auto; min-height: 44px; @@ -1392,11 +1480,20 @@ body.is-drawer-open #scrim { design system's reset.css already zeroes every margin globally. The area route's default h1 weight/color already match what `.area__title` used to set explicitly, so there's nothing left to fold in. */ -.page-header__date { +.page-header__greeting { margin: var(--space-1) 0 0; - color: var(--text-faint); + color: var(--text-muted); + font-size: var(--text-sm); +} +.page-header__summary { + margin: var(--space-1) 0 0; + color: var(--text-muted); font-size: var(--text-sm); } +.page-header__overdue { + color: var(--color-overdue); + font-weight: 600; +} /* --- Area icon picker --- */ /* Auto-fit rather than a fixed column count. A fixed count cannot be right at From d93548960293100fc5b84bc43b9013b6ccf54513 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Fri, 14 Aug 2026 01:04:46 +0200 Subject: [PATCH 18/21] fix(focus): stop horizontal scroll and fix tab strip contrast, ring and label wrap - #main was missing min-width: 0, so its automatic min-content size forced the layout wider than the viewport between 768px and ~911px on every route - .task__time-label wrapped onto two lines on the Tomorrow tab, breaking row-height alignment with the other tabs - .focus-tab__count used --text-faint, which fails WCAG AA (4.21:1) in light theme; switched to --text-muted (6.70:1 light / 10.11:1 dark) and dropped the now-redundant active-state override - the tab strip's own overflow-x: auto clipped the keyboard focus ring off the first/last tab; outline-offset: -2px keeps it inside --- main.css | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/main.css b/main.css index e48ea9f..501a653 100644 --- a/main.css +++ b/main.css @@ -106,6 +106,12 @@ body { #main { width: 100%; + /* #main is a grid item in body's 1fr column. Without this, its automatic + minimum size is its content's min-content width — and the task row's + non-shrinking badge/time columns plus a nowrap title push that past + 600px, forcing the column wider than the viewport between 768px and + ~911px and causing horizontal page scroll. */ + min-width: 0; max-width: var(--main-max-width); margin-inline: auto; padding: var(--main-padding); @@ -321,6 +327,13 @@ body { .focus-tab:hover { color: var(--text); } +/* The strip's own overflow-x: auto forces overflow-y to compute to auto too, + which clips painting to the padding box. A positive outline-offset (the + design system's default :focus-visible) draws 2px outside that box and gets + cut off on the first/last tab. Negative offset keeps the ring inside. */ +.focus-tab:focus-visible { + outline-offset: -2px; +} .focus-tab.is-active { color: var(--text); border-bottom-color: var(--accent); @@ -329,13 +342,14 @@ body { behind it, so a value that passes contrast on one surface fails on another. Spec §8.2. `--text-sm` matches `.group__count`, which is the same idea in the same palette — `--text-xs` (12px) would make this the smallest text anywhere - in the app, below even the 13.6px time label. */ + in the app, below even the 13.6px time label. `--text-muted` (not + `--text-faint`, which measures 4.21:1 against --surface-1 at 14px — under + WCAG AA's 4.5:1) — measured 6.70:1 light / 10.11:1 dark. The active tab + already reads --text at full contrast via .focus-tab.is-active above, so no + separate active-state override is needed. */ .focus-tab__count { - color: var(--text-faint); - font-size: var(--text-sm); -} -.focus-tab.is-active .focus-tab__count { color: var(--text-muted); + font-size: var(--text-sm); } .focus-panel:focus-visible { outline: 2px solid var(--accent); @@ -497,6 +511,9 @@ body { .task__time-label { flex: 0 0 6rem; text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } .task__file { From 2e599b273d711ff3873083a6d8df51dac8076605 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Fri, 14 Aug 2026 01:04:58 +0200 Subject: [PATCH 19/21] fix(focus): restore panel focus, fix stranded renames, and finish the Today-to-Focus rename - an empty tabpanel dropped focus to on the 60s tick or any model notify because the pre-rewrite focus capture only matched .focus-tab, not .focus-panel; now captures and restores both - a live rename could be stranded off-tab when a task's bucket changed at the day boundary, silently dropping the typed text; selectTab and destroy() now fall back to pendingRenameTaskValue when the row (and its input) is already gone from the DOM - deleteAreaCascade and the area-not-found route still redirected to #today; both now use #focus, matching every other redirect in the branch - stale doc comments in focus.js and task.js brought up to date with the callbacks and options the views actually use --- src/controller.js | 2 +- src/views/area.js | 2 +- src/views/focus.js | 50 +++++++++++++++++++++++++++++++++----------- src/views/sidebar.js | 2 +- src/views/task.js | 4 ++++ 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/controller.js b/src/controller.js index 62356b8..7d93fd3 100644 --- a/src/controller.js +++ b/src/controller.js @@ -565,7 +565,7 @@ export function createController({ models, els }) { // Without this, applyState fires between areas.remove and the redirect // and the user sees an "Area not found" flash. if (currentRoute.name === "area" && currentRoute.id === areaId) { - window.location.hash = "#today"; + window.location.hash = "#focus"; } // 3. Cascade: tasks → sections → area. Guard empty layers — diff --git a/src/views/area.js b/src/views/area.js index a8eedc0..c5860c4 100644 --- a/src/views/area.js +++ b/src/views/area.js @@ -864,7 +864,7 @@ function template( return `

    - Back to Today + Back to Focus

    `; diff --git a/src/views/focus.js b/src/views/focus.js index 8cf3091..f666ee2 100644 --- a/src/views/focus.js +++ b/src/views/focus.js @@ -1,4 +1,5 @@ -// createFocusView(rootEl, { onToggleComplete, onToggleStar, onDelete }) +// createFocusView(rootEl, { onToggleComplete, onToggleStar, onDelete, +// onCommitTaskRename, onMoveTaskToSection, onOpenRepeatEditor }) // → { render, focusTaskMenu, selectTab, getActiveTab, destroy } // // state expected: { tasks, sections, areas, settings, now } @@ -108,8 +109,12 @@ export function createFocusView(rootEl, callbacks) { let renameCommitted = false; if (renamingTaskId) { + // The task may have crossed a bucket boundary (e.g. a Tomorrow-tab task + // rolling into Today at midnight) between typing and this tab switch — + // its row, and the rename input inside it, may already be gone from THIS + // tab's rendered DOM. pendingRenameTaskValue still holds what was typed. const input = rootEl.querySelector(".task__rename-input"); - const value = (input?.value ?? "").trim(); + const value = (input?.value ?? pendingRenameTaskValue ?? "").trim(); const id = renamingTaskId; renamingTaskId = null; pendingRenameTaskValue = null; @@ -385,12 +390,23 @@ export function createFocusView(rootEl, callbacks) { // parked on a tab drops to once a minute — and the tab strip is now // the whole surface's navigation, so that is not a small loss. // - // Only ever re-asserts focus that was ALREADY on a tab, and the guard means - // it never overrides a tab switch that has explicitly asked for focus. - if (!pendingFocusTab) { - pendingFocusTab = - document.activeElement?.closest?.(".focus-tab")?.dataset?.tab ?? null; + // An EMPTY panel is the only focusable panel (it carries tabindex="0" so a + // keyboard user arrowing off the strip is not stranded) — so a focused + // element inside rootEl is either a tab or that panel. Capture both here, + // before the rewrite detaches whichever one held focus; refocusPanel is + // read after the rewrite below. + // + // Only ever re-asserts focus that was ALREADY on one of the two, and the + // guard means it never overrides a tab switch that has explicitly asked + // for focus. + const activeInRoot = pendingFocusTab + ? null + : (document.activeElement?.closest?.(".focus-tab, .focus-panel") ?? null); + if (activeInRoot?.classList.contains("focus-tab")) { + pendingFocusTab = activeInRoot.dataset.tab; } + const refocusPanel = + activeInRoot?.classList.contains("focus-panel") ?? false; isRendering = true; try { @@ -455,10 +471,9 @@ export function createFocusView(rootEl, callbacks) { pendingMenuFocusTaskId = null; } - // Last flag consumed, cleared unconditionally. selectTab nulls the two - // above before setting this one, so it is mutually exclusive with them - // THERE — but the pre-rewrite capture at the top of this function (the - // `if (!pendingFocusTab)` fallback that reads document.activeElement + // selectTab nulls the two flags above before setting this one, so it is + // mutually exclusive with them THERE — but the pre-rewrite capture at the + // top of this function (the `activeInRoot` read of document.activeElement // before the rewrite) sets this flag directly without touching // pendingFocusTaskId or pendingMenuFocusTaskId, so that exclusivity is // not a property of every path that sets it. @@ -468,6 +483,15 @@ export function createFocusView(rootEl, callbacks) { ?.focus(); pendingFocusTab = null; } + + // The empty panel is the only focusable panel (it carries tabindex="0" so + // a keyboard user arrowing off the strip is not stranded). Restore it only + // when it actually held focus and no tab switch is asking for the strip + // instead; the [tabindex] guard makes this a no-op if the new panel is + // non-empty. + if (refocusPanel && !pendingFocusTab) { + rootEl.querySelector(".focus-panel[tabindex]")?.focus(); + } } return { @@ -485,8 +509,10 @@ export function createFocusView(rootEl, callbacks) { // a non-empty trimmed value, commit it BEFORE listener unbinding so // the typed value isn't silently lost on route change. if (renamingTaskId) { + // Same cross-bucket fallback as selectTab: the row may already be + // gone from the DOM, but pendingRenameTaskValue still holds the text. const input = rootEl.querySelector(".task__rename-input"); - const value = (input?.value ?? "").trim(); + const value = (input?.value ?? pendingRenameTaskValue ?? "").trim(); if (value) { callbacks.onCommitTaskRename({ taskId: renamingTaskId, name: value }); } diff --git a/src/views/sidebar.js b/src/views/sidebar.js index e6d1f3d..b01ac1a 100644 --- a/src/views/sidebar.js +++ b/src/views/sidebar.js @@ -362,7 +362,7 @@ export function createSidebarView( } // Post-render lookup: an area cascade delete removed the row whose ⋯ - // had focus and redirected to #today, so focus would fall to . + // had focus and redirected to #focus, so focus would fall to . // The wordmark IS the Today nav item — focusing it lands the user // where the redirect took them. // diff --git a/src/views/task.js b/src/views/task.js index 5cf5a0f..10eb1bd 100644 --- a/src/views/task.js +++ b/src/views/task.js @@ -18,6 +18,10 @@ // that appears at >=768px on the dated tabs. Escaped — // area names are user-authored and this is a new // interpolation site. +// showFile - true to render a "File" button (data-action="file-task") +// that opens the move picker directly. Used on the Focus +// tab's notepad, where filing is the row's one-tap +// affordance out of the notepad. import { escapeHtml } from "../utils/dom.js"; import { describeRecurrence } from "../utils/text.js"; From c5f47391e03c508a8c7e44b344ff4c642b6a8281 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Fri, 14 Aug 2026 01:13:07 +0200 Subject: [PATCH 20/21] docs(focus): correct five comments that misdescribed their code --- main.css | 11 +++++++---- src/views/focus.js | 22 +++++++++++----------- src/views/sidebar.js | 2 +- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/main.css b/main.css index 501a653..d641bf2 100644 --- a/main.css +++ b/main.css @@ -330,7 +330,10 @@ body { /* The strip's own overflow-x: auto forces overflow-y to compute to auto too, which clips painting to the padding box. A positive outline-offset (the design system's default :focus-visible) draws 2px outside that box and gets - cut off on the first/last tab. Negative offset keeps the ring inside. */ + cut off on the first/last tab. Negative offset keeps the outline inside, but + the design system's box-shadow: var(--shadow-focus) glow is not offset, so it + still clips at the strip's edge on first/last tabs. The outline is the + WCAG-relevant indicator and is fully visible; this is accepted. */ .focus-tab:focus-visible { outline-offset: -2px; } @@ -344,9 +347,9 @@ body { same palette — `--text-xs` (12px) would make this the smallest text anywhere in the app, below even the 13.6px time label. `--text-muted` (not `--text-faint`, which measures 4.21:1 against --surface-1 at 14px — under - WCAG AA's 4.5:1) — measured 6.70:1 light / 10.11:1 dark. The active tab - already reads --text at full contrast via .focus-tab.is-active above, so no - separate active-state override is needed. */ + WCAG AA's 4.5:1) — measured 6.70:1 light / 10.11:1 dark. The element-level + rule beats inheritance, so even in the active tab (which inherits --text), the + count renders --text-muted. No separate active-state override needed. */ .focus-tab__count { color: var(--text-muted); font-size: var(--text-sm); diff --git a/src/views/focus.js b/src/views/focus.js index f666ee2..dd5d498 100644 --- a/src/views/focus.js +++ b/src/views/focus.js @@ -391,14 +391,14 @@ export function createFocusView(rootEl, callbacks) { // the whole surface's navigation, so that is not a small loss. // // An EMPTY panel is the only focusable panel (it carries tabindex="0" so a - // keyboard user arrowing off the strip is not stranded) — so a focused - // element inside rootEl is either a tab or that panel. Capture both here, - // before the rewrite detaches whichever one held focus; refocusPanel is - // read after the rewrite below. - // - // Only ever re-asserts focus that was ALREADY on one of the two, and the - // guard means it never overrides a tab switch that has explicitly asked - // for focus. + // keyboard user arrowing off the strip is not stranded). Capture the + // currently-focused element (before the rewrite detaches it) to check if + // it is a tab or anywhere inside a panel. When closest(".focus-panel") + // matches for a focused descendant of a panel, refocusPanel is set true; + // the restore below uses [tabindex] to no-op if the panel re-renders as + // non-empty (e.g. a task row remains). This fires and helps when a render + // empties the panel entirely — deleting the last row — where focus would + // otherwise drop to . const activeInRoot = pendingFocusTab ? null : (document.activeElement?.closest?.(".focus-tab, .focus-panel") ?? null); @@ -486,9 +486,9 @@ export function createFocusView(rootEl, callbacks) { // The empty panel is the only focusable panel (it carries tabindex="0" so // a keyboard user arrowing off the strip is not stranded). Restore it only - // when it actually held focus and no tab switch is asking for the strip - // instead; the [tabindex] guard makes this a no-op if the new panel is - // non-empty. + // when it actually held focus. By this point pendingFocusTab is always null + // (cleared at line 484), so the guard is belt-and-braces; the [tabindex] + // selector is the actual safeguard — it matches only an empty panel. if (refocusPanel && !pendingFocusTab) { rootEl.querySelector(".focus-panel[tabindex]")?.focus(); } diff --git a/src/views/sidebar.js b/src/views/sidebar.js index b01ac1a..397bbbe 100644 --- a/src/views/sidebar.js +++ b/src/views/sidebar.js @@ -363,7 +363,7 @@ export function createSidebarView( // Post-render lookup: an area cascade delete removed the row whose ⋯ // had focus and redirected to #focus, so focus would fall to . - // The wordmark IS the Today nav item — focusing it lands the user + // The wordmark IS the Focus nav item — focusing it lands the user // where the redirect took them. // // Cleared UNCONDITIONALLY; skipped while a rename is live so it can't From 24cf1ba1f37f092d7caa6df5435d896fafa62907 Mon Sep 17 00:00:00 2001 From: Malin Fossum Date: Mon, 17 Aug 2026 09:56:16 +0200 Subject: [PATCH 21/21] fix(focus): move the badge and time columns to 1024px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §7 put the area badge and time column at 768px, but at that width the sidebar already claims 240px, so reserving 8rem + 6rem left the task title 6-9 characters. Malin chose 1024px, her documented desktop breakpoint: tablet now keeps the phone row, desktop gets the columns. Verified at 1000, 1023, 1024 and 1440px: badge hidden through 1023, block with a 128px basis at 1024, time column 96px and right-aligned, no horizontal scroll at any width. --- main.css | 11 +++++++---- src/views/task.js | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/main.css b/main.css index d641bf2..2e640d4 100644 --- a/main.css +++ b/main.css @@ -481,9 +481,9 @@ body { color: var(--color-text-muted); font-size: 0.85rem; } -/* Phone: the row is title-first, so the badge drops out entirely (spec §7). - display:none takes it out of the accessibility tree too — accepted, because - the area is not conveyed to the sighted phone user either. */ +/* Phone and tablet: the row is title-first, so the badge drops out entirely + (spec §7). display:none takes it out of the accessibility tree too — accepted, + because the area is not conveyed to the sighted user at these widths either. */ .task__area-badge { display: none; color: var(--text-muted); @@ -503,7 +503,10 @@ body { .task__menu-btn { flex: 0 0 auto; } -@media (min-width: 768px) { +/* 1024px, not the 768px of spec §7: at 768 the sidebar already claims 240px, so + reserving 8rem + 6rem for these two columns left the title 6–9 characters. + Malin's call 2026-08-14 — desktop-only columns, tablet keeps the phone row. */ +@media (min-width: 1024px) { .task__area-badge { display: block; flex: 0 0 8rem; diff --git a/src/views/task.js b/src/views/task.js index 10eb1bd..4f404fe 100644 --- a/src/views/task.js +++ b/src/views/task.js @@ -65,7 +65,7 @@ export function renderTaskRow( aria-label="File ${escapeHtml(task.title)}">File` : ""; // The badge is a plain , not a link or a button: it says where the task - // lives, it does not navigate. main.css hides it below 768px. + // lives, it does not navigate. main.css hides it below 1024px. const areaBadge = areaName ? `${escapeHtml(areaName)}` : "";