Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 46 additions & 12 deletions dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -92,7 +92,15 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
};

const theme = accentColors[themeAccent];
const displayedShipProgress = reducedMotion ? 0.5 : feedback.shipProgress;
const shipVisual = reducedMotion
? {
trackXPercent: 50,
opacity: 1,
visible: true,
phase: "crossing" as const,
}
: feedback.shipVisual;
const displayedProgress = Math.round(Math.min(0.99, Math.max(0, feedback.progress)) * 100);

const getBadgeText = () => {
if (actionType === "quicksprint") return "Quicksprint in motion";
Expand Down Expand Up @@ -143,31 +151,57 @@ export const PlanningProgressOverlay: FunctionComponent<PlanningProgressOverlayP
<X className="h-4 w-4" />
</button>

<div
className="relative mb-12 flex h-32 w-full max-w-md items-center justify-center overflow-hidden pointer-events-none"
<div
className="relative mb-12 h-36 w-full max-w-md overflow-hidden rounded-[1.5rem] border border-black/[0.06] bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(226,232,240,0.68)_52%,rgba(203,213,225,0.55))] shadow-[inset_0_1px_0_rgba(255,255,255,0.75),0_18px_44px_rgba(15,23,42,0.08)] pointer-events-none dark:border-white/[0.08] dark:bg-[linear-gradient(180deg,rgba(24,20,17,0.92),rgba(15,29,51,0.72)_48%,rgba(8,16,28,0.82))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_20px_48px_rgba(0,0,0,0.3)]"
role="progressbar"
aria-live="polite"
aria-valuenow={Math.round(displayedShipProgress * 100)}
aria-valuenow={displayedProgress}
aria-valuemin={0}
aria-valuemax={100}
aria-label={feedback.text}
data-testid="planning-ship-course"
data-reduced-motion={reducedMotion ? "true" : "false"}
>
<div className="absolute inset-x-0 bottom-8 h-px bg-gradient-to-r from-transparent via-slate-200 to-transparent dark:via-white/10" />
<div className="absolute inset-x-0 top-0 h-1/2 bg-[radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.9),transparent_18%),radial-gradient(circle_at_82%_8%,rgba(0,224,160,0.14),transparent_24%)] dark:bg-[radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.12),transparent_18%),radial-gradient(circle_at_82%_8%,rgba(0,224,160,0.18),transparent_24%)]" />
<div className="absolute inset-x-5 bottom-10 h-12 rounded-full bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.78),rgba(125,211,252,0.2)_42%,transparent_72%)] blur-xl dark:bg-[radial-gradient(ellipse_at_center,rgba(128,255,214,0.16),rgba(59,130,246,0.12)_44%,transparent_72%)]" />
<div className="absolute inset-x-8 bottom-[2.25rem] h-px bg-gradient-to-r from-transparent via-slate-500/28 to-transparent dark:via-white/18" />
<div className="absolute left-7 bottom-5 h-12 w-10 rounded-t-[1rem] border border-black/[0.08] bg-white/65 shadow-[8px_10px_22px_rgba(15,23,42,0.08)] dark:border-white/[0.08] dark:bg-white/[0.06]" />
<div className="absolute right-7 bottom-5 h-12 w-10 rounded-t-[1rem] border border-black/[0.08] bg-white/65 shadow-[-8px_10px_22px_rgba(15,23,42,0.08)] dark:border-white/[0.08] dark:bg-white/[0.06]" />
<div className="absolute inset-x-12 bottom-[3.05rem] flex items-center justify-between" aria-hidden="true">
<span className="h-1.5 w-1.5 rounded-full bg-slate-400/55 shadow-[0_0_12px_rgba(100,116,139,0.35)] dark:bg-signal-300/45 dark:shadow-[0_0_14px_rgba(128,255,214,0.3)]" />
<span className="h-1.5 w-1.5 rounded-full bg-slate-400/35 shadow-[0_0_12px_rgba(100,116,139,0.25)] dark:bg-white/25 dark:shadow-[0_0_14px_rgba(255,255,255,0.18)]" />
</div>
<svg
aria-hidden="true"
className="absolute inset-x-8 bottom-8 h-10 w-[calc(100%-4rem)] overflow-visible text-slate-300/70 dark:text-white/[0.12]"
viewBox="0 0 360 42"
preserveAspectRatio="none"
>
<path d="M2 26 C62 6 98 40 154 22 S250 8 358 23" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeDasharray="2 10" />
<path d="M8 34 C70 20 105 46 164 31 S258 18 350 32" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" opacity="0.45" />
</svg>
<div
className="absolute transition-[left] ease-linear motion-reduce:transition-none"
className="absolute inset-y-8 left-0 w-full transform-gpu transition-[transform,opacity,visibility] ease-linear motion-reduce:transition-none"
data-testid="planning-ship-traveler"
data-ship-phase={shipVisual.phase}
data-ship-visible={shipVisual.visible ? "true" : "false"}
style={{
left: `${displayedShipProgress * 100}%`,
transform: "translateX(-50%)",
opacity: shipVisual.opacity,
visibility: shipVisual.visible ? "visible" : "hidden",
transform: `translate3d(${shipVisual.trackXPercent}%, 0, 0)`,
transitionDuration: `${MODAL_MOTION.overlay.exit}s`,
}}
>
<svg width="120" height="60" viewBox="-60 -30 120 60">
<div className="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="absolute left-[-74px] top-[30px] h-3 w-24 rounded-full bg-gradient-to-l from-transparent via-white/55 to-transparent blur-[2px] motion-safe:animate-pulse motion-reduce:animate-none dark:via-signal-300/22" />
<svg width="132" height="72" viewBox="-66 -38 132 72" className="drop-shadow-[0_12px_16px_rgba(15,23,42,0.18)] dark:drop-shadow-[0_14px_18px_rgba(0,0,0,0.45)]">
{feedback.shipType === "container" ? (
<ContainerShip accentColor={theme.shipContainer} isMoving={!reducedMotion} isDark={isDark} />
) : (
<WoodenShip accentColor={theme.shipWooden} isMoving={!reducedMotion} isDark={isDark} />
)}
</svg>
</svg>
</div>
</div>
</div>

Expand Down
85 changes: 54 additions & 31 deletions dashboard/src/v2/components/ui/PlanningShip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,48 @@ interface ShipProps {
export const ContainerShipDef: FunctionComponent<ShipProps> = ({ 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 (
<g>
<ellipse cx={0} cy={24} rx={46} ry={8} fill={accentColor} opacity={0.08}>
{shouldAnimate && <animate attributeName="ry" values="8;10;8" dur="2.5s" repeatCount="indefinite" />}
<ellipse cx={-8} cy={25} rx={48} ry={7} fill="#0f172a" opacity={isDark ? 0.28 : 0.12} />
<ellipse cx={-16} cy={27} rx={34} ry={4} fill={accentColor} opacity={0.14}>
{shouldAnimate && <animate attributeName="ry" values="4;5.5;4" dur="2.5s" repeatCount="indefinite" />}
</ellipse>
<path d="M-52 4 L52 4 L42 22 L-42 22 Z" fill={hullFill} stroke={hullStroke} strokeWidth={1.5} />
<path d="M-44 4 L44 4 L38 18 L-38 18 Z" fill={deckFill} opacity={0.4} />
<rect x={-12} y={-18} width={28} height={22} rx={2} fill={bridgeFill} stroke={bridgeStroke} strokeWidth={1} />
<rect x={-8} y={-14} width={20} height={6} rx={1} fill={windowFill} opacity={0.6} />
<rect x={-1} y={-28} width={6} height={10} rx={1} fill={funnelFill} />
<rect x={-1} y={-28} width={6} height={3} fill={isDark ? "#ff4d4d" : "#e3000f"} opacity={0.8} />
<path d="M-54 3 H50 C55 3 57 6 53 10 L43 23 H-41 C-47 23 -52 20 -54 14 L-58 5 C-58 4 -56 3 -54 3 Z" fill={hullFill} stroke={hullStroke} strokeWidth={1.4} />
<path d="M-49 5 H48 L40 18 H-41 L-45 8 Z" fill={deckFill} opacity={0.66} />
<path d="M-47 10 H47" stroke={railFill} strokeWidth={0.9} strokeLinecap="round" opacity={0.5} />
<g stroke={isDark ? "#07111f" : "#ffffff"} strokeWidth={0.7} opacity={0.92}>
{[-38, -28, -18, -8, 2, 12].map((x, index) => (
<rect key={x} x={x} y={-7 - (index % 2) * 8} width={10} height={8} rx={1.2} fill={containerFills[index % containerFills.length]} />
))}
{[-33, -23, -13, -3, 7].map((x, index) => (
<rect key={x} x={x} y={1 - (index % 2) * 8} width={10} height={7} rx={1.1} fill={containerFills[(index + 2) % containerFills.length]} />
))}
</g>
<rect x={18} y={-20} width={24} height={23} rx={3} fill={bridgeFill} stroke={bridgeStroke} strokeWidth={1} />
<rect x={22} y={-15} width={15} height={5} rx={1.5} fill={windowFill} opacity={0.72} />
<rect x={22} y={-7} width={16} height={3} rx={1.2} fill={railFill} opacity={0.34} />
<rect x={26} y={-31} width={7} height={11} rx={1.5} fill={funnelFill} />
<rect x={26} y={-31} width={7} height={3} rx={1} fill={isDark ? "#ff6b6b" : "#e3000f"} opacity={0.86} />
<path d="M-39 19 H38" stroke={isDark ? "#80FFD6" : "#005EB8"} strokeWidth={1.2} strokeLinecap="round" opacity={0.35} />
{shouldAnimate && (
<g opacity={0.4}>
{[0, 1, 2, 3, 4].map(j => (
<circle key={j} cx={2 + j * 0.8} cy={-30} r={1} fill={smokeFill}>
<animate attributeName="cy" values="-30;-52" dur={`${2 + j * 0.5}s`} repeatCount="indefinite" begin={`${j * 0.5}s`} />
<animate attributeName="r" values="1;4" dur={`${2 + j * 0.5}s`} repeatCount="indefinite" begin={`${j * 0.5}s`} />
<circle key={j} cx={29 + j * 0.9} cy={-33} r={1} fill={smokeFill}>
<animate attributeName="cy" values="-33;-53" dur={`${2 + j * 0.5}s`} repeatCount="indefinite" begin={`${j * 0.5}s`} />
<animate attributeName="r" values="1;4.5" dur={`${2 + j * 0.5}s`} repeatCount="indefinite" begin={`${j * 0.5}s`} />
<animate attributeName="opacity" values="0.2;0" dur={`${2 + j * 0.5}s`} repeatCount="indefinite" begin={`${j * 0.5}s`} />
</circle>
))}
Expand All @@ -56,23 +72,30 @@ export const ContainerShip: FunctionComponent<ShipProps> = (props) => {
export const WoodenShipDef: FunctionComponent<ShipProps> = ({ 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 (
<g>
<ellipse cx={0} cy={24} rx={38} ry={7} fill={accentColor} opacity={0.06}>
{shouldAnimate && <animate attributeName="ry" values="7;9;7" dur="2.8s" repeatCount="indefinite" />}
<ellipse cx={-8} cy={25} rx={40} ry={7} fill="#0f172a" opacity={isDark ? 0.26 : 0.1} />
<ellipse cx={-15} cy={27} rx={28} ry={4} fill={accentColor} opacity={0.14}>
{shouldAnimate && <animate attributeName="ry" values="4;5.5;4" dur="2.8s" repeatCount="indefinite" />}
</ellipse>
<path d="M-40 4 L40 4 L32 20 L-32 20 Z" fill={hullFill} stroke={hullStroke} strokeWidth={1.5} />
<path d="M-34 4 L34 4 L28 16 L-28 16 Z" fill={deckFill} opacity={0.3} />
<line x1={0} y1={4} x2={0} y2={-32} stroke={mastStroke} strokeWidth={2.5} />
<path d="M2 -30 Q22 -16 2 2 Z" fill={sailFill} stroke={sailStroke} strokeWidth={1} />
<path d="M-2 -30 Q-22 -16 -2 2 Z" fill={sailFill} stroke={sailStroke} strokeWidth={1} />
<path d="M-42 3 H42 L34 18 C31 23 -28 23 -34 18 Z" fill={hullFill} stroke={hullStroke} strokeWidth={1.5} />
<path d="M-33 5 H34 L27 15 C20 18 -20 18 -27 15 Z" fill={deckFill} opacity={0.42} />
<path d="M-30 12 C-14 16 11 16 30 12" fill="none" stroke={isDark ? "#c89142" : "#6b4310"} strokeWidth={1} opacity={0.45} />
<line x1={0} y1={5} x2={0} y2={-34} stroke={mastStroke} strokeWidth={2.8} strokeLinecap="round" />
<line x1={-18} y1={-13} x2={20} y2={-13} stroke={mastStroke} strokeWidth={1.5} strokeLinecap="round" opacity={0.8} />
<path d="M3 -32 Q25 -17 3 3 Z" fill={sailFill} stroke={sailStroke} strokeWidth={1.1} />
<path d="M4 -27 Q17 -15 4 -2 Z" fill={sailShade} opacity={0.38} />
<path d="M-3 -32 Q-24 -17 -3 3 Z" fill={sailFill} stroke={sailStroke} strokeWidth={1.1} />
<path d="M-4 -27 Q-17 -15 -4 -2 Z" fill={sailShade} opacity={0.34} />
<path d="M-31 19 H28" stroke={accentColor} strokeWidth={1.1} strokeLinecap="round" opacity={0.32} />
{shouldAnimate && (
<g opacity={0.4}>
{[0, 1, 2].map(j => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<PlanningProgressOverlay
isBusy
feedback={feedback}
planningEta={60_000}
elapsedMs={10_000}
isDark={false}
actionType="plan_only"
onDismiss={() => {}}
/>
);

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(<CanvasBackground />);

Expand Down
2 changes: 2 additions & 0 deletions docs-web/user/dashboard/sprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard/design-system-sprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading