From 416650229bb6c81d2fc5f837d2fe30fb5727010f Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 12:47:45 +0000 Subject: [PATCH] feat(task T02): implement via codex --- .../components/ui/PlanningProgressOverlay.tsx | 58 ++++++++++--- .../src/v2/components/ui/PlanningShip.tsx | 85 ++++++++++++------- .../__tests__/ReducedMotionVisuals.test.tsx | 23 +++++ docs-web/user/dashboard/sprints.md | 2 + docs/dashboard/design-system-sprints.md | 2 +- tests/dashboard/v2/ui-components.test.tsx | 31 ++++++- 6 files changed, 155 insertions(+), 46 deletions(-) diff --git a/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx b/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx index 6eb1f88fbb..1210b13281 100644 --- a/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx +++ b/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx @@ -4,13 +4,13 @@ import { useRef, useLayoutEffect, useEffect } from "preact/hooks"; import gsap from "gsap"; import { useReducedMotion } from "../../hooks/use-reduced-motion.js"; import { ContainerShip, WoodenShip } from "./PlanningShip.js"; -import { type PlanningActionType, PLANNING_ACTION_LABELS } from "../../lib/sprint-planning-feedback.js"; +import { type PlanningActionType, type PlanningFeedback, PLANNING_ACTION_LABELS } from "../../lib/sprint-planning-feedback.js"; import { MODAL_MOTION } from "../../lib/motion/modal-motion.js"; interface PlanningProgressOverlayProps { isBusy: boolean; isDismissed?: boolean; - feedback: { shipType: "container" | "wooden"; shipProgress: number; text: string } | null; + feedback: PlanningFeedback | null; planningEta: number; elapsedMs: number; isDark: boolean; @@ -92,7 +92,15 @@ export const PlanningProgressOverlay: FunctionComponent { if (actionType === "quicksprint") return "Quicksprint in motion"; @@ -143,31 +151,57 @@ export const PlanningProgressOverlay: FunctionComponent -
-
+
+
+
+
+
+ +
- +
+
+ {feedback.shipType === "container" ? ( ) : ( )} - + +
diff --git a/dashboard/src/v2/components/ui/PlanningShip.tsx b/dashboard/src/v2/components/ui/PlanningShip.tsx index 407cc8225f..d7afe63adb 100644 --- a/dashboard/src/v2/components/ui/PlanningShip.tsx +++ b/dashboard/src/v2/components/ui/PlanningShip.tsx @@ -10,32 +10,48 @@ interface ShipProps { export const ContainerShipDef: FunctionComponent = ({ accentColor, isMoving, isDark }) => { const isReducedMotion = useReducedMotion(); const shouldAnimate = isMoving && !isReducedMotion; - const hullFill = isDark ? "#0f1d33" : "#c8d6e5"; - const hullStroke = isDark ? "#1a3050" : "#8395a7"; - const deckFill = isDark ? "#162840" : "#a4b0be"; - const bridgeFill = isDark ? "#1a2d50" : "#8395a7"; - const bridgeStroke = isDark ? "#25406a" : "#576574"; - const funnelFill = isDark ? "#1e3450" : "#8395a7"; + const hullFill = isDark ? "#10233d" : "#d9e4ee"; + const hullStroke = isDark ? "#2c4a70" : "#7d8fa3"; + const deckFill = isDark ? "#1b3354" : "#edf3f8"; + const railFill = isDark ? "#93c5fd" : "#475569"; + const bridgeFill = isDark ? "#203a5d" : "#f8fafc"; + const bridgeStroke = isDark ? "#3e5f8a" : "#94a3b8"; + const funnelFill = isDark ? "#263f5d" : "#64748b"; const windowFill = isDark ? "#4a8ad4" : "#2e86de"; - const smokeFill = isDark ? "#b8c8d8" : "#636e72"; + const smokeFill = isDark ? "#dbeafe" : "#64748b"; + const containerFills = isDark + ? ["#00E0A0", "#3b82f6", "#f59e0b", "#94a3b8"] + : ["#005EB8", "#00AB84", "#FFB800", "#94a3b8"]; return ( - - {shouldAnimate && } + + + {shouldAnimate && } - - - - - - + + + + + {[-38, -28, -18, -8, 2, 12].map((x, index) => ( + + ))} + {[-33, -23, -13, -3, 7].map((x, index) => ( + + ))} + + + + + + + {shouldAnimate && ( {[0, 1, 2, 3, 4].map(j => ( - - - + + + ))} @@ -56,23 +72,30 @@ export const ContainerShip: FunctionComponent = (props) => { export const WoodenShipDef: FunctionComponent = ({ accentColor, isMoving, isDark }) => { const isReducedMotion = useReducedMotion(); const shouldAnimate = isMoving && !isReducedMotion; - const hullFill = isDark ? "#5C3D0E" : "#8B6914"; - const hullStroke = isDark ? "#7A5518" : "#A67B20"; - const deckFill = isDark ? "#7A5518" : "#A67B20"; - const mastStroke = isDark ? "#4A3008" : "#5C3D0E"; - const sailFill = isDark ? "#F5EFE0" : "#FFF8E7"; - const sailStroke = isDark ? "#C9BFA8" : "#B8A888"; + const hullFill = isDark ? "#664414" : "#9a6d20"; + const hullStroke = isDark ? "#a16f24" : "#7c4f11"; + const deckFill = isDark ? "#8a5e20" : "#b9812e"; + const mastStroke = isDark ? "#3f2a0b" : "#5C3D0E"; + const sailFill = isDark ? "#f8f1dd" : "#fff8e7"; + const sailShade = isDark ? "#e6d8b8" : "#eadbbf"; + const sailStroke = isDark ? "#c9bfa8" : "#b8a888"; return ( - - {shouldAnimate && } + + + {shouldAnimate && } - - - - - + + + + + + + + + + {shouldAnimate && ( {[0, 1, 2].map(j => ( diff --git a/dashboard/src/v2/components/ui/__tests__/ReducedMotionVisuals.test.tsx b/dashboard/src/v2/components/ui/__tests__/ReducedMotionVisuals.test.tsx index 4309aba339..671a76ce07 100644 --- a/dashboard/src/v2/components/ui/__tests__/ReducedMotionVisuals.test.tsx +++ b/dashboard/src/v2/components/ui/__tests__/ReducedMotionVisuals.test.tsx @@ -5,10 +5,12 @@ import { Sparkline } from "../Sparkline.js"; import { WaveFluid } from "../WaveFluid.js"; import { BorderTrace } from "../BorderTrace.js"; import { ContainerShip } from "../PlanningShip.js"; +import { PlanningProgressOverlay } from "../PlanningProgressOverlay.js"; import { LiveDurationBadge } from "../LiveDurationBadge.js"; import { CanvasBackground } from "../../CanvasBackground.js"; import { RuntimeEventFeed } from "../../RuntimeEventFeed.js"; import { SkeletonLoader, SkeletonPanel } from "../../layout/SkeletonLoader.js"; +import { getPlanningFeedback } from "../../../lib/sprint-planning-feedback.js"; import type { ExecutionRuntimeEventSummary } from "../../../../types.js"; import gsap from "gsap"; import * as matchers from '@testing-library/jest-dom/matchers'; @@ -103,6 +105,27 @@ describe("Reduced Motion Visuals", () => { expect(animate).toBeNull(); }); + it("PlanningProgressOverlay renders a static legible ship course when reduced motion is enabled", () => { + const feedback = { ...getPlanningFeedback("plan_only", 10_000), text: "Static planning state" }; + const { container, getByTestId, getByRole } = render( + {}} + /> + ); + + expect(getByRole("progressbar", { name: "Static planning state" })).toBeInTheDocument(); + expect(getByTestId("planning-ship-course")).toHaveAttribute("data-reduced-motion", "true"); + expect(getByTestId("planning-ship-traveler")).toHaveAttribute("data-ship-phase", "crossing"); + expect(getByTestId("planning-ship-traveler")).toHaveStyle({ visibility: "visible" }); + expect(container.querySelector("animate")).toBeNull(); + }); + it("CanvasBackground skips GSAP loops and resolves ambient transitions through motion tokens", () => { const { container } = render(); diff --git a/docs-web/user/dashboard/sprints.md b/docs-web/user/dashboard/sprints.md index 2eb7d0105e..1b5265110a 100644 --- a/docs-web/user/dashboard/sprints.md +++ b/docs-web/user/dashboard/sprints.md @@ -36,6 +36,8 @@ Click **Plan sprint**. The planner agent (typically a Gemini, Codex or Claude se - Inferred `depends_on` edges. - A best-effort `is_independent` flag. +While planning is in flight, the dashboard shows an ETA/elapsed overlay with a course indicator, request-specific status copy, minimize controls, and cancel/recovery actions when they are available. + You can: - **Edit** each subtask inline. diff --git a/docs/dashboard/design-system-sprints.md b/docs/dashboard/design-system-sprints.md index 07d5b44f79..324d7ec1d8 100644 --- a/docs/dashboard/design-system-sprints.md +++ b/docs/dashboard/design-system-sprints.md @@ -52,7 +52,7 @@ This document outlines the design system for the Sprints page and related planni * Visual alignment with the rest of the sprints workspace. * Consistent treatment for async feedback states, planning ETA indicators, and linked issue chips. * The expanded task append flows should transition smoothly and maintain context. -* Planning, replan, append, and prompt-improvement requests use client request IDs and `ActionFeedbackRegion`. The composer form sets `aria-busy` while a request is active, disables duplicate controls, preserves current field values, and exposes a `PlanningProgressOverlay` with cancel and "New Sprint" recovery actions when available. +* Planning, replan, append, and prompt-improvement requests use client request IDs and `ActionFeedbackRegion`. The composer form sets `aria-busy` while a request is active, disables duplicate controls, preserves current field values, and exposes a `PlanningProgressOverlay` with cancel and "New Sprint" recovery actions when available. The overlay renders ETA/elapsed telemetry with the shared planning feedback ship-visual contract, using transform-based offscreen entry/exit phases and a static midpoint course under reduced motion. * Pending planning uses polite live-region feedback and `asyncFeedback`; blocking request failures use persistent assertive errors with retry actions; operator cancellation uses a non-auto-dismissing warning. On validation failure, custom validation runs because native validation is disabled, and focus moves to the first missing required field. * Composer entry and field stagger use modal/list reveal timing and resolve to instant state changes under reduced motion. Reduced motion must not remove required progress, cancel, warning, or error copy. * Quicksprint planning buttons expose `aria-busy` for the active request, describe disabled controls through the visible busy status, and keep the combined prompt expansion available through `aria-expanded`/`aria-controls`. diff --git a/tests/dashboard/v2/ui-components.test.tsx b/tests/dashboard/v2/ui-components.test.tsx index b1e0a35fac..8b1e47cd69 100644 --- a/tests/dashboard/v2/ui-components.test.tsx +++ b/tests/dashboard/v2/ui-components.test.tsx @@ -9,6 +9,7 @@ import { useReducedMotion } from "../../../dashboard/src/v2/hooks/use-reduced-mo import { afterEach, describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/preact"; import { PlanningProgressOverlay } from "../../../dashboard/src/v2/components/ui/PlanningProgressOverlay.js"; +import { getPlanningFeedback, SHIP_LOOP_MS } from "../../../dashboard/src/v2/lib/sprint-planning-feedback.js"; import { ToastProvider, useToast } from "../../../dashboard/src/v2/components/feedback/ToastProvider.js"; import { ActionFeedbackRegion } from "../../../dashboard/src/v2/components/ui/ActionFeedbackRegion.js"; import { SkeletonRow, SkeletonCard, SkeletonPanel } from "../../../dashboard/src/v2/components/layout/SkeletonLoader.js"; @@ -227,7 +228,7 @@ describe("UI Components Coverage", () => { }); it("renders PlanningProgressOverlay in various states", () => { - const feedback = { shipType: "container" as const, shipProgress: 0.5, text: "Test Message" }; + const feedback = { ...getPlanningFeedback("plan_only", SHIP_LOOP_MS * 0.45), text: "Test Message" }; const { rerender } = render( { ); expect(document.body.textContent).toContain("Test Message"); expect(document.body.textContent).toContain("Generating subtasks"); + expect(screen.getByRole("progressbar", { name: "Test Message" })).toHaveAttribute( + "aria-valuenow", + String(Math.round(feedback.progress * 100)) + ); + expect(screen.getByTestId("planning-ship-course")).toHaveAttribute("data-reduced-motion", "false"); + const traveler = screen.getByTestId("planning-ship-traveler"); + expect(traveler).toHaveAttribute("data-ship-phase", feedback.shipVisual.phase); + expect(traveler).toHaveAttribute("data-ship-visible", "true"); + expect(traveler.getAttribute("style")).toContain("transform: translate3d("); + expect(traveler.getAttribute("style")).not.toContain("left:"); rerender( { ); expect(document.body.textContent).toContain("The Planning agent is researching your codebase"); + const hiddenWrapFeedback = { ...getPlanningFeedback("plan_only", SHIP_LOOP_MS * 0.95), text: "Test Message" }; + rerender( + {}} + /> + ); + expect(screen.getByTestId("planning-ship-traveler")).toHaveAttribute("data-ship-phase", "hidden"); + expect(screen.getByTestId("planning-ship-traveler")).toHaveAttribute("data-ship-visible", "false"); + expect(screen.getByTestId("planning-ship-traveler")).toHaveStyle({ visibility: "hidden" }); + rerender(