From e0f2cf3d240618baf28bbf8e5f0b6a41367fef87 Mon Sep 17 00:00:00 2001
From: Code UX
Date: Tue, 7 Jul 2026 12:58:53 +0000
Subject: [PATCH] feat(task T03): implement via codex
---
.../components/ui/PlanningProgressOverlay.tsx | 77 +++++++++--
.../src/v2/components/ui/PlanningShip.tsx | 89 +++++++++++++
tests/dashboard/v2/sprint-composer.test.tsx | 10 +-
tests/dashboard/v2/ui-components.test.tsx | 122 ++++++++++++++++++
4 files changed, 286 insertions(+), 12 deletions(-)
diff --git a/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx b/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx
index 1210b13281..39e969b7e8 100644
--- a/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx
+++ b/dashboard/src/v2/components/ui/PlanningProgressOverlay.tsx
@@ -1,9 +1,9 @@
import type { FunctionComponent } from "preact";
import { X } from "lucide-preact";
-import { useRef, useLayoutEffect, useEffect } from "preact/hooks";
+import { useRef, useLayoutEffect, useEffect, useState } from "preact/hooks";
import gsap from "gsap";
import { useReducedMotion } from "../../hooks/use-reduced-motion.js";
-import { ContainerShip, WoodenShip } from "./PlanningShip.js";
+import { CoffeeCup, ContainerShip, WoodenShip } from "./PlanningShip.js";
import { type PlanningActionType, type PlanningFeedback, PLANNING_ACTION_LABELS } from "../../lib/sprint-planning-feedback.js";
import { MODAL_MOTION } from "../../lib/motion/modal-motion.js";
@@ -38,8 +38,20 @@ export const PlanningProgressOverlay: FunctionComponent {
const textContainerRef = useRef(null);
const prevTextRef = useRef(feedback?.text);
+ const prevBusyStateRef = useRef<{ isBusy: boolean; actionType: PlanningProgressOverlayProps["actionType"] }>({ isBusy, actionType });
+ const [isCoffeeBreak, setIsCoffeeBreak] = useState(false);
const reducedMotion = useReducedMotion();
+ useEffect(() => {
+ const previous = prevBusyStateRef.current;
+ if (!isBusy) {
+ setIsCoffeeBreak(false);
+ } else if (!previous.isBusy || previous.actionType !== actionType) {
+ setIsCoffeeBreak(false);
+ }
+ prevBusyStateRef.current = { isBusy, actionType };
+ }, [actionType, isBusy]);
+
useEffect(() => {
if (!isBusy || isDismissed) {
return;
@@ -79,6 +91,10 @@ export const PlanningProgressOverlay: FunctionComponent
-
-
+
@@ -245,6 +293,15 @@ export const PlanningProgressOverlay: FunctionComponent
{getDescriptionText()}
+ {isCoffeeBreak && (
+
+ Coffee break unlocked. Grab a fresh cup while planning keeps moving.
+
+ )}
You can minimize this panel and keep the request running, or cancel it from here.
diff --git a/dashboard/src/v2/components/ui/PlanningShip.tsx b/dashboard/src/v2/components/ui/PlanningShip.tsx
index d7afe63adb..4a4919713d 100644
--- a/dashboard/src/v2/components/ui/PlanningShip.tsx
+++ b/dashboard/src/v2/components/ui/PlanningShip.tsx
@@ -117,3 +117,92 @@ export const WoodenShip: FunctionComponent = (props) => {
);
};
+
+export const CoffeeCup: FunctionComponent = ({ accentColor, isMoving, isDark }) => {
+ const isReducedMotion = useReducedMotion();
+ const shouldAnimate = isMoving && !isReducedMotion;
+ const cupFill = isDark ? "#162337" : "#f8fafc";
+ const cupStroke = isDark ? "#6b7fa0" : "#94a3b8";
+ const coffeeFill = isDark ? "#c0842f" : "#7c3f16";
+ const saucerFill = isDark ? "#0f1a2c" : "#e2e8f0";
+ const steamColor = isDark ? "#e0f2fe" : "#64748b";
+ const highlightFill = isDark ? "#ffffff" : "#ffffff";
+
+ const steamPaths = [
+ { d: "M-20 -18 C-30 -31 -12 -36 -22 -49", delay: "0s" },
+ { d: "M0 -17 C-10 -30 10 -36 0 -50", delay: "0.22s" },
+ { d: "M20 -18 C10 -31 30 -36 18 -49", delay: "0.44s" },
+ ];
+
+ return (
+
+
+
+ {shouldAnimate && }
+
+
+
+
+
+
+
+
+
+
+
+
+ {steamPaths.map((path) => (
+
+ {shouldAnimate && (
+ <>
+
+
+ >
+ )}
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/tests/dashboard/v2/sprint-composer.test.tsx b/tests/dashboard/v2/sprint-composer.test.tsx
index 067bb5d4d6..d691c73022 100644
--- a/tests/dashboard/v2/sprint-composer.test.tsx
+++ b/tests/dashboard/v2/sprint-composer.test.tsx
@@ -590,7 +590,7 @@ describe("SprintComposer", () => {
const mockOnCancelPlanningRequest = vi.fn();
const mockOnSubmit = vi.fn(async () => new Promise(() => undefined));
- const { getByText, getByPlaceholderText, queryByText, getAllByText } = render(
+ const { getByText, getByPlaceholderText, queryByText, getAllByText, getByRole } = render(
);
@@ -611,6 +611,9 @@ describe("SprintComposer", () => {
expect(mockOnSubmit).toHaveBeenCalled();
+ fireEvent.click(getByRole("button", { name: /turn planning vessel into a coffee break reminder/i }));
+ expect(getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument();
+
// Click Cancel Active Request through the overlay specifically.
const cancelBtns = getAllByText("Cancel Active Request");
// Click the one inside the overlay
@@ -644,7 +647,7 @@ describe("SprintComposer", () => {
const mockOnStartNewSprint = vi.fn();
const mockOnClose = vi.fn();
- const { getByText, getByPlaceholderText, getAllByText } = render(
+ const { getByText, getByPlaceholderText, getAllByText, getByRole } = render(
{
const firstSignal = mockOnSubmit.mock.calls[0]?.[0]?.signal as AbortSignal;
expect(firstSignal).toBeInstanceOf(AbortSignal);
+ fireEvent.click(getByRole("button", { name: /turn planning vessel into a coffee break reminder/i }));
+ expect(getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument();
+
// Click New Sprint
const newSprintBtn = getByText("New Sprint");
fireEvent.click(newSprintBtn);
diff --git a/tests/dashboard/v2/ui-components.test.tsx b/tests/dashboard/v2/ui-components.test.tsx
index 8b1e47cd69..82ea6279a2 100644
--- a/tests/dashboard/v2/ui-components.test.tsx
+++ b/tests/dashboard/v2/ui-components.test.tsx
@@ -326,6 +326,128 @@ describe("UI Components Coverage", () => {
expect(document.body.textContent).toContain("New Sprint");
});
+ it("activates the planning vessel coffee reminder accessibly without breaking overlay controls", async () => {
+ const user = userEvent.setup();
+ const onDismiss = vi.fn();
+ const onCancel = vi.fn();
+ const onSecondaryAction = vi.fn();
+ const feedback = { ...getPlanningFeedback("plan_only", SHIP_LOOP_MS * 0.45), text: "Coffee Test" };
+ const { rerender } = render(
+
+ );
+
+ const vesselButton = screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i });
+ expect(vesselButton).toHaveAttribute("aria-pressed", "false");
+
+ await user.click(vesselButton);
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument();
+ expect(screen.getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument();
+ expect(screen.getByText("ETA")).toBeInTheDocument();
+ expect(screen.getByText("Elapsed")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Minimize" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "New Sprint" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Cancel Active Request" })).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "New Sprint" }));
+ expect(onSecondaryAction).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole("button", { name: "Cancel Active Request" }));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole("dialog"));
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+
+ rerender(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).not.toBeInTheDocument();
+ });
+
+ const resetVesselButton = screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i });
+ resetVesselButton.focus();
+ await user.keyboard("{Enter}");
+ expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument();
+ expect(screen.getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument();
+
+ rerender(
+
+ );
+ rerender(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).not.toBeInTheDocument();
+ });
+
+ const freshVesselButton = screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i });
+ freshVesselButton.focus();
+ fireEvent.keyDown(freshVesselButton, { key: " ", code: "Space" });
+ expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument();
+ expect(screen.getByText("Coffee break unlocked. Grab a fresh cup while planning keeps moving.")).toBeInTheDocument();
+ });
+
+ it("renders static coffee steam when reduced motion is enabled", async () => {
+ vi.mocked(useReducedMotion).mockReturnValue(true);
+ const user = userEvent.setup();
+ const feedback = { ...getPlanningFeedback("plan_only", SHIP_LOOP_MS * 0.45), text: "Static Coffee Test" };
+ const { container } = render(
+ {}}
+ />
+ );
+
+ await user.click(screen.getByRole("button", { name: /turn planning vessel into a coffee break reminder/i }));
+
+ expect(screen.getByTestId("planning-coffee-cup")).toBeInTheDocument();
+ expect(container.querySelector("animate")).toBeNull();
+ expect(container.querySelector("animateTransform")).toBeNull();
+ });
+
it("handles keyboard navigation in FilterStrip", () => {
const options = [{ value: "1", label: "Opt 1" }, { value: "2", label: "Opt 2" }];
const onChange = vi.fn();