jettypod 4.4.118 → 4.4.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { useCallback, useRef, useEffect, useState, memo } from 'react';
|
|
2
|
+
import { Link } from 'react-router-dom';
|
|
3
|
+
import { useDroppable } from '@dnd-kit/core';
|
|
4
|
+
import type { WorkItem, InFlightItem } from '@/lib/db';
|
|
5
|
+
import { KanbanCard } from './KanbanCard';
|
|
6
|
+
import { useDragContext } from './DragContext';
|
|
7
|
+
import { DraggableCard } from './DraggableCard';
|
|
8
|
+
import { TypeIcon } from './TypeIcon';
|
|
9
|
+
|
|
10
|
+
// Safe bounds for display_order to prevent overflow
|
|
11
|
+
export const MIN_DISPLAY_ORDER = 0;
|
|
12
|
+
export const MAX_DISPLAY_ORDER = Number.MAX_SAFE_INTEGER - 1000;
|
|
13
|
+
export const DISPLAY_ORDER_INCREMENT = 10;
|
|
14
|
+
|
|
15
|
+
export interface EpicGroupProps {
|
|
16
|
+
epicId: number | null;
|
|
17
|
+
epicTitle: string | null;
|
|
18
|
+
items: WorkItem[];
|
|
19
|
+
isInFlight?: boolean;
|
|
20
|
+
inFlightItems?: InFlightItem[];
|
|
21
|
+
isDraggable?: boolean;
|
|
22
|
+
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
23
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
24
|
+
onReject?: (id: number, reason: string) => Promise<void>;
|
|
25
|
+
onRestart?: (id: number) => void;
|
|
26
|
+
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
27
|
+
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
28
|
+
onTriggerClaude?: (id: number, title: string, type: string, conversational?: boolean, description?: string | null) => void;
|
|
29
|
+
activeSessionIds?: Set<string>;
|
|
30
|
+
onOpenSession?: (id: string) => void;
|
|
31
|
+
onCloseSession?: (id: string) => void;
|
|
32
|
+
onError?: (message: string) => void;
|
|
33
|
+
usageAllowed?: boolean;
|
|
34
|
+
// Animation state lifted to board level
|
|
35
|
+
animatingItemId?: number | null;
|
|
36
|
+
onAnimationComplete?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const EpicGroup = memo(function EpicGroup({ epicId, epicTitle, items, isInFlight = false, inFlightItems, isDraggable = true, onTitleSave, onStatusChange, onReject, onRestart, onEpicAssign, onOrderChange, onTriggerClaude, activeSessionIds, onOpenSession, onCloseSession, onError, usageAllowed = true, animatingItemId, onAnimationComplete }: EpicGroupProps) {
|
|
40
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const { isDragging, draggedItem, activeEpicZone, activeDropZone, registerEpicDropZone, unregisterEpicDropZone, getCardPositions, pointerPositionRef } = useDragContext();
|
|
42
|
+
|
|
43
|
+
// Use @dnd-kit's useDroppable for epic zone collision detection
|
|
44
|
+
const zoneId = epicId !== null ? `epic-${epicId}` : undefined;
|
|
45
|
+
const { setNodeRef } = useDroppable({
|
|
46
|
+
id: zoneId || 'ungrouped',
|
|
47
|
+
disabled: epicId === null, // Don't use droppable for ungrouped section
|
|
48
|
+
data: { epicId },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Combine refs
|
|
52
|
+
const setRefs = useCallback((node: HTMLDivElement | null) => {
|
|
53
|
+
if (epicId !== null) {
|
|
54
|
+
setNodeRef(node);
|
|
55
|
+
}
|
|
56
|
+
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
57
|
+
}, [epicId, setNodeRef]);
|
|
58
|
+
|
|
59
|
+
// Use ref for items to avoid re-registering drop zone when items change
|
|
60
|
+
const itemsRef = useRef(items);
|
|
61
|
+
itemsRef.current = items;
|
|
62
|
+
|
|
63
|
+
// Use ref for callbacks to keep drop zone registration stable
|
|
64
|
+
const onOrderChangeRef = useRef(onOrderChange);
|
|
65
|
+
onOrderChangeRef.current = onOrderChange;
|
|
66
|
+
|
|
67
|
+
// Use ref for error handler to keep reorder handler stable
|
|
68
|
+
const onErrorRef = useRef(onError);
|
|
69
|
+
onErrorRef.current = onError;
|
|
70
|
+
|
|
71
|
+
// Stable reorder handler that reads from refs
|
|
72
|
+
const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
73
|
+
if (!onOrderChangeRef.current) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const currentItems = itemsRef.current.filter(item => item.id !== itemId);
|
|
78
|
+
if (currentItems.length === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Read fresh positions from DOM (not stale cache)
|
|
83
|
+
const allPositions = getCardPositions();
|
|
84
|
+
const itemIds = new Set(currentItems.map(item => item.id));
|
|
85
|
+
const cardPositions = allPositions
|
|
86
|
+
.filter(pos => itemIds.has(pos.id))
|
|
87
|
+
.map(pos => ({
|
|
88
|
+
id: pos.id,
|
|
89
|
+
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
90
|
+
}))
|
|
91
|
+
.sort((a, b) => a.midY - b.midY);
|
|
92
|
+
|
|
93
|
+
if (cardPositions.length === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find insertion index based on pointer Y
|
|
98
|
+
let insertIndex = cardPositions.length;
|
|
99
|
+
for (let i = 0; i < cardPositions.length; i++) {
|
|
100
|
+
if (pointerY < cardPositions[i].midY) {
|
|
101
|
+
insertIndex = i;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Map visual positions to items for display_order midpoint calculation
|
|
107
|
+
const itemMap = new Map(currentItems.map(item => [item.id, item]));
|
|
108
|
+
const visualOrder = cardPositions.map(pos => itemMap.get(pos.id)!).filter(Boolean);
|
|
109
|
+
|
|
110
|
+
// Calculate proper midpoint display_order between surrounding items.
|
|
111
|
+
// Fallback uses id * INCREMENT to match the sort comparator and give proper gaps.
|
|
112
|
+
let newOrder: number;
|
|
113
|
+
if (visualOrder.length === 0) {
|
|
114
|
+
newOrder = DISPLAY_ORDER_INCREMENT;
|
|
115
|
+
} else if (insertIndex === 0) {
|
|
116
|
+
const firstOrder = visualOrder[0].display_order ?? visualOrder[0].id * DISPLAY_ORDER_INCREMENT;
|
|
117
|
+
newOrder = firstOrder - DISPLAY_ORDER_INCREMENT;
|
|
118
|
+
} else if (insertIndex >= visualOrder.length) {
|
|
119
|
+
const lastOrder = visualOrder[visualOrder.length - 1].display_order ?? visualOrder[visualOrder.length - 1].id * DISPLAY_ORDER_INCREMENT;
|
|
120
|
+
newOrder = lastOrder + DISPLAY_ORDER_INCREMENT;
|
|
121
|
+
} else {
|
|
122
|
+
const before = visualOrder[insertIndex - 1].display_order ?? visualOrder[insertIndex - 1].id * DISPLAY_ORDER_INCREMENT;
|
|
123
|
+
const after = visualOrder[insertIndex].display_order ?? visualOrder[insertIndex].id * DISPLAY_ORDER_INCREMENT;
|
|
124
|
+
newOrder = Math.floor((before + after) / 2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
newOrder = Math.max(MIN_DISPLAY_ORDER, Math.min(MAX_DISPLAY_ORDER, newOrder));
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await onOrderChangeRef.current(itemId, newOrder);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const errorMessage = error instanceof Error ? error.message : 'Failed to reorder item. Please try again.';
|
|
133
|
+
onErrorRef.current?.(errorMessage);
|
|
134
|
+
}
|
|
135
|
+
}, [getCardPositions]);
|
|
136
|
+
|
|
137
|
+
// Register as epic drop zone - stable registration that doesn't change with items
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!containerRef.current || !onEpicAssign || epicId === null) return;
|
|
140
|
+
|
|
141
|
+
const zoneId = `epic-${epicId}`;
|
|
142
|
+
registerEpicDropZone(zoneId, {
|
|
143
|
+
epicId,
|
|
144
|
+
element: containerRef.current,
|
|
145
|
+
onEpicAssign,
|
|
146
|
+
onReorder: handleEpicReorder,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
unregisterEpicDropZone(zoneId);
|
|
151
|
+
};
|
|
152
|
+
}, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
|
|
153
|
+
|
|
154
|
+
// Check if this epic zone is the active drop target
|
|
155
|
+
const isActiveTarget = activeEpicZone === `epic-${epicId}`;
|
|
156
|
+
|
|
157
|
+
// Check if the dragged item is from a different epic or same epic
|
|
158
|
+
const draggedItemEpicId = draggedItem ? (draggedItem.parent_id || draggedItem.epic_id) : null;
|
|
159
|
+
const isDifferentEpic = isDragging && draggedItem && draggedItemEpicId !== epicId;
|
|
160
|
+
const isSameEpic = isDragging && draggedItem && draggedItemEpicId === epicId;
|
|
161
|
+
|
|
162
|
+
// Show highlight when dragging an item from different epic over this group (indigo)
|
|
163
|
+
const showHighlight = isActiveTarget && isDifferentEpic;
|
|
164
|
+
// Show reorder highlight when dragging within same epic (purple)
|
|
165
|
+
const showReorderHighlight = isActiveTarget && isSameEpic;
|
|
166
|
+
|
|
167
|
+
// For ungrouped section (epicId === null)
|
|
168
|
+
const isUngroupedSection = epicId === null;
|
|
169
|
+
// Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
|
|
170
|
+
const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
|
|
171
|
+
|
|
172
|
+
// Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
|
|
173
|
+
const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
174
|
+
const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
175
|
+
|
|
176
|
+
// Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
|
|
177
|
+
const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
|
|
178
|
+
|
|
179
|
+
// Insertion preview — tracks pointer position via rAF loop so the line
|
|
180
|
+
// moves continuously even when DragContext change guards suppress re-renders.
|
|
181
|
+
const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
|
|
182
|
+
const [insertAfterItemId, setInsertAfterItemId] = useState<number | null | undefined>(undefined);
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!showPreview || !draggedItem) {
|
|
186
|
+
setInsertAfterItemId(undefined);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Build item ID set once — items array is stable during a drag
|
|
191
|
+
const itemIds = new Set(items.map(item => item.id));
|
|
192
|
+
const dragId = draggedItem.id;
|
|
193
|
+
|
|
194
|
+
let rafId: number;
|
|
195
|
+
let lastTickTime = 0;
|
|
196
|
+
// Reusable array to avoid allocations per frame
|
|
197
|
+
const groupPositions: Array<{ id: number; midY: number }> = [];
|
|
198
|
+
|
|
199
|
+
const tick = () => {
|
|
200
|
+
const now = performance.now();
|
|
201
|
+
// Throttle to ~100ms — fast enough for smooth preview, avoids layout
|
|
202
|
+
// thrashing on slower hardware (Intel Macs).
|
|
203
|
+
if (now - lastTickTime < 100) {
|
|
204
|
+
rafId = requestAnimationFrame(tick);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
lastTickTime = now;
|
|
208
|
+
|
|
209
|
+
const allPositions = getCardPositions();
|
|
210
|
+
groupPositions.length = 0;
|
|
211
|
+
for (const pos of allPositions) {
|
|
212
|
+
if (itemIds.has(pos.id) && pos.id !== dragId) {
|
|
213
|
+
groupPositions.push({ id: pos.id, midY: (pos.rect.top + pos.rect.bottom) / 2 });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
groupPositions.sort((a, b) => a.midY - b.midY);
|
|
217
|
+
|
|
218
|
+
const currentPointerY = pointerPositionRef.current.y;
|
|
219
|
+
let newInsert: number | null = null;
|
|
220
|
+
for (const pos of groupPositions) {
|
|
221
|
+
if (currentPointerY > pos.midY) {
|
|
222
|
+
newInsert = pos.id;
|
|
223
|
+
} else {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setInsertAfterItemId(prev => prev === newInsert ? prev : newInsert);
|
|
229
|
+
rafId = requestAnimationFrame(tick);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
rafId = requestAnimationFrame(tick);
|
|
233
|
+
return () => cancelAnimationFrame(rafId);
|
|
234
|
+
}, [showPreview, draggedItem, items, getCardPositions, pointerPositionRef]);
|
|
235
|
+
|
|
236
|
+
if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
|
|
237
|
+
|
|
238
|
+
// Standalone done items (single item, no epic) use tighter spacing
|
|
239
|
+
const isStandaloneItem = !epicTitle && items.length === 1;
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div
|
|
243
|
+
ref={setRefs}
|
|
244
|
+
className={`${isStandaloneItem ? 'mb-2' : 'mb-6 p-3 -mx-3'} rounded-lg transition-[color,background-color] duration-200 ease-out ${
|
|
245
|
+
showHighlight
|
|
246
|
+
? 'ring-2 ring-[#819D9F] bg-[#E8EEEF]/50 dark:bg-[#819D9F]/20'
|
|
247
|
+
: showReorderHighlight
|
|
248
|
+
? 'ring-2 ring-[#E3D985] bg-[#F9F7E8]/50 dark:bg-[#E3D985]/20'
|
|
249
|
+
: showRemoveFromEpicZone
|
|
250
|
+
? 'ring-2 ring-[#E57A44] bg-[#FCEEE6]/50 dark:bg-[#E57A44]/20'
|
|
251
|
+
: ''
|
|
252
|
+
}`}
|
|
253
|
+
data-epic-id={epicId}
|
|
254
|
+
>
|
|
255
|
+
{epicTitle && (
|
|
256
|
+
<div className="flex items-center gap-3 mb-3">
|
|
257
|
+
<Link
|
|
258
|
+
to={`/work/${epicId}`}
|
|
259
|
+
viewTransition
|
|
260
|
+
className="group/epic flex items-center gap-1.5 text-base font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
261
|
+
>
|
|
262
|
+
<TypeIcon type="epic" />
|
|
263
|
+
<span className="group-hover/epic:underline">{epicTitle}</span>
|
|
264
|
+
</Link>
|
|
265
|
+
{isInFlight && (
|
|
266
|
+
<span
|
|
267
|
+
className="relative group/inflight text-xs px-2 py-1 rounded bg-[#e8f0f0] text-[#5a7d7f] dark:bg-[#819D9F]/20 dark:text-[#a3bfc0] cursor-default"
|
|
268
|
+
>
|
|
269
|
+
in flight
|
|
270
|
+
{inFlightItems && inFlightItems.length > 0 && (
|
|
271
|
+
<span className="pointer-events-none absolute left-1/2 -translate-x-1/2 top-full mt-2 z-50 hidden group-hover/inflight:block w-max max-w-xs">
|
|
272
|
+
<span className="block rounded-lg bg-zinc-800 dark:bg-zinc-700 text-zinc-100 text-xs px-3 py-2 shadow-lg">
|
|
273
|
+
{inFlightItems.map(item => (
|
|
274
|
+
<span key={item.id} className="flex items-center gap-1 py-0.5 truncate">
|
|
275
|
+
<TypeIcon type={item.type} /> {item.title}
|
|
276
|
+
</span>
|
|
277
|
+
))}
|
|
278
|
+
</span>
|
|
279
|
+
</span>
|
|
280
|
+
)}
|
|
281
|
+
</span>
|
|
282
|
+
)}
|
|
283
|
+
{showHighlight && (
|
|
284
|
+
<span className="text-xs px-2 py-1 rounded bg-[#E8EEEF] text-[#4A6365] dark:bg-[#819D9F]/20 dark:text-[#819D9F]">
|
|
285
|
+
drop to assign
|
|
286
|
+
</span>
|
|
287
|
+
)}
|
|
288
|
+
{showReorderHighlight && (
|
|
289
|
+
<span className="text-xs px-2 py-1 rounded bg-[#F9F7E8] text-[#8B7D2F] dark:bg-[#E3D985]/20 dark:text-[#E3D985]">
|
|
290
|
+
reorder
|
|
291
|
+
</span>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
{/* Ungrouped section header - shown when dragging from epic */}
|
|
296
|
+
{isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
|
|
297
|
+
<div className="flex items-center gap-3 py-4">
|
|
298
|
+
<span className="text-base font-medium text-[#E57A44] dark:text-[#E57A44]">
|
|
299
|
+
Drop here to remove from epic
|
|
300
|
+
</span>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
{isUngroupedSection && items.length > 0 && isDraggable && showRemoveFromEpicZone && (
|
|
304
|
+
<div className="flex items-center gap-3 mb-3">
|
|
305
|
+
<span className="text-xs px-2 py-1 rounded bg-[#FCEEE6] text-[#9E4A1E] dark:bg-[#E57A44]/20 dark:text-[#E57A44]">
|
|
306
|
+
drop to remove from epic
|
|
307
|
+
</span>
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
<div className="space-y-3">
|
|
311
|
+
{items.map((item, index) => (
|
|
312
|
+
<div key={item.id}>
|
|
313
|
+
{/* CSS insertion indicator — replaces AnimatePresence+PlaceholderCard for less overhead */}
|
|
314
|
+
{index === 0 && insertAfterItemId === null && (
|
|
315
|
+
<div
|
|
316
|
+
data-testid="drag-placeholder"
|
|
317
|
+
style={{
|
|
318
|
+
height: 3,
|
|
319
|
+
borderRadius: 2,
|
|
320
|
+
background: 'linear-gradient(90deg, transparent, #819D9F, transparent)',
|
|
321
|
+
margin: '2px 8px 8px',
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
<DraggableCard item={item} disabled={!isDraggable}>
|
|
326
|
+
<KanbanCard
|
|
327
|
+
item={item}
|
|
328
|
+
onTitleSave={onTitleSave}
|
|
329
|
+
onStatusChange={onStatusChange}
|
|
330
|
+
onReject={onReject}
|
|
331
|
+
onRestart={onRestart}
|
|
332
|
+
onTriggerClaude={onTriggerClaude}
|
|
333
|
+
hasActiveSession={activeSessionIds?.has(String(item.id))}
|
|
334
|
+
onOpenSession={onOpenSession}
|
|
335
|
+
onCloseSession={onCloseSession}
|
|
336
|
+
usageAllowed={usageAllowed}
|
|
337
|
+
isCompletingAnimation={animatingItemId === item.id}
|
|
338
|
+
onAnimationComplete={onAnimationComplete}
|
|
339
|
+
isHighlighted={false}
|
|
340
|
+
/>
|
|
341
|
+
</DraggableCard>
|
|
342
|
+
{/* CSS insertion indicator after this card */}
|
|
343
|
+
{insertAfterItemId === item.id && (
|
|
344
|
+
<div
|
|
345
|
+
data-testid="drag-placeholder"
|
|
346
|
+
style={{
|
|
347
|
+
height: 3,
|
|
348
|
+
borderRadius: 2,
|
|
349
|
+
background: 'linear-gradient(90deg, transparent, #819D9F, transparent)',
|
|
350
|
+
margin: '8px 8px 2px',
|
|
351
|
+
}}
|
|
352
|
+
/>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
))}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
2
|
import { useState } from 'react';
|
|
4
3
|
import type { ClaudeMessage } from '../lib/session-stream-manager';
|
|
@@ -6,6 +5,7 @@ import { GateChoiceCard } from './GateChoiceCard';
|
|
|
6
5
|
import type { ChoiceOption } from './GateChoiceCard';
|
|
7
6
|
import { ModeStartCard, isModeStartGate } from './ModeStartCard';
|
|
8
7
|
import { TipCard } from './TipCard';
|
|
8
|
+
import { TypeIcon } from './TypeIcon';
|
|
9
9
|
|
|
10
10
|
// Gate display configuration matching JettyPod design system
|
|
11
11
|
const GATE_CONFIG: Record<string, {
|
|
@@ -90,11 +90,11 @@ const GATE_CONFIG: Record<string, {
|
|
|
90
90
|
label: 'Saving Changes',
|
|
91
91
|
bg: 'bg-white',
|
|
92
92
|
darkBg: 'dark:bg-zinc-800',
|
|
93
|
-
border: 'border-
|
|
94
|
-
darkBorder: 'dark:border-
|
|
93
|
+
border: 'border-[#819D9F]/30',
|
|
94
|
+
darkBorder: 'dark:border-[#819D9F]/30',
|
|
95
95
|
text: 'text-zinc-700',
|
|
96
96
|
darkText: 'dark:text-zinc-300',
|
|
97
|
-
fileMono: 'text-
|
|
97
|
+
fileMono: 'text-[#5a7d7f]',
|
|
98
98
|
},
|
|
99
99
|
'complete': {
|
|
100
100
|
emoji: '✨',
|
|
@@ -118,6 +118,17 @@ const GATE_CONFIG: Record<string, {
|
|
|
118
118
|
darkText: 'dark:text-zinc-300',
|
|
119
119
|
fileMono: 'text-indigo-600',
|
|
120
120
|
},
|
|
121
|
+
'work-item-card': {
|
|
122
|
+
emoji: '📋',
|
|
123
|
+
label: 'Added to Backlog',
|
|
124
|
+
bg: 'bg-white',
|
|
125
|
+
darkBg: 'dark:bg-zinc-800',
|
|
126
|
+
border: 'border-emerald-200',
|
|
127
|
+
darkBorder: 'dark:border-emerald-800',
|
|
128
|
+
text: 'text-zinc-700',
|
|
129
|
+
darkText: 'dark:text-zinc-300',
|
|
130
|
+
fileMono: 'text-emerald-600',
|
|
131
|
+
},
|
|
121
132
|
'tip': {
|
|
122
133
|
emoji: '💡',
|
|
123
134
|
label: 'Tip',
|
|
@@ -129,6 +140,17 @@ const GATE_CONFIG: Record<string, {
|
|
|
129
140
|
darkText: 'dark:text-zinc-300',
|
|
130
141
|
fileMono: 'text-teal-600',
|
|
131
142
|
},
|
|
143
|
+
'rejection': {
|
|
144
|
+
emoji: '❌',
|
|
145
|
+
label: 'Work Rejected',
|
|
146
|
+
bg: 'bg-red-50',
|
|
147
|
+
darkBg: 'dark:bg-red-900/20',
|
|
148
|
+
border: 'border-red-200',
|
|
149
|
+
darkBorder: 'dark:border-red-800',
|
|
150
|
+
text: 'text-zinc-700',
|
|
151
|
+
darkText: 'dark:text-zinc-300',
|
|
152
|
+
fileMono: 'text-red-600',
|
|
153
|
+
},
|
|
132
154
|
};
|
|
133
155
|
|
|
134
156
|
const DEFAULT_CONFIG = {
|
|
@@ -143,9 +165,7 @@ const DEFAULT_CONFIG = {
|
|
|
143
165
|
fileMono: 'text-zinc-500',
|
|
144
166
|
};
|
|
145
167
|
|
|
146
|
-
|
|
147
|
-
const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.03), 0 4px 8px rgba(0,0,0,0.02)';
|
|
148
|
-
const CARD_SHADOW_ACTIVE = '0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.04), 0 8px 16px rgba(0,0,0,0.03)';
|
|
168
|
+
import { shadow } from '@/lib/shadows';
|
|
149
169
|
|
|
150
170
|
/**
|
|
151
171
|
* Render a human-friendly description based on gate type and data
|
|
@@ -163,7 +183,8 @@ function getGateDescription(gateType: string, data: Record<string, unknown>): st
|
|
|
163
183
|
};
|
|
164
184
|
return routeLabels[route || ''] || `Routing to ${route || 'workflow'}`;
|
|
165
185
|
}
|
|
166
|
-
case 'work-created':
|
|
186
|
+
case 'work-created':
|
|
187
|
+
case 'work-item-card': {
|
|
167
188
|
const title = data.title as string | undefined;
|
|
168
189
|
const id = data.id as number | undefined;
|
|
169
190
|
return title ? `#${id || '?'} ${title}` : 'Work item created';
|
|
@@ -195,6 +216,10 @@ function getGateDescription(gateType: string, data: Record<string, unknown>): st
|
|
|
195
216
|
const question = data.question as string | undefined;
|
|
196
217
|
return question || 'A decision is needed';
|
|
197
218
|
}
|
|
219
|
+
case 'rejection': {
|
|
220
|
+
const reason = data.reason as string | undefined;
|
|
221
|
+
return reason || 'Work was rejected';
|
|
222
|
+
}
|
|
198
223
|
default:
|
|
199
224
|
return (data.message as string) || gateType;
|
|
200
225
|
}
|
|
@@ -205,9 +230,10 @@ interface GateCardProps {
|
|
|
205
230
|
isLatest?: boolean;
|
|
206
231
|
onAnswerQuestion?: (optionId: string, optionLabel: string) => void;
|
|
207
232
|
answeredQuestionId?: string | null;
|
|
233
|
+
onStartWorkItem?: (id: number, title: string, type: string) => void;
|
|
208
234
|
}
|
|
209
235
|
|
|
210
|
-
export function GateCard({ message, isLatest = false, onAnswerQuestion, answeredQuestionId }: GateCardProps) {
|
|
236
|
+
export function GateCard({ message, isLatest = false, onAnswerQuestion, answeredQuestionId, onStartWorkItem }: GateCardProps) {
|
|
211
237
|
const gateType = message.gateType || 'unknown';
|
|
212
238
|
const gateData = message.gateData || {};
|
|
213
239
|
const config = GATE_CONFIG[gateType] || DEFAULT_CONFIG;
|
|
@@ -228,6 +254,42 @@ export function GateCard({ message, isLatest = false, onAnswerQuestion, answered
|
|
|
228
254
|
return <TipCard tipId={tipId} icon={icon} title={title} body={body} />;
|
|
229
255
|
}
|
|
230
256
|
|
|
257
|
+
// Work item card gates render as mini kanban cards with Start button
|
|
258
|
+
if (gateType === 'work-item-card') {
|
|
259
|
+
const itemId = gateData.id as number;
|
|
260
|
+
const itemTitle = gateData.title as string;
|
|
261
|
+
const itemType = (gateData.type as string) || 'feature';
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
className="bg-white dark:bg-zinc-800 border-2 border-emerald-200 dark:border-emerald-800 rounded-xl p-4 transition-shadow duration-200 ease-out"
|
|
265
|
+
style={{ boxShadow: shadow.sm }}
|
|
266
|
+
data-testid="gate-work-item-card"
|
|
267
|
+
>
|
|
268
|
+
<div className="flex items-center justify-between gap-3">
|
|
269
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
270
|
+
<span className="text-base flex-shrink-0"><TypeIcon type={itemType} /></span>
|
|
271
|
+
<div className="min-w-0">
|
|
272
|
+
<span className="text-xs font-medium text-zinc-400 dark:text-zinc-500">
|
|
273
|
+
#{itemId} · {itemType}
|
|
274
|
+
</span>
|
|
275
|
+
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 truncate">
|
|
276
|
+
{itemTitle}
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
{onStartWorkItem && (
|
|
281
|
+
<button
|
|
282
|
+
onClick={() => onStartWorkItem(itemId, itemTitle, itemType)}
|
|
283
|
+
className="flex-shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-[#819D9F] hover:bg-[#6b8587] text-white transition-colors duration-150"
|
|
284
|
+
>
|
|
285
|
+
Start
|
|
286
|
+
</button>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
231
293
|
// Question gates render as interactive choice cards
|
|
232
294
|
if (gateType === 'question') {
|
|
233
295
|
const question = (gateData.question as string) || 'A decision is needed';
|
|
@@ -259,24 +321,24 @@ export function GateCard({ message, isLatest = false, onAnswerQuestion, answered
|
|
|
259
321
|
<div
|
|
260
322
|
className={`
|
|
261
323
|
${config.bg} ${config.darkBg}
|
|
262
|
-
border ${config.border} ${config.darkBorder}
|
|
263
|
-
rounded-xl p-
|
|
264
|
-
${isLatest ? 'ring-2 ring-
|
|
324
|
+
border-2 ${config.border} ${config.darkBorder}
|
|
325
|
+
rounded-xl p-4 transition-shadow duration-200 ease-out
|
|
326
|
+
${isLatest ? 'ring-2 ring-[#819D9F]/50 ring-offset-1' : ''}
|
|
265
327
|
`}
|
|
266
|
-
style={{ boxShadow: isLatest ?
|
|
328
|
+
style={{ boxShadow: isLatest ? shadow.md : shadow.sm }}
|
|
267
329
|
data-testid={`gate-card-${gateType}`}
|
|
268
330
|
>
|
|
269
|
-
<div className="flex items-start gap-
|
|
331
|
+
<div className="flex items-start gap-4">
|
|
270
332
|
<span className="text-base flex-shrink-0 mt-0.5">{config.emoji}</span>
|
|
271
333
|
<div className="flex-1 min-w-0">
|
|
272
|
-
<span className={`text-
|
|
334
|
+
<span className={`text-base font-semibold ${config.text} ${config.darkText}`}>
|
|
273
335
|
{config.label}
|
|
274
336
|
</span>
|
|
275
|
-
<p className={`text-
|
|
337
|
+
<p className={`text-base mt-1 ${config.text} ${config.darkText}`}>
|
|
276
338
|
{description}
|
|
277
339
|
</p>
|
|
278
340
|
{hasFiles && (
|
|
279
|
-
<div className="mt-
|
|
341
|
+
<div className="mt-3 space-y-1">
|
|
280
342
|
{(gateData.files as string[]).map((file, i) => (
|
|
281
343
|
<div key={i} className={`text-xs font-mono truncate ${config.fileMono}`}>
|
|
282
344
|
{file}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
'use client';
|
|
2
1
|
|
|
3
2
|
import { useState } from 'react';
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
const CARD_SHADOW = '0 1px 2px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.03), 0 4px 8px rgba(0,0,0,0.02)';
|
|
7
|
-
const CARD_SHADOW_HOVER = '0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.04), 0 8px 16px rgba(0,0,0,0.03), 0 12px 24px rgba(129,157,159,0.08)';
|
|
4
|
+
import { shadow } from '@/lib/shadows';
|
|
8
5
|
|
|
9
6
|
export interface ChoiceOption {
|
|
10
7
|
id: string;
|
|
@@ -32,22 +29,22 @@ export function GateChoiceCard({
|
|
|
32
29
|
|
|
33
30
|
return (
|
|
34
31
|
<div
|
|
35
|
-
className="bg-white dark:bg-zinc-800
|
|
36
|
-
style={{ boxShadow:
|
|
32
|
+
className="bg-white dark:bg-zinc-800 rounded-xl p-4 transition-shadow duration-200 ease-out"
|
|
33
|
+
style={{ boxShadow: shadow.sm }}
|
|
37
34
|
data-testid="gate-choice-card"
|
|
38
35
|
>
|
|
39
|
-
<div className="flex items-start gap-
|
|
36
|
+
<div className="flex items-start gap-4">
|
|
40
37
|
<span className="text-base flex-shrink-0 mt-0.5">💬</span>
|
|
41
38
|
<div className="flex-1 min-w-0">
|
|
42
|
-
<span className="text-
|
|
39
|
+
<span className="text-base font-semibold text-zinc-700 dark:text-zinc-300">
|
|
43
40
|
Input Needed
|
|
44
41
|
</span>
|
|
45
|
-
<p className="text-
|
|
42
|
+
<p className="text-base mt-1 text-zinc-700 dark:text-zinc-300">
|
|
46
43
|
{question}
|
|
47
44
|
</p>
|
|
48
45
|
|
|
49
46
|
{/* Option cards */}
|
|
50
|
-
<div className="mt-
|
|
47
|
+
<div className="mt-4 space-y-3">
|
|
51
48
|
{options.map((option) => {
|
|
52
49
|
const isSelected = selectedId === option.id;
|
|
53
50
|
const isHovered = hoveredId === option.id;
|
|
@@ -60,35 +57,35 @@ export function GateChoiceCard({
|
|
|
60
57
|
onMouseLeave={() => setHoveredId(null)}
|
|
61
58
|
disabled={disabled && !isSelected}
|
|
62
59
|
className={`
|
|
63
|
-
w-full text-left rounded-xl border p-
|
|
60
|
+
w-full text-left rounded-xl border-2 p-4 transition-[color,background-color,border-color] duration-200 ease-out
|
|
64
61
|
${isSelected
|
|
65
|
-
? 'border-
|
|
62
|
+
? 'border-[#819D9F] dark:border-[#819D9F] bg-[#e8f0f0] dark:bg-[#819D9F]/20 ring-2 ring-[#819D9F]/30'
|
|
66
63
|
: 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-600'
|
|
67
64
|
}
|
|
68
65
|
${disabled && !isSelected ? 'opacity-50 cursor-default' : 'cursor-pointer'}
|
|
69
66
|
${!disabled && !isSelected ? 'hover:-translate-y-0.5' : ''}
|
|
70
67
|
`}
|
|
71
68
|
style={{
|
|
72
|
-
boxShadow: isSelected ?
|
|
69
|
+
boxShadow: isSelected ? shadow.lg : isHovered && !disabled ? shadow.lg : shadow.sm,
|
|
73
70
|
}}
|
|
74
71
|
data-testid={`choice-option-${option.id}`}
|
|
75
72
|
>
|
|
76
|
-
<div className="flex items-start gap-
|
|
73
|
+
<div className="flex items-start gap-4">
|
|
77
74
|
{option.emoji && (
|
|
78
75
|
<span className="text-base flex-shrink-0">{option.emoji}</span>
|
|
79
76
|
)}
|
|
80
77
|
<div className="flex-1 min-w-0">
|
|
81
|
-
<div className="flex items-center gap-
|
|
82
|
-
<span className={`text-
|
|
78
|
+
<div className="flex items-center gap-3">
|
|
79
|
+
<span className={`text-base font-medium ${isSelected ? 'text-[#5a7d7f] dark:text-[#a3bfc0]' : 'text-zinc-900 dark:text-zinc-100'}`}>
|
|
83
80
|
{option.label}
|
|
84
81
|
</span>
|
|
85
82
|
{isSelected && (
|
|
86
|
-
<svg className="w-4 h-4 text-
|
|
83
|
+
<svg className="w-4 h-4 text-[#819D9F] flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
87
84
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
88
85
|
</svg>
|
|
89
86
|
)}
|
|
90
87
|
</div>
|
|
91
|
-
<p className={`text-
|
|
88
|
+
<p className={`text-base mt-1 ${isSelected ? 'text-[#5a7d7f]/70 dark:text-[#a3bfc0]/70' : 'text-zinc-500 dark:text-zinc-400'}`}>
|
|
92
89
|
{option.description}
|
|
93
90
|
</p>
|
|
94
91
|
</div>
|