Skip to content

WS-27 S1–S4: Tasks becomes a real slice of Projects — one card, one detail surface, one selection grammar; plus the one-store decision and the fences that found three latent defects - #419

Merged
vjvarada merged 10 commits into
mainfrom
claude/paca-research-task-management-a1f6zd
Aug 10, 2026
Merged

WS-27 S1–S4: Tasks becomes a real slice of Projects — one card, one detail surface, one selection grammar; plus the one-store decision and the fences that found three latent defects#419
vjvarada merged 10 commits into
mainfrom
claude/paca-research-task-management-a1f6zd

Conversation

@vjvarada

Copy link
Copy Markdown
Contributor

Driven by the owner's reframing:

"Technically, the Tasks app is only a slice of the Projects app… do a comprehensive review of the Tasks app to ensure there's at least parity in terms of the UI/UX."

Ruling applied throughout: Projects is canonical, Tasks conforms — except where Tasks is demonstrably better, which S4 handles in the other direction.

No migrations, no backend changes. Frontend, specs and tests only. (167 shipped in #418 and is already applied.)


The three divergences the owner named, from looking at the deployed app

Cards. Tasks' board adopts Projects' column shape, surface and gutter. The shared shell's completed and atCursor props are finally passed by their Tasks caller — done tasks were never dimmed or struck through, because the shell supported it and nobody wired it. The private AvatarStack copy is deleted for the shared one. Titles clamp in TaskCardShell so both apps move together rather than forking.

Detail surface. /tasks docks a w-[380px] pane instead of opening a pop-up. This was a bug against an existing rule, not a design difference: DESIGN_SYSTEM.md already specifies a side panel, and Tasks was the only overlay in the product (email uses a reading pane, notes a route, projects an aside). The modal survives as the phone branch and as an explicit maximise, because Tasks' detail is genuinely denser than Projects' and 380px cannot give it the max-w-3xl reading width. Two pieces of dead code — a side-panel entry imported nowhere and a maximise button that could never render — came back to life rather than being deleted.

Selection. The checkbox is permanent and sits in its own gutter beside the row. selectMode is now a derived mirror of "something is selected" and gates only the bulk bar. The bar moved to the top on Projects' chrome and primitives; select-all exists.

The previous slice had deliberately kept Tasks' modal select-mode, reasoning a permanent checkbox would steal the drag-grip gutter. That reason did not survive: Projects puts its checkbox outside the card as a sibling with stopPropagation, so there are two targets and the card surface stays the open affordance. Tasks put it inside, overlapping the grip — it created the collision and then documented it as a constraint. The comment now says what is true.

And the other direction (S4) — three findings where Projects carried the defect: the bg-accent active token (house norm is bg-primary/10 text-primary), a fourth bespoke task row in MyWork that bypassed the shared card vocabulary entirely, and no distinct filtered-empty state (Projects conflated no data with filtered to nothing and asked the user to guess).

The architecture decision

Owner question: one database with per-user separation, or two apps? One store, and the mechanism was already built and unused — pm_projects.personal_owner (a personal project, granted to one email) plus the per-member pm_task_personal overlay. "Local" is a visibility property, not a storage location.

The argument that settles it is already in migration 147's header: a single disposition column on a shared task cannot express delegation — one person doing it, another waiting on it — and two separate stores cannot either, because the task would exist twice and the copies disagree the moment either moves.

§7.5.1 is new and is the substance here. WS-27h said the move is "a copy rather than a translation." True of the seven overlay columns; false of the rest. gtd_items carries 31 columns plus gtd_waiting, and fifteen have no pm_tasks home — the founder priority matrix, all of timeboxing, deep_work, the email-capture badge, and the entire Waiting-For view. Executed on that sentence the migration would have been a feature deletion reporting success. Every column now has a named destination, decided by one question: if Ana and Ben are both on this task, can their answers legitimately differ? Two sequencing constraints recorded — the provider columns cannot drop before WS-27g, and horizon_id belongs to WS-21, which is DO-NOT-DISPATCH.

Three latent defects the fences found — none of which was the ticket

A rule I wrote in the previous PR was wrong. rounded-xl is not a fixed 12px: --radius-xl: var(--radius), identical to rounded-lg, and the whole named scale is derived. Acting on my claim would have baselined ~274 correctly-themed occurrences across ~70 files as debt — a fence against a non-violation, worse than no fence. Corrected in AGENTS.md with the correction visible, and DESIGN_SYSTEM.md spelled out so the misreading cannot recur.

Two source files contained literal NUL bytes. ripgrep stops at the first one — so every grep-based fence in this repo, and every grep a human or agent ran, silently saw only the first third of projects/TaskBoard.tsx, and stayed silent even when the file was named explicitly. The fences passed because they never looked. sourceHygiene.test.ts now fails on any NUL under src/; it found a second offender on its first run and a third in its own docstring, where the warning had pasted the very byte it warns about.

Local main was ~50 commits stale, so three agents cut branches backwards; one noticed only because half the WS-27 files vanished. Reset.

Also: sharedTaskUi's fence caught a genuine cross-slice gap — S4 built on the shared shell without declaring atCursor, which is indistinguishable from forgetting it. Now stated with its reason.

Verification

tsc clean · 1629 tests / 76 files (from 1586) · theme suite 356 · production next build compiles and prerenders all 39 pages · backend 189 passed on the touched suites. Every new guard was mutation-measured red and reverted byte-identical.

Owed, and not claimed

No visual check was performed, by any slice. Playwright cannot install in the build environment, so neither the phone-viewport pass nor the Fluent → Material → Graphite sweep happened. Each agent compensated with what it could inspect — class strings against the theme tokens, icon names against the registry, a production build — and each said plainly that it did not look at the running app.

This matters more than usual because there is still no structural or layout test anywhere in this tree. Nothing asserts panel composition, shell adoption, or that two apps draw a card the same way. The docked pane in particular is held by review alone.

Known, deliberate, and worth seeing before judging: WaitingForView still gates its checkbox on the old mode, so its first pick must come from select-all until it gets the same treatment. The shared card title is still text-[13px] against the house scale — the one contradiction left for the owner rather than settled by whichever slice touched the file last.


Generated by Claude Code

claude added 10 commits August 10, 2026 13:19
…s as a pop-up

Owner ruling: Projects is canonical, Tasks conforms. DESIGN_SYSTEM §6 has
specified the house layout as an optional w-[380px] desktop side panel (bottom
sheet on mobile) all along; /tasks was the only app opening a record as an
overlay.

Desktop list/board views now dock the detail as a third column — page.tsx
renders <aside className="w-[380px] …"><ItemDetail /></aside> beside ItemList,
driven by the store's selectedItemId, mirroring how projects/page.tsx docks
TaskPanel. Present only while something is selected, closes from its own ✕.

TaskFocusModal keeps two lives: the phone branch (store-driven, full-screen,
the same move Projects makes with fixed inset-0), and an explicit maximise from
the docked pane (controlled by itemId/onClose), which is the mitigation for the
max-w-3xl reading width the 380px column cannot give. Inbox, Engage, Calendar
and the Assistant carry no pane and keep raising the store-driven overlay.

Two dead exports came back rather than being deleted: ItemDetail() as the pane's
entry (restoring the INBOX → ClarifyPanel branch, now with the pane's ✕ so an
inbox selection on a task view is dismissable) and the "Open full page" button,
which now has an unfocused mount to live on.

openFocus stays the app-wide "open this task" verb every list component calls;
on the docked surface the page drops its focus half so a row selects into the
pane — dropped, not ignored, or a stale id would pop the overlay open on the
next view switch.

Also: sm:rounded-2xl → sm:rounded-lg on the modal sheet (§4 radius; a fixed 16px
corner ignores Graphite's 0.125rem and Material's 1rem). No other rounded-xl
site under src/app/tasks/ was swept.

R4: task_manager_app.md status header records the change; workbench/AGENTS.md
gains the tasks entry with the openFocus rule. R7, honestly: nothing in this
tree tests layout, panel counts or mobile branches, so the composition is
ADVISORY — held by review and the theme-switch pass, which could not be run
here (no browser; Playwright cannot install).

Verified: npx tsc --noEmit → 0 · npx vitest run → 73 files, 1586 tests, 0 · npx
vitest run src/lib/theme/ → 353 tests, 0 · eslint on the three files → 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Owner ruling, not re-litigated: "the Tasks app is only a slice of the
Projects app" — Projects is canonical, Tasks conforms where neither is
clearly better, and a GTD-only need diverges only with its reason next
to the code.

Six items, frontend only, no migration, no API change:

1. Column chrome — `rounded-xl` + `bg-secondary/30` becomes /projects'
   `rounded-lg border border-border bg-card`, refusal overlay
   `rounded-lg`, and the gutter moves from a per-card `mb-2` to the
   column's `space-y-1` (cards and drop gaps are siblings now, because
   `space-y-*` only reaches direct children).
