From 65f533e3105d7855523b530a7359b40c8cee3790 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:48:07 +0000 Subject: [PATCH 01/11] =?UTF-8?q?docs(WS-27):=20mint=20the=20post-tenancy?= =?UTF-8?q?=20Projects=20queue=20as=20spec=20=C2=A79.2=20(aa=E2=80=93ae)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H2 is on main and routes/projects is fully converted, so Projects feature work is unblocked. Four dispatchable tickets plus one banked: - aa the two tenancy residues this app owns — run_lifecycle_sweep sweeps every tenant's projects with no predicate (D27 finding 2, MT-1d's named site) and agent_dispatch is the H2 ratchet's one Projects exemption. Closing both means Projects is not what gates WS-29's phase-4 promotion. - ab view ergonomics: peek escalation + Esc-returns-focus, dirty-view affordances, palette action registry with generated shortcuts help. - ac calendar week layout, per-day quick-add, exact overflow. - ad Tasks<->Projects continuity round 2 — the divergences the first backport recorded and deliberately left. - ae export / delta-sync / small columns — minted, not dispatched. None needs a migration, so R1 costs this wave nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- project-docs/specs/project_management_app.md | 103 +++++++++++++++++-- project-docs/work_plan.md | 2 +- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/project-docs/specs/project_management_app.md b/project-docs/specs/project_management_app.md index 57064396a..e7d8af78e 100644 --- a/project-docs/specs/project_management_app.md +++ b/project-docs/specs/project_management_app.md @@ -1208,15 +1208,100 @@ beyond the window and closes stale open ones to the project's default closing st distinctly in the timeline; (4) tasks in `triage` (WS-27u) are exempt; (5) the manual archive guard (WS-27w item 1) ships first — this ticket depends on it. -**Deferred small basket** *(no ticket yet — pull individually when adjacent code is -touched)*: peek size escalation + Esc-returns-focus (P-14), Save/**Update view** dirty -affordances (P-15), palette action registry + go-sequences (P-16), calendar week layout + -per-day quick-add/overflow (P-19), filtered-list CSV export (P-26), delta-sync feed + -satellite `updated_at` bump (P-27), `is_epic` flag + per-user view state + session -`user_id` denorm (P-28 rest). Banked for their trigger events: sprints (P-23, when -sprints are wanted), webhook-out checklist (P-24, when `/workflows` grows the node), -email digest outbox (P-25, when PM emails). Owner-decided: docs = knowledge base -(D-PM-13); public boards deferred (D-PM-14). +**Deferred small basket** *(pull individually when adjacent code is touched)*: peek size +escalation + Esc-returns-focus (P-14), Save/**Update view** dirty affordances (P-15), +palette action registry + go-sequences (P-16), calendar week layout + per-day +quick-add/overflow (P-19), filtered-list CSV export (P-26), delta-sync feed + satellite +`updated_at` bump (P-27), `is_epic` flag + per-user view state + session `user_id` denorm +(P-28 rest). **§9.2 promotes P-14/15/16 (WS-27ab), P-19 (WS-27ac) and banks the rest as +WS-27ae.** Banked for their trigger events: sprints (P-23, when sprints are wanted), +webhook-out checklist (P-24, when `/workflows` grows the node), email digest outbox (P-25, +when PM emails). Owner-decided: docs = knowledge base (D-PM-13); public boards deferred +(D-PM-14). + +--- + +### 9.2 The post-tenancy queue (minted 2026-08-10, after H2 landed on `main`) + +Four tickets. The first exists because the **Projects app owns two of the residues that +gate WS-29's phase-4 promotion** (D27 findings 2 and 3) — closing them here means Projects +is not the reason RLS cannot be switched on. The other three drain the deferred basket and +the continuity audit. None needs a migration, so R1 costs this wave nothing; all four +inherit the standing protocol (hermetic tests against the fake, a live Postgres run, and +for anything tenant-shaped, **R8** — verified against a real database, never a fake alone). + +**WS-27aa — the two tenancy residues Projects owns.** 🟢 AGENT-SAFE *(D27 (2); MT-1d's +named site; the H2 ratchet's one Projects exemption)*. +Two scheduled/background paths in this app still touch the database with no tenant. +Done when: (1) **`run_lifecycle_sweep` takes an explicit tenant and refuses without one** — +the signature gains a required `organization_id`, the roots query gains +`AND organization_id = :org`, and a sweep constructed without a tenant raises rather than +sweeping every customer's projects (H4's rule: *a job that forgets doesn't leak one row, it +leaks unbounded*); (2) the tenant comes from a **stored fact, never request input** (R11) — +the workflow's owner resolved through `app_user`, the shape +`routes/crm/auto_lead._owner_organization` already uses, and a resolution that finds +nothing is an error, not a fallback; `_pm_lifecycle_sweeper` binds it with +`bind_tenant`/`release_tenant` around the sweep so the writes carry the right GUC the +moment phase 4 lands; (3) **`agent_dispatch` carries its tenant on the event payload** — +`pm.task.assigned` emits the task's own `organization_id`, read inside the request's bound +session at emit time, and `on_event`/`_run_and_record` bind that explicitly instead of +inheriting an ambient one; a payload without an org refuses and records the refusal on the +task timeline rather than running unbound; (4) **two-org proof against a real database**: a +sweep bound to org A leaves org B's stale tasks untouched, and a dispatch bound to A writes +A's activity row — plus a refusal test for each path; (5) the `projects/agent_dispatch` +entry leaves `H2_EXEMPT_FILES` (the file no longer needs it), every seam ratchet stays +green, and the handover's MT-1d site + D27 finding 2 are struck with the measurement that +replaced them. **Not in scope:** the other H4 consumers (ingestion, reconciler, broker) — +they belong to WS-29's own H4 slice; this ticket closes only what Projects owns. + +**WS-27ab — view ergonomics: peek, dirty views, one palette registry.** 🟢 AGENT-SAFE +*(P-14, P-15, P-16)*. +Done when: (1) **peek escalation** — `TaskPanel` offers peek → side → full, the choice +persists per user, and Esc returns focus to whatever opened the panel so the card/row keeps +the cursor (WS-27y's cursor is the thing being returned to); (2) **dirty-view affordances** +— `FilterBar` shows when live filter/sort/group/shown-field state diverges from the saved +view, offering *Update view*, *Save as new* and *Reset*; divergence is **one exported pure +function** over the config with its own tests, never scattered comparisons — the config +round-trip (`toConfig`/`fromConfig`) is the single fact it reads; (3) **palette action +registry** — `SearchPalette`'s commands become a declared registry (`id`, `label`, +`section`, `keywords`, `run`, `when`) instead of inline branches, `g`-sequences navigate +(`g p`, `g m`, `g t`…), and `?` renders a shortcuts sheet **generated from that same +registry** so the help cannot drift from the behaviour; (4) tests over the registry: every +action carries a label and section, every go-sequence resolves to a route that exists, and +no two actions share a key sequence; (5) DESIGN_SYSTEM throughout — no raw colours, the +`Icon`/`Button`/`Input` primitives, theme suite green. + +**WS-27ac — calendar: week layout, per-day quick-add, honest overflow.** 🟢 AGENT-SAFE +*(P-19)*. +Done when: (1) `CalendarView` gains a **week** layout beside month, both driven by the +existing `lib/calendar.ts` date math — one implementation, extended, never a second; +(2) each day cell carries the shared group-context quick-add (`components/QuickAdd.tsx`) +pre-filled with that day's date; (3) **overflow is exact** — a day with more tasks than fit +shows `+N more` with the true count and expands rather than clipping silently; (4) dragging +between days reschedules through the existing `PATCH` path wearing WS-27y's drop-refusal +reason and post-drop flash; (5) the §11.16 parameter-coverage test extends to the week +range, so the `triage` exclusion (WS-27u) cannot be dropped by the new surface. + +**WS-27ad — Tasks ↔ Projects continuity, round 2.** 🟢 AGENT-SAFE *(the backport agent's +recorded gap list, HANDOVER §1)*. +The first backport promoted chips, cursor, quick-add and flash to shared code. These are +the divergences it recorded and deliberately left. +Done when: (1) **one selection grammar** — the shift-range anchor moves into shared code +beside the cursor, both apps consume it, and Tasks' modal select-mode either becomes the +shared range behaviour or is kept with the reason written next to it (a divergence with a +recorded reason is a decision; an undocumented one is drift); (2) **board chrome +converges** — Tasks' accent caps + drop-gap reorder and Projects' swimlanes + +append-on-drop are reconciled, the winning behaviour implemented once and consumed twice; +(3) Tasks' flat lists (Done/Waiting/Someday/Archive), `WaitingForView` and the Inbox gain +the shared cursor and group-context quick-add, retiring the Inbox's local `j`/`k` idiom; +(4) a test asserts both apps import the shared modules rather than re-declaring them — the +re-export shims stay, a third copy is a failure; (5) calendar asymmetry stays **out of +scope** and stays recorded (Tasks has a ten-file module, Projects one view). + +**WS-27ae — export, delta-sync, small columns.** 🟢 AGENT-SAFE, **not this wave** *(P-26, +P-27, P-28 rest)*. Filtered-list CSV export on the export-job pattern; a delta-sync list +variant plus satellite `updated_at` bumps for agents/mobile; `is_epic`, per-user view state +and the session `user_id` denorm. Minted so the basket has an owner; dispatch after aa–ad. --- diff --git a/project-docs/work_plan.md b/project-docs/work_plan.md index 8305d207b..7a8c96348 100644 --- a/project-docs/work_plan.md +++ b/project-docs/work_plan.md @@ -211,7 +211,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS-21 | **Calendar F2/F3** | 🟡 partial | `calendar_focus_os.md` §9 (+§5) + `calendar_timeboxing.md` §13 · board record 2026-08-09 | P3 roll-over + ideal-week + packer-breaks all shipped (struck from scope 2026-08-03). `gtd_time_blocks` is **four slices S1–S4** — the "one non-breaking PR" claim was false (17 TS files + 3 gateway modules + skill + agent). Focus Shield is AGENT-SAFE (needs a design, not a credential). Owns Horizons (§4) — DO-NOT-DISPATCH, no acceptance. 🔴 external-sync OAuth credentials (§6) · shared nudge-send gate (§6). Never `pytest tests/unit -k calendar` (collection hangs). (2026-08-03) | | WS-22 | **draw.io** | ⏸ PARKED | `drawio_integration.md` | **PARKED BY OWNER 2026-08-10 (D25.7)** — no agent time until a real need (proposal diagrams, KB visuals) pulls it back. The spec's acceptance structure keeps; anchors need re-verification at un-park. | | WS-26 | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | ✅ a–g · D5 PR open | `specs/crm_app.md` · board record 2026-08-09 | a + b + c + d (read · email · write) **merged + deployed** (d-write log-verified via deploy `31217978773`, 2026-08-08); f + g **merged to main** (#391, #397 — the old "on branch, NOT run against prod" wording is struck; f's stage repair still needs its 🔴 `?apply=true` run, §6 WS-26 (d)). **D5 d-autolead MERGED 2026-08-09 (#403; migration renumbered 158→163 at merge)** — remaining: 🔴 `CRM_AUTO_LEAD` flip (§6 WS-26 (b); clamp-anchor design, never reset-to-now). Zoho sync loop **ENABLED by the owner 2026-08-06** (§6 WS-26 (a)) — every "ships OFF / never run" sentence about it is struck. Next: **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views — spec-thin, audit-narrow first · **e** cutover + retirement 🔴 (§6 WS-26 (c)). ⚠️ D15 coda: built single-Zoho-tenant by design; per-org credentials (migration 158) + per-org sync flags arrive with MT-1/MT-2, and D-CRM-3's org-wide read becomes org-scoped **by RLS**, not by hand-written predicates. (2026-08-08) | -| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–t merged · **u–z on PR #408** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted → **minted as dispatchable tickets WS-27u–z (spec §9.1)**: u intake/triage · v watchers+mention-diff · w read-path/history hardening · x spreadsheet+shown-fields · y board upgrades · z lifecycle policy (🟡 per-project, default off) + a deferred small basket, 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ✅ **WS-27u–z ALL BUILT 2026-08-10 on the restarted branch** (#399 merged; branch restarted from main per the merged-PR rule) — migrations **164** intake · **165** watchers · **166** lifecycle; plus the **Tasks↔Projects continuity backport** (shared chips/cursor/QuickAdd/flash promoted to `src/lib`+`src/components`, both apps consume one implementation; remaining gaps recorded in HANDOVER). z's sweeper is wired as a `pm_lifecycle` workflow node — the scheduled workflow itself is an owner authoring step on the live box (workflows are DB rows, never files). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. ✅ **Tenancy alignment AUDITED 2026-08-10 (D27): substantially aligned** — all 19 `pm_*` tables keyed, seam clean, no request-input tenant, no homonym. Fixed in the same pass: the generated RLS set was **stale AND carried a phantom `ALTER TABLE if`** (2 Projects tables would have promoted with no policy; the phantom would have aborted the window) — regenerated, generator taught to strip comments, and a new fence `test_the_generated_set_on_disk_matches_the_tables_that_exist` pins both. Two non-mechanical conversion sites recorded where the tenancy agents read them: `core.resolve_organization_id` (handover H2) · `run_lifecycle_sweep` (MT-1d). (2026-08-10) | +| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–t merged · **u–z on PR #408** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted → **minted as dispatchable tickets WS-27u–z (spec §9.1)**: u intake/triage · v watchers+mention-diff · w read-path/history hardening · x spreadsheet+shown-fields · y board upgrades · z lifecycle policy (🟡 per-project, default off) + a deferred small basket, 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ✅ **WS-27u–z ALL BUILT 2026-08-10 on the restarted branch** (#399 merged; branch restarted from main per the merged-PR rule) — migrations **164** intake · **165** watchers · **166** lifecycle; plus the **Tasks↔Projects continuity backport** (shared chips/cursor/QuickAdd/flash promoted to `src/lib`+`src/components`, both apps consume one implementation; remaining gaps recorded in HANDOVER). z's sweeper is wired as a `pm_lifecycle` workflow node — the scheduled workflow itself is an owner authoring step on the live box (workflows are DB rows, never files). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. ✅ **Tenancy alignment AUDITED 2026-08-10 (D27): substantially aligned** — all 19 `pm_*` tables keyed, seam clean, no request-input tenant, no homonym. Fixed in the same pass: the generated RLS set was **stale AND carried a phantom `ALTER TABLE if`** (2 Projects tables would have promoted with no policy; the phantom would have aborted the window) — regenerated, generator taught to strip comments, and a new fence `test_the_generated_set_on_disk_matches_the_tables_that_exist` pins both. Two non-mechanical conversion sites recorded where the tenancy agents read them: `core.resolve_organization_id` (handover H2) · `run_lifecycle_sweep` (MT-1d). ➡️ **Next queue minted 2026-08-10 as spec §9.2** (after H2 landed on main; none needs a migration): **aa** the two tenancy residues Projects owns — explicit tenant for `run_lifecycle_sweep` (MT-1d's named site, D27 (2)) + `agent_dispatch`'s tenant on the event payload, closing the H2 ratchet's one Projects exemption so this app is not what gates phase 4 · **ab** view ergonomics (peek escalation, dirty-view affordances, palette action registry) · **ac** calendar week layout + per-day quick-add + honest overflow · **ad** Tasks↔Projects continuity round 2 (shared selection anchor, board-chrome convergence, cursor/quick-add on the flat lists) · **ae** export/delta-sync/small columns, minted not dispatched. (2026-08-10) | | WS-28 | **People Center — directory, org chart, assignment seam** *(minted 2026-08-06)* | ✅ a+b+b-write | `specs/people_center_app.md` · board record 2026-08-09 | a (key shape, mig 148 + quarantine table) · b (directory + person page, mig 149, five-place registration) · b-write (create/edit UI restored; found three ways mig 148 had broken the write routes) — built 2026-08-06/07; **closes WS-13's directory item**. 🟢 c org chart · d capability search (**ranking EVAL-LOCKED**) · e Projects seams; 🔴 f seats/roles writes (§6 WS-24 (d) analogue). ⚠️ `schema.generated.sql` regeneration is **due**: stale since ~migration 113, and 148 reached prod ~2026-08-07 (after the #384 cast fix). (2026-08-07) | --- From 0f1de50f31aced258d5e2a7b4da9ff24497aad7f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 09:09:12 +0000 Subject: [PATCH 02/11] feat(WS-27ad): one colour vocabulary and one card shell for /projects and /tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-directed: the Kanban boards, lists, cards and colour schemes of the two task apps looked like two products. They were. Three vocabularies existed and a fourth fact was stored and thrown away: * app/tasks/lib/stageColors.ts — a real accent system (dot/soft/text/bar), resolved by name keyword then position, driving Tasks' board columns, list group headers and status pill; * app/projects/lib/tags.ts — a six-name chip palette, tags only; * pm_task_statuses.color — stored since migration 146, exposed as StatusRow.color, and RENDERED NOWHERE. Every Projects board column drew the same bg-muted, so Done and Backlog were the same grey while the Tasks board next door was colour-coded. src/lib/statusAccent.ts is now the one palette, resolved by precedence: stored colour → status category (Projects' six, which Tasks does not have) → name keyword (Tasks' user-named stages; regexes ported unchanged) → positional, with lastIsDone as Tasks' own rule. Tokens only; class strings are the existing ones, so Tasks renders identically and its tests say so. Consuming it: * Projects board — accent cap, tinted header, dot and text tone per column; swimlane column headers too. A Done lane now reads green. * Projects list group headers — the left bar accent Tasks' list already had. * Projects list + table status cells — the shared StatusChip pill, not a bare grey word; the board card carries it too when the board is off the status axis (Tasks' showStage rule). * projects/lib/tags.ts chipClass delegates; TAG_COLORS is now ACCENT_HUES. * tasks/lib/stageColors.ts is a thin adapter; StatusPill's body is StatusChip. src/components/TaskCardShell.tsx is the shared box. Projects' card was rounded-md/bg-background/p-2 — the page colour, i.e. a card-shaped hole in the column — against Tasks' rounded-lg/bg-card/p-3 with a shadow lift. Tasks' wins; each app still feeds its own facts and Projects' shown_fields gating is intact. R7: src/lib/statusAccent.test.ts pins the precedence, every category and colour name, unknown input falling through rather than throwing, and the byte-identical Tasks/tag class strings. src/lib/sharedTaskUi.test.ts fences the seam — each shared thing declared once, both apps reaching it, shims staying shims, and no second name→class palette in either app. Found and NOT swept in: app/crm/lib/board.ts holds a third such map for pipeline stages. Exempted in the test with the reason; it is a CRM decision. npx tsc --noEmit clean; npx vitest run 1381 passed (was 1278); theme conformance 208 passed with no baseline change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../src/app/projects/components/TableView.tsx | 14 +- .../src/app/projects/components/TaskBoard.tsx | 132 +++++++--- .../src/app/projects/components/TaskList.tsx | 35 ++- .../src/app/projects/lib/accent.test.ts | 93 +++++++ .../src/app/projects/lib/accent.ts | 67 +++++ .../src/app/projects/lib/tags.ts | 38 ++- .../src/app/tasks/components/StatusPill.tsx | 20 +- .../src/app/tasks/components/TaskCard.tsx | 31 +-- .../src/app/tasks/lib/stageColors.ts | 92 ++----- .../src/components/StatusChip.tsx | 49 ++++ .../src/components/TaskCardShell.tsx | 122 +++++++++ .../src/lib/sharedTaskUi.test.ts | 211 +++++++++++++++ .../src/lib/statusAccent.test.ts | 207 +++++++++++++++ .../control_plane/src/lib/statusAccent.ts | 244 ++++++++++++++++++ 14 files changed, 1187 insertions(+), 168 deletions(-) create mode 100644 workbench/control_plane/src/app/projects/lib/accent.test.ts create mode 100644 workbench/control_plane/src/app/projects/lib/accent.ts create mode 100644 workbench/control_plane/src/components/StatusChip.tsx create mode 100644 workbench/control_plane/src/components/TaskCardShell.tsx create mode 100644 workbench/control_plane/src/lib/sharedTaskUi.test.ts create mode 100644 workbench/control_plane/src/lib/statusAccent.test.ts create mode 100644 workbench/control_plane/src/lib/statusAccent.ts diff --git a/workbench/control_plane/src/app/projects/components/TableView.tsx b/workbench/control_plane/src/app/projects/components/TableView.tsx index ae44a30f2..b29fdf2c5 100644 --- a/workbench/control_plane/src/app/projects/components/TableView.tsx +++ b/workbench/control_plane/src/app/projects/components/TableView.tsx @@ -25,11 +25,13 @@ */ import Icon from "@/components/Icon"; +import { StatusChip } from "@/components/StatusChip"; import { AvatarStack } from "@/components/TaskMeta"; import { Input } from "@/components/ui/Input"; import { durationLabel } from "@/lib/taskCard"; import { useMemo, useRef, useState } from "react"; +import { accentForStatus } from "../lib/accent"; import type { FieldRow, StatusRow, TaskRow } from "../lib/api"; import { projectsApi } from "../lib/api"; import { parseAssignees } from "../lib/assignees"; @@ -259,8 +261,16 @@ export function TableView({ function readOnlyCell(task: TaskRow, column: TableColumn): React.ReactNode { switch (column.key) { - case "status": - return statusById.get(task.status_id)?.name ?? "—"; + case "status": { + // WS-27ad — the shared pill, not a bare word. Same fact, same + // appearance, whichever of the two apps you are reading it in. + const status = statusById.get(task.status_id); + return status ? ( + + ) : ( + "—" + ); + } case "assignees": return task.assignees?.length ? ( diff --git a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx index 74a3454ef..c44880728 100644 --- a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx @@ -25,9 +25,12 @@ */ import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; import Icon from "@/components/Icon"; +import { StatusChip } from "@/components/StatusChip"; +import { TaskCardShell, TaskCardTitle } from "@/components/TaskCardShell"; import Button from "@/components/ui/Button"; import { useMemo, useState } from "react"; +import { accentForGroup, accentForStatus } from "../lib/accent"; import type { StatusRow, TaskRow } from "../lib/api"; import { projectsApi } from "../lib/api"; import { @@ -119,6 +122,28 @@ export function TaskBoard({ [groups] ); + const statusById = useMemo( + () => new Map(statuses.map((row) => [row.id, row])), + [statuses] + ); + + /** + * WS-27ad — the column's colour. + * + * `pm_task_statuses.color` has been stored since migration 146 and drawn + * nowhere: every column header was the same `bg-muted`, so a Done lane and a + * Backlog lane were indistinguishable while the /tasks board next door was + * colour-coded per stage. The owner's stored colour answers first, then the + * status category (`accentForGroup`), then position. + */ + const columnAccents = useMemo( + () => + columns.map((column, index) => + accentForGroup(groupBy, column.key, index, columns.length, statuses) + ), + [columns, groupBy, statuses] + ); + const swimlanes = useMemo( () => laned ? buildSwimlanes(columns, subBy, { statuses, projectName }) : null, @@ -286,11 +311,11 @@ export function TaskBoard({ ) : null; const card = (task: TaskRow) => ( -
  • +
  • {onToggle ? ( ) : null} - +
  • ); @@ -375,18 +412,29 @@ export function TaskBoard({ {!laned ? (
    - {columns.map((column) => ( + {columns.map((column, columnIndex) => { + const accent = columnAccents[columnIndex]; + return (
    {refusalOverlay(null, column.key)} -
    - - {column.label} + {/* The accent cap — /tasks' board grammar, now shared: a 1px + band of the lane's colour above a faintly tinted header, so + "where is Done" is a glance rather than a read. */} +
    +
    + + + + {column.label} + - + {column.tasks.length}
    @@ -405,26 +453,34 @@ export function TaskBoard({ />
    - ))} + ); + })}
    ) : (
    - {/* Column headers once, up top — every lane below shares them. */} + {/* Column headers once, up top — every lane below shares them, and + they carry the same accent the flat board's headers do. */}
    - {columns.map((column) => ( -
    - - {column.label} - - - {column.tasks.length} - -
    - ))} + {columns.map((column, columnIndex) => { + const accent = columnAccents[columnIndex]; + return ( +
    + + + + {column.label} + + + + {column.tasks.length} + +
    + ); + })}
    {(shownLanes ?? []).map((lane) => { diff --git a/workbench/control_plane/src/app/projects/components/TaskList.tsx b/workbench/control_plane/src/app/projects/components/TaskList.tsx index fc2e7ee66..155b15bd0 100644 --- a/workbench/control_plane/src/app/projects/components/TaskList.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskList.tsx @@ -18,9 +18,11 @@ * the rows — Shift extends the WS-27n selection, Enter opens the panel. */ import Icon from "@/components/Icon"; +import { StatusChip } from "@/components/StatusChip"; import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; import { useMemo, useState } from "react"; +import { accentForGroup, accentForStatus } from "../lib/accent"; import type { StatusRow, TaskRow } from "../lib/api"; import { projectsApi } from "../lib/api"; import { sortForView } from "../lib/board"; @@ -205,15 +207,26 @@ export function TaskList({ {/* An empty status lane is kept on the board so a missing column reads as a missing state; a list has no columns, so an empty section is just a heading with nothing under it — `sections` dropped it. */} - {sections.map((group) => { + {sections.map((group, groupIndex) => { const isFolded = groupBy !== "none" && folded.has(group.key); + // WS-27ad — the same accent the board's column for this group wears, + // as the left bar /tasks' list-group headers already used. A grouped + // list and a board are two drawings of one grouping; they must not + // disagree about what colour "Done" is. + const accent = accentForGroup( + groupBy, + group.key, + groupIndex, + sections.length, + statuses + ); return ( {groupBy === "none" ? null : ( - + {/* The /tasks group-header grammar (TaskListGrouped): chevron to collapse, label, then the count as a pill — @@ -226,10 +239,11 @@ export function TaskList({ > + {group.label} {group.tasks.length} @@ -277,7 +291,18 @@ export function TaskList({ - {status?.name ?? "—"} + {/* WS-27ad — the shared status pill, coloured by the + owner's stored colour / the status category. It used + to be a bare grey word while the same status on the + /tasks side was a coloured pill. */} + {status ? ( + + ) : ( + "—" + )} {task.assignees?.length ? ( diff --git a/workbench/control_plane/src/app/projects/lib/accent.test.ts b/workbench/control_plane/src/app/projects/lib/accent.test.ts new file mode 100644 index 000000000..398f411cf --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/accent.test.ts @@ -0,0 +1,93 @@ +/** + * Projects · the facts this app feeds the shared colour vocabulary (WS-27ad). + * + * The palette and its precedence are pinned in `src/lib/statusAccent.test.ts`. + * What is pinned here is the translation — and specifically the two ways it + * could quietly go wrong: a stored colour being ignored (which is the bug this + * ticket exists to fix — `pm_task_statuses.color` had been stored and never + * drawn since migration 146), and a non-status axis being read for meaning it + * does not have. + */ + +import { describe, expect, it } from "vitest"; + +import { statusAccent } from "@/lib/statusAccent"; + +import { accentForGroup, accentForStatus } from "./accent"; +import type { StatusRow } from "./api"; + +const status = (over: Partial): StatusRow => ({ + id: "s1", + project_id: "p1", + name: "To do", + color: "gray", + position: 10, + category: "todo", + is_default: false, + ...over, +}); + +describe("accentForStatus", () => { + it("draws the colour the owner stored", () => { + // The whole point of the ticket: this column used to be `bg-muted` like + // every other, whatever the row said. + expect(accentForStatus(status({ color: "green", category: "todo" }))).toEqual( + statusAccent({ color: "green" }), + ); + }); + + it("falls back to the category when no colour is stored", () => { + expect(accentForStatus(status({ color: "", category: "in_progress" }))).toEqual( + statusAccent({ category: "in_progress" }), + ); + }); + + it("never reads the status NAME", () => { + // A Projects lane called "Waiting on legal" in the `todo` category is a + // todo lane. /tasks guesses from names because it has nothing better; + // Projects has a category and must not second-guess it. + const named = accentForStatus(status({ name: "Waiting on legal", color: "", category: "todo" })); + expect(named).toEqual(statusAccent({ category: "todo" })); + expect(named).not.toEqual(statusAccent({ name: "Waiting on legal" })); + }); + + it("is positional when there is no status at all", () => { + expect(accentForStatus(undefined, 1, 4)).toEqual(statusAccent({ index: 1, total: 4 })); + }); +}); + +describe("accentForGroup", () => { + const statuses = [ + status({ id: "todo", color: "blue", category: "todo" }), + status({ id: "done", color: "green", category: "done" }), + ]; + + it("resolves a status column through its row", () => { + expect(accentForGroup("status", "done", 1, 2, statuses)).toEqual( + statusAccent({ color: "green" }), + ); + }); + + it("is positional for the unset lane, which has no row", () => { + expect(accentForGroup("status", "__unset__", 1, 3, statuses)).toEqual( + statusAccent({ index: 1, total: 3 }), + ); + }); + + it("never reads a person's or a tag's name for meaning", () => { + // "Mark Green" is a colleague, not a done lane. + expect(accentForGroup("assignee", "Mark Green", 1, 3, statuses)).toEqual( + statusAccent({ index: 1, total: 3 }), + ); + expect(accentForGroup("tag", "blocked", 0, 3, statuses)).toEqual( + statusAccent({ index: 0, total: 3 }), + ); + }); + + it("gives neighbouring lanes different hues off the status axis", () => { + const hues = [0, 1, 2, 3].map( + (index) => accentForGroup("assignee", `p${index}`, index, 4, statuses).dot, + ); + expect(new Set(hues).size).toBe(4); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/accent.ts b/workbench/control_plane/src/app/projects/lib/accent.ts new file mode 100644 index 000000000..61f27f5ac --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/accent.ts @@ -0,0 +1,67 @@ +/** + * Projects · which facts this app hands the shared colour vocabulary (WS-27ad). + * + * `src/lib/statusAccent.ts` owns the palette and the precedence. This owns the + * translation from a Projects thing — a status row, a board column on whatever + * axis the view is grouped by — into the input that module reads. + * + * It is a separate file rather than three inline calls because the interesting + * decision is *which axes carry meaning*. Only the status axis has a stored + * colour and a machine-readable category; every other axis (assignee, project, + * tag, importance) is a bag of values whose colour can only ever be positional, + * and pretending otherwise — reading a keyword out of a person's name — is how + * a board decides that "Mark Green" is a done lane. + */ + +import { statusAccent, type StatusAccent } from "@/lib/statusAccent"; + +import type { StatusRow } from "./api"; +import type { GroupBy } from "./grouping"; + +/** + * The accent for one status. + * + * `color` is the owner's own choice (`pm_task_statuses.color`, stored since + * migration 146 and — until this ticket — rendered nowhere), `category` is the + * machine-readable fallback, and the name is not consulted at all: a status + * that HAS a category never needs guessing at, and a Projects lane called + * "Waiting on legal" is not automatically amber the way a /tasks stage is. + */ +export function accentForStatus( + status: Pick | undefined | null, + index = 0, + total = 0 +): StatusAccent { + return statusAccent({ + color: status?.color, + category: status?.category, + index, + total, + }); +} + +/** + * The accent for a board column / list section on any axis. + * + * On the status axis the column key IS a status id, so the real row is looked + * up and its colour and category answer. Off the status axis nothing + * meaningful is known, so the hue is purely positional — neighbouring lanes + * differ, and nothing pretends to mean anything. + */ +export function accentForGroup( + groupBy: GroupBy, + groupKey: string, + index: number, + total: number, + statuses: readonly StatusRow[] +): StatusAccent { + if (groupBy === "status") { + const status = statuses.find((row) => row.id === groupKey); + // A key with no matching row is the UNSET lane (a task with no status) or + // a status deleted since the page loaded. Positional is the honest answer; + // inventing a colour for "no status" would make the empty case look + // deliberate. + if (status) return accentForStatus(status, index, total); + } + return statusAccent({ index, total }); +} diff --git a/workbench/control_plane/src/app/projects/lib/tags.ts b/workbench/control_plane/src/app/projects/lib/tags.ts index 8f9701741..aef5e663d 100644 --- a/workbench/control_plane/src/app/projects/lib/tags.ts +++ b/workbench/control_plane/src/app/projects/lib/tags.ts @@ -12,6 +12,8 @@ * as the app rewriting what was typed. */ +import { ACCENT_HUES, type AccentHue, statusAccent } from "@/lib/statusAccent"; + export interface TagRow { id: string; project_id: string; @@ -31,30 +33,26 @@ export const MAX_TAGS_PER_TASK = 25; * Names, not values: `DESIGN_SYSTEM.md` forbids a colour literal at a call * site, and a tag stores what it means rather than what it looks like, so a * theme change repaints every tag instead of stranding them at last year's hex. + * + * WS-27ad: the names and their classes now live in `src/lib/statusAccent.ts`, + * shared with statuses and with /tasks' stage accents. A tag and a status lane + * that both say "green" have to BE the same green, and two tables of class + * strings is how that stops being true. */ -export const TAG_COLORS = [ - "gray", - "red", - "amber", - "green", - "blue", - "violet", -] as const; - -export type TagColor = (typeof TAG_COLORS)[number]; +export const TAG_COLORS = ACCENT_HUES; -/** Tailwind classes per colour, from tokens only. */ -const CHIP: Record = { - gray: "bg-secondary text-muted-foreground", - red: "bg-destructive/10 text-destructive", - amber: "bg-warning/10 text-warning", - green: "bg-success/10 text-success", - blue: "bg-primary/10 text-primary", - violet: "bg-accent text-accent-foreground", -}; +export type TagColor = AccentHue; +/** + * A tag's chip classes. + * + * Delegates to the shared vocabulary. An unrecognised stored colour still lands + * on gray, as it always did — `resolveHue` falls through to the positional + * fallback, whose first slot is gray, rather than throwing at a tag somebody + * coloured from an older palette. + */ export const chipClass = (color: string | undefined): string => - CHIP[color ?? "gray"] ?? CHIP.gray; + statusAccent({ color }).chip; /** `lower(name)` → the registry's display form. */ export function registryOf(tags: TagRow[]): Map { diff --git a/workbench/control_plane/src/app/tasks/components/StatusPill.tsx b/workbench/control_plane/src/app/tasks/components/StatusPill.tsx index 33d133b73..0953b3f41 100644 --- a/workbench/control_plane/src/app/tasks/components/StatusPill.tsx +++ b/workbench/control_plane/src/app/tasks/components/StatusPill.tsx @@ -1,6 +1,7 @@ "use client"; import Icon from "@/components/Icon"; +import { StatusChip } from "@/components/StatusChip"; import { useState } from "react"; import { GtdItem } from "../lib/types"; import { useCardActions } from "../lib/useCardActions"; @@ -13,6 +14,11 @@ import { stageAccent } from "../lib/stageColors"; // In-progress → blue, …), so status reads the same everywhere. Click opens a // small stage list to change it — picking the last stage marks the task done // (see useCardActions). stopPropagation so it never opens the card/row. +// +// WS-27ad: the pill BODY is `@/components/StatusChip`, the same one /projects +// draws in its list and table cells — the classes are unchanged, they just live +// somewhere both apps can reach. What stays here is the interaction: /projects' +// status is read-only or a `
    {!selectMode && showStage && } -

    - {item.title} -

    + {item.title}
    {item.nextAction && item.nextAction !== item.title && (

    @@ -321,7 +312,7 @@ export function TaskCard({ )}

    - + {contextMenu} ); diff --git a/workbench/control_plane/src/app/tasks/lib/stageColors.ts b/workbench/control_plane/src/app/tasks/lib/stageColors.ts index 63aa1c446..1fe1e031d 100644 --- a/workbench/control_plane/src/app/tasks/lib/stageColors.ts +++ b/workbench/control_plane/src/app/tasks/lib/stageColors.ts @@ -1,84 +1,26 @@ -// Stage colour accents shared by the Next Actions list headers and board -// columns, so a stage reads the same on both surfaces. Stages are user-named, -// so the accent is derived from name keywords first (a "Done"/"Waiting"/"In -// progress" column gets the semantically-right tone regardless of position), -// then falls back to a stable position-based hue. Uses the app's semantic -// tokens (primary/success/warning/muted) — never raw colours — so it tracks -// the theme automatically. +// /tasks · stage accents — now a thin adapter over the SHARED vocabulary. +// +// The palette, the keyword regexes and the positional fallback moved to +// `src/lib/statusAccent.ts` when /projects adopted them (WS-27ad): /projects' +// board columns were all one grey while this app's were colour-coded, which is +// two products, not one. The class strings are unchanged — the shared module's +// tests pin them byte-for-byte — so nothing here renders differently. +// +// What stays local is the one rule that is /tasks' and not /projects': the LAST +// configured stage is this app's Done stage (dropping a card there completes +// the task), so it reads green when nothing else has said otherwise. /projects +// must not inherit that — a lane's meaning there comes from its category, never +// from where it sits. -export interface StageAccent { - /** the small dot beside the label + column header underline */ - dot: string; - /** a faint tinted background for the column header strip */ - soft: string; - /** a readable text tone for the label on the soft background */ - text: string; - /** left border accent for the list group header */ - bar: string; -} - -const ACCENTS: Record<"grey" | "blue" | "amber" | "green" | "violet", StageAccent> = { - grey: { - dot: "bg-muted-foreground/60", - soft: "bg-muted/40", - text: "text-muted-foreground", - bar: "border-l-muted-foreground/40", - }, - blue: { - dot: "bg-primary", - soft: "bg-primary/10", - text: "text-primary", - bar: "border-l-primary", - }, - amber: { - dot: "bg-warning", - soft: "bg-warning/10", - text: "text-warning", - bar: "border-l-warning", - }, - green: { - dot: "bg-success", - soft: "bg-success/10", - text: "text-success", - bar: "border-l-success", - }, - violet: { - // no dedicated violet token — reuse primary at lower emphasis so a 5th+ - // stage still differs from plain blue without inventing a raw colour. - dot: "bg-primary/60", - soft: "bg-primary/5", - text: "text-primary/80", - bar: "border-l-primary/50", - }, -}; - -type Hue = keyof typeof ACCENTS; +import { statusAccent, type StatusAccent } from "@/lib/statusAccent"; -/** Positional fallback hues, in board order (grey → blue → amber → … → green - * reserved for the last stage). Keeps early stages visually distinct. */ -const POSITIONAL: Hue[] = ["grey", "blue", "violet", "amber"]; - -function keywordHue(name: string): Hue | null { - const n = name.toLowerCase(); - if (/(done|complete|closed|finished|shipped)/.test(n)) return "green"; - if (/(wait|block|hold|paused|stuck)/.test(n)) return "amber"; - if (/(progress|doing|active|working|review|in[\s-]?process)/.test(n)) - return "blue"; - if (/(todo|to[\s-]?do|backlog|new|open|inbox)/.test(n)) return "grey"; - return null; -} +export type StageAccent = StatusAccent; -/** The accent for a stage given its name, index, and how many stages exist. - * The LAST stage is the "done" stage in this app (drop there = complete), so - * it always reads green unless a keyword says otherwise. */ +/** The accent for a stage given its name, index, and how many stages exist. */ export function stageAccent( name: string, index: number, total: number, ): StageAccent { - const isLast = index === total - 1; - const kw = keywordHue(name); - if (kw) return ACCENTS[kw]; - if (isLast) return ACCENTS.green; - return ACCENTS[POSITIONAL[index % POSITIONAL.length]]; + return statusAccent({ name, index, total, lastIsDone: true }); } diff --git a/workbench/control_plane/src/components/StatusChip.tsx b/workbench/control_plane/src/components/StatusChip.tsx new file mode 100644 index 000000000..3e2e9ba1f --- /dev/null +++ b/workbench/control_plane/src/components/StatusChip.tsx @@ -0,0 +1,49 @@ +"use client"; + +/** + * The status pill — one shape for /projects and /tasks (WS-27ad). + * + * A status was drawn three different ways: a coloured pill with a dot on + * /tasks' cards, a bare grey word in /projects' list and table cells, and + * nothing at all on /projects' board. Same fact, three appearances. + * + * This is only the *appearance*. WHICH accent a status earns is + * `src/lib/statusAccent.ts`, fed by each app's own facts (/projects hands over + * the stored colour and category, /tasks the stage name and position) — a + * shared component that knew about either app's status model would be shared in + * name only. + * + * Not interactive on purpose: /tasks' pill opens a stage menu and /projects' + * cells are read-only or drive a ` + toggleSelected( + item.id, + (e.nativeEvent as MouseEvent).shiftKey, + rows, + ) + } + className="h-4 w-4 shrink-0 accent-primary" + /> +
    + +
    + + ) : ( + + )} + + ); + })} + {/* The view's own capture box, where the view can honestly take one. */} + {box ? ( +
    + +
    + ) : null} + + ); +} diff --git a/workbench/control_plane/src/app/tasks/components/InboxCard.tsx b/workbench/control_plane/src/app/tasks/components/InboxCard.tsx index 294cdcde4..8022988bc 100644 --- a/workbench/control_plane/src/app/tasks/components/InboxCard.tsx +++ b/workbench/control_plane/src/app/tasks/components/InboxCard.tsx @@ -33,7 +33,8 @@ export interface InboxCardProps { selected: boolean; selectionMode: boolean; editing: boolean; - onSelectToggle: () => void; + /** `shift` extends the selection from the anchor (`@/lib/selection`). */ + onSelectToggle: (shift: boolean) => void; onEditStart: () => void; onEditEnd: () => void; } @@ -190,7 +191,7 @@ export function InboxCard({ type="button" onClick={(e) => { e.stopPropagation(); - onSelectToggle(); + onSelectToggle(e.shiftKey); }} aria-label={selected ? "Deselect" : "Select"} className={[ diff --git a/workbench/control_plane/src/app/tasks/components/InboxTable.tsx b/workbench/control_plane/src/app/tasks/components/InboxTable.tsx index b597430f9..4c0e2eef4 100644 --- a/workbench/control_plane/src/app/tasks/components/InboxTable.tsx +++ b/workbench/control_plane/src/app/tasks/components/InboxTable.tsx @@ -30,8 +30,10 @@ export function InboxTable({ }: { items: GtdItem[]; cursorId: string | null; - selectedIds: Set; - onSelectToggle: (id: string) => void; + /** Read-only: the table never mutates the caller's selection. */ + selectedIds: ReadonlySet; + /** `shift` extends the selection from the anchor (`@/lib/selection`). */ + onSelectToggle: (id: string, shift: boolean) => void; }) { const people = useTaskStore((s) => s.people); const projects = useTaskStore((s) => s.projects); @@ -73,7 +75,7 @@ export function InboxTable({ aria-label={selected ? "Deselect" : "Select"} onClick={(e) => { e.stopPropagation(); - onSelectToggle(item.id); + onSelectToggle(item.id, e.shiftKey); }} className={[ "tech-transition h-3.5 w-3.5 rounded-full border-2", diff --git a/workbench/control_plane/src/app/tasks/components/InboxView.tsx b/workbench/control_plane/src/app/tasks/components/InboxView.tsx index 268cb86ed..1cb8bf358 100644 --- a/workbench/control_plane/src/app/tasks/components/InboxView.tsx +++ b/workbench/control_plane/src/app/tasks/components/InboxView.tsx @@ -3,6 +3,7 @@ import Button from "@/components/ui/Button"; import AppIcon, { themedIcon, type ThemedIcon } from "@/components/Icon"; import { + useCallback, useEffect, useMemo, useState, @@ -10,6 +11,12 @@ import { KeyboardEvent, } from "react"; import FilterPills from "@/components/FilterPills"; +import { clampCursor, stepCursor } from "@/lib/cursor"; +import { + NO_SELECTION, + type SelectionState, + clickSelect, +} from "@/lib/selection"; import { useTaskStore } from "../lib/taskStore"; import { Disposition, GtdItem } from "../lib/types"; import { @@ -28,6 +35,10 @@ import { ClarifyModal } from "./ClarifyModal"; const AGING_MS = 3 * 24 * 3600 * 1000; // GTD: empty regularly — flag stale items +/** The inbox's selection is its own (its bulk actions are GTD dispositions, + * not archive/delete), but the GRAMMAR is the shared one — see `toggleSelect`. */ +const NOBODY: ReadonlySet = new Set(); + // Density preference (cards vs Notion-style dense list), sticky per browser. // Read via useSyncExternalStore so SSR HTML (always "cards") hydrates cleanly // and the client value takes over without a mismatch. @@ -124,7 +135,11 @@ export function InboxView() { const [showTickler, setShowTickler] = useState(false); const [cursorId, setCursorId] = useState(null); const [editingId, setEditingId] = useState(null); - const [selectedIds, setSelectedIds] = useState>(new Set()); + // Selection and its shift-anchor as ONE value: they are only meaningful + // together, and holding them apart meant the keyboard's `x` could write a + // selection from fresh state and an anchor from stale state. + const [selection, setSelection] = useState(NO_SELECTION); + const selectedIds = selection.selected; const [showShortcuts, setShowShortcuts] = useState(false); // Inline editor for the dup-notice "rename existing" affordance: seeded with // the new capture's (usually clearer) title. @@ -159,14 +174,20 @@ export function InboxView() { { id: "older", label: "Older", count: bucketCounts.older }, ].filter((p) => p.id === "all" || p.count > 0); - const toggleSelect = (id: string) => - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - const clearSelection = () => setSelectedIds(new Set()); + // WS-27ad — the same transition /projects' board and the rest of /tasks use: + // a plain pick toggles and becomes the anchor, a shift-pick adds the range + // between them in the order the list is drawn. + // `useCallback` because the keyboard effect below calls it and it now closes + // over `visible` (the shift-range needs the on-screen order). Without it the + // effect would re-subscribe on every render. + const toggleSelect = useCallback( + (id: string, shift = false) => + setSelection((prev) => + clickSelect(prev, visible.map((i) => i.id), id, shift), + ), + [visible], + ); + const clearSelection = () => setSelection(NO_SELECTION); const bulk = (d: Disposition) => { bulkDispose([...selectedIds], d); clearSelection(); @@ -177,6 +198,19 @@ export function InboxView() { }; // ── keyboard navigation + triage over the visible list ── + // + // WS-27ad — the movement half is the SHARED cursor (`@/lib/cursor`), the same + // transition the board, the grouped list, the flat lists and both /projects + // surfaces run. The inbox's own `j`/`k` walk is retired: it was the last + // place in either app where the arrow keys meant something different, and a + // vim idiom on exactly one screen is a shortcut nobody can rely on. + // + // What stays local is TRIAGE — `e`/`x`/`t`/`s`/`r`/`2` are GTD dispositions, + // not navigation, and they belong to this screen alone. + // + // The cursor is held as an ID here rather than an index because triage + // ADVANCES it: dispose the current item and the row under it is gone, so + // "the next id" has to be read before the list changes. useEffect(() => { if (showTickler) return; const onKey = (e: globalThis.KeyboardEvent) => { @@ -192,6 +226,7 @@ export function InboxView() { if (e.metaKey || e.ctrlKey || e.altKey) return; if (!visible.length) return; + const rows = visible.map((i) => i.id); const idx = visible.findIndex((i) => i.id === cursorId); const cur = idx >= 0 ? visible[idx] : visible[0]; const disposeAdvance = (d: Disposition) => { @@ -207,23 +242,25 @@ export function InboxView() { setCursorId(nextId); }; + // Arrows and Enter are the shared cursor's. `stepCursor` returns null for + // anything it does not own, so the triage switch below still sees every + // key it cares about and nothing is eaten twice. + const moved = stepCursor( + rows, + { cursor: clampCursor(rows.length, idx), anchor: null, selection: NOBODY }, + e.key, + false, + ); + if (moved) { + e.preventDefault(); + // Enter clarifies rather than "opens" — the inbox's whole job is to + // process, and `open` is the row the shared cursor was standing on. + if (moved.open) openClarify(moved.open); + else setCursorId(rows[moved.cursor] ?? null); + return; + } + switch (e.key) { - case "j": - case "ArrowDown": - e.preventDefault(); - setCursorId( - idx < 0 ? visible[0].id : visible[Math.min(visible.length - 1, idx + 1)].id, - ); - break; - case "k": - case "ArrowUp": - e.preventDefault(); - setCursorId(idx < 0 ? visible[0].id : visible[Math.max(0, idx - 1)].id); - break; - case "Enter": - e.preventDefault(); - openClarify(cur.id); - break; case "e": e.preventDefault(); setEditingId(cur.id); @@ -268,6 +305,7 @@ export function InboxView() { openClarify, quickDispose, requestDelete, + toggleSelect, ]); const submit = () => { @@ -354,7 +392,10 @@ export function InboxView() { {showShortcuts && (
    capture - move + {/* WS-27ad — was "j / k". The arrows are the one movement idiom + across both task apps now; a vim walk on this screen only was a + shortcut nobody could carry anywhere else. */} + move clarify edit select @@ -674,7 +715,7 @@ export function InboxView() { selected={selectedIds.has(item.id)} selectionMode={selectionActive} editing={editingId === item.id} - onSelectToggle={() => toggleSelect(item.id)} + onSelectToggle={(shift) => toggleSelect(item.id, shift)} onEditStart={() => setEditingId(item.id)} onEditEnd={() => setEditingId(null)} /> diff --git a/workbench/control_plane/src/app/tasks/components/ItemList.tsx b/workbench/control_plane/src/app/tasks/components/ItemList.tsx index d2aaac161..e00dd655b 100644 --- a/workbench/control_plane/src/app/tasks/components/ItemList.tsx +++ b/workbench/control_plane/src/app/tasks/components/ItemList.tsx @@ -8,7 +8,7 @@ import { isUntagged } from "../lib/priority"; import { ViewKey } from "../lib/types"; import { isWaitingOverdue } from "../lib/waiting"; import { applyFilters, applySort, type GroupBy } from "../lib/ordering"; -import { TaskCard } from "./TaskCard"; +import { FlatList } from "./FlatList"; import { TaskBoard } from "./TaskBoard"; import { TaskListGrouped } from "./TaskListGrouped"; import { TaskToolbar } from "./TaskToolbar"; @@ -73,10 +73,27 @@ export function ItemList() { // works on every Next-Actions surface — the flat lists (Done/Waiting/…), the // status-grouped list, AND the Kanban board — and survives the list/board // toggle within a view. The inbox keeps its own selection UI. + // + // ── WS-27ad · a divergence KEPT, and why ──────────────────────────────── + // /projects has no mode: its rows carry a permanent checkbox column and + // shift-click extends. /tasks keeps this modal Select button, deliberately. + // + // The reason is not taste, it is that `selectMode` changes what a CLICK + // MEANS. A /projects row is a table row with a dedicated checkbox cell, so + // clicking the row opens and clicking the box selects — two targets, two + // outcomes. A /tasks row is a TaskCard: the whole card is the open affordance + // and it carries its own controls (the schedule button, the context menu, the + // status pill's stage menu). Giving every card a permanent checkbox would + // either steal the drag-grip gutter the manual sort needs or make one click + // mean two things — the exact trap /projects' own card comments name. + // + // What DID converge is the grammar INSIDE the mode: once you are selecting, + // shift-click and Shift+Arrow extend from an anchor through + // `@/lib/selection`, identically in both apps. The mode is the entry; the + // selection model underneath is now one. const selectMode = useTaskStore((s) => s.selectMode); const selectedIds = useTaskStore((s) => s.selectedIds); const setSelectMode = useTaskStore((s) => s.setSelectMode); - const toggleSelected = useTaskStore((s) => s.toggleSelected); const clearSelection = useTaskStore((s) => s.clearSelection); // The view's items (source/archive-filtered), then the toolbar's search/ @@ -318,33 +335,11 @@ export function ItemList() { // attribute); the list handles that internally. ) : ( -
    - {visible.map((item) => - selectMode ? ( - - ) : ( - - ), - )} -
    + // WS-27ad — the flat views (Done / Someday / Archive / Engage / + // Priority). Lifted into their own component when they gained the + // shared cursor, flash and per-view quick-add: they were the last + // /tasks surfaces where the arrow keys did nothing. + )} {/* Bulk action bar — archive/restore/delete the current selection. */} diff --git a/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx b/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx index c8e237c2b..d6640d39a 100644 --- a/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx +++ b/workbench/control_plane/src/app/tasks/components/TaskBoard.tsx @@ -1,7 +1,9 @@ "use client"; +import { DropGap } from "@/components/DropGap"; import { QuickAdd } from "@/components/QuickAdd"; import { useFlash } from "@/components/useFlash"; +import { gapKey } from "@/lib/boardDrop"; import { clampCursor, stepCursor } from "@/lib/cursor"; import { useCallback, useMemo, useState } from "react"; import { GtdItem, ViewKey } from "../lib/types"; @@ -31,9 +33,26 @@ import { formatStatus } from "../lib/utils"; // // The board is only offered for Next Actions (see ItemList `boardable`); other // views render list-only until their own status model is designed. +// +// ── WS-27ad · what this board shares, and the one thing it does not ──────── +// Shared with /projects' board: the keyboard cursor and its Shift-sweep +// (`@/lib/cursor`), the selection grammar (`@/lib/selection`), the landing +// flash (`@/components/useFlash`), the group-context quick-add +// (`@/components/QuickAdd`), the column accent (`@/lib/statusAccent` via +// lib/stageColors), the card shell (`@/components/TaskCardShell`) and — as of +// this ticket, in the other direction — the drop-gap reorder +// (`@/components/DropGap` + `@/lib/boardDrop`), which /projects lacked. +// +// NOT shared: /projects' SWIMLANES (a second grouping axis drawn as rows). +// Deliberate, and the reason is this app's data rather than effort: /tasks' +// board axis is the fixed workflow-stage set, and its other axes are COMPUTED +// projections — priority and mode come from important × leveraged × urgent-from- +// dueAt, which no drop can write (`lib/quickAdd` refuses a quick-add on them for +// the same reason, and `lib/dropRules` refuses the drag). A lane grid here would +// be a grid most of whose cells refuse every gesture. If /tasks ever grows a +// second SETTABLE axis, this is the note to delete. -/** The cursor never carries a selection here — see the note in onKeyDown. */ -const EMPTY_SELECTION: ReadonlySet = new Set(); +const NOBODY: ReadonlySet = new Set(); export function TaskBoard({ items, @@ -60,6 +79,7 @@ export function TaskBoard({ const selectMode = useTaskStore((s) => s.selectMode); const selectedIds = useTaskStore((s) => s.selectedIds); const toggleSelected = useTaskStore((s) => s.toggleSelected); + const extendSelection = useTaskStore((s) => s.extendSelection); // Columns: an explicit stage set (the per-project view's real ClickUp // statuses) or the user's 4 fixed workflow stages. A LOCAL task keys off its @@ -84,6 +104,7 @@ export function TaskBoard({ // WS-27y backport: the keyboard cursor and the landing flash — the same // shared machinery the Projects board runs (`@/lib/cursor`, `useFlash`). const [cursor, setCursor] = useState(-1); + const [anchor, setAnchor] = useState(null); const { flash, attach, scrollTo } = useFlash(); // An unstaged task sits in the FIRST column of the axis. @@ -134,19 +155,23 @@ export function TaskBoard({ ) ) return; - // Plain cursor + Enter only. /tasks has no shift-range selection model - // (`selectedIds` is a bare toggle set with no anchor — see taskStore), so - // the shared cursor's shift-sweep stays dormant here rather than - // half-growing a second selection grammar on one surface. + // WS-27ad — Shift+Arrow sweeps a range, exactly as the /projects board + // does, but ONLY inside select mode. Outside it there is no selection on + // screen and no bulk bar, so a shift-arrow would grow a set nobody can + // see; the mode is what makes the gesture legible here (see the note on + // `selectMode` in ItemList). + const picked = selectMode ? selectedIds : NOBODY; const next = stepCursor( rows, - { cursor: cursorAt, anchor: null, selection: EMPTY_SELECTION }, + { cursor: cursorAt, anchor, selection: picked }, event.key, - false, + selectMode && event.shiftKey, ); if (!next) return; event.preventDefault(); setCursor(next.cursor); + setAnchor(next.anchor); + if (next.selection !== picked) extendSelection([...next.selection]); if (next.open) openFocus(next.open); if (next.cursor >= 0) scrollTo(rows[next.cursor]); } @@ -298,8 +323,9 @@ export function TaskBoard({ {/* drop gap ABOVE this card (manual reorder) */} {manual && ( dragId && setDropAt(`${col.key}:${idx}`)} + active={dropAt === gapKey(col.key, idx)} + dragging={Boolean(dragId)} + onOver={() => dragId && setDropAt(gapKey(col.key, idx))} onDrop={() => dropAtIndex(col.key, idx)} /> )} @@ -322,7 +348,7 @@ export function TaskBoard({ // The column IS the stage here — a per-card status pill // would just repeat it, so it's off on the board. showStage={false} - onToggleSelected={() => toggleSelected(i.id)} + onToggleSelected={(shift) => toggleSelected(i.id, shift, rows)} onDragStart={() => setDragId(i.id)} onDragEnd={() => { setDragId(null); setOverCol(null); setDropAt(null); }} /> @@ -332,8 +358,9 @@ export function TaskBoard({ {/* trailing gap → drop at the end */} {manual && colItems.length > 0 && ( dragId && setDropAt(`${col.key}:${colItems.length}`)} + active={dropAt === gapKey(col.key, colItems.length)} + dragging={Boolean(dragId)} + onOver={() => dragId && setDropAt(gapKey(col.key, colItems.length))} onDrop={() => dropAtIndex(col.key, colItems.length)} /> )} @@ -358,33 +385,3 @@ export function TaskBoard({
    ); } - -/** A thin, highlight-on-hover drop target between cards for manual reorder. */ -function DropGap({ - active, - onOver, - onDrop, -}: { - active: boolean; - onOver: () => void; - onDrop: () => void; -}) { - return ( -
    { - e.preventDefault(); - e.stopPropagation(); - onOver(); - }} - onDrop={(e) => { - e.preventDefault(); - e.stopPropagation(); - onDrop(); - }} - className={[ - "-my-1 h-2 rounded transition-colors", - active ? "bg-primary/40" : "bg-transparent", - ].join(" ")} - /> - ); -} diff --git a/workbench/control_plane/src/app/tasks/components/TaskCard.tsx b/workbench/control_plane/src/app/tasks/components/TaskCard.tsx index f6e749510..c54595715 100644 --- a/workbench/control_plane/src/app/tasks/components/TaskCard.tsx +++ b/workbench/control_plane/src/app/tasks/components/TaskCard.tsx @@ -57,7 +57,8 @@ export function TaskCard({ * instead of opening the focus modal. Drag is suppressed by the parent. */ selectMode?: boolean; selected?: boolean; - onToggleSelected?: () => void; + /** `shift` extends the selection from the anchor (`@/lib/selection`). */ + onToggleSelected?: (shift: boolean) => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: (e: React.DragEvent) => void; }) { @@ -234,8 +235,8 @@ export function TaskCard({ // In select mode the card is a selection toggle, not a link: clicking checks // the box (drag is disabled by the parent) so a batch can be archived/deleted // right on the board. A selected card gets a primary ring. - const activate = () => { - if (selectMode) onToggleSelected?.(); + const activate = (shift: boolean) => { + if (selectMode) onToggleSelected?.(shift); else openFocus(item.id); }; return ( @@ -257,7 +258,9 @@ export function TaskCard({ onToggleSelected?.()} + onChange={(e) => + onToggleSelected?.((e.nativeEvent as MouseEvent).shiftKey) + } onClick={(e) => e.stopPropagation()} aria-label={selected ? "Deselect task" : "Select task"} className="absolute right-1.5 top-1.5 h-4 w-4 accent-primary" diff --git a/workbench/control_plane/src/app/tasks/components/TaskListGrouped.tsx b/workbench/control_plane/src/app/tasks/components/TaskListGrouped.tsx index 1056c5b5a..8c709279f 100644 --- a/workbench/control_plane/src/app/tasks/components/TaskListGrouped.tsx +++ b/workbench/control_plane/src/app/tasks/components/TaskListGrouped.tsx @@ -36,8 +36,7 @@ import { quickAddPrefill } from "../lib/quickAdd"; import { ColumnHeader, ColumnCell } from "./ListColumns"; import { StatusPill } from "./StatusPill"; -/** The cursor never carries a selection here — see the note in onKeyDown. */ -const EMPTY_SELECTION: ReadonlySet = new Set(); +const NOBODY: ReadonlySet = new Set(); // A status-segmented list (Jira backlog style): rows grouped under collapsible // stage headers with counts. In Manual sort the rows are drag-reorderable — @@ -84,6 +83,7 @@ export function TaskListGrouped({ const selectMode = useTaskStore((s) => s.selectMode); const selectedIds = useTaskStore((s) => s.selectedIds); const toggleSelected = useTaskStore((s) => s.toggleSelected); + const extendSelection = useTaskStore((s) => s.extendSelection); // Status grouping (the default): the drag-reorderable workflow-stage swimlanes. // A lens grouping (priority/mode/energy/context) is read-only swimlanes over @@ -181,6 +181,7 @@ export function TaskListGrouped({ // WS-27y backport: the keyboard cursor and the landing flash — the same // shared machinery the Projects list runs (`@/lib/cursor`, `useFlash`). const [cursor, setCursor] = useState(-1); + const [anchor, setAnchor] = useState(null); const { flash, attach, scrollTo } = useFlash(); // The cursor's world: the rows in render order, skipping collapsed groups. @@ -208,19 +209,20 @@ export function TaskListGrouped({ ) ) return; - // Plain cursor + Enter only. /tasks has no shift-range selection model - // (`selectedIds` is a bare toggle set with no anchor — see taskStore), so - // the shared cursor's shift-sweep stays dormant here rather than - // half-growing a second selection grammar on one surface. + // WS-27ad — Shift+Arrow sweeps a range, the same gesture the /projects + // list has, and only inside select mode (see the board's note). + const picked = selectMode ? selectedIds : NOBODY; const next = stepCursor( rows, - { cursor: cursorAt, anchor: null, selection: EMPTY_SELECTION }, + { cursor: cursorAt, anchor, selection: picked }, event.key, - false, + selectMode && event.shiftKey, ); if (!next) return; event.preventDefault(); setCursor(next.cursor); + setAnchor(next.anchor); + if (next.selection !== picked) extendSelection([...next.selection]); if (next.open) openFocus(next.open); if (next.cursor >= 0) scrollTo(rows[next.cursor]); } @@ -369,7 +371,7 @@ export function TaskListGrouped({ manual={manual} selectMode={selectMode} selected={selectedIds.has(item.id)} - onToggleSelected={() => toggleSelected(item.id)} + onToggleSelected={(shift) => toggleSelected(item.id, shift, rows)} columns={cols} grid={grid} // The status pill on the card is redundant when the list is @@ -461,7 +463,8 @@ function DraggableRow({ manual: boolean; selectMode: boolean; selected: boolean; - onToggleSelected: () => void; + /** `shift` extends the selection from the anchor (`@/lib/selection`). */ + onToggleSelected: (shift: boolean) => void; /** Visible desktop columns (empty → no columnar layout, stacked card only). */ columns: ColumnDef[]; /** grid-template-columns matching the header (only used when columns set). */ @@ -518,7 +521,9 @@ function DraggableRow({ + onToggleSelected((e.nativeEvent as MouseEvent).shiftKey) + } className="h-4 w-4 accent-primary" aria-label={selected ? "Deselect task" : "Select task"} /> @@ -555,7 +560,7 @@ function DraggableRow({ // so a click selects rather than opening the task.
    - - ) : ( - - ), - )} + + )} + + ))} ))} diff --git a/workbench/control_plane/src/app/tasks/lib/quickAdd.test.ts b/workbench/control_plane/src/app/tasks/lib/quickAdd.test.ts index bc485f448..b737936a0 100644 --- a/workbench/control_plane/src/app/tasks/lib/quickAdd.test.ts +++ b/workbench/control_plane/src/app/tasks/lib/quickAdd.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; import { NO_CONTEXT_GROUP } from "./priority"; -import { quickAddPrefill } from "./quickAdd"; +import { quickAddPrefill, viewQuickAdd } from "./quickAdd"; describe("quickAddPrefill", () => { it("files a status-axis add into its stage — board column or list section", () => { @@ -44,3 +44,42 @@ describe("quickAddPrefill", () => { expect(quickAddPrefill("mode", "do")).toBeNull(); }); }); + +describe("viewQuickAdd — the flat views, whose group IS the view (WS-27ad)", () => { + it("files a Someday add into Someday", () => { + // The clean case: "incubate this" is a whole decision, and the box saves + // the capture-then-clarify round trip that was the only way to say it. + expect(viewQuickAdd("someday")?.prefill).toEqual({ disposition: "SOMEDAY" }); + }); + + it("logs a Done add as already done", () => { + expect(viewQuickAdd("done")?.prefill).toEqual({ disposition: "DONE" }); + }); + + it("refuses Waiting-For — a create cannot promise the delegation", () => { + // The view groups by PERSON. A create can set the bucket but not who owes + // it, so the task would visibly file itself under "Unassigned" — a sibling + // group, i.e. the same lie the computed axes refuse above. + expect(viewQuickAdd("waiting")).toBeNull(); + }); + + it("refuses Archive — creating a task to immediately hide it is not a gesture", () => { + expect(viewQuickAdd("archive")).toBeNull(); + }); + + it("refuses the views that already have their own capture affordance", () => { + // Inbox has the always-open capture row; next/engage/priority are grouped + // surfaces served by `quickAddPrefill`; calendar days get a per-day box. + for (const view of ["inbox", "next", "engage", "priority", "calendar"] as const) { + expect(viewQuickAdd(view), view).toBeNull(); + } + }); + + it("labels every box it offers", () => { + // A capture box whose placeholder does not say where the task lands is the + // failure the group-context quick-add exists to avoid. + for (const view of ["someday", "done"] as const) { + expect(viewQuickAdd(view)?.label, view).toBeTruthy(); + } + }); +}); diff --git a/workbench/control_plane/src/app/tasks/lib/quickAdd.ts b/workbench/control_plane/src/app/tasks/lib/quickAdd.ts index 2f19b6aa3..74a425740 100644 --- a/workbench/control_plane/src/app/tasks/lib/quickAdd.ts +++ b/workbench/control_plane/src/app/tasks/lib/quickAdd.ts @@ -21,7 +21,7 @@ import type { GroupBy } from "./ordering"; import { NO_CONTEXT_GROUP } from "./priority"; -import type { Energy } from "./types"; +import type { Disposition, Energy, ViewKey } from "./types"; /** What a quick-added task must carry to belong to its group. */ export interface QuickAddPrefill { @@ -29,6 +29,8 @@ export interface QuickAddPrefill { context?: string; energy?: Energy; deepWork?: boolean; + /** The GTD bucket the add lands in. Absent = the default NEXT. */ + disposition?: Disposition; } /** The axes a /tasks quick-add can sit inside: the status axis (`""`, the @@ -68,3 +70,46 @@ export function quickAddPrefill( return null; } } + +/** + * WS-27ad — the quick-add for a FLAT view, whose "group" is the view itself. + * + * The Done / Someday / Waiting / Archive lists had no capture box at all, so + * the only way to put something on your Someday list was to capture it into the + * Inbox and clarify it there — two steps for a thought that was already + * classified when you had it. + * + * Same null grammar as the axes above: a view that cannot honestly take the add + * offers no box, and each refusal has a reason rather than an omission. + */ +export function viewQuickAdd( + view: ViewKey, +): { prefill: QuickAddPrefill; label: string } | null { + switch (view) { + case "someday": + // The clean case: "incubate this" is a complete decision, and Someday is + // exactly where an incubating thought belongs. + return { prefill: { disposition: "SOMEDAY" }, label: "Add to Someday" }; + case "done": + // A log entry, not a to-do — the same thing the board already means when + // you quick-add into the last stage (see `quickAddNext`). Worth having: + // recording work that was never a task is otherwise impossible here. + return { prefill: { disposition: "DONE" }, label: "Log something done" }; + case "waiting": + // NO box. Waiting-For is grouped by PERSON and a waiting-for's whole + // content is who owes it and since when; a create can set the bucket but + // not the delegation (that is the delegate path, which asks for the + // person and stamps `delegatedAt`). An add here would visibly file itself + // into "Unassigned" — a sibling group — which this module's header calls + // what it is: a lie about where the task went. + return null; + case "archive": + // Creating a task in order to immediately hide it is not a gesture. + return null; + default: + // Inbox has its own always-open capture row; next/priority/engage are + // grouped surfaces served by `quickAddPrefill`; calendar days get their + // own per-day box. + return null; + } +} diff --git a/workbench/control_plane/src/app/tasks/lib/taskStore.ts b/workbench/control_plane/src/app/tasks/lib/taskStore.ts index 833f6995d..4fe484854 100644 --- a/workbench/control_plane/src/app/tasks/lib/taskStore.ts +++ b/workbench/control_plane/src/app/tasks/lib/taskStore.ts @@ -1,4 +1,6 @@ import { create } from "zustand"; +import { dropIndexFor } from "@/lib/boardDrop"; +import { clickSelect } from "@/lib/selection"; import { Disposition, Energy, @@ -572,10 +574,30 @@ interface TaskState { * selection survives switching between list/board mode within a view. */ selectMode: boolean; selectedIds: Set; + /** The last row picked WITHOUT shift — what a shift-click measures from. + * + * WS-27ad: /tasks had no anchor at all, so shift did nothing here while it + * swept a range on /projects. The grammar is now one shared transition + * (`@/lib/selection.clickSelect`), so a member who learns shift-click on one + * surface has learnt it on both. */ + selectAnchor: string | null; /** Enter/leave select mode (leaving clears the selection). */ setSelectMode: (on: boolean) => void; - /** Toggle one id in the current selection. */ - toggleSelected: (id: string) => void; + /** + * Pick one id. + * + * `visible` is the surface's OWN render order — the board walks columns, the + * grouped list walks sections, and "between these two" means between them on + * screen. The store cannot know it, so the surface passes it; a shift-click + * with no order to measure against falls back to a plain toggle. + */ + toggleSelected: ( + id: string, + shift?: boolean, + visible?: readonly string[], + ) => void; + /** Replace the selection outright — Shift+Arrow's swept superset. */ + extendSelection: (ids: readonly string[]) => void; /** Clear the selection and leave select mode. */ clearSelection: () => void; /** Delete an item. SOFT delete (tombstone) → lossless Undo within the window, @@ -813,17 +835,27 @@ export const useTaskStore = create((set, get) => ({ pendingDeleteIds: null, selectMode: false, selectedIds: new Set(), + selectAnchor: null, setSelectMode: (on) => - set(on ? { selectMode: true } : { selectMode: false, selectedIds: new Set() }), - toggleSelected: (id) => + set( + on + ? { selectMode: true } + : { selectMode: false, selectedIds: new Set(), selectAnchor: null }, + ), + toggleSelected: (id, shift = false, visible = []) => set((s) => { - const next = new Set(s.selectedIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { selectedIds: next }; + const next = clickSelect( + { selected: s.selectedIds, anchor: s.selectAnchor }, + visible, + id, + shift, + ); + return { selectedIds: new Set(next.selected), selectAnchor: next.anchor }; }), - clearSelection: () => set({ selectedIds: new Set(), selectMode: false }), + extendSelection: (ids) => set({ selectedIds: new Set(ids) }), + clearSelection: () => + set({ selectedIds: new Set(), selectMode: false, selectAnchor: null }), selectView: (view) => set({ @@ -837,6 +869,7 @@ export const useTaskStore = create((set, get) => ({ // don't archive/delete rows you can no longer see. selectMode: false, selectedIds: new Set(), + selectAnchor: null, }), selectContext: (context) => @@ -977,20 +1010,29 @@ export const useTaskStore = create((set, get) => ({ // stage means the task is DONE — a quick-add into the Done column is a // log entry, not a to-do. const stages = get().settings.workflowStages; - const done = + const lastStage = prefill.workflowStage !== undefined && stages.length > 0 && prefill.workflowStage === stages[stages.length - 1]; + // WS-27ad — a flat view's box says which bucket outright (`viewQuickAdd`: + // Someday incubates, Done logs). The board's last-stage rule stands where + // nothing was said. + const disposition: Disposition = + prefill.disposition ?? (lastStage ? "DONE" : "NEXT"); const item: GtdItem = { ...makeCaptureItem(t), // Born clarified: the group the add sits in already answered "what is // this?" — it is a next action ON that stage/context/energy, and the // title IS the next physical step. - disposition: done ? "DONE" : "NEXT", nextAction: t, clarifiedAt: now, - ...(done ? { completedAt: now } : {}), ...prefill, + disposition, + // Stamped from the resolved disposition, not from the stage rule alone — + // a Done logged from the Done list is as complete as one dropped in the + // last column, and a completed row with no `completedAt` sorts as if it + // finished in 1970. + ...(disposition === "DONE" ? { completedAt: now } : {}), }; set((s) => ({ items: [item, ...s.items] })); if (get().backend === "live") { @@ -1845,15 +1887,12 @@ export const useTaskStore = create((set, get) => ({ const moving = get().items.find((i) => i.id === id); if (!moving) return; // `toIndex` is the gap index within `groupItems` (which may still include - // the moved card, e.g. an intra-group drag). The neighbour set is that - // group WITHOUT the moved card; when the card originally sat BEFORE the - // gap, removing it shifts every later index down by one — adjust so the - // card lands in the visually-targeted slot rather than one past it. - const fromIndex = groupItems.findIndex((i) => i.id === id); + // the moved card, e.g. an intra-group drag). Translating that into an index + // in the neighbour set is `@/lib/boardDrop.dropIndexFor` — shared with + // /projects' board since WS-27ad, because the off-by-one it handles is + // invisible in review and only bites on a downward intra-group drag. const others = groupItems.filter((i) => i.id !== id); - const destIndex = - fromIndex !== -1 && fromIndex < toIndex ? toIndex - 1 : toIndex; - const newKey = rankForDrop(others, destIndex); + const newKey = rankForDrop(others, dropIndexFor(groupItems, id, toIndex)); // One patch carries the rank and any stage re-file, so a cross-column drag // that also repositions is a single write (and one optimistic update). const patch: ItemMetaPatch = { sortKey: newKey }; diff --git a/workbench/control_plane/src/components/DropGap.tsx b/workbench/control_plane/src/components/DropGap.tsx new file mode 100644 index 000000000..486bc03ef --- /dev/null +++ b/workbench/control_plane/src/components/DropGap.tsx @@ -0,0 +1,52 @@ +"use client"; + +/** + * The drop target between two cards — shared by /projects and /tasks (WS-27ad). + * + * The visual half of the drop-gap reorder /tasks had and /projects did not (see + * `@/lib/boardDrop` for the arithmetic and the reason it won). A thin strip + * that grows while a drag is in flight, because an empty or short column is + * otherwise a 2px target somebody has to aim at. + * + * `stopPropagation` on both handlers is load-bearing: the gap sits inside a + * column that is itself a drop target, and without it the column's own handler + * fires too and appends the card — turning every precise drop back into the + * append this component exists to replace. + */ + +export function DropGap({ + active, + dragging = false, + onOver, + onDrop, + className = "", +}: { + /** This is the gap the card would land in. */ + active: boolean; + /** A drag is in flight — grow, so short and empty groups stay hittable. */ + dragging?: boolean; + onOver: () => void; + onDrop: () => void; + className?: string; +}) { + return ( +
    { + event.preventDefault(); + event.stopPropagation(); + onOver(); + }} + onDrop={(event) => { + event.preventDefault(); + event.stopPropagation(); + onDrop(); + }} + className={[ + "-my-0.5 rounded transition-all", + dragging ? "h-3" : "h-1.5", + active ? "bg-primary/50" : "bg-transparent", + className, + ].join(" ")} + /> + ); +} diff --git a/workbench/control_plane/src/components/TaskCardShell.tsx b/workbench/control_plane/src/components/TaskCardShell.tsx index 93161cd14..b9d9d944a 100644 --- a/workbench/control_plane/src/components/TaskCardShell.tsx +++ b/workbench/control_plane/src/components/TaskCardShell.tsx @@ -46,7 +46,13 @@ export function TaskCardShell({ completed?: boolean; className?: string; innerRef?: (element: HTMLDivElement | null) => void; - onActivate?: () => void; + /** + * Click / Enter / Space. `shift` is passed through rather than swallowed + * because in a selection mode the card IS the checkbox, and a shift-click on + * it has to mean the same range-extend it means on a real one + * (`@/lib/selection`). + */ + onActivate?: (shift: boolean) => void; onContextMenu?: (event: React.MouseEvent) => void; onDragStart?: (event: React.DragEvent) => void; onDragEnd?: (event: React.DragEvent) => void; @@ -65,12 +71,12 @@ export function TaskCardShell({ draggable={draggable} onDragStart={onDragStart} onDragEnd={onDragEnd} - onClick={onActivate} + onClick={(event) => onActivate?.(event.shiftKey)} onContextMenu={onContextMenu} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - onActivate?.(); + onActivate?.(event.shiftKey); } }} className={[ diff --git a/workbench/control_plane/src/lib/boardDrop.test.ts b/workbench/control_plane/src/lib/boardDrop.test.ts new file mode 100644 index 000000000..754ee2283 --- /dev/null +++ b/workbench/control_plane/src/lib/boardDrop.test.ts @@ -0,0 +1,61 @@ +/** + * The drop-gap arithmetic (WS-27ad). + * + * One property carries the whole file: dropping a card into the gap the user + * aimed at must land it THERE, including the downward intra-group drag where + * removing the card first shifts every later slot down by one. + */ + +import { describe, expect, it } from "vitest"; + +import { dropIndexFor, gapKey } from "./boardDrop"; + +const rows = ["a", "b", "c", "d"].map((id) => ({ id })); + +describe("gapKey", () => { + it("is a stable string so a highlight compares by value", () => { + expect(gapKey("todo", 2)).toBe("todo:2"); + expect(gapKey("todo", 2)).toBe(gapKey("todo", 2)); + }); +}); + +describe("dropIndexFor", () => { + it("shifts a DOWNWARD intra-group drop back by one", () => { + // "a" is at 0; the gap under "c" is index 3 on screen. Without "a" the + // neighbour list is [b, c, d] and the slot after "c" is 2. + expect(dropIndexFor(rows, "a", 3)).toBe(2); + }); + + it("leaves an UPWARD intra-group drop alone", () => { + // "d" is at 3; the gap above "b" is index 1, and removing "d" changes + // nothing before it. + expect(dropIndexFor(rows, "d", 1)).toBe(1); + }); + + it("leaves a cross-group drop alone — nothing was removed", () => { + expect(dropIndexFor(rows, "elsewhere", 2)).toBe(2); + }); + + it("keeps a drop at the very top at the top", () => { + expect(dropIndexFor(rows, "c", 0)).toBe(0); + }); + + it("appends when the gap is the trailing one", () => { + // The trailing gap of a 4-row group is index 4; without the moved card the + // neighbour list is 3 long, so 3 IS the append slot. + expect(dropIndexFor(rows, "a", 4)).toBe(3); + expect(dropIndexFor(rows, "elsewhere", 4)).toBe(4); + }); + + it("clamps rather than reading off the end", () => { + // An unclamped index reads `undefined` as its right-hand neighbour, which + // both apps' rank maths quietly treat as "append" — exactly the + // append-on-drop behaviour this replaces. + expect(dropIndexFor(rows, "a", 99)).toBe(3); + expect(dropIndexFor(rows, "a", -5)).toBe(0); + }); + + it("handles an empty destination group", () => { + expect(dropIndexFor([], "a", 0)).toBe(0); + }); +}); diff --git a/workbench/control_plane/src/lib/boardDrop.ts b/workbench/control_plane/src/lib/boardDrop.ts new file mode 100644 index 000000000..3163d6254 --- /dev/null +++ b/workbench/control_plane/src/lib/boardDrop.ts @@ -0,0 +1,59 @@ +/** + * Where a dragged card lands — shared by /projects and /tasks (WS-27ad). + * + * The two boards disagreed about what a drop MEANS. /tasks drew a thin target + * between every pair of cards and dropped the card exactly there; /projects + * accepted the drop anywhere on the column and appended to the bottom + * (`planDrop(cell, id, cell.length)`), even though `planDrop` had taken an + * index all along. Dragging a card two rows up and watching it fall to the + * bottom of the column is the gesture failing, not a preference. + * + * So the drop-gap wins, and this is the arithmetic behind it. It is here rather + * than in either app because the off-by-one below is the part that is easy to + * get wrong and impossible to see in review — /tasks had it right inside + * `taskStore.reorderItem`, /projects would have grown its own copy, and the two + * would have drifted the moment one of them was fixed. + * + * Each app keeps its own RANK maths: /tasks writes a fractional `sortKey` + * (`lib/ordering.rankForDrop`), /projects writes per-view float positions + * (`lib/board.planDrop`). Only "which slot did they mean" is one answer. + */ + +/** + * The drop target's identity, as `":"`. + * + * A string rather than an object because it is held in `useState` and compared + * on every dragover of every gap; two objects that describe the same slot are + * not `===`, which is how a highlight ends up flickering or sticking. + */ +export function gapKey(group: string, index: number): string { + return `${group}:${index}`; +} + +/** + * A gap index, translated into an index in the destination list WITHOUT the + * card being moved. + * + * The gap the user aimed at is measured against the list they can see, which + * still contains the dragged card. Remove it and every slot after it shifts + * down by one — so dropping a card into the gap below its own current position + * lands it one slot past where the line was drawn, every time, and only when + * dragging downward within a group. That asymmetry is why the bug survives + * casual testing. + * + * `movingId` not being in `ordered` is the normal cross-group case: nothing + * shifts, and the gap index is already right. + */ +export function dropIndexFor( + ordered: readonly { id: string }[], + movingId: string, + gapIndex: number +): number { + const from = ordered.findIndex((row) => row.id === movingId); + const index = from !== -1 && from < gapIndex ? gapIndex - 1 : gapIndex; + // Clamped against the neighbour set, which is what both apps' rank maths + // interpolate over. An unclamped index reads `undefined` off the end and + // silently becomes "append" — the very behaviour this replaces. + const others = from === -1 ? ordered.length : ordered.length - 1; + return Math.max(0, Math.min(index, others)); +} diff --git a/workbench/control_plane/src/lib/cursor.ts b/workbench/control_plane/src/lib/cursor.ts index de384d283..f7b581cd5 100644 --- a/workbench/control_plane/src/lib/cursor.ts +++ b/workbench/control_plane/src/lib/cursor.ts @@ -17,10 +17,19 @@ * extend: sweeping over rows adds them, and un-selecting is a click, exactly * as it already was. A second removal grammar here would make the keyboard * and the mouse disagree about what shift means. A surface with no - * shift-selection model (the /tasks list today) simply never passes - * `shift=true`, and the sweep branch stays dormant. + * shift-selection model simply never passes `shift=true`, and the sweep branch + * stays dormant. + * + * WS-27ad: the sweep's index walk was a private copy of + * `app/projects/lib/selection.range`, carried here because a shared lib must + * not import app code. It now reads `./selection`, which is that same walk + * promoted beside this file — the mouse's shift-click and the keyboard's + * shift-sweep are one gesture expressed twice, and two copies of it were + * already the beginning of two selection models. */ +import { range } from "./selection"; + export interface CursorState { /** Index into the visible rows. `-1` = no active row. */ cursor: number; @@ -39,27 +48,6 @@ export interface CursorNext extends CursorState { open: string | null; } -/** - * The inclusive id range between two rows of `visible`, in render order. - * - * Mirrors `app/projects/lib/selection.range` (WS-27n), which stays where it is - * because that module is the Projects selection model's home and this shared - * module must not import app code. Same contract: either end missing from the - * visible set means the anchor scrolled out from under the sweep, and - * selecting just the target is the honest fallback. - */ -function sweepRange( - visible: readonly string[], - anchor: string, - target: string -): string[] { - const from = visible.indexOf(anchor); - const to = visible.indexOf(target); - if (from === -1 || to === -1) return [target]; - const [lo, hi] = from <= to ? [from, to] : [to, from]; - return visible.slice(lo, hi + 1); -} - /** * One keystroke. Returns `null` for keys the cursor does not own, so callers * can `preventDefault` exactly when the key was consumed and never eat a @@ -102,7 +90,7 @@ export function stepCursor( // cursor, and everything in between joins the selection. const anchor = state.anchor ?? (cursor >= 0 ? cursor : next); const selection = new Set(state.selection); - for (const id of sweepRange(rows, rows[anchor], rows[next])) selection.add(id); + for (const id of range(rows, rows[anchor], rows[next])) selection.add(id); return { cursor: next, anchor, selection, open: null }; } diff --git a/workbench/control_plane/src/lib/selection.test.ts b/workbench/control_plane/src/lib/selection.test.ts new file mode 100644 index 000000000..29de4e18f --- /dev/null +++ b/workbench/control_plane/src/lib/selection.test.ts @@ -0,0 +1,108 @@ +/** + * The shared selection grammar (WS-27ad). + * + * The cases a selection UI always gets wrong: a shift-click with no anchor, an + * anchor a filter has since removed, a sweep that must widen rather than + * restart, and a selection that outlived its rows. + */ + +import { describe, expect, it } from "vitest"; + +import { + NO_SELECTION, + allSelected, + clickSelect, + prune, + range, + toggle, +} from "./selection"; + +const visible = ["a", "b", "c", "d"]; + +describe("toggle", () => { + it("adds what is missing and removes what is there", () => { + expect([...toggle(new Set(), "a")]).toEqual(["a"]); + expect([...toggle(new Set(["a"]), "a")]).toEqual([]); + }); +}); + +describe("range", () => { + it("returns the inclusive span in render order, either direction", () => { + expect(range(visible, "b", "d")).toEqual(["b", "c", "d"]); + expect(range(visible, "d", "b")).toEqual(["b", "c", "d"]); + }); + + it("returns just the target when an end is off-screen", () => { + // The anchor scrolled out from under the sweep — selecting only what was + // actually clicked is the honest answer. + expect(range(visible, "zz", "c")).toEqual(["c"]); + expect(range(visible, "b", "zz")).toEqual(["zz"]); + }); +}); + +describe("clickSelect — the one grammar both apps speak", () => { + it("a plain click toggles and becomes the anchor", () => { + const first = clickSelect(NO_SELECTION, visible, "b", false); + expect([...first.selected]).toEqual(["b"]); + expect(first.anchor).toBe("b"); + + const second = clickSelect(first, visible, "b", false); + expect([...second.selected]).toEqual([]); + expect(second.anchor).toBe("b"); + }); + + it("a shift-click adds the range and leaves the anchor put", () => { + const first = clickSelect(NO_SELECTION, visible, "b", false); + const swept = clickSelect(first, visible, "d", true); + expect([...swept.selected].sort()).toEqual(["b", "c", "d"]); + expect(swept.anchor).toBe("b"); + }); + + it("widens from the SAME anchor on a second shift-click", () => { + // Moving the anchor to the previous target is the classic bug: the second + // click would then measure from "d" and lose "b"/"c". + const first = clickSelect(NO_SELECTION, visible, "d", false); + const back = clickSelect(first, visible, "b", true); + const wider = clickSelect(back, visible, "a", true); + expect([...wider.selected].sort()).toEqual(["a", "b", "c", "d"]); + expect(wider.anchor).toBe("d"); + }); + + it("never removes on shift — un-selecting stays a plain click", () => { + const seeded = { selected: new Set(["a", "b", "c"]), anchor: "a" }; + const swept = clickSelect(seeded, visible, "c", true); + expect([...swept.selected].sort()).toEqual(["a", "b", "c"]); + }); + + it("falls back to a toggle when shift has no anchor to measure from", () => { + const next = clickSelect(NO_SELECTION, visible, "c", true); + expect([...next.selected]).toEqual(["c"]); + expect(next.anchor).toBe("c"); + }); + + it("selects only the target when the anchor has been filtered away", () => { + const stale = { selected: new Set(), anchor: "gone" }; + const next = clickSelect(stale, visible, "c", true); + expect([...next.selected]).toEqual(["c"]); + // The anchor survives the click that could not use it, so the next + // shift-click is not silently a toggle too. + expect(next.anchor).toBe("gone"); + }); +}); + +describe("prune", () => { + it("drops ids that left the page", () => { + expect([...prune(new Set(["a", "zz"]), visible)]).toEqual(["a"]); + }); +}); + +describe("allSelected", () => { + it("is true only when every visible row is picked", () => { + expect(allSelected(new Set(visible), visible)).toBe(true); + expect(allSelected(new Set(["a"]), visible)).toBe(false); + }); + + it("is false on an empty page — nothing is not everything", () => { + expect(allSelected(new Set(), [])).toBe(false); + }); +}); diff --git a/workbench/control_plane/src/lib/selection.ts b/workbench/control_plane/src/lib/selection.ts new file mode 100644 index 000000000..24039c3a1 --- /dev/null +++ b/workbench/control_plane/src/lib/selection.ts @@ -0,0 +1,119 @@ +/** + * The selection grammar — shared by /projects and /tasks (WS-27ad). + * + * Round 1 of the continuity backport promoted the chips, the keyboard cursor, + * the group-context quick-add and the post-drop flash. It left the biggest + * divergence alone and wrote it down: Projects selected with a shift-range + * anchor, /tasks with a bare toggle set. Two apps, two answers to "what does + * Shift do", used by the same person in the same hour. + * + * This module is the one answer. It sits beside `lib/cursor.ts` deliberately — + * the cursor's shift-sweep and the mouse's shift-click are the SAME gesture + * expressed twice, and `stepCursor` reads `range` from here rather than + * carrying the copy it used to (the copy was already drifting from + * `app/projects/lib/selection.range`, which is exactly how two selection + * models get born). + * + * Everything is a pure transition over `(state, visible, id, shift)`, so the + * awkward cases are assertions rather than manual clicking: an anchor a filter + * has since removed, a shift-click with no anchor at all, a selection that + * outlived the rows it was made on. + * + * The grammar, stated once: + * • a plain click TOGGLES the row and becomes the new anchor; + * • a shift-click ADDS the inclusive range anchor→target, anchor unmoved; + * • un-selecting is a plain click. Shift never removes. + * Additive, because that is what shift-click already did in Projects — and a + * keyboard that removed while a mouse added would make Shift mean two things. + */ + +/** A selection and the row a shift-click measures its range from. */ +export interface SelectionState { + selected: ReadonlySet; + /** The last row picked WITHOUT shift, or null before the first pick. */ + anchor: string | null; +} + +export const NO_SELECTION: SelectionState = { + selected: new Set(), + anchor: null, +}; + +/** Toggle one id. */ +export function toggle(selected: ReadonlySet, id: string): Set { + const next = new Set(selected); + if (!next.delete(id)) next.add(id); + return next; +} + +/** + * The ids between the anchor and the target, in the order the surface is + * currently drawing them. + * + * Order comes from the caller's *visible* list rather than from the selection, + * because "between these two" means between them **on screen** — after a + * filter and a grouping have decided what is on screen at all. + * + * Either end missing means the anchor scrolled out of the filtered set; + * selecting just the target is the honest fallback, and it is what leaves the + * next shift-click sensible. + */ +export function range( + visible: readonly string[], + anchor: string, + target: string +): string[] { + const from = visible.indexOf(anchor); + const to = visible.indexOf(target); + if (from === -1 || to === -1) return [target]; + const [lo, hi] = from <= to ? [from, to] : [to, from]; + return visible.slice(lo, hi + 1); +} + +/** + * One click. The whole grammar in one transition, so a surface adopting + * selection cannot accidentally adopt three quarters of it. + * + * `shift` without an anchor falls back to a plain toggle rather than doing + * nothing: the first click of a session is genuinely ambiguous, and a + * shift-click that silently does nothing reads as a broken checkbox. + */ +export function clickSelect( + state: SelectionState, + visible: readonly string[], + id: string, + shift: boolean +): SelectionState { + if (shift && state.anchor) { + const next = new Set(state.selected); + for (const each of range(visible, state.anchor, id)) next.add(each); + // The anchor STAYS: a sweep is usually several shift-clicks widening the + // same range, and moving it would make the second click measure from the + // first click's target. + return { selected: next, anchor: state.anchor }; + } + return { selected: toggle(state.selected, id), anchor: id }; +} + +/** + * Drop ids that are no longer on the page. + * + * A selection that outlives its filter is how a bulk edit hits rows nobody can + * see any more: somebody selects forty, narrows the filter to three, then + * presses "Archive" believing they are acting on the three in front of them. + */ +export function prune( + selected: ReadonlySet, + visible: readonly string[] +): Set { + const onScreen = new Set(visible); + return new Set([...selected].filter((id) => onScreen.has(id))); +} + +/** Whether every visible row is selected — drives the "select all" checkbox. */ +export function allSelected( + selected: ReadonlySet, + visible: readonly string[] +): boolean { + return visible.length > 0 && visible.every((id) => selected.has(id)); +} diff --git a/workbench/control_plane/src/lib/sharedTaskUi.test.ts b/workbench/control_plane/src/lib/sharedTaskUi.test.ts index 78c300fec..fb31bd30a 100644 --- a/workbench/control_plane/src/lib/sharedTaskUi.test.ts +++ b/workbench/control_plane/src/lib/sharedTaskUi.test.ts @@ -67,6 +67,21 @@ const SEAM: { what: string; home: string; declaration: RegExp }[] = [ home: "components/useFlash.ts", declaration: /export\s+function\s+useFlash\b/, }, + { + what: "the selection grammar", + home: "lib/selection.ts", + declaration: /export\s+function\s+(clickSelect|range|toggle|prune)\b/, + }, + { + what: "the drop-gap target", + home: "components/DropGap.tsx", + declaration: /export\s+function\s+DropGap\b/, + }, + { + what: "the drop-index arithmetic", + home: "lib/boardDrop.ts", + declaration: /export\s+function\s+(dropIndexFor|gapKey)\b/, + }, { what: "the chip vocabulary", home: "lib/taskCard.ts", @@ -129,17 +144,23 @@ describe("both apps reach the shared modules", () => { it.each([ ["projects", "lib/cursor"], + ["projects", "lib/selection"], ["projects", "components/QuickAdd"], ["projects", "components/useFlash"], ["projects", "lib/statusAccent"], ["projects", "components/StatusChip"], ["projects", "components/TaskCardShell"], + ["projects", "components/DropGap"], + ["projects", "lib/boardDrop"], ["tasks", "lib/cursor"], + ["tasks", "lib/selection"], ["tasks", "components/QuickAdd"], ["tasks", "components/useFlash"], ["tasks", "lib/statusAccent"], ["tasks", "components/StatusChip"], ["tasks", "components/TaskCardShell"], + ["tasks", "components/DropGap"], + ["tasks", "lib/boardDrop"], ])("/%s consumes @/%s", (app, module) => { expect( reaches(app, module), From ce795ca6973cc736e93244ebbd06e7466bdf0e9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 09:38:23 +0000 Subject: [PATCH 05/11] fix(WS-27ad): make the category hues agree with the name keywords The shared vocabulary landed, but two of the four default lanes still drew different colours in /projects and /tasks -- which is the divergence the module was built to end, reintroduced one layer down. CATEGORY_HUES had been written to match the seeded colours (To do blue, In progress amber) while the ported keyword rules said gray and blue. Since a stored colour outranks the category, every uncustomised project kept overriding the shared vocabulary: lane /projects /tasks Backlog gray gray ok To do blue gray mismatch In progress amber blue mismatch Done green green ok The semantics belong to the token vocabulary, not to whatever four rows a migration inserts, so the category map now follows the keywords and the seed follows the category map. bg-primary is this UI's "active" tone everywhere else, so in_progress is blue; nothing has started in backlog or todo, so both stay muted -- which is /tasks' existing behaviour, and matching it is the ask. Fences, both named in the code they guard: - test_category_and_keyword_agree (statusAccent.test.ts) -- every category and a name a person would plausibly type for it resolve to one hue. - test_seed_status_colours_match_the_shared_vocabulary (test_projects_routes) -- reads CATEGORY_HUES out of the TypeScript rather than mirroring it, because a mirror is the thing that goes stale and then lies. - test_every_seeded_category_is_one_the_shared_vocabulary_colours -- the companion direction; an uncoloured category renders grey, which is the pre-WS-27ad failure wearing a different hat. Known and deliberate: backlog and todo now share a hue, so a board carrying both draws two muted lanes. That is what /tasks does today. An owner who wants them distinct has a working lever for the first time -- status.color outranks this map and is finally rendered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../gateway/gateway/routes/projects/tree.py | 17 ++++- tests/unit/test_projects_routes.py | 67 +++++++++++++++++++ .../src/lib/statusAccent.test.ts | 31 +++++++-- .../control_plane/src/lib/statusAccent.ts | 37 +++++++--- 4 files changed, 138 insertions(+), 14 deletions(-) diff --git a/apps/services/gateway/gateway/routes/projects/tree.py b/apps/services/gateway/gateway/routes/projects/tree.py index 1b11868f8..43c9cb628 100644 --- a/apps/services/gateway/gateway/routes/projects/tree.py +++ b/apps/services/gateway/gateway/routes/projects/tree.py @@ -91,10 +91,23 @@ def _refuse_lifecycle_on_child(values: dict, parent_project_id: object) -> None: #: Seeded on every ROOT project. The owner reshapes these in the app; they exist #: so a new project has a working board on its first render rather than an empty #: status picker. +#: +#: ⚠️ **The colours here must equal what `CATEGORY_HUES` derives from the +#: category** (`workbench/control_plane/src/lib/statusAccent.ts`), because a +#: stored colour OUTRANKS the category — that is what lets an owner choose. So a +#: seed that disagrees is a seed that silently overrides the shared vocabulary on +#: every project nobody has customised, and /projects goes back to looking +#: different from /tasks. It did: this tuple used to seed `To do` blue and +#: `In progress` amber against a category map of gray and blue, and two of the +#: four default lanes rendered differently in the two apps. +#: +#: These are defaults, not decisions. If the shared vocabulary changes, change +#: them here too; `test_seed_status_colours_match_the_shared_vocabulary` fails +#: until you do. _SEED_STATUSES: tuple[tuple[str, str, int, str, bool], ...] = ( ("Backlog", "gray", 10, "backlog", True), - ("To do", "blue", 20, "todo", False), - ("In progress", "amber", 30, "in_progress", False), + ("To do", "gray", 20, "todo", False), + ("In progress", "blue", 30, "in_progress", False), ("Done", "green", 40, "done", False), ) diff --git a/tests/unit/test_projects_routes.py b/tests/unit/test_projects_routes.py index 00dbc086f..c4edcea12 100644 --- a/tests/unit/test_projects_routes.py +++ b/tests/unit/test_projects_routes.py @@ -951,3 +951,70 @@ async def test_the_actor_comes_from_the_session_not_the_body( assert db.activities("comment")[0]["created_by"] == "owner@fracktal.in" assert "created_by" not in pm_activities.CommentIn.model_fields assert "created_by" not in pm_tasks.TaskIn.model_fields + + +# ── The seed colours and the shared UI vocabulary ──────────────────────────── +# +# `_SEED_STATUSES` writes a `color` on every new root project, and in the UI a +# stored colour OUTRANKS the category (that is what lets an owner choose one). +# So a seed that disagrees with the shared category→hue map silently overrides +# the shared vocabulary on every project nobody has customised — and /projects +# goes back to looking different from /tasks, which is the whole thing WS-27ad +# set out to fix. +# +# It is not hypothetical: the seed shipped `To do` blue and `In progress` amber +# against a category map of gray and blue, and two of the four default lanes +# rendered differently in the two apps. +# +# This reads the TypeScript rather than mirroring it, because a mirror is +# exactly the thing that goes stale and then lies. + + +def test_seed_status_colours_match_the_shared_vocabulary() -> None: + from pathlib import Path + + source = ( + Path(__file__).resolve().parents[2] + / "workbench" / "control_plane" / "src" / "lib" / "statusAccent.ts" + ).read_text(encoding="utf-8") # Windows defaults to cp1252 and crashes. + + block = re.search( + r"const CATEGORY_HUES: Record = \{(.*?)\}", source, re.S, + ) + assert block, "CATEGORY_HUES not found — did statusAccent.ts move or rename it?" + hues = dict(re.findall(r"(\w+):\s*\"(\w+)\"", block.group(1))) + assert hues, "CATEGORY_HUES parsed empty" + + mismatched = { + name: (colour, hues[category]) + for name, colour, _position, category, _default in pm_tree._SEED_STATUSES + if category in hues and colour != hues[category] + } + assert not mismatched, ( + "seeded colours disagree with CATEGORY_HUES in statusAccent.ts " + f"(lane: seeded vs derived): {mismatched}. A stored colour outranks the " + "category, so these seeds would override the shared vocabulary." + ) + + +def test_every_seeded_category_is_one_the_shared_vocabulary_colours() -> None: + """The companion direction: a seeded category the UI cannot colour renders + grey, which is the pre-WS-27ad failure wearing a different hat.""" + from pathlib import Path + + source = ( + Path(__file__).resolve().parents[2] + / "workbench" / "control_plane" / "src" / "lib" / "statusAccent.ts" + ).read_text(encoding="utf-8") + block = re.search( + r"const CATEGORY_HUES: Record = \{(.*?)\}", source, re.S, + ) + assert block + hues = dict(re.findall(r"(\w+):\s*\"(\w+)\"", block.group(1))) + + uncoloured = [ + category + for _name, _colour, _position, category, _default in pm_tree._SEED_STATUSES + if category not in hues + ] + assert not uncoloured, f"seeded categories with no hue: {uncoloured}" diff --git a/workbench/control_plane/src/lib/statusAccent.test.ts b/workbench/control_plane/src/lib/statusAccent.test.ts index 5973bbf39..5bc3628a2 100644 --- a/workbench/control_plane/src/lib/statusAccent.test.ts +++ b/workbench/control_plane/src/lib/statusAccent.test.ts @@ -38,8 +38,9 @@ describe("precedence", () => { }); it("a category beats a name that disagrees with it", () => { - // A lane named "Shipped" whose category is `in_progress` is in progress. - expect(resolveHue({ category: "in_progress", name: "Shipped" })).toBe("amber"); + // A lane named "Shipped" whose category is `in_progress` is in progress — + // the name would have said green, and the category is the better fact. + expect(resolveHue({ category: "in_progress", name: "Shipped" })).toBe("blue"); }); it("a name keyword beats position", () => { @@ -84,8 +85,8 @@ describe("the six status categories all resolve", () => { // gateway can store and this cannot colour is a lane that silently goes grey. it.each([ ["backlog", "gray"], - ["todo", "blue"], - ["in_progress", "amber"], + ["todo", "gray"], + ["in_progress", "blue"], ["done", "green"], ["cancelled", "red"], ["triage", "violet"], @@ -96,6 +97,28 @@ describe("the six status categories all resolve", () => { it("falls through an unknown category", () => { expect(resolveHue({ category: "parked", name: "Done" })).toBe("green"); }); + + // THE fence for this module's whole reason to exist. + // + // /projects learns what a lane means from its `category`; /tasks can only + // read the words in its name. If those two routes disagreed, the same lane + // would draw one colour in one app and another colour next door — which is + // the divergence this module was built to end, reintroduced one layer down. + // + // It happened: the category map originally mirrored the seeded colours + // (`todo` blue, `in_progress` amber) while the keyword rules said gray and + // blue, so a default board still mismatched in two of its four lanes. + // + // Each pair below is a category and a lane name a person would plausibly type + // for it. `backlog`/`todo` share a hue on purpose (see CATEGORY_HUES). + it.each([ + ["backlog", "Backlog"], + ["todo", "To do"], + ["in_progress", "In progress"], + ["done", "Done"], + ])("category %s and the name %j resolve to the same hue", (category, name) => { + expect(resolveHue({ category })).toBe(resolveHue({ name })); + }); }); describe("name keywords — /tasks' stages, ported unchanged", () => { diff --git a/workbench/control_plane/src/lib/statusAccent.ts b/workbench/control_plane/src/lib/statusAccent.ts index ca396e6af..d904b2de2 100644 --- a/workbench/control_plane/src/lib/statusAccent.ts +++ b/workbench/control_plane/src/lib/statusAccent.ts @@ -152,17 +152,38 @@ const COLOR_ALIASES: Record = { /** * `pm_task_statuses.category` → hue. * - * Matches the seeded statuses' own colours (`routes/projects/tree.py` - * `_SEED_STATUSES`: Backlog gray, To do blue, In progress amber, Done green) so - * a project whose owner cleared a colour does not suddenly repaint. `triage` - * (WS-27u) is the parked-at-the-front-door lane and gets the one remaining - * distinct hue; `cancelled` is terminal-and-not-successful, which is what the - * destructive tone means everywhere else in this UI. + * **Agrees with `keywordHue` below, lane for lane — that agreement IS the + * feature.** A category and a name are two ways of learning the same thing, so + * if they disagreed, the same lane would render one colour in /projects (which + * has categories) and another in /tasks (which has only names), which is the + * exact divergence this module exists to end. `test_category_and_keyword_agree` + * is the fence. + * + * It was briefly the other way round: this map was written to match the seeded + * colours in `routes/projects/tree.py` (`To do` blue, `In progress` amber), and + * the result was that two of the four default lanes still read differently in + * the two apps. The seed moved to match this instead — the semantics belong to + * the token vocabulary, not to whatever four rows a migration happened to + * insert. `bg-primary` is this UI's "active" tone everywhere else, so + * `in_progress` is blue; nothing has started in `backlog` or `todo`, so both + * stay muted. + * + * `cancelled` is terminal-and-not-successful, which is what the destructive + * tone means everywhere else here. `triage` (WS-27u) is the + * parked-at-the-front-door lane; /tasks has no counterpart, so it takes the one + * remaining distinct hue freely. + * + * ⚠️ `backlog` and `todo` are both gray, so a board carrying both draws two + * muted lanes side by side. That is /tasks' existing behaviour (its keyword + * rule maps both words to grey) and matching it is the point of this change, + * not a regression introduced by it. An owner who wants them distinct now has a + * working lever for the first time: `status.color` outranks this map, and as of + * WS-27ad it is actually rendered. */ const CATEGORY_HUES: Record = { backlog: "gray", - todo: "blue", - in_progress: "amber", + todo: "gray", + in_progress: "blue", done: "green", cancelled: "red", triage: "violet", From 77da6553bde765aa607bddcdbd45109ac39bd54f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 09:39:30 +0000 Subject: [PATCH 06/11] docs: every app renders through the theming engine (owner directive) "I want the UI for the projects to match the theming configuration used in the Command Center... ensuring future development considers it." Recorded where future work actually reads it rather than in a new document nobody loads: workbench/control_plane/AGENTS.md (auto-loaded via its CLAUDE.md whenever UI code is touched) and one bullet in the root always-loaded briefing. Four rules added on top of the existing three, each one written because it was already broken somewhere in the tree: 4. One vocabulary per concept, shared -- statusAccent.ts is the single place a status, tag, column, group header or pill becomes a hue. Do not add a second palette; extend the shared one. 5. A category and a name must resolve to the same colour, or the same lane draws two colours in two apps. Seeded data counts as a UI decision, because a stored colour outranks a derived one. 6. House tokens, not synonyms: bg-primary/10 for active (not bg-accent), radius from --radius (rounded-xl ignores Graphite and Material). 7. Categorical hues belong to the theme too -- bg-sky-500/10 passes the conformance regex, so that one is on the author, not on CI. Also states plainly what CI does not cover: there is no structural or layout test in this tree, so cross-app continuity is measured by switching the theme to Fluent, Material and Graphite and looking -- at the surface changed AND its neighbour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- CLAUDE.md | 13 ++++++-- workbench/control_plane/AGENTS.md | 54 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c861e699c..2f2ed361f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,8 +89,17 @@ Rules that make it work: numbers). Specs go stale; the code is the fact. - **Respect the seams.** Extend the shared seam, never add a parallel one: one DB engine (`acb_common.db`), one entitlement intersect, one subject-grammar - validator, one task store, one Center registry (`lib/centers.ts`). A second - implementation of an existing seam is a defect, not a feature. + validator, one task store, one Center registry (`lib/centers.ts`), one status + colour vocabulary (`src/lib/statusAccent.ts`). A second implementation of an + existing seam is a defect, not a feature. +- **The UI is one product, themed centrally.** Every app is a projection, never + a surface with its own look: no app-local palette, no second colour + vocabulary, no hand-rolled control. `workbench/control_plane/DESIGN_SYSTEM.md` + is the contract and `AGENTS.md` beside it carries the seven rules and their + fences — both are auto-loaded when you touch UI code. Owner directive + 2026-08-10. The conformance suite checks four regexes and **nothing tests + layout or cross-app continuity**, so the theme-switch check (Fluent → + Material → Graphite, on your surface *and* its neighbour) is the real gate. - **Keep branches short and integrate often.** Long branches are the root cause behind the migration-renumber collisions, the green-alone/red-together PRs and a duplicated tenancy design. Three or four in flight is the ceiling. diff --git a/workbench/control_plane/AGENTS.md b/workbench/control_plane/AGENTS.md index d711bff86..f998d57c9 100644 --- a/workbench/control_plane/AGENTS.md +++ b/workbench/control_plane/AGENTS.md @@ -24,3 +24,57 @@ baselined file may not get worse, and a baselined file that got *better* fails until you lower its number — so the debt figures never quietly become fiction. `npx vitest run src/lib/theme/` before you push. + +## Every app renders through the theming engine — there are no app-local looks + +*(Owner directive, 2026-08-10: "I want the UI for the projects to match the +theming configuration used in the Command Center… ensuring future development +considers it." It applies to every surface, not only Projects.)* + +An app inside CommandCenter is a **projection of one product**, not a product +with its own visual identity. `/projects`, `/tasks`, `/email`, `/notes`, `/crm` +and everything after them draw from the same engine, so switching the org to +Fluent or Material or Graphite repaints all of them together. The moment one +app carries its own palette, that app is the one that looks broken on the day +somebody changes the theme — and nobody notices until then, because a hardcoded +value renders *fine*. + +Four rules on top of the three above. Each one exists because it was broken: + +4. **One vocabulary per concept, in `src/lib/` or `src/components/`, consumed by + every app.** Status and lane colour is `src/lib/statusAccent.ts` — the single + place a status, tag, board column, group header or pill becomes a hue. Before + it there were three vocabularies plus a colour column + (`pm_task_statuses.color`) that was stored and drawn nowhere, so every + Projects board column rendered the same grey while the Tasks board next door + was colour-coded. **Do not add a second palette.** If you need a hue a shared + module does not express, extend the shared module. +5. **A category and a name must resolve to the same colour.** Some apps know + what a lane *means* (Projects has `STATUS_CATEGORIES`); some can only read + what it is *called* (Tasks' stages are user-typed). Those two routes must + agree, or the same lane draws two colours in two apps. Fences: + `test_category_and_keyword_agree` and, on the gateway side, + `test_seed_status_colours_match_the_shared_vocabulary` — which reads + `CATEGORY_HUES` out of the TypeScript rather than mirroring it, because a + mirror goes stale and then lies. **Seeded data counts as a UI decision**: a + stored colour outranks a derived one, so a seed that disagrees silently + overrides the shared vocabulary on every uncustomised project. +6. **Use the house tokens, not a synonym.** Active/selected is + `bg-primary/10 text-primary` (the measured norm across `/tasks`, `/email` and + `src/components`), not `bg-accent`. Radius comes from `--radius` via + `rounded-sm/md/lg` — `rounded-xl` is a fixed 12px that ignores Graphite's + `0.125rem` and Material's `1rem`. +7. **Categorical hues are a theme decision too.** A set of colours that only + has to be *mutually distinguishable* (contexts, tags, labels) still belongs to + the theme: use the categorical ramp, never a raw Tailwind palette class. + `bg-sky-500/10` passes the conformance regex — it is a named class, not a + bracket class — so this one is on you, not on CI. + +**What CI cannot catch, and you must.** There is no structural or layout test in +this tree: nothing asserts panel counts, shell adoption, mobile branches, or that +two apps draw a card the same way. The conformance suite checks four regexes. +So the real check is `DESIGN_SYSTEM.md` §8: **switch the theme to Fluent, then +Material, then Graphite, and look at the surface you changed** — and at the +neighbouring app, because continuity between two apps is exactly what no test in +this repo measures. That check is what would have caught every divergence listed +above before it landed. From 458eb1efe1e4ab3bc05f7e62f06190376888414d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 10:05:33 +0000 Subject: [PATCH 07/11] =?UTF-8?q?feat(projects):=20WS-27ag=20=E2=80=94=20/?= =?UTF-8?q?projects=20joins=20the=20house=20shell,=20and=20gets=20a=20mobi?= =?UTF-8?q?le=20UI=20at=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/projects` was the only cross-cutting app in the tree with no mobile layout: page.tsx imported neither useViewMode nor useMobileDrawer, so a phone got a fixed 256px nav beside a five-mode canvas — and a third column the moment a task opened — inside AppShell's pb-nav scroller. AppShell enumerated six `isPage` branches and none of them was Projects. - Mobile branch: one pane. ProjectTree + My work become a drawer sheet, the five view modes a second sheet, and an opened TaskPanel a full-screen surface (its `max-w-md` is a docked-column width, lifted by the shell). - AppShell: additive `isProjectsPage` const and a Projects · Views · Search tab set beside the existing ones — exactly what the desktop layout owns and a phone cannot reach (a 240px rail, a toolbar of five modes, a ⌘K palette with no keyboard). Notifications stay in the page's title row: the bell is self-anchored and has no external open control. - Desktop: the house shell — a slim h-10 bar (rail toggle · divider · title · app actions) over a collapsible rail at Tasks' w-60; the six-purpose header splits into a title row and an action row. - Three bg-accent sites → bg-primary/10 text-primary (My work, mode switch, selected tree node). FilterBar/TaskList/MyWork/SearchPalette are a later slice's files and keep theirs. - "Loading projects…" de-duplicated into LOADING_COPY behind one renderState() seam that also carries the empty and error surfaces; the failure strip no longer wears bg-muted, the token for quiet. Two seams left marked `── SEAM (WS-27ag) ──`: the shared mount point inside `overlays`, and renderState() as the single call site replaces. Neither is built here. Two rules learned and written into the code: the shell drawer holds a SNAPSHOT (re-inject on every change to what the sheet draws; keep every callback a useState setter or the re-injection loops through the drawer's context), and dismissing the drawer from the outside must clear the page's sheet state or the next data change reopens it. Both recorded in workbench/AGENTS.md. Advisory, not fenced: this tree has no structural or layout test at all — nothing checks that an app has a mobile branch or that a cc-mobile-nav detail string agrees at both ends. tsc --noEmit, 1278 vitest cases, the theme suite and a production next build are green; no browser was runnable here, so the phone-viewport and four-theme pass is owed at review (spec §11.20). R4: project_management_app.md status header + §11.20. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- project-docs/specs/project_management_app.md | 72 +- workbench/AGENTS.md | 11 + .../app/projects/components/ProjectTree.tsx | 7 +- .../control_plane/src/app/projects/page.tsx | 1088 +++++++++++------ .../control_plane/src/components/AppShell.tsx | 26 + 5 files changed, 840 insertions(+), 364 deletions(-) diff --git a/project-docs/specs/project_management_app.md b/project-docs/specs/project_management_app.md index 57064396a..76e03b9c5 100644 --- a/project-docs/specs/project_management_app.md +++ b/project-docs/specs/project_management_app.md @@ -2,7 +2,8 @@ > **Product:** CommandCenter · **Feature:** Projects (the People Center's primary work-management > module, sliced into every other Center) · **Created:** 2026-08-05 · **Updated: 2026-08-10** -> (status truth pass + tenancy alignment — R4) · +> (status truth pass + tenancy alignment — R4; **WS-27ag shell/mobile slice built the same +> day**) · > **Status:** ✅ **WS-27 a–t MERGED AND DEPLOYED** (a b d e f i j k l m n via #390/#393/#394/#398; > o–t via **#399**; **u–z via #408**, 2026-08-10) — migrations **146, 147, 150, 152, 155, 156, > 160, 161, 164, 165, 166 are applied on prod** (164/165/166 log-verified on the 2026-08-10 @@ -11,6 +12,13 @@ > 🟡 **c** two-way sync (waits on WS-1 BO-1a+BO-1b) · 🔴 **g** cutover/retirement · 🟡 **h** > `gtd_items` retirement (data move 🔴) · 🟢 **u–z shipped**, their owner activation steps in > HANDOVER §1 · +> 🟢 **ag BUILT 2026-08-10, on branch, NOT merged and NOT deployed** (§11.20) — the app joins +> the house shell and gets a mobile layout at all: `AppShell` learns `isProjectsPage` +> (Projects · Views · Search), the tree and the mode picker become drawer sheets, an opened +> task is full-screen on a phone, the desktop rail collapses at Tasks' `w-60`, and the +> six-purpose header splits into a title row and an action row. Frontend only — no migration, +> no API change. ⚠️ **The phone-viewport and four-theme visual pass is still owed**: no +> browser was runnable in the build environment (§11.20's closing note). · > **Owner:** vjvarada · **Board row: WS-27** > > **Tenancy (audited 2026-08-10 — this spec previously cited no tenancy decision at all).** @@ -2297,6 +2305,68 @@ the research doc's license wall is binding on every ticket below. snapshot-on-close, carry-forward — research doc §3.7) so the eventual build starts from a settled shape rather than a blank page. +### 11.20 WS-27ag — the house shell, and a mobile UI at all (built 2026-08-10) + +**The measured problem.** `/projects` shipped twenty-plus letters of function with **no +mobile layout of any kind**. `page.tsx` imported neither `useViewMode` nor +`useMobileDrawer` — the only cross-cutting app in the tree that did not — so a phone got +the desktop tree: a fixed 256px `