From 471015cfff8332da6bf7d2edbdb083c444cc5641 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 13:57:59 +0000 Subject: [PATCH 1/5] fix(tasks): the details grid follows the surface, not the viewport Owner-reported from the deployed app, with a screenshot: text overflowing in the /tasks detail. My regression from S2. ItemDetail was built for the max-w-3xl modal, where `grid-cols-2` is comfortable. Docking it into DESIGN_SYSTEM 6's 380px pane left each cell about 170px, and labels collided with their values. A media query cannot fix this -- the pane is 380px on a 4K monitor too, so viewport tells you nothing about the space the component actually has. The switch is `focused`, the prop that already distinguishes the modal from the pane and was threaded for exactly this two-mode life. Docked: one column. Maximised/phone modal: two, unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W --- .../src/app/tasks/components/ItemDetail.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/workbench/control_plane/src/app/tasks/components/ItemDetail.tsx b/workbench/control_plane/src/app/tasks/components/ItemDetail.tsx index 55bf3be28..e47aaad93 100644 --- a/workbench/control_plane/src/app/tasks/components/ItemDetail.tsx +++ b/workbench/control_plane/src/app/tasks/components/ItemDetail.tsx @@ -354,10 +354,17 @@ export function TaskDetail({ /> - {/* Metadata grid — every cell is click-to-edit */} + {/* Metadata grid — every cell is click-to-edit. + ⚠️ Column count follows the SURFACE, not the viewport. This detail + was built for the `max-w-3xl` modal, where two columns are + comfortable; docking it into DESIGN_SYSTEM §6's 380px pane left each + cell ~170px and its label and value collided (owner-reported after + the S2 deploy, with a screenshot). A media query cannot fix it — + the pane is 380px on a 4K monitor too — so the switch is `focused`, + the prop that already distinguishes the two lives. */}
Details -
+
{/* Context */} Date: Mon, 10 Aug 2026 14:18:26 +0000 Subject: [PATCH 2/5] =?UTF-8?q?S5=20=E2=80=94=20Projects'=20task=20panel?= =?UTF-8?q?=20adopts=20the=20Tasks=20detail's=20composition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-reported from screenshots of the deployed app, comparing the two detail panels side by side ("Task cards seem to be very different"). The standing ruling this wave is "Projects is canonical, Tasks conforms"; this surface is the exception and the direction is reversed, the same way S4 reversed it for the empty states and the active token. - `app/projects/components/TaskPanel.tsx`: header (ref · title · actions · StatusChip row) → grouped DETAILS cells → discrete sections (Description, Properties, custom Fields, Links & subtasks, Files, Activity) → pinned comment composer, on ItemDetail's `SectionLabel`/`MetaEdit` grammar. One scroll region instead of two. Status is a themed `` (per-file ratchet, 37 files baselined) and a file input that is not hidden behind a real control (absolute, zero offenders). SOLID_BUTTON_DEBT 30 → 29. Frontend only: no migration, no API change, no new dependency. No browser was run — Playwright cannot install here — so the phone viewport and the Fluent → Material → Graphite sweep are owed at review. --- .../src/app/projects/components/TaskPanel.tsx | 726 ++++++++++++------ .../control_plane/src/components/ui/Input.tsx | 68 +- .../src/lib/theme/conformance.test.ts | 216 +++++- 3 files changed, 769 insertions(+), 241 deletions(-) diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index 74ef1091e..9769e15a7 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -6,11 +6,47 @@ * Comments and system events come from ONE endpoint because they are one table * (§3.8) — the timeline shows a status change, an assignment, an agent run and * a comment in the same stream, which is the point of the shared spine. + * + * ## Composition (S5) — this panel follows `/tasks`' `ItemDetail` + * + * The standing ruling on the two task surfaces is "Projects is canonical, Tasks + * conforms". **This surface is the exception, and the direction is reversed** + * (owner-reported from screenshots of the deployed app, comparing the two + * detail panels: *"Task cards seem to be very different"*). `ItemDetail` is a + * designed surface — a header carrying the chip row and the title, a grouped + * details block of bordered field cells, then discrete labelled sections. This + * was a plain vertical form: a bare ` changeStatus(e.target.value)} - className="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground" - > - {statuses.map((s) => ( - - ))} - - -
- Assignees -
- {assignees.map((who) => { - const kind = classify(who); - return ( - " : who} - className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs ${ - kind === "unknown" - ? "border border-border text-muted-foreground" - : "bg-muted text-foreground" - }`} + {error ? ( +

+ {error} +

+ ) : null} + + {/* ONE scroll region. It used to be two: a fixed field block that could + eat the whole panel on a short window, with the timeline scrolling + under it. ItemDetail scrolls the whole detail, and so does this now — + only the header and the comment composer are pinned. */} +
+
+
+ Details + {/* `grid-cols-1`, with no responsive variant on purpose — see the + width note in this file's header. */} +
+ + } + > + + + + + {assignees.length} + + ) : null + } + > +
+ {assignees.map((who) => { + const kind = classify(who); + return ( + ` + // is a typo somebody has to see, so it takes the warning + // tone rather than a quieter outline. + tone={kind === "unknown" ? "warning" : "neutral"} + // Agents and people are one vocabulary (D-PM-4), so the + // difference is an icon, never a separate field. + icon={kind === "agent" ? "Bot" : undefined} + title={ + kind === "unknown" ? "Not an email or agent:" : who + } + > + {assigneeLabel(who)} + + + ); + })} + {assignees.length === 0 ? ( + Nobody yet + ) : null} +
+ setAssignee(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void addAssignees(); + } + }} + onBlur={() => void addAssignees()} + placeholder="email or agent:name" + aria-label="Add an assignee" + /> +
+
+
+ + {task.description ? ( +
+ Description +

+ {task.description} +

+
+ ) : null} + + {/* Tags and recurrence draw their own small labels, so they are + sub-fields of one section rather than two sections whose headings + would compete with the ones those components already render. + Promoting those labels onto `SectionLabel` means editing + `TagPicker`/`RepeatEditor`, which S5 does not own. */} +
+ Properties +
+ {/* Saved on every change rather than behind a button: a chip is a + single decision, and a Save beside it would be a second click + for something that is already unambiguous. */} + { + void (async () => { + try { + onChanged(await projectsApi.patchTask(task.id, { tags: next })); + } catch (err) { + setError(String((err as Error).message)); + } + })(); + }} + /> + +
+
+
+ + {/* Custom fields bring their own section chrome — a top rule, their own + gutter and an `h4` — so they sit OUTSIDE the padded column rather + than nesting one gutter inside another. Renders nothing at all when + the project defined no fields, so a project that never wanted them + never grows an empty heading. */} + + +
+ {/* Both halves existed in the schema since WS-27a with no surface: + links could be created and deleted but never listed, and subtasks + could be created but never shown. */} +
+ Links & subtasks +
+ {onOpenTask ? ( + + ) : null} + setSubtask(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void addSubtask(); + } + }} + placeholder="Break this down…" + aria-label="Add a subtask" + /> +
+
+ +
+ + Files{files.length ? ` · ${files.length}` : ""} + +
+ {files.map((f) => ( +
- {/* Agents and people are one vocabulary (D-PM-4), so the - difference is an icon, never a separate field. */} - {kind === "agent" ? : null} - {assigneeLabel(who)} + {f.kind === "image" ? ( + + ) : ( + + )} + + {f.name} + + + {Math.max(1, Math.round(f.size / 1024))} KB + - - ); - })} - {assignees.length === 0 ? ( - Nobody yet - ) : null} -
- setAssignee(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void addAssignees(); - } - }} - onBlur={() => void addAssignees()} - placeholder="email or agent:name" - aria-label="Add an assignee" - className="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground" - /> -
- {task.description ? ( -

- {task.description} -

- ) : null} - {/* Renders nothing at all when the project has no custom fields, so a - project that never wanted them never grows an empty heading. */} - {/* Saved on every change rather than behind a button: a chip is a - single decision, and a Save beside it would be a second click for - something that is already unambiguous. */} - { - void (async () => { - try { - onChanged(await projectsApi.patchTask(task.id, { tags: next })); - } catch (err) { - setError(String((err as Error).message)); - } - })(); - }} - /> - {/* Both halves existed in the schema since WS-27a with no surface: - links could be created and deleted but never listed, and subtasks - could be created but never shown. */} - {onOpenTask ? ( - - ) : null} - - -
- Files -
- {files.map((f) => ( -
- {f.kind === "image" ? ( - - ) : ( - - )} - - {f.name} - - - {Math.max(1, Math.round(f.size / 1024))} KB - -
+ ))} + {/* What is going up right now, by name. */} + {uploading.map((name) => ( +
- - -
- ))} - {files.length === 0 ? ( -

Nothing attached.

- ) : null} -
- { - void uploadFiles(e.target.files); - // Reset so picking the SAME file twice still fires a change. - e.target.value = ""; - }} - className="mt-1 w-full text-xs text-muted-foreground file:mr-2 file:rounded-md file:border file:border-border file:bg-background file:px-2 file:py-1 file:text-xs file:text-foreground" - /> -
-
- Subtask - setSubtask(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void addSubtask(); - } - }} - placeholder="Break this down…" - aria-label="Add a subtask" - className="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground" - /> -
-
- - {error ? ( -

- {error} -

- ) : null} + + {name} +
+ ))} + {files.length === 0 && uploading.length === 0 ? ( +

Nothing attached.

+ ) : null} +
+ {/* S5 — the picker is a Button, not a bare file input. The + browser's own "Choose Files / No file chosen" is not a design: + it follows neither the theme's control personality nor the icon + pack, and it was the most visibly unstyled control on this + panel. The input stays in the tree, hidden, because it is the + only way to raise the OS file dialog. Fenced by conformance + rule 7. */} + { + void uploadFiles(e.target.files); + // Reset so picking the SAME file twice still fires a change. + e.target.value = ""; + }} + /> + +
-
    - {timeline.map((activity) => ( -
  1. -

    - {/* WS-27z — an automated entry says so. The flag is the row's - meta.automation, written only by the workflow engine; a - sweep archiving a task must not read as a person did it. */} - {isAutomated(activity) ? ( - - - auto - +

    + Activity +
      + {timeline.map((activity) => ( +
    1. +

      + {/* WS-27z — an automated entry says so. The flag is the row's + meta.automation, written only by the workflow engine; a + sweep archiving a task must not read as a person did it. */} + {isAutomated(activity) ? ( + + auto + + ) : null} + + {activity.created_by ?? "system"} + {activity.created_at + ? ` · ${new Date(activity.created_at).toLocaleString()}` + : ""} + +

      +

      + {describe(activity, fields)} +

      +
    2. + ))} + {timeline.length === 0 ? ( +
    3. + Nothing on the timeline yet. +
    4. ) : null} - - {activity.created_by ?? "system"} - {activity.created_at - ? ` · ${new Date(activity.created_at).toLocaleString()}` - : ""} - -

      -

      {describe(activity, fields)}

      - - ))} - {timeline.length === 0 ? ( -
    5. Nothing on the timeline yet.
    6. - ) : null} -
    +
+ + + -
-