2. Completed — TaskCardShell had `completed` since WS-27ad and nothing
   under app/tasks/ had ever passed it, so a done task was dimmed and
   struck through on one board and drawn as live work on the other,
   from one component, with every test green.
3. Cursor ring — the board's wrapper div re-drawing `ring-2 ring-ring`
   is gone; the card takes `atCursor` and the shell draws the ring on
   the card's own radius. `useFlash`'s attach moved onto the card too.
4. Avatars — TaskCard's private Avatar/AvatarStack deleted for
   @/components/TaskMeta's shared pair.
5. Checkbox — out of the card and into a left-gutter sibling
   (/projects' `flex items-start gap-1.5` + stopPropagation), always
   present, never mode-gated: `selectMode` no longer changes what a
   click on a card means, and TaskCard has no `selectMode` prop. The
   drag grip is DROPPED, not relocated — the whole card is `draggable`,
   so it was never a handle, /projects draws none, and the shell's
   hover lift is the affordance both boards already use.
6. Title — clamped to `line-clamp-2` in TaskCardShell so BOTH apps
   change together; a caller-supplied `truncate`/`line-clamp-*` is
   stripped rather than merged, because nowrap vs -webkit-box on one
   element resolves by CSS source order.

Fences (R7), all in src/lib/sharedTaskUi.test.ts and all mutation-
measured red: every TaskCardShell caller passes `completed` and
`atCursor` (brace-aware tag scan — a lazy regex stops at the first
arrow function and passes for the wrong reason); no board re-implements
the card cursor ring; both boards' columns share one radius.
AvatarStack and TaskCardTitle join the SEAM table, which gains an
`except` map with an argument per file plus a staleness check.

Correction to the audit's premise: `rounded-xl` is NOT unthemed in this
tree. globals.css derives the whole `--radius-*` scale from `--radius`
inside @theme and `--radius-xl` IS `var(--radius)`, so a tree-wide
ratchet would have baselined 274 correctly-themed occurrences. The
fence is written to the true rule — the two boards agree — and the
false claim in AGENTS.md rule 6 / DESIGN_SYSTEM §4 is reported, not
edited from here.

R4: project-docs/specs/project_management_app.md status header + §9.2,
including striking WS-27ad's "Tasks' modal select-mode is KEPT" on the
card side.

Not verified: how any of it looks. No browser is runnable here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Owner decision: ONE store. GTD tasks stay private via the personal-project
mechanism migration 147 already built (pm_projects.personal_owner + the
per-member pm_task_personal overlay); project tasks are ordinary rows in
ordinary projects. Not two databases -- "local" is a visibility property, not
a storage location.

The argument that settles it is already in 147's header: a single disposition
column on a shared task cannot express delegation (one person is doing it,
the other is waiting on it), and two separate stores cannot either, because
the task would exist twice and the copies disagree the moment either moves.

What this decision creates is the real work, and it was mis-specified. 7.5
said the move is "a copy rather than a translation". True of the seven overlay
columns pm_task_personal mirrors; false of the rest. gtd_items carries 31
columns plus gtd_waiting, and fifteen have no pm_tasks home. Executed on that
sentence, WS-27h would delete the founder priority matrix, all of timeboxing
and the entire Waiting-For view -- and report success, because a migration
that drops an unmapped column looks exactly like one with nothing to map.

7.5.1 now names the destination of every column, decided by one question:
whose fact is it? A task fact is true for everyone on the row (pm_tasks); a
member fact can legitimately differ between two people on the same task
(pm_task_personal). The test: if Ana and Ben are both on this task, can their
answers differ? The priority matrix, timeboxing and deep_work all fail that
test as shared columns, so they go per-member -- the same argument 147 makes
for disposition, applied consistently rather than only where first noticed.

Two sequencing constraints recorded, both load-bearing: the provider columns
cannot drop before WS-27g retires the arm that fills them (or SourceBadge
loses its data while the integration is live), and horizon_id belongs to
WS-21, which is DO-NOT-DISPATCH -- this ticket may not decide it either way.

R7: tests/unit/test_gtd_retirement_plan.py reads the migrations and the spec
and fails when a legacy column has no named destination. It earned its place
on the first run by catching one I had missed -- gtd_waiting.item_id -- which
is exactly the failure mode the section exists to prevent. It also pins the
blocked markers and the NEW markers, since losing either turns a schema change
back into "just a data move", and guards its own parser against matching
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
S1 measured it: globals.css's @theme derives sm/md/lg/xl/2xl/3xl from --radius,
and --radius-xl IS var(--radius), i.e. identical to rounded-lg. The rule I added
to AGENTS.md this session claimed rounded-xl was a fixed 12px ignoring Graphite
and Material. False. Acting on it would have baselined ~274 correctly-themed
occurrences across ~70 files as debt -- a fence against a non-violation, which
is worse than no fence.

DESIGN_SYSTEM 4 was not wrong, only incomplete: it listed sm/md/lg and forbade
rounded-[14px], which reads as "the others are unthemed". Now spelled out,
with the note that an agent acted on that reading.

What survives from the rule: consistency between surfaces. Two boards at
different radii look like two products even when both are themed -- which is
the real finding S1 fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Owner ruling 2026-08-10 — "Projects is canonical, Tasks conforms" — reversing
WS-27ad's kept modal select-mode. The reason it recorded ("a permanent checkbox
would take the drag-grip gutter or make one gesture mean two things") was never
structural: /projects put the box OUTSIDE the card as a sibling in the row,
/tasks put it INSIDE over the grip, so the collision was one /tasks built for
itself.

The list surfaces (ItemList, TaskListGrouped, FlatList) + the store's selection
slice. The board card and WaitingForView are sibling/unowned slices and still
draw their box behind `selectMode`.

- `selectMode` is no longer a mode: a derived mirror of `selectedIds.size > 0`,
  maintained in one helper, meaning only "the bulk bar is up". `setSelectMode`
  and the "Select" button are gone; the three list surfaces never read it.
- The checkbox is unconditional, in its own gutter beside the grip. The row's
  click still opens the task, and drag-reorder is no longer disabled while
  something is selected.
- Shift-sweep ungated on both lists (it needed select mode; /projects never did).
- The bulk bar moved to the TOP onto /projects' chrome (`border-b border-border
  bg-muted`) and onto Button/Badge — the hand-rolled outline buttons were
  AGENTS.md rule 3, and the conformance regexes only see solid fills.
- Select-all added (`selectAllVisible`) over the FILTERED set, plus
  `pruneSelection` so a selection cannot outlive the filter it was made in.
- Bulk power stays archive/restore/delete and says why in code: `/items/bulk` +
  `/items/bulk-archive` is the whole gateway surface and `gtd_items` has no
  tags column.

Fence (R7): app/tasks/lib/selectionParity.test.ts — store invariant, no
setSelectMode, no surface reading selectMode or drawing a conditional checkbox,
both apps' bar on one chrome string. Mutation-verified.

R4: project_management_app.md status header + the WS-27ad selection bullet
(struck) + an S3 as-built entry. DOX: workbench/AGENTS.md gains the /tasks entry.

No browser was run: the phone-viewport and four-theme pass is owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Three findings where Projects, not Tasks, carried the defect. The standing
ruling is "Projects is canonical, Tasks conforms"; these are the exceptions,
and convergence runs both ways when the evidence says so. Spec §11.21.

1. The active token. MyWork's selected context pill was
   `bg-accent text-accent-foreground`; the measured house token is
   `bg-primary/10 text-primary` (AGENTS.md rule 6). Two sites swapped, plus
   the `aria-pressed` the toggles never carried. The lasting deliverable is
   the fence: conformance gains a SIXTH rule matching the pair, ratcheted
   per file, with `lib/statusAccent.ts` excepted with its argument (its
   violet chip is a hue, not a state). FilterBar 2 / SearchPalette 1 /
   people/page 1 are baselined and can only go down.

2. The fourth task card is gone. MyWork's `Row` bypassed TaskCardShell,
   TaskMeta and StatusChip — and MyWork is the personal task list INSIDE
   Projects, so it sits opposite /tasks in the owner's comparison and looked
   like neither app. Rebuilt on the shared shell/title/chip row, with the
   GTD disposition as a StatusChip whose hue goes through `statusAccent`
   (`accentForDisposition`), pinned by a test to agree with the name-keyword
   route wherever that route has an opinion (rule 5). Capture field and
   triage buttons become Input/Button primitives.

3. Two empty states where there was one. "Nothing to show. Clear a filter,
   or this project has no statuses yet" named both causes and asked the
   reader to guess. Now: "No tasks match your filters." + Clear filters when
   `isFiltered(filters)` — the same predicate the toolbar reads, not a
   second one — and otherwise "This project has no statuses yet." on the
   status axis or "No tasks here yet.". The box is promoted to
   `src/components/EmptyState.tsx`; the decision and copy are pure and
   unit-tested in `projects/lib/emptyState.ts`.

Fences (R7): conformance rule 6 · sharedTaskUi seam entry + consumer row for
EmptyState · emptyState.test.ts (including "the filtered copy never mentions
statuses, the empty copy never mentions filters", and that every icon it
names is mapped in every pack — which caught a FilterX mapped in none) ·
accent.test.ts disposition agreement · `filters`/`onClearFilters` are
REQUIRED props, so tsc catches an unwired call site. All mutation-checked.

Frontend only; no migration, no API change. R4: spec status header + §11.21
updated in this change. NOT verified in a browser — Playwright cannot
install here, so the four-theme sweep on /projects and /tasks is owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…task-management-a1f6zd

# Conflicts:
#	project-docs/specs/project_management_app.md
#	workbench/AGENTS.md
…-management-a1f6zd

# Conflicts:
#	project-docs/specs/project_management_app.md
#	workbench/control_plane/AGENTS.md
#	workbench/control_plane/src/lib/sharedTaskUi.test.ts
@vjvarada
vjvarada merged commit 54e4b88 into main Aug 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants