jettypod 4.4.120 → 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.
Files changed (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -1,10 +1,8 @@
1
- 'use client';
2
1
 
3
- import { useState, memo } from 'react';
4
- import Link from 'next/link';
5
- import { useRouter } from 'next/navigation';
6
- import { m } from 'framer-motion';
2
+ import { useState, useRef, useCallback, memo } from 'react';
3
+ import { Link, useNavigate } from 'react-router-dom';
7
4
  import type { WorkItem } from '@/lib/db';
5
+ import { prefetch } from '@/lib/data-bridge';
8
6
  import { EditableTitle } from './EditableTitle';
9
7
  import { CardMenu } from './CardMenu';
10
8
  import { CopyableId } from './CopyableId';
@@ -15,6 +13,26 @@ import { MODE_LABELS } from '@/lib/constants';
15
13
  import { TypeIcon } from './TypeIcon';
16
14
  import { shadow } from '@/lib/shadows';
17
15
 
16
+ function formatRejectionTime(isoString: string): string {
17
+ try {
18
+ const date = new Date(isoString);
19
+ const now = new Date();
20
+ const diffMs = now.getTime() - date.getTime();
21
+ const diffMins = Math.floor(diffMs / 60000);
22
+ const diffHours = Math.floor(diffMs / 3600000);
23
+ const diffDays = Math.floor(diffMs / 86400000);
24
+
25
+ if (diffMins < 1) return 'just now';
26
+ if (diffMins < 60) return `${diffMins}m ago`;
27
+ if (diffHours < 24) return `${diffHours}h ago`;
28
+ if (diffDays === 1) return 'yesterday';
29
+ if (diffDays < 7) return `${diffDays}d ago`;
30
+ return date.toLocaleDateString();
31
+ } catch {
32
+ return '';
33
+ }
34
+ }
35
+
18
36
  function getModeLabel(item: WorkItem): string {
19
37
  if (!item.mode) return '';
20
38
  const base = MODE_LABELS[item.mode]?.label || item.mode;
@@ -33,6 +51,122 @@ function SessionIndicatorIcon({ className }: { className?: string }) {
33
51
  );
34
52
  }
35
53
 
54
+ interface RejectionHistoryEntry {
55
+ round: number;
56
+ reason: string;
57
+ at: string;
58
+ }
59
+
60
+ function RejectionTimeline({ item, allChores, allBugs, isDone }: { item: WorkItem; allChores: WorkItem[]; allBugs: WorkItem[]; isDone: boolean }) {
61
+ let history: RejectionHistoryEntry[] = [];
62
+ try {
63
+ history = item.rejection_history ? JSON.parse(item.rejection_history) : [];
64
+ } catch {
65
+ // Corrupt JSON — degrade gracefully, show segments without reasons
66
+ }
67
+ const allChildren = [...allChores, ...allBugs];
68
+
69
+ // Build timeline segments
70
+ const segments: Array<{ label: string; round: number | null; reason: string | null; isLatest: boolean; children: WorkItem[] }> = [];
71
+
72
+ // Original segment (children with null rejection_round)
73
+ const originalChildren = allChildren.filter(c => c.rejection_round == null);
74
+ if (originalChildren.length > 0) {
75
+ segments.push({ label: 'Original', round: null, reason: null, isLatest: false, children: originalChildren });
76
+ }
77
+
78
+ // Rejection round segments
79
+ for (let r = 1; r <= (item.rejection_count || 0); r++) {
80
+ const roundChildren = allChildren.filter(c => c.rejection_round === r);
81
+ const entry = history.find(h => h.round === r);
82
+ segments.push({
83
+ label: `Rejection #${r}`,
84
+ round: r,
85
+ reason: entry?.reason || null,
86
+ isLatest: r === item.rejection_count,
87
+ children: roundChildren,
88
+ });
89
+ }
90
+
91
+ return (
92
+ <div className="relative pl-[18px]">
93
+ {segments.map((seg, idx) => {
94
+ const isLast = idx === segments.length - 1;
95
+ return (
96
+ <div key={seg.label} className="relative" style={{ paddingBottom: isLast ? 0 : 4 }}>
97
+ {/* Vertical connector line */}
98
+ {!isLast && (
99
+ <div
100
+ className="absolute border-l-2 border-zinc-200 dark:border-zinc-700"
101
+ style={{ left: -12, top: 14, bottom: -4 }}
102
+ />
103
+ )}
104
+ {/* Node marker + label */}
105
+ <div className="relative flex items-center gap-2 py-1">
106
+ <div
107
+ className="absolute rounded-full"
108
+ style={{
109
+ left: -16, top: '50%', transform: 'translateY(-50%)',
110
+ width: 10, height: 10,
111
+ background: seg.round === null ? '#819D9F' : '#ef4444',
112
+ border: '2px solid var(--card, #27272a)',
113
+ zIndex: 1,
114
+ ...(seg.isLatest ? { boxShadow: '0 0 0 3px rgba(239, 68, 68, 0.2)' } : {}),
115
+ }}
116
+ />
117
+ <span className={`text-[11px] font-semibold uppercase tracking-wide ${
118
+ seg.round === null ? 'text-[#819D9F]' : 'text-red-400 dark:text-red-400'
119
+ }`}>{seg.label}</span>
120
+ {seg.reason && (
121
+ <span className="text-[11px] italic text-zinc-500 dark:text-zinc-500 truncate max-w-[180px]">
122
+ {seg.reason}
123
+ </span>
124
+ )}
125
+ </div>
126
+ {/* Children under this segment */}
127
+ <div className="py-0.5">
128
+ {seg.children.map((child) => {
129
+ const isComplete = child.status === 'done';
130
+ const isBug = child.type === 'bug';
131
+ return (
132
+ <Link
133
+ key={child.id}
134
+ to={`/work/${child.id}`}
135
+ viewTransition
136
+ className={`block py-1.5 px-2.5 text-base rounded transition-colors duration-200 ease-out ${
137
+ isComplete
138
+ ? 'bg-zinc-100 dark:bg-zinc-800/50'
139
+ : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
140
+ }`}
141
+ >
142
+ <div className="flex items-center gap-3">
143
+ <span className={`font-mono text-xs ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{child.id}</span>
144
+ {isBug && <TypeIcon type="bug" />}
145
+ {!isDone && !isBug && child.mode && MODE_LABELS[child.mode] && (
146
+ <span className={`px-1 py-0.5 rounded text-[10px] ${MODE_LABELS[child.mode].color}`}>
147
+ {getModeLabel(child)}
148
+ </span>
149
+ )}
150
+ <span className={`truncate ${
151
+ isComplete ? 'text-zinc-500' : 'text-zinc-700 dark:text-zinc-300'
152
+ }`}>
153
+ {child.title || <span className="text-zinc-400 italic">(Untitled)</span>}
154
+ </span>
155
+ </div>
156
+ </Link>
157
+ );
158
+ })}
159
+ {seg.children.length === 0 && (
160
+ <span className="text-xs text-zinc-500 italic pl-2.5">No items</span>
161
+ )}
162
+ </div>
163
+ </div>
164
+ );
165
+ })}
166
+ </div>
167
+ );
168
+ }
169
+
36
170
  export interface KanbanCardProps {
37
171
  item: WorkItem;
38
172
  epicTitle?: string | null;
@@ -58,7 +192,8 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
58
192
  const [showRejectInput, setShowRejectInput] = useState(false);
59
193
  const [rejectReason, setRejectReason] = useState('');
60
194
  const [isEditingTitle, setIsEditingTitle] = useState(false);
61
- const router = useRouter();
195
+ const [rejectionExpanded, setRejectionExpanded] = useState(false);
196
+ const navigate = useNavigate();
62
197
 
63
198
  const handleOpenSession = (e: React.MouseEvent) => {
64
199
  e.stopPropagation(); // Prevent card navigation
@@ -131,10 +266,25 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
131
266
  const incompleteBugs = allBugs.filter(b => b.status !== 'done');
132
267
  const hasBugs = allBugs.length > 0;
133
268
 
269
+ const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
270
+
134
271
  const handleCardClick = () => {
135
- router.push(`/work/${item.id}`);
272
+ navigate(`/work/${item.id}`);
136
273
  };
137
274
 
275
+ const handleMouseEnter = useCallback(() => {
276
+ hoverTimer.current = setTimeout(() => {
277
+ prefetch.workItem(item.id);
278
+ }, 100);
279
+ }, [item.id]);
280
+
281
+ const handleMouseLeave = useCallback(() => {
282
+ if (hoverTimer.current) {
283
+ clearTimeout(hoverTimer.current);
284
+ hoverTimer.current = null;
285
+ }
286
+ }, []);
287
+
138
288
  const handleTitleSave = async (id: number, newTitle: string) => {
139
289
  if (onTitleSave) {
140
290
  await onTitleSave(id, newTitle);
@@ -150,27 +300,23 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
150
300
 
151
301
  const isDone = item.status === 'done';
152
302
 
153
- const getCardStyles = () => {
154
- return {
155
- className: 'bg-white dark:bg-zinc-800',
156
- boxShadow: shadow.sm,
157
- hoverBoxShadow: shadow.lg,
158
- };
303
+ const cardStyles = {
304
+ className: 'bg-white dark:bg-zinc-800',
305
+ boxShadow: shadow.sm,
306
+ hoverBoxShadow: shadow.lg,
159
307
  };
160
308
 
161
- const cardStyles = getCardStyles();
162
-
163
309
  const cardContent = (
164
310
  <WaveCompletionAnimation isPlaying={isCompletingAnimation} onComplete={onAnimationComplete || (() => {})}>
165
311
  <div
166
- className={`rounded-xl overflow-hidden transition-[color,background-color,border-color,box-shadow,transform,translate,opacity] duration-200 ease-out hover:-translate-y-0.5 ${cardStyles.className}`}
167
- style={{ boxShadow: cardStyles.boxShadow }}
168
- onMouseEnter={(e) => { e.currentTarget.style.boxShadow = cardStyles.hoverBoxShadow; }}
169
- onMouseLeave={(e) => { e.currentTarget.style.boxShadow = cardStyles.boxShadow; }}
312
+ className={`kanban-card rounded-xl overflow-hidden transition-[background-color,opacity] duration-200 ease-out ${cardStyles.className}`}
313
+ style={{ boxShadow: cardStyles.boxShadow, '--hover-shadow': cardStyles.hoverBoxShadow } as React.CSSProperties}
170
314
  data-testid={`kanban-card-${item.id}`}>
171
315
  <div
172
316
  onClick={handleCardClick}
173
- className="block p-4 cursor-pointer"
317
+ onMouseEnter={handleMouseEnter}
318
+ onMouseLeave={handleMouseLeave}
319
+ className={`block ${isDone ? 'pt-4 px-4 pb-3' : 'p-4'} cursor-pointer`}
174
320
  >
175
321
  <div className="flex items-start gap-3">
176
322
  <span className="text-base flex-shrink-0"><TypeIcon type={item.type} /></span>
@@ -190,7 +336,7 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
190
336
  <SessionIndicatorIcon className="w-4 h-4" />
191
337
  </button>
192
338
  )}
193
- <span className="text-base font-medium text-zinc-900 dark:text-zinc-100">
339
+ <span className="text-base font-medium leading-snug text-zinc-900 dark:text-zinc-100">
194
340
  {item.title || <span className="text-zinc-400 italic">(Untitled)</span>}
195
341
  </span>
196
342
  </div>
@@ -219,6 +365,20 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
219
365
  </div>
220
366
  {isReviewable ? (
221
367
  <div className="flex items-center gap-1.5 flex-shrink-0">
368
+ {item.type === 'feature' && (
369
+ <Button
370
+ onClick={(e: React.MouseEvent) => {
371
+ e.stopPropagation();
372
+ navigate(`/work/${item.id}/proof`);
373
+ }}
374
+ variant="secondary"
375
+ size="xs"
376
+ aria-label="Launch QA proof dashboard"
377
+ data-testid={`qa-button-${item.id}`}
378
+ >
379
+ QA
380
+ </Button>
381
+ )}
222
382
  {item.status !== 'done' && onStatusChange && (
223
383
  <Button
224
384
  onClick={handleAccept}
@@ -244,12 +404,6 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
244
404
  </div>
245
405
  ) : item.rejection_reason && !isDone ? (
246
406
  <div className="flex items-center gap-1.5 flex-shrink-0">
247
- <span
248
- className="text-xs px-2 py-1 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
249
- data-testid={`rejected-badge-${item.id}`}
250
- >
251
- Rejected
252
- </span>
253
407
  {!hasActiveSession && onRestart && (
254
408
  <Button
255
409
  onClick={handleRestart}
@@ -288,8 +442,8 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
288
442
  {showEpic && epicTitle && (() => {
289
443
  const epicId = item.parent_id || item.epic_id;
290
444
  return epicId ? (
291
- <Link
292
- href={`/work/${epicId}`}
445
+ <Link to={`/work/${epicId}`}
446
+ viewTransition
293
447
  onClick={(e) => e.stopPropagation()}
294
448
  className="group/epic text-sm text-zinc-400 dark:text-zinc-500 mt-2 flex items-center gap-1.5 hover:text-zinc-600 dark:hover:text-zinc-300"
295
449
  >
@@ -370,15 +524,98 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
370
524
  </div>
371
525
  </div>
372
526
  )}
373
- {/* Rejected indicator */}
374
- {item.rejection_reason && (
375
- <div className="px-4 pb-3 border-t border-red-200 dark:border-red-800">
376
- <div className="mt-2 flex items-start gap-2 text-base text-red-600 dark:text-red-400">
377
- <span className="flex-shrink-0">⚠️</span>
378
- <span className="italic">{item.rejection_reason}</span>
527
+ {/* Rejection banner — hidden for done cards */}
528
+ {item.rejection_reason && !isDone && (() => {
529
+ let history: RejectionHistoryEntry[] = [];
530
+ try {
531
+ history = item.rejection_history ? JSON.parse(item.rejection_history) : [];
532
+ } catch { /* degrade gracefully */ }
533
+ const currentRound = item.rejection_count || 1;
534
+ const hasMultipleRounds = history.length > 1;
535
+
536
+ return (
537
+ <div>
538
+ <div
539
+ onClick={(e) => {
540
+ e.stopPropagation();
541
+ if (hasMultipleRounds) setRejectionExpanded(!rejectionExpanded);
542
+ }}
543
+ className={`bg-red-50 dark:bg-red-950/40 ${hasMultipleRounds ? 'cursor-pointer' : ''}`}
544
+ style={{ padding: '10px 16px' }}
545
+ data-testid={`rejection-banner-${item.id}`}
546
+ >
547
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
548
+ <span className="text-red-700 dark:text-red-400" style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
549
+ Rejected
550
+ </span>
551
+ <span className="bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400" style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '12px', fontWeight: 600 }}>
552
+ Round {currentRound}
553
+ </span>
554
+ {hasMultipleRounds && (
555
+ <span className="text-red-600 dark:text-red-400" style={{ fontSize: '11px', opacity: 0.5, marginLeft: 'auto' }}>
556
+ {rejectionExpanded ? '▲' : '▼'}
557
+ </span>
558
+ )}
559
+ </div>
560
+ <div className="text-red-700/75 dark:text-red-400/75" style={{ fontSize: '14px', lineHeight: 1.4 }}>
561
+ {item.rejection_reason}
562
+ </div>
563
+ </div>
564
+ {/* Expanded stacked history */}
565
+ {rejectionExpanded && hasMultipleRounds && (
566
+ <div
567
+ onClick={(e) => e.stopPropagation()}
568
+ className="bg-red-50 dark:bg-red-950/40"
569
+ style={{ padding: '12px 16px' }}
570
+ >
571
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
572
+ <span className="text-red-700 dark:text-red-400" style={{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.03em' }}>
573
+ Rejections
574
+ </span>
575
+ <span className="bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400" style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '12px', fontWeight: 600 }}>
576
+ {history.length}
577
+ </span>
578
+ </div>
579
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
580
+ {history.map((entry) => {
581
+ const isLatest = entry.round === currentRound;
582
+ return (
583
+ <div key={entry.round} style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
584
+ <span
585
+ className={isLatest
586
+ ? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-400'
587
+ : 'bg-red-100/60 dark:bg-red-900/20 text-red-600/60 dark:text-red-400/50'
588
+ }
589
+ style={{
590
+ fontSize: '11px',
591
+ fontWeight: 600,
592
+ padding: '2px 8px',
593
+ borderRadius: '8px',
594
+ flexShrink: 0,
595
+ marginTop: '1px',
596
+ }}
597
+ >
598
+ R{entry.round}
599
+ </span>
600
+ <div>
601
+ <div className="text-red-700/75 dark:text-red-400/75" style={{ fontSize: '14px', lineHeight: 1.4 }}>
602
+ {entry.reason}
603
+ </div>
604
+ {entry.at && (
605
+ <div className="text-red-600/40 dark:text-red-400/30" style={{ fontSize: '11px', marginTop: '2px' }}>
606
+ {formatRejectionTime(entry.at)}
607
+ </div>
608
+ )}
609
+ </div>
610
+ </div>
611
+ );
612
+ })}
613
+ </div>
614
+ </div>
615
+ )}
379
616
  </div>
380
- </div>
381
- )}
617
+ );
618
+ })()}
382
619
  {/* Show expandable section for features with chores or bugs */}
383
620
  {(hasChores || hasBugs) && (
384
621
  <div className={"border-t border-zinc-200 dark:border-zinc-700"}>
@@ -416,62 +653,70 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
416
653
  </button>
417
654
  {expanded && (
418
655
  <div className="px-4 pb-3 space-y-1.5">
419
- {allChores.map((chore) => {
420
- const isComplete = chore.status === 'done';
421
- return (
422
- <Link
423
- key={chore.id}
424
- href={`/work/${chore.id}`}
425
- className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
426
- isComplete
427
- ? 'bg-zinc-100 dark:bg-zinc-800/50'
428
- : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
429
- }`}
430
- >
431
- <div className="flex items-center gap-3">
432
- <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
433
- {!isDone && chore.mode && MODE_LABELS[chore.mode] && (
434
- <span className={`px-1 py-0.5 rounded text-[10px] ${MODE_LABELS[chore.mode].color}`}>
435
- {getModeLabel(chore)}
436
- </span>
437
- )}
438
- <span className={`truncate ${
439
- isComplete
440
- ? 'text-zinc-500'
441
- : 'text-zinc-700 dark:text-zinc-300'
442
- }`}>
443
- {chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
444
- </span>
445
- </div>
446
- </Link>
447
- );
448
- })}
449
- {allBugs.map((bug) => {
450
- const isComplete = bug.status === 'done';
451
- return (
452
- <Link
453
- key={bug.id}
454
- href={`/work/${bug.id}`}
455
- className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
456
- isComplete
457
- ? 'bg-zinc-100 dark:bg-zinc-800/50'
458
- : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
459
- }`}
460
- >
461
- <div className="flex items-center gap-3">
462
- <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{bug.id}</span>
463
- <TypeIcon type="bug" />
464
- <span className={`truncate ${
465
- isComplete
466
- ? 'text-zinc-500'
467
- : 'text-zinc-700 dark:text-zinc-300'
468
- }`}>
469
- {bug.title || <span className="text-zinc-400 italic">(Untitled)</span>}
470
- </span>
471
- </div>
472
- </Link>
473
- );
474
- })}
656
+ {item.rejection_count > 0 ? (
657
+ <RejectionTimeline item={item} allChores={allChores} allBugs={allBugs} isDone={isDone} />
658
+ ) : (
659
+ <>
660
+ {allChores.map((chore) => {
661
+ const isComplete = chore.status === 'done';
662
+ return (
663
+ <Link
664
+ key={chore.id}
665
+ to={`/work/${chore.id}`}
666
+ viewTransition
667
+ className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
668
+ isComplete
669
+ ? 'bg-zinc-100 dark:bg-zinc-800/50'
670
+ : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
671
+ }`}
672
+ >
673
+ <div className="flex items-center gap-3">
674
+ <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
675
+ {!isDone && chore.mode && MODE_LABELS[chore.mode] && (
676
+ <span className={`px-1 py-0.5 rounded text-[10px] ${MODE_LABELS[chore.mode].color}`}>
677
+ {getModeLabel(chore)}
678
+ </span>
679
+ )}
680
+ <span className={`truncate ${
681
+ isComplete
682
+ ? 'text-zinc-500'
683
+ : 'text-zinc-700 dark:text-zinc-300'
684
+ }`}>
685
+ {chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
686
+ </span>
687
+ </div>
688
+ </Link>
689
+ );
690
+ })}
691
+ {allBugs.map((bug) => {
692
+ const isComplete = bug.status === 'done';
693
+ return (
694
+ <Link
695
+ key={bug.id}
696
+ to={`/work/${bug.id}`}
697
+ viewTransition
698
+ className={`block py-1.5 px-3 text-base rounded transition-colors duration-200 ease-out ${
699
+ isComplete
700
+ ? 'bg-zinc-100 dark:bg-zinc-800/50'
701
+ : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
702
+ }`}
703
+ >
704
+ <div className="flex items-center gap-3">
705
+ <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{bug.id}</span>
706
+ <TypeIcon type="bug" />
707
+ <span className={`truncate ${
708
+ isComplete
709
+ ? 'text-zinc-500'
710
+ : 'text-zinc-700 dark:text-zinc-300'
711
+ }`}>
712
+ {bug.title || <span className="text-zinc-400 italic">(Untitled)</span>}
713
+ </span>
714
+ </div>
715
+ </Link>
716
+ );
717
+ })}
718
+ </>
719
+ )}
475
720
  </div>
476
721
  )}
477
722
  </div>
@@ -482,23 +727,12 @@ export const KanbanCard = memo(function KanbanCard({ item, epicTitle, showEpic =
482
727
 
483
728
  if (isHighlighted) {
484
729
  return (
485
- <m.div
486
- animate={{
487
- boxShadow: [
488
- '0 0 0 0px rgba(129, 157, 159, 0)',
489
- '0 0 0 3px rgba(129, 157, 159, 0.4)',
490
- '0 0 0 0px rgba(129, 157, 159, 0)',
491
- ],
492
- }}
493
- transition={{
494
- duration: 2,
495
- repeat: Infinity,
496
- ease: 'easeInOut',
497
- }}
730
+ <div
498
731
  className="rounded-xl"
732
+ style={{ animation: 'highlight-pulse 2s ease-in-out infinite' }}
499
733
  >
500
734
  {cardContent}
501
- </m.div>
735
+ </div>
502
736
  );
503
737
  }
504
738
 
@@ -0,0 +1,62 @@
1
+ import { useRef, useState, useEffect, memo } from 'react';
2
+
3
+ /**
4
+ * Finds the nearest scrollable ancestor for IntersectionObserver root.
5
+ */
6
+ function getScrollParent(el: HTMLElement): HTMLElement | null {
7
+ let parent = el.parentElement;
8
+ while (parent) {
9
+ const { overflowY } = getComputedStyle(parent);
10
+ if (overflowY === 'auto' || overflowY === 'scroll') return parent;
11
+ parent = parent.parentElement;
12
+ }
13
+ return null;
14
+ }
15
+
16
+ interface LazyCardProps {
17
+ children: React.ReactNode;
18
+ /** Estimated card height used before first measurement */
19
+ estimatedHeight?: number;
20
+ }
21
+
22
+ /**
23
+ * Renders children only when visible in the scroll container.
24
+ * Offscreen cards are replaced with a height-matched placeholder,
25
+ * dramatically reducing DOM node count for large lists.
26
+ */
27
+ export const LazyCard = memo(function LazyCard({
28
+ children,
29
+ estimatedHeight = 82,
30
+ }: LazyCardProps) {
31
+ const ref = useRef<HTMLDivElement>(null);
32
+ const [visible, setVisible] = useState(false);
33
+ const measuredHeight = useRef(estimatedHeight);
34
+
35
+ useEffect(() => {
36
+ const el = ref.current;
37
+ if (!el) return;
38
+
39
+ const root = getScrollParent(el);
40
+ const io = new IntersectionObserver(
41
+ ([entry]) => {
42
+ if (entry.isIntersecting) {
43
+ setVisible(true);
44
+ } else {
45
+ // Capture height before replacing children with placeholder
46
+ const h = el.offsetHeight;
47
+ if (h > 0) measuredHeight.current = h;
48
+ setVisible(false);
49
+ }
50
+ },
51
+ { root, rootMargin: '300px 0px' }
52
+ );
53
+ io.observe(el);
54
+ return () => io.disconnect();
55
+ }, []);
56
+
57
+ return (
58
+ <div ref={ref} style={visible ? undefined : { height: measuredHeight.current }}>
59
+ {visible ? children : null}
60
+ </div>
61
+ );
62
+ });
@@ -1,4 +1,3 @@
1
- 'use client';
2
1
 
3
2
  import ReactMarkdown, { type Components } from 'react-markdown';
4
3
  import remarkGfm from 'remark-gfm';