mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): Mini App V5 — brand typography, icon depth, motion, detail sheets (#583)
* feat(tg): Mini App V5 — brand typography, icon depth, motion, detail sheets Share Tech Mono (the vendored motion-brand face) becomes the cockpit's display voice via next/font/local scoped to #tg-shell; icon tiles, circle actions, and avatars get gradient/ring depth; a dependency-free motion vocabulary lands (spend count-up, tab rise-in, staggered sections, sparkline draw-in, sheet slide-up with native BackButton dismiss); the Board tab gains a tap-through task sheet (ACs, open findings, PR link), Today's fleet opens a full-roster sheet, and Board/Inbox join the /tg?demo=1 fixtures. * feat(tg): custom RoboCo icon set + operations ring The cockpit stops using stock lucide on its hero surfaces: a hand-drawn duotone icon set (speedometer, seal, bell, kanban, brand-cursor bubble, rocket, double-check, broom, robot head) covers the tab bar and the Today ring. The ring itself stops duplicating the tab bar and becomes real operations: Ship deep-focuses the release proposal in Approvals, Ack all bulk-acknowledges pending notifications, Sweep runs the stale-branch cleanup across every git-configured project behind a confirm sheet, and Fleet opens the roster. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { TgSheet, useCountUp } from "../motion";
|
||||
|
||||
describe("useCountUp", () => {
|
||||
it("jumps straight to the target under reduced motion", async () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as typeof window.matchMedia;
|
||||
try {
|
||||
const { result } = renderHook(() => useCountUp(42));
|
||||
await waitFor(() => expect(result.current).toBe(42));
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("settles on the exact target when animating", async () => {
|
||||
const { result } = renderHook(() => useCountUp(12.34, 50));
|
||||
// Generous timeout: rAF frames starve under parallel test workers.
|
||||
await waitFor(() => expect(result.current).toBe(12.34), { timeout: 4000 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TgSheet", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
render(
|
||||
<TgSheet open={false} onClose={vi.fn()} title="Task">
|
||||
<p>body</p>
|
||||
</TgSheet>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows title and children when open, closes on backdrop tap", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<TgSheet open onClose={onClose} title="Fleet">
|
||||
<p>sheet body</p>
|
||||
</TgSheet>,
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fleet")).toBeInTheDocument();
|
||||
expect(screen.getByText("sheet body")).toBeInTheDocument();
|
||||
|
||||
const [backdrop] = screen.getAllByRole("button", { name: /close/i });
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TgTaskSheet } from "../tg-task-sheet";
|
||||
import type { Task } from "@/types";
|
||||
import type { TaskFindingsResponse } from "@/lib/api/tasks";
|
||||
|
||||
const { findings } = vi.hoisted(() => ({
|
||||
findings: vi.fn<() => { data: TaskFindingsResponse | undefined }>(() => ({
|
||||
data: undefined,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useTaskFindings: findings,
|
||||
}));
|
||||
|
||||
function task(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "t1",
|
||||
title: "Harden the retry queue",
|
||||
description: "Webhook retries with backoff.",
|
||||
acceptance_criteria: ["Retries back off", "DLQ after 5 attempts"],
|
||||
status: "awaiting_ceo_approval",
|
||||
priority: 1,
|
||||
sequence: 0,
|
||||
team: "backend",
|
||||
created_by: "main-pm",
|
||||
assigned_to: "be-dev-1",
|
||||
parent_task_id: null,
|
||||
dependency_ids: [],
|
||||
blocker_ids: [],
|
||||
created_at: "2026-07-18T10:00:00Z",
|
||||
updated_at: "2026-07-19T09:00:00Z",
|
||||
claimed_at: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
target_date: null,
|
||||
estimated_complexity: "medium",
|
||||
nature: "technical",
|
||||
task_type: "code",
|
||||
project_id: "p1",
|
||||
docs_complete: false,
|
||||
pr_created: true,
|
||||
pm_approvals: {},
|
||||
plan: null,
|
||||
checkpoints: [],
|
||||
progress_updates: [],
|
||||
commits: [],
|
||||
dev_notes: null,
|
||||
qa_notes: null,
|
||||
auditor_notes: null,
|
||||
quick_context: null,
|
||||
self_verified: true,
|
||||
qa_verified: true,
|
||||
revision_count: 2,
|
||||
branch_name: "feature/backend/T1",
|
||||
pr_number: 612,
|
||||
pr_url: "https://example.com/pull/612",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("TgTaskSheet", () => {
|
||||
it("renders nothing without a task", () => {
|
||||
render(<TgTaskSheet task={null} onClose={vi.fn()} />);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows title, ACs, bounce chip, and the PR link", () => {
|
||||
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
|
||||
expect(screen.getByText("Harden the retry queue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Retries back off")).toBeInTheDocument();
|
||||
expect(screen.getByText("DLQ after 5 attempts")).toBeInTheDocument();
|
||||
expect(screen.getByText(/bounced ×2/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /open pr #612/i })).toHaveAttribute(
|
||||
"href",
|
||||
"https://example.com/pull/612",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists only the open findings", () => {
|
||||
findings.mockReturnValue({
|
||||
data: {
|
||||
findings: [
|
||||
{
|
||||
id: "f1",
|
||||
task_id: "t1",
|
||||
origin: "qa",
|
||||
round: 1,
|
||||
author_slug: "be-qa",
|
||||
file: "roboco/services/queue.py",
|
||||
line: 42,
|
||||
severity: "major",
|
||||
criterion: null,
|
||||
expected: "Backoff is exponential",
|
||||
actual: "Fixed 1s delay",
|
||||
fix: "Use exponential backoff with jitter",
|
||||
evidence: null,
|
||||
status: "open",
|
||||
addressed_by_commit: null,
|
||||
resolution_note: null,
|
||||
created_at: "2026-07-19T08:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
task_id: "t1",
|
||||
origin: "qa",
|
||||
round: 1,
|
||||
author_slug: "be-qa",
|
||||
file: "roboco/services/dlq.py",
|
||||
line: 7,
|
||||
severity: "minor",
|
||||
criterion: null,
|
||||
expected: "x",
|
||||
actual: "y",
|
||||
fix: null,
|
||||
evidence: null,
|
||||
status: "verified",
|
||||
addressed_by_commit: null,
|
||||
resolution_note: null,
|
||||
created_at: "2026-07-19T08:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
summary: [],
|
||||
total: 2,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
render(<TgTaskSheet task={task()} onClose={vi.fn()} />);
|
||||
expect(screen.getByText(/open findings · 1/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("roboco/services/queue.py:42")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/dlq\.py/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,22 @@ import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TgTodayTab } from "../tg-today-tab";
|
||||
|
||||
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
vi.mock("@/lib/api/client", () => ({ default: { get } }));
|
||||
const { get, ackMock, notifItems } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
ackMock: vi.fn(),
|
||||
notifItems: { current: [] as Array<Record<string, unknown>> },
|
||||
}));
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
default: { get },
|
||||
getErrorMessage: () => "error",
|
||||
}));
|
||||
vi.mock("@/hooks/use-notifications", () => ({
|
||||
notificationKeys: { all: ["notifications"] },
|
||||
useNotifications: () => ({ data: { items: notifItems.current } }),
|
||||
}));
|
||||
vi.mock("@/lib/api/notifications", () => ({
|
||||
notificationsApi: { acknowledge: ackMock },
|
||||
}));
|
||||
|
||||
function renderTab(onNavigate = vi.fn()) {
|
||||
const client = new QueryClient({
|
||||
@@ -52,14 +66,28 @@ describe("TgTodayTab", () => {
|
||||
// vitest call it as an after-test teardown hook.
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
ackMock.mockReset();
|
||||
notifItems.current = [];
|
||||
// Reduced motion → the spend count-up lands instantly; these tests
|
||||
// assert content, not animation timing.
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes("prefers-reduced-motion"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as typeof window.matchMedia;
|
||||
});
|
||||
|
||||
it("shows skeletons while loading", () => {
|
||||
get.mockReturnValue(new Promise(() => {}));
|
||||
renderTab();
|
||||
expect(document.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
document.querySelectorAll("[data-slot=skeleton]").length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the all-clear state and the spend/ship numbers", async () => {
|
||||
@@ -67,7 +95,8 @@ describe("TgTodayTab", () => {
|
||||
renderTab();
|
||||
|
||||
expect(await screen.findByText(/all clear/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("$12.34")).toBeInTheDocument();
|
||||
// The spend hero counts up to the target, so wait for the final frame.
|
||||
expect(await screen.findByText("$12.34")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.2M tokens/)).toBeInTheDocument();
|
||||
expect(screen.getByText("v0.25.0")).toBeInTheDocument();
|
||||
expect(screen.getByText(/no release pending/i)).toBeInTheDocument();
|
||||
@@ -110,6 +139,91 @@ describe("TgTodayTab", () => {
|
||||
expect(onNavigate).toHaveBeenCalledWith("board");
|
||||
});
|
||||
|
||||
it("renders the operations ring and deep-links Ship into the release", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: brief({
|
||||
ship: { version: "0.25.0", open_release_proposal: true, ci_fix_tasks: 0 },
|
||||
}),
|
||||
});
|
||||
const onNavigate = vi.fn();
|
||||
renderTab(onNavigate);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /ship/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith("approvals", "release");
|
||||
expect(screen.getByRole("button", { name: /sweep/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /fleet/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ack-all acknowledges every pending notification", async () => {
|
||||
get.mockResolvedValue({ data: brief() });
|
||||
notifItems.current = [
|
||||
{ id: "n1", requires_ack: true, is_acknowledged: false },
|
||||
{ id: "n2", requires_ack: true, is_acknowledged: false },
|
||||
{ id: "n3", requires_ack: false, is_acknowledged: false },
|
||||
];
|
||||
ackMock.mockResolvedValue({});
|
||||
renderTab();
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole("button", { name: /ack all/i }),
|
||||
);
|
||||
await waitFor(() => expect(ackMock).toHaveBeenCalledTimes(2));
|
||||
expect(ackMock).toHaveBeenCalledWith("n1");
|
||||
expect(ackMock).toHaveBeenCalledWith("n2");
|
||||
});
|
||||
|
||||
it("opens the fleet sheet with the full working roster", async () => {
|
||||
get.mockResolvedValue({
|
||||
data: brief({
|
||||
fleet: {
|
||||
total: 26,
|
||||
by_status: { active: 5, idle: 21 },
|
||||
working: [
|
||||
{
|
||||
name: "be-dev-1",
|
||||
role: "developer",
|
||||
team: "backend",
|
||||
task_title: "Task A",
|
||||
},
|
||||
{
|
||||
name: "be-dev-2",
|
||||
role: "developer",
|
||||
team: "backend",
|
||||
task_title: "Task B",
|
||||
},
|
||||
{
|
||||
name: "fe-dev-1",
|
||||
role: "developer",
|
||||
team: "frontend",
|
||||
task_title: "Task C",
|
||||
},
|
||||
{
|
||||
name: "fe-qa",
|
||||
role: "qa",
|
||||
team: "frontend",
|
||||
task_title: "Task D",
|
||||
},
|
||||
{
|
||||
name: "ux-dev-1",
|
||||
role: "developer",
|
||||
team: "ux_ui",
|
||||
task_title: "Task E",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
renderTab();
|
||||
|
||||
// The section previews 3 of 5; tapping it opens the full-roster sheet.
|
||||
await userEvent.click(
|
||||
await screen.findByText(/\+2 more · tap for the full roster/),
|
||||
);
|
||||
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task E")).toBeInTheDocument();
|
||||
expect(screen.getByText(/idle · 21/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error state when the brief fails to load", async () => {
|
||||
get.mockRejectedValue(new Error("boom"));
|
||||
renderTab();
|
||||
|
||||
Reference in New Issue
Block a user