jettypod 4.4.116 → 4.4.120

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 (162) hide show
  1. package/.env +7 -0
  2. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +124 -48
  3. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +171 -58
  4. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +161 -10
  5. package/apps/dashboard/app/api/tests/run/stream/route.ts +13 -1
  6. package/apps/dashboard/app/api/usage/route.ts +17 -0
  7. package/apps/dashboard/app/api/work/[id]/route.ts +35 -0
  8. package/apps/dashboard/app/api/work/[id]/status/route.ts +43 -1
  9. package/apps/dashboard/app/connect-claude/page.tsx +24 -0
  10. package/apps/dashboard/app/decision/[id]/page.tsx +14 -14
  11. package/apps/dashboard/app/demo/gates/page.tsx +42 -42
  12. package/apps/dashboard/app/design-system/page.tsx +868 -0
  13. package/apps/dashboard/app/globals.css +6 -2
  14. package/apps/dashboard/app/install-claude/page.tsx +9 -7
  15. package/apps/dashboard/app/layout.tsx +17 -5
  16. package/apps/dashboard/app/login/page.tsx +250 -0
  17. package/apps/dashboard/app/page.tsx +11 -9
  18. package/apps/dashboard/app/settings/page.tsx +4 -2
  19. package/apps/dashboard/app/signup/page.tsx +245 -0
  20. package/apps/dashboard/app/subscribe/page.tsx +11 -0
  21. package/apps/dashboard/app/welcome/page.tsx +24 -1
  22. package/apps/dashboard/app/work/[id]/page.tsx +34 -50
  23. package/apps/dashboard/components/AppShell.tsx +95 -55
  24. package/apps/dashboard/components/CardMenu.tsx +56 -13
  25. package/apps/dashboard/components/ClaudePanel.tsx +301 -582
  26. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -14
  27. package/apps/dashboard/components/ConnectClaudeScreen.tsx +210 -0
  28. package/apps/dashboard/components/CopyableId.tsx +3 -3
  29. package/apps/dashboard/components/DetailReviewActions.tsx +109 -0
  30. package/apps/dashboard/components/DragContext.tsx +75 -65
  31. package/apps/dashboard/components/DraggableCard.tsx +6 -46
  32. package/apps/dashboard/components/DropZone.tsx +2 -2
  33. package/apps/dashboard/components/EditableDetailDescription.tsx +1 -1
  34. package/apps/dashboard/components/EditableTitle.tsx +26 -6
  35. package/apps/dashboard/components/ElapsedTimer.tsx +54 -0
  36. package/apps/dashboard/components/EpicGroup.tsx +329 -0
  37. package/apps/dashboard/components/GateCard.tsx +100 -16
  38. package/apps/dashboard/components/GateChoiceCard.tsx +15 -17
  39. package/apps/dashboard/components/InstallClaudeScreen.tsx +140 -51
  40. package/apps/dashboard/components/JettyLoader.tsx +38 -0
  41. package/apps/dashboard/components/KanbanBoard.tsx +147 -766
  42. package/apps/dashboard/components/KanbanCard.tsx +506 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +12 -0
  44. package/apps/dashboard/components/MainNav.tsx +20 -54
  45. package/apps/dashboard/components/MessageBlock.tsx +391 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -15
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +214 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +11 -21
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +36 -8
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +25 -25
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +265 -301
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +97 -74
  53. package/apps/dashboard/components/ReviewFooter.tsx +141 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -18
  55. package/apps/dashboard/components/SubscribeContent.tsx +206 -0
  56. package/apps/dashboard/components/TestTree.tsx +15 -14
  57. package/apps/dashboard/components/TipCard.tsx +177 -0
  58. package/apps/dashboard/components/Toast.tsx +5 -5
  59. package/apps/dashboard/components/TypeIcon.tsx +56 -0
  60. package/apps/dashboard/components/UpgradeBanner.tsx +30 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +61 -62
  62. package/apps/dashboard/components/WelcomeScreen.tsx +25 -27
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -4
  64. package/apps/dashboard/components/WorkItemTree.tsx +9 -28
  65. package/apps/dashboard/components/settings/AccountSection.tsx +169 -0
  66. package/apps/dashboard/components/settings/EnvVarsSection.tsx +54 -79
  67. package/apps/dashboard/components/settings/GeneralSection.tsx +26 -31
  68. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -4
  69. package/apps/dashboard/components/ui/Button.tsx +104 -0
  70. package/apps/dashboard/components/ui/Input.tsx +78 -0
  71. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +408 -105
  72. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -4
  73. package/apps/dashboard/contexts/UsageContext.tsx +155 -0
  74. package/apps/dashboard/contexts/usageHelpers.js +9 -0
  75. package/apps/dashboard/electron/ipc-handlers.js +281 -88
  76. package/apps/dashboard/electron/main.js +691 -131
  77. package/apps/dashboard/electron/preload.js +25 -4
  78. package/apps/dashboard/electron/session-manager.js +163 -0
  79. package/apps/dashboard/electron-builder.config.js +3 -5
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/lib/backlog-parser.ts +50 -0
  83. package/apps/dashboard/lib/claude-process-manager.ts +50 -11
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/db-bridge.ts +33 -0
  86. package/apps/dashboard/lib/db.ts +136 -20
  87. package/apps/dashboard/lib/kanban-utils.ts +70 -0
  88. package/apps/dashboard/lib/run-migrations.js +27 -2
  89. package/apps/dashboard/lib/session-state-machine.ts +3 -0
  90. package/apps/dashboard/lib/session-stream-manager.ts +144 -38
  91. package/apps/dashboard/lib/shadows.ts +7 -0
  92. package/apps/dashboard/lib/tests.ts +3 -1
  93. package/apps/dashboard/lib/utils.ts +6 -0
  94. package/apps/dashboard/next.config.js +35 -14
  95. package/apps/dashboard/package.json +6 -3
  96. package/apps/dashboard/public/bug-icon.svg +9 -0
  97. package/apps/dashboard/public/buoy-icon.svg +9 -0
  98. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  99. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  100. package/apps/dashboard/public/in-flight-seagull.svg +9 -0
  101. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  102. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  103. package/apps/dashboard/public/jettypod_logo.png +0 -0
  104. package/apps/dashboard/public/pier-icon.svg +14 -0
  105. package/apps/dashboard/public/star-icon.svg +9 -0
  106. package/apps/dashboard/public/wrench-icon.svg +9 -0
  107. package/apps/dashboard/scripts/upload-to-r2.js +89 -0
  108. package/apps/dashboard/scripts/ws-server.js +191 -0
  109. package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
  110. package/apps/update-server/package.json +16 -0
  111. package/apps/update-server/schema.sql +31 -0
  112. package/apps/update-server/src/index.ts +1085 -0
  113. package/apps/update-server/tsconfig.json +16 -0
  114. package/apps/update-server/wrangler.toml +35 -0
  115. package/cucumber.js +9 -3
  116. package/docs/COMMAND_REFERENCE.md +34 -0
  117. package/hooks/post-checkout +32 -75
  118. package/hooks/post-merge +111 -10
  119. package/jest.setup.js +1 -0
  120. package/jettypod.js +54 -116
  121. package/lib/chore-taxonomy.js +33 -10
  122. package/lib/database.js +36 -16
  123. package/lib/db-watcher.js +1 -1
  124. package/lib/git-hooks/pre-commit +1 -1
  125. package/lib/jettypod-backup.js +27 -4
  126. package/lib/migrations/027-plan-at-creation-column.js +33 -0
  127. package/lib/migrations/028-ready-for-review-column.js +27 -0
  128. package/lib/migrations/029-remove-autoincrement.js +307 -0
  129. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  130. package/lib/migrations/index.js +47 -4
  131. package/lib/schema.js +13 -6
  132. package/lib/seed-onboarding.js +101 -69
  133. package/lib/update-command/index.js +9 -175
  134. package/lib/work-commands/index.js +129 -16
  135. package/lib/work-tracking/index.js +86 -46
  136. package/lib/worktree-diagnostics.js +16 -16
  137. package/lib/worktree-facade.js +1 -1
  138. package/lib/worktree-manager.js +8 -8
  139. package/lib/worktree-reconciler.js +5 -5
  140. package/package.json +9 -2
  141. package/scripts/ndjson-to-cucumber-json.js +152 -0
  142. package/scripts/postinstall.js +25 -0
  143. package/skills-templates/bug-mode/SKILL.md +39 -28
  144. package/skills-templates/bug-planning/SKILL.md +25 -29
  145. package/skills-templates/chore-mode/SKILL.md +131 -68
  146. package/skills-templates/chore-mode/verification.js +51 -10
  147. package/skills-templates/chore-planning/SKILL.md +47 -18
  148. package/skills-templates/epic-planning/SKILL.md +68 -48
  149. package/skills-templates/external-transition/SKILL.md +47 -47
  150. package/skills-templates/feature-planning/SKILL.md +83 -73
  151. package/skills-templates/production-mode/SKILL.md +49 -49
  152. package/skills-templates/request-routing/SKILL.md +27 -14
  153. package/skills-templates/simple-improvement/SKILL.md +68 -44
  154. package/skills-templates/speed-mode/SKILL.md +209 -128
  155. package/skills-templates/stable-mode/SKILL.md +105 -94
  156. package/templates/bdd-guidance.md +139 -0
  157. package/templates/bdd-scaffolding/wait.js +18 -0
  158. package/templates/bdd-scaffolding/world.js +19 -0
  159. package/.jettypod-backup/work.db +0 -0
  160. package/apps/dashboard/app/access-code/page.tsx +0 -110
  161. package/lib/discovery-checkpoint.js +0 -123
  162. package/skills-templates/project-discovery/SKILL.md +0 -372
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import { Button } from '@/components/ui/Button';
5
+
6
+ const API_BASE = 'https://jettypod-update-server.spangbaryn2.workers.dev';
7
+
8
+ type PageState = 'idle' | 'checkout-opened' | 'polling' | 'upgraded';
9
+ type PlanType = 'monthly' | 'lifetime';
10
+
11
+ interface SubscribeContentProps {
12
+ onClose?: () => void;
13
+ }
14
+
15
+ export function SubscribeContent({ onClose }: SubscribeContentProps) {
16
+ const [error, setError] = useState<string | null>(null);
17
+ const [checkoutPlan, setCheckoutPlan] = useState<string | null>(null);
18
+ const [pageState, setPageState] = useState<PageState>('idle');
19
+ const [userEmail, setUserEmail] = useState<string | null>(null);
20
+ const [userPlan, setUserPlan] = useState<string | null>(null);
21
+ const [pollingPlan, setPollingPlan] = useState<PlanType | null>(null);
22
+ const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
23
+
24
+ useEffect(() => {
25
+ async function loadAuth() {
26
+ if (!window.electronAPI?.isElectron) return;
27
+ const status = await window.electronAPI.auth.getStatus();
28
+ if (status.authenticated && status.user) {
29
+ setUserEmail(status.user.email);
30
+ setUserPlan(status.user.plan || 'free');
31
+
32
+ // Check server-side plan in case webhook updated it after local JWT was issued
33
+ const token = await window.electronAPI.auth.getToken();
34
+ if (token) {
35
+ try {
36
+ const res = await fetch(`${API_BASE}/auth/me`, {
37
+ headers: { 'Authorization': `Bearer ${token}` },
38
+ });
39
+ if (res.ok) {
40
+ const data = await res.json() as { user: { plan: string; email: string }; token?: string };
41
+ if (data.user.plan !== 'free') {
42
+ if (data.token) {
43
+ await window.electronAPI.auth.saveToken(data.token, data.user);
44
+ }
45
+ setUserPlan(data.user.plan);
46
+ setPageState('upgraded');
47
+ return;
48
+ }
49
+ }
50
+ } catch {
51
+ // Fall through to show upgrade UI on network error
52
+ }
53
+ }
54
+ }
55
+ }
56
+ loadAuth();
57
+ }, []);
58
+
59
+ useEffect(() => {
60
+ return () => {
61
+ if (pollingRef.current) clearInterval(pollingRef.current);
62
+ };
63
+ }, []);
64
+
65
+ const startPolling = () => {
66
+ setPageState('polling');
67
+
68
+ pollingRef.current = setInterval(async () => {
69
+ try {
70
+ const token = await window.electronAPI!.auth.getToken();
71
+ if (!token) return;
72
+
73
+ const res = await fetch(`${API_BASE}/auth/me`, {
74
+ headers: { 'Authorization': `Bearer ${token}` },
75
+ });
76
+
77
+ if (!res.ok) return;
78
+
79
+ const data = await res.json() as { user: { plan: string; email: string }; token?: string };
80
+
81
+ if (data.user.plan !== 'free') {
82
+ if (data.token) {
83
+ await window.electronAPI!.auth.saveToken(data.token, data.user);
84
+ }
85
+ setUserPlan(data.user.plan);
86
+ setPageState('upgraded');
87
+ if (pollingRef.current) clearInterval(pollingRef.current);
88
+ }
89
+ } catch {
90
+ // Silently continue polling on network/parse errors
91
+ }
92
+ }, 3000);
93
+ };
94
+
95
+ const handleCheckout = async (plan: 'monthly' | 'lifetime') => {
96
+ if (!window.electronAPI?.isElectron) return;
97
+ setCheckoutPlan(plan);
98
+ setError(null);
99
+
100
+ const result = await window.electronAPI.subscription.createCheckout(plan);
101
+ if (!result.success) {
102
+ setError(result.error || 'Failed to start checkout.');
103
+ setCheckoutPlan(null);
104
+ return;
105
+ }
106
+
107
+ setCheckoutPlan(null);
108
+ setPollingPlan(plan);
109
+ startPolling();
110
+ };
111
+
112
+ const handleDone = () => {
113
+ if (onClose) {
114
+ onClose();
115
+ } else {
116
+ window.location.href = '/';
117
+ }
118
+ };
119
+
120
+ if (pageState === 'upgraded') {
121
+ return (
122
+ <div className="max-w-md w-full space-y-10 text-center">
123
+ <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
124
+ You&apos;re all set!
125
+ </h1>
126
+ <p className="text-zinc-500 dark:text-zinc-400">
127
+ Your plan has been upgraded to <span className="font-medium text-zinc-900 dark:text-zinc-100">{userPlan}</span>.
128
+ </p>
129
+ <Button onClick={handleDone} size="lg" fullWidth>
130
+ {onClose ? 'Done' : 'Go to Dashboard'}
131
+ </Button>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ const isPolling = pageState === 'polling';
137
+
138
+ return (
139
+ <div className="max-w-md w-full space-y-10">
140
+ <div className="flex flex-col items-center space-y-6">
141
+ <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100 text-center">
142
+ Unlock Unlimited Use
143
+ </h1>
144
+ <p className="text-zinc-500 dark:text-zinc-400 text-center">
145
+ You&apos;re currently on the free plan.
146
+ </p>
147
+ </div>
148
+
149
+ {userEmail && (
150
+ <div className="bg-zinc-50 dark:bg-zinc-800 rounded-xl px-5 py-4 text-base">
151
+ <div className="flex justify-between items-center">
152
+ <span className="text-zinc-500 dark:text-zinc-400">{userEmail}</span>
153
+ <span className="text-zinc-400 dark:text-zinc-500 capitalize">{userPlan}</span>
154
+ </div>
155
+ </div>
156
+ )}
157
+
158
+ {error && (
159
+ <div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-5 py-4 rounded-xl text-base">
160
+ {error}
161
+ </div>
162
+ )}
163
+
164
+ <div className="pt-6 space-y-4">
165
+ {!onClose && (
166
+ <Button
167
+ variant="ghost"
168
+ size="sm"
169
+ fullWidth
170
+ className="mb-2"
171
+ onClick={() => window.history.back()}
172
+ >
173
+ &larr; Go back
174
+ </Button>
175
+ )}
176
+ <Button
177
+ onClick={() => handleCheckout('monthly')}
178
+ disabled={checkoutPlan !== null || isPolling}
179
+ size="lg"
180
+ fullWidth
181
+ >
182
+ {pollingPlan === 'monthly' ? (
183
+ <span className="inline-flex items-center gap-3">
184
+ <span className="animate-spin inline-block h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
185
+ Upgrading...
186
+ </span>
187
+ ) : checkoutPlan === 'monthly' ? 'Opening checkout...' : 'Monthly'}
188
+ </Button>
189
+
190
+ <Button
191
+ onClick={() => handleCheckout('lifetime')}
192
+ disabled={checkoutPlan !== null || isPolling}
193
+ size="lg"
194
+ fullWidth
195
+ >
196
+ {pollingPlan === 'lifetime' ? (
197
+ <span className="inline-flex items-center gap-3">
198
+ <span className="animate-spin inline-block h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
199
+ Upgrading...
200
+ </span>
201
+ ) : checkoutPlan === 'lifetime' ? 'Opening checkout...' : 'Lifetime Access'}
202
+ </Button>
203
+ </div>
204
+ </div>
205
+ );
206
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
  import type { TestDashboardData, TestEpic, TestFeature, TestScenario } from '@/lib/tests';
5
+ import { TypeIcon } from './TypeIcon';
5
6
 
6
7
  const statusIcons: Record<string, string> = {
7
8
  pass: '✅',
@@ -27,7 +28,7 @@ function ScenarioNode({ scenario, depth }: ScenarioNodeProps) {
27
28
  return (
28
29
  <div className="select-none">
29
30
  <div
30
- className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors ${hasError ? 'cursor-pointer' : ''}`}
31
+ className={`flex items-center gap-2 py-2 px-3 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors duration-200 ease-out ${hasError ? 'cursor-pointer' : ''}`}
31
32
  style={{ paddingLeft: `${depth * 20 + 8}px` }}
32
33
  onClick={() => hasError && setExpanded(!expanded)}
33
34
  >
@@ -38,8 +39,8 @@ function ScenarioNode({ scenario, depth }: ScenarioNodeProps) {
38
39
  ) : (
39
40
  <span className="w-4" />
40
41
  )}
41
- <span className="text-sm">{statusIcons[scenario.status]}</span>
42
- <span className={`flex-1 text-sm ${statusColors[scenario.status]}`}>
42
+ <span className="text-base">{statusIcons[scenario.status]}</span>
43
+ <span className={`flex-1 text-base ${statusColors[scenario.status]}`}>
43
44
  {scenario.title}
44
45
  </span>
45
46
  <span className="text-xs text-zinc-400 font-mono">{scenario.duration}</span>
@@ -48,11 +49,11 @@ function ScenarioNode({ scenario, depth }: ScenarioNodeProps) {
48
49
  {/* Error details panel */}
49
50
  {expanded && hasError && (
50
51
  <div
51
- className="mt-1 mb-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm"
52
+ className="mt-1.5 mb-3 p-4 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded text-base"
52
53
  style={{ marginLeft: `${depth * 20 + 28}px` }}
53
54
  >
54
55
  {scenario.failedStep && (
55
- <div className="mb-2">
56
+ <div className="mb-3">
56
57
  <span className="font-semibold text-red-700 dark:text-red-300">Failed step: </span>
57
58
  <span className="text-red-600 dark:text-red-400">{scenario.failedStep}</span>
58
59
  </div>
@@ -60,7 +61,7 @@ function ScenarioNode({ scenario, depth }: ScenarioNodeProps) {
60
61
  {scenario.error && (
61
62
  <div>
62
63
  <span className="font-semibold text-red-700 dark:text-red-300">Error: </span>
63
- <pre className="mt-1 p-2 bg-red-100 dark:bg-red-900/40 rounded text-xs text-red-800 dark:text-red-200 overflow-x-auto whitespace-pre-wrap">
64
+ <pre className="mt-1.5 p-3 bg-red-100 dark:bg-red-900/40 rounded text-xs text-red-800 dark:text-red-200 overflow-x-auto whitespace-pre-wrap">
64
65
  {scenario.error}
65
66
  </pre>
66
67
  </div>
@@ -87,7 +88,7 @@ function FeatureNode({ feature, depth }: FeatureNodeProps) {
87
88
  return (
88
89
  <div className="select-none">
89
90
  <div
90
- className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
91
+ className="flex items-center gap-2 py-2 px-3 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors duration-200 ease-out cursor-pointer"
91
92
  style={{ paddingLeft: `${depth * 20 + 8}px` }}
92
93
  onClick={() => setExpanded(!expanded)}
93
94
  >
@@ -98,12 +99,12 @@ function FeatureNode({ feature, depth }: FeatureNodeProps) {
98
99
  ) : (
99
100
  <span className="w-4" />
100
101
  )}
101
- <span className="text-sm">✨</span>
102
+ <span className="text-base"><TypeIcon type="feature" /></span>
102
103
  <span className={`flex-1 font-medium ${allPassing ? 'text-zinc-900 dark:text-zinc-100' : 'text-red-600 dark:text-red-400'}`}>
103
104
  {feature.title}
104
105
  </span>
105
106
  {/* Health badge */}
106
- <span className="text-xs px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800">
107
+ <span className="text-xs px-2 py-1 rounded bg-zinc-100 dark:bg-zinc-800">
107
108
  <span className="text-green-600 dark:text-green-400">{passingCount}</span>
108
109
  <span className="text-zinc-400 mx-1">/</span>
109
110
  <span className={failingCount > 0 ? 'text-red-600 dark:text-red-400' : 'text-zinc-400'}>
@@ -135,7 +136,7 @@ function EpicNode({ epic }: EpicNodeProps) {
135
136
  return (
136
137
  <div className="select-none">
137
138
  <div
138
- className="flex items-center gap-2 py-2 px-2 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
139
+ className="flex items-center gap-2 py-3 px-3 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors duration-200 ease-out cursor-pointer"
139
140
  onClick={() => setExpanded(!expanded)}
140
141
  >
141
142
  {hasFeatures ? (
@@ -145,12 +146,12 @@ function EpicNode({ epic }: EpicNodeProps) {
145
146
  ) : (
146
147
  <span className="w-4" />
147
148
  )}
148
- <span className="text-base">🎯</span>
149
+ <span className="text-base"><TypeIcon type="epic" /></span>
149
150
  <span className={`flex-1 font-semibold ${allPassing ? 'text-zinc-900 dark:text-zinc-100' : 'text-red-600 dark:text-red-400'}`}>
150
151
  {epic.title}
151
152
  </span>
152
153
  {/* Health badge */}
153
- <span className="text-xs px-2 py-1 rounded bg-zinc-100 dark:bg-zinc-800 font-mono">
154
+ <span className="text-xs px-3 py-1.5 rounded bg-zinc-100 dark:bg-zinc-800 font-mono">
154
155
  <span className="text-green-600 dark:text-green-400">{epic.healthBadge.passing}</span>
155
156
  <span className="text-zinc-400 mx-1">/</span>
156
157
  <span className={epic.healthBadge.failing > 0 ? 'text-red-600 dark:text-red-400' : 'text-zinc-400'}>
@@ -184,7 +185,7 @@ export function TestTree({ data }: TestTreeProps) {
184
185
  }
185
186
 
186
187
  return (
187
- <div className="font-mono text-sm space-y-2">
188
+ <div className="font-mono text-base space-y-2">
188
189
  {/* Epics */}
189
190
  {data.epics.map(epic => (
190
191
  <EpicNode key={epic.id} epic={epic} />
@@ -194,7 +195,7 @@ export function TestTree({ data }: TestTreeProps) {
194
195
  {data.standaloneFeatures.length > 0 && (
195
196
  <div className="mt-4">
196
197
  {data.epics.length > 0 && (
197
- <div className="text-xs text-zinc-400 uppercase tracking-wider mb-2 px-2">
198
+ <div className="text-base text-zinc-400 uppercase tracking-wider mb-2 px-2">
198
199
  Standalone Features
199
200
  </div>
200
201
  )}
@@ -0,0 +1,177 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { m, AnimatePresence, useReducedMotion } from 'framer-motion';
5
+ import dynamic from 'next/dynamic';
6
+
7
+ const LazyMarkdown = dynamic(() => import('./LazyMarkdown'), { ssr: false });
8
+
9
+ const STORAGE_KEY = 'jettypod-dismissed-tips';
10
+
11
+ function getDismissedTips(): string[] {
12
+ try {
13
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+
19
+ function dismissTip(tipId: string): void {
20
+ try {
21
+ const dismissed = getDismissedTips();
22
+ if (!dismissed.includes(tipId)) {
23
+ dismissed.push(tipId);
24
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(dismissed));
25
+ }
26
+ } catch {
27
+ // localStorage unavailable (private browsing, storage full, etc.)
28
+ // Dismiss still works for current session via React state
29
+ }
30
+ }
31
+
32
+ import { shadow } from '@/lib/shadows';
33
+
34
+ interface TipCardProps {
35
+ tipId: string;
36
+ icon: string;
37
+ title: string;
38
+ body: string | React.ReactNode;
39
+ onDismiss?: (tipId: string) => void;
40
+ }
41
+
42
+ export function TipCard({ tipId, icon, title, body, onDismiss }: TipCardProps) {
43
+ const prefersReducedMotion = useReducedMotion();
44
+ const [dismissed, setDismissed] = useState(() => getDismissedTips().includes(tipId));
45
+
46
+ // Sync with localStorage on mount (in case it changed between renders)
47
+ useEffect(() => {
48
+ setDismissed(getDismissedTips().includes(tipId));
49
+ }, [tipId]);
50
+
51
+ function handleDismiss() {
52
+ dismissTip(tipId);
53
+ setDismissed(true);
54
+ onDismiss?.(tipId);
55
+ }
56
+
57
+ return (
58
+ <AnimatePresence>
59
+ {!dismissed && (
60
+ <m.div
61
+ data-testid={`tip-card-${tipId}`}
62
+ initial={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
63
+ animate={prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
64
+ exit={prefersReducedMotion
65
+ ? { opacity: 0 }
66
+ : { opacity: 0, y: -8, scale: 0.97, transition: { duration: 0.25 } }
67
+ }
68
+ transition={{ duration: prefersReducedMotion ? 0.15 : 0.35, ease: [0.22, 1, 0.36, 1] }}
69
+ style={{
70
+ background: 'linear-gradient(135deg, #f0fdfa 0%, #f0fdfb 100%)',
71
+ border: '2px solid #ccfbf1',
72
+ borderRadius: 12,
73
+ padding: 14,
74
+ boxShadow: shadow.sm,
75
+ }}
76
+ >
77
+ {/* Header: icon + label/title */}
78
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
79
+ <div
80
+ style={{
81
+ width: 34,
82
+ height: 34,
83
+ borderRadius: '50%',
84
+ background: 'rgba(20, 184, 166, 0.12)',
85
+ display: 'flex',
86
+ alignItems: 'center',
87
+ justifyContent: 'center',
88
+ fontSize: 16,
89
+ flexShrink: 0,
90
+ }}
91
+ >
92
+ {icon}
93
+ </div>
94
+ <div>
95
+ <div
96
+ style={{
97
+ fontSize: 10,
98
+ fontWeight: 700,
99
+ textTransform: 'uppercase' as const,
100
+ letterSpacing: '0.06em',
101
+ color: '#0d9488',
102
+ opacity: 0.7,
103
+ }}
104
+ >
105
+ Tip
106
+ </div>
107
+ <div
108
+ style={{
109
+ fontSize: 14,
110
+ fontWeight: 600,
111
+ color: '#134e4a',
112
+ marginTop: 1,
113
+ }}
114
+ >
115
+ {title}
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ {/* Body */}
121
+ <div
122
+ style={{
123
+ fontSize: 13,
124
+ lineHeight: 1.55,
125
+ color: '#52525b',
126
+ marginLeft: 50,
127
+ }}
128
+ >
129
+ {typeof body === 'string' ? (
130
+ <LazyMarkdown
131
+ components={{
132
+ p: ({ children }) => <p style={{ margin: '0 0 8px 0' }}>{children}</p>,
133
+ strong: ({ children }) => <strong style={{ fontWeight: 600, color: '#3f3f46' }}>{children}</strong>,
134
+ }}
135
+ >
136
+ {body.replace(/\n/g, ' \n')}
137
+ </LazyMarkdown>
138
+ ) : body}
139
+ </div>
140
+
141
+ {/* Footer: Got it button */}
142
+ <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
143
+ <button
144
+ onClick={handleDismiss}
145
+ data-testid={`tip-dismiss-${tipId}`}
146
+ style={{
147
+ background: 'transparent',
148
+ border: '2px solid #99f6e4',
149
+ fontSize: 12,
150
+ fontWeight: 600,
151
+ color: '#0d9488',
152
+ cursor: 'pointer',
153
+ padding: '6px 16px',
154
+ borderRadius: 8,
155
+ transition: 'background 0.15s, border-color 0.15s',
156
+ }}
157
+ onMouseEnter={(e) => {
158
+ e.currentTarget.style.background = 'rgba(20, 184, 166, 0.08)';
159
+ e.currentTarget.style.borderColor = '#14b8a6';
160
+ }}
161
+ onMouseLeave={(e) => {
162
+ e.currentTarget.style.background = 'transparent';
163
+ e.currentTarget.style.borderColor = '#99f6e4';
164
+ }}
165
+ >
166
+ Got it
167
+ </button>
168
+ </div>
169
+ </m.div>
170
+ )}
171
+ </AnimatePresence>
172
+ );
173
+ }
174
+
175
+ export function isTipDismissed(tipId: string): boolean {
176
+ return getDismissedTips().includes(tipId);
177
+ }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
4
- import { AnimatePresence, motion } from 'framer-motion';
4
+ import { AnimatePresence, m } from 'framer-motion';
5
5
 
6
6
  interface Toast {
7
7
  id: string;
@@ -52,16 +52,16 @@ export function ToastProvider({ children }: { children: ReactNode }) {
52
52
 
53
53
  function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
54
54
  return (
55
- <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" data-testid="toast-container">
55
+ <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-3" data-testid="toast-container">
56
56
  <AnimatePresence mode="popLayout">
57
57
  {toasts.map(toast => (
58
- <motion.div
58
+ <m.div
59
59
  key={toast.id}
60
60
  initial={{ opacity: 0, y: 20, scale: 0.95 }}
61
61
  animate={{ opacity: 1, y: 0, scale: 1 }}
62
62
  exit={{ opacity: 0, y: -10, scale: 0.95 }}
63
63
  transition={{ duration: 0.2 }}
64
- className={`px-4 py-2 rounded-lg shadow-lg text-sm font-medium cursor-pointer ${
64
+ className={`px-5 py-3 rounded-lg shadow-lg text-base font-medium cursor-pointer ${
65
65
  toast.type === 'success'
66
66
  ? 'bg-green-600 text-white'
67
67
  : toast.type === 'error'
@@ -72,7 +72,7 @@ function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id
72
72
  data-testid="toast"
73
73
  >
74
74
  {toast.message}
75
- </motion.div>
75
+ </m.div>
76
76
  ))}
77
77
  </AnimatePresence>
78
78
  </div>
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useCallback, useEffect } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { TYPE_ICONS } from '@/lib/constants';
6
+ import { shadow } from '@/lib/shadows';
7
+
8
+ const SVG_ICONS: Record<string, { src: string; label: string }> = {
9
+ bug: { src: '/bug-icon.svg', label: 'Bug' },
10
+ chore: { src: '/wrench-icon.svg', label: 'Chore' },
11
+ epic: { src: '/buoy-icon.svg', label: 'Epic' },
12
+ feature: { src: '/star-icon.svg', label: 'Feature' },
13
+ };
14
+
15
+ export function TypeIcon({ type, className }: { type: string; className?: string }) {
16
+ const svg = SVG_ICONS[type];
17
+ const [tooltip, setTooltip] = useState<{ x: number; y: number } | null>(null);
18
+ const [mounted, setMounted] = useState(false);
19
+ const ref = useRef<HTMLSpanElement>(null);
20
+
21
+ const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
22
+
23
+ useEffect(() => {
24
+ setMounted(true);
25
+ setPortalRoot(document.getElementById('tooltip-root'));
26
+ }, []);
27
+
28
+ const showTooltip = useCallback(() => {
29
+ if (ref.current) {
30
+ const rect = ref.current.getBoundingClientRect();
31
+ setTooltip({ x: rect.left + rect.width / 2, y: rect.bottom + 4 });
32
+ }
33
+ }, []);
34
+
35
+ const hideTooltip = useCallback(() => setTooltip(null), []);
36
+
37
+ if (svg) {
38
+ return (
39
+ <span ref={ref} className="inline-flex" onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
40
+ <img src={svg.src} alt="" className={className || "w-8 h-8"} />
41
+ {mounted && tooltip && portalRoot && createPortal(
42
+ <span
43
+ className="pointer-events-none fixed"
44
+ style={{ left: tooltip.x, top: tooltip.y, transform: 'translateX(-50%)' }}
45
+ >
46
+ <span className="block rounded-lg bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-xs px-2 py-1 whitespace-nowrap" style={{ boxShadow: shadow.sm }}>
47
+ {svg.label}
48
+ </span>
49
+ </span>,
50
+ portalRoot
51
+ )}
52
+ </span>
53
+ );
54
+ }
55
+ return <>{TYPE_ICONS[type] || '📄'}</>;
56
+ }
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+
3
+ import { useUsage } from '../contexts/UsageContext';
4
+
5
+ export function UpgradeBanner() {
6
+ const { allowed, used, limit, plan, loading } = useUsage();
7
+
8
+ if (loading || allowed || plan !== 'free') return null;
9
+
10
+ return (
11
+ <div
12
+ className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200 px-5 py-4 rounded-lg flex items-center justify-between flex-shrink-0"
13
+ data-testid="upgrade-banner"
14
+ >
15
+ <div className="flex items-center gap-3">
16
+ <span className="text-amber-600 dark:text-amber-400 text-lg">&#9888;</span>
17
+ <span className="text-base font-medium">
18
+ Weekly limit reached ({used}/{limit} work items). Claude features are disabled until your usage resets.
19
+ </span>
20
+ </div>
21
+ <a
22
+ href="/subscribe"
23
+ className="inline-flex items-center justify-center px-3.5 py-1.5 text-base font-medium text-white rounded-xl hover:brightness-105 active:scale-[0.98] transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-200 ease-out whitespace-nowrap"
24
+ style={{ backgroundColor: '#e57a44', boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(229, 122, 68, 0.2)' }}
25
+ >
26
+ Upgrade
27
+ </a>
28
+ </div>
29
+ );
30
+ }