tanuki-telemetry 1.1.0

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 (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,213 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import gsap from "gsap";
3
+ import type { AuthUser } from "@/hooks/useAuth";
4
+ import { apiFetch } from "@/lib/api";
5
+
6
+ interface ApiKeyItem {
7
+ id: string;
8
+ label: string;
9
+ last_used_at: string | null;
10
+ created_at: string;
11
+ }
12
+
13
+ export function SettingsPage({ user }: { user: AuthUser | null }) {
14
+ const [keys, setKeys] = useState<ApiKeyItem[]>([]);
15
+ const [newKeyLabel, setNewKeyLabel] = useState("");
16
+ const [generatedKey, setGeneratedKey] = useState<string | null>(null);
17
+ const [copied, setCopied] = useState(false);
18
+ const [loading, setLoading] = useState(true);
19
+
20
+ const loadKeys = useCallback(() => {
21
+ apiFetch("/api/keys")
22
+ .then((r) => r.json())
23
+ .then((data) => {
24
+ setKeys(data);
25
+ setLoading(false);
26
+ })
27
+ .catch(() => setLoading(false));
28
+ }, []);
29
+
30
+ useEffect(() => {
31
+ loadKeys();
32
+ }, [loadKeys]);
33
+
34
+ const handleGenerate = async () => {
35
+ const label = newKeyLabel.trim() || "default";
36
+ try {
37
+ const res = await apiFetch("/api/keys", {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify({ label }),
41
+ });
42
+ const data = await res.json();
43
+ setGeneratedKey(data.key);
44
+ setNewKeyLabel("");
45
+ setCopied(false);
46
+ loadKeys();
47
+ } catch {
48
+ // handled by apiFetch
49
+ }
50
+ };
51
+
52
+ const handleRevoke = async (keyId: string) => {
53
+ try {
54
+ await apiFetch(`/api/keys/${keyId}`, { method: "DELETE" });
55
+ loadKeys();
56
+ if (generatedKey) setGeneratedKey(null);
57
+ } catch {
58
+ // handled by apiFetch
59
+ }
60
+ };
61
+
62
+ const handleCopy = () => {
63
+ if (generatedKey) {
64
+ navigator.clipboard.writeText(generatedKey);
65
+ setCopied(true);
66
+ setTimeout(() => setCopied(false), 2000);
67
+ }
68
+ };
69
+
70
+ const pageRef = useRef<HTMLDivElement>(null);
71
+
72
+ useEffect(() => {
73
+ if (!pageRef.current) return;
74
+ gsap.fromTo(
75
+ pageRef.current.children,
76
+ { opacity: 0, y: 10 },
77
+ { opacity: 1, y: 0, duration: 0.3, stagger: 0.08, ease: "power2.out" }
78
+ );
79
+ }, []);
80
+
81
+ return (
82
+ <div ref={pageRef} className="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
83
+ {/* User info */}
84
+ {user && (
85
+ <div className="mb-8 border border-border p-4">
86
+ <div className="text-[10px] text-accent font-bold tracking-widest mb-3">
87
+ ACCOUNT
88
+ </div>
89
+ <div className="flex items-center gap-3">
90
+ {user.avatar_url && (
91
+ <img
92
+ src={user.avatar_url}
93
+ alt=""
94
+ className="w-8 h-8 rounded-full"
95
+ />
96
+ )}
97
+ <div>
98
+ <div className="text-text text-xs font-mono">{user.name}</div>
99
+ <div className="text-text-dim text-[10px] font-mono">
100
+ {user.email}
101
+ </div>
102
+ </div>
103
+ <a
104
+ href="/auth/logout"
105
+ className="ml-auto text-[10px] text-error hover:text-text font-mono transition-colors"
106
+ >
107
+ LOGOUT
108
+ </a>
109
+ </div>
110
+ </div>
111
+ )}
112
+
113
+ {/* API Keys */}
114
+ <div className="border border-border p-4">
115
+ <div className="text-[10px] text-accent font-bold tracking-widest mb-3">
116
+ API KEYS
117
+ </div>
118
+ <p className="text-text-dim text-[10px] font-mono mb-4">
119
+ Use API keys to connect Claude Code MCP clients to this Tanuki
120
+ server.
121
+ </p>
122
+
123
+ {/* Generate new key */}
124
+ <div className="flex gap-2 mb-4">
125
+ <input
126
+ type="text"
127
+ placeholder="label (optional)"
128
+ value={newKeyLabel}
129
+ onChange={(e) => setNewKeyLabel(e.target.value)}
130
+ className="flex-1 bg-bg border border-border px-3 py-1.5 text-xs font-mono text-text placeholder:text-text-dim focus:outline-none focus:border-accent"
131
+ onKeyDown={(e) => {
132
+ if (e.key === "Enter") handleGenerate();
133
+ }}
134
+ />
135
+ <button
136
+ onClick={handleGenerate}
137
+ className="px-4 py-1.5 border border-accent text-accent hover:bg-accent hover:text-bg text-[10px] font-mono font-bold tracking-wider transition-colors"
138
+ >
139
+ GENERATE
140
+ </button>
141
+ </div>
142
+
143
+ {/* Show generated key (one-time) */}
144
+ {generatedKey && (
145
+ <div className="mb-4 border border-accent bg-bg p-3">
146
+ <div className="text-[10px] text-accent font-bold mb-2">
147
+ NEW API KEY — COPY NOW (shown once)
148
+ </div>
149
+ <div className="flex items-center gap-2">
150
+ <code className="flex-1 text-xs font-mono text-text bg-bg-elevated px-2 py-1 overflow-x-auto select-all">
151
+ {generatedKey}
152
+ </code>
153
+ <button
154
+ onClick={handleCopy}
155
+ className="px-3 py-1 border border-border text-[10px] font-mono hover:border-accent hover:text-accent transition-colors"
156
+ >
157
+ {copied ? "COPIED" : "COPY"}
158
+ </button>
159
+ </div>
160
+ <div className="text-[10px] text-text-dim font-mono mt-2">
161
+ Add to your <code>.claude.json</code>:
162
+ </div>
163
+ <pre className="text-[10px] text-text-muted font-mono mt-1 overflow-x-auto">
164
+ {`"telemetry": {
165
+ "type": "sse",
166
+ "url": "${window.location.origin}/mcp/sse",
167
+ "headers": { "Authorization": "Bearer ${generatedKey}" }
168
+ }`}
169
+ </pre>
170
+ </div>
171
+ )}
172
+
173
+ {/* Key list */}
174
+ {loading ? (
175
+ <div className="text-text-dim text-[10px] font-mono">Loading...</div>
176
+ ) : keys.length === 0 ? (
177
+ <div className="text-text-dim text-[10px] font-mono">
178
+ No API keys yet. Generate one to connect Claude Code.
179
+ </div>
180
+ ) : (
181
+ <div className="space-y-1">
182
+ {keys.map((k) => (
183
+ <div
184
+ key={k.id}
185
+ className="flex items-center justify-between border border-border px-3 py-2"
186
+ >
187
+ <div className="flex items-center gap-3">
188
+ <span className="text-xs font-mono text-text">{k.label}</span>
189
+ <span className="text-[10px] font-mono text-text-dim">
190
+ created{" "}
191
+ {new Date(k.created_at + "Z").toLocaleDateString()}
192
+ </span>
193
+ {k.last_used_at && (
194
+ <span className="text-[10px] font-mono text-text-dim">
195
+ last used{" "}
196
+ {new Date(k.last_used_at + "Z").toLocaleDateString()}
197
+ </span>
198
+ )}
199
+ </div>
200
+ <button
201
+ onClick={() => handleRevoke(k.id)}
202
+ className="text-[10px] font-mono text-error hover:text-text transition-colors"
203
+ >
204
+ REVOKE
205
+ </button>
206
+ </div>
207
+ ))}
208
+ </div>
209
+ )}
210
+ </div>
211
+ </div>
212
+ );
213
+ }
@@ -0,0 +1,228 @@
1
+ import { useRef, useEffect, useState, useMemo } from "react"
2
+ import gsap from "gsap"
3
+ import type { Session, Event } from "@/types"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ type MsgType = "broadcast" | "finding" | "blocker" | "done" | "directive"
7
+
8
+ interface StreamMessage {
9
+ stream_name: string
10
+ msg_type: MsgType
11
+ message: string
12
+ timestamp: string
13
+ for_streams?: string[]
14
+ }
15
+
16
+ interface Props {
17
+ childSessions: Session[]
18
+ parentSessionId?: string
19
+ }
20
+
21
+ const MSG_TYPE_STYLES: Record<MsgType, { border: string; text: string; icon: string }> = {
22
+ broadcast: {
23
+ border: "border-l-info",
24
+ text: "text-info",
25
+ icon: "\u2139",
26
+ },
27
+ finding: {
28
+ border: "border-l-warning",
29
+ text: "text-warning",
30
+ icon: "\uD83D\uDCA1",
31
+ },
32
+ blocker: {
33
+ border: "border-l-error",
34
+ text: "text-error",
35
+ icon: "\u26D4",
36
+ },
37
+ done: {
38
+ border: "border-l-accent",
39
+ text: "text-accent",
40
+ icon: "\u2705",
41
+ },
42
+ directive: {
43
+ border: "border-l-purple",
44
+ text: "text-purple",
45
+ icon: "\u27A1",
46
+ },
47
+ }
48
+
49
+ export function StreamComms({ childSessions, parentSessionId }: Props) {
50
+ const ref = useRef<HTMLDivElement>(null)
51
+ const bottomRef = useRef<HTMLDivElement>(null)
52
+ const [messages, setMessages] = useState<StreamMessage[]>([])
53
+ const [loading, setLoading] = useState(true)
54
+
55
+ // Build a map of session_id -> worktree_name
56
+ const sessionNameMap = useMemo(() => {
57
+ const map = new Map<string, string>()
58
+ for (const s of childSessions) {
59
+ map.set(s.id, s.worktree_name)
60
+ }
61
+ return map
62
+ }, [childSessions])
63
+
64
+ // Fetch events from all child sessions + parent coordinator events
65
+ useEffect(() => {
66
+ if (childSessions.length === 0) {
67
+ setMessages([])
68
+ setLoading(false)
69
+ return
70
+ }
71
+
72
+ const fetchAll = async () => {
73
+ const allMessages: StreamMessage[] = []
74
+
75
+ // Fetch child session events
76
+ await Promise.all(
77
+ childSessions.map(async (child) => {
78
+ try {
79
+ const res = await fetch(`/api/sessions/${child.id}`)
80
+ const data = await res.json()
81
+ const events: Event[] = data.events || []
82
+
83
+ for (const ev of events) {
84
+ if (!ev.metadata) continue
85
+ try {
86
+ const meta = JSON.parse(ev.metadata)
87
+ if (meta.msg_type && isValidMsgType(meta.msg_type)) {
88
+ allMessages.push({
89
+ stream_name: sessionNameMap.get(child.id) || child.id.slice(0, 8),
90
+ msg_type: meta.msg_type as MsgType,
91
+ message: ev.message,
92
+ timestamp: ev.timestamp,
93
+ for_streams: meta.for_streams,
94
+ })
95
+ }
96
+ } catch {
97
+ // ignore parse errors
98
+ }
99
+ }
100
+ } catch {
101
+ // ignore fetch errors
102
+ }
103
+ })
104
+ )
105
+
106
+ // Fetch coordinator events from parent session
107
+ if (parentSessionId) {
108
+ try {
109
+ const res = await fetch(`/api/sessions/${parentSessionId}`)
110
+ const data = await res.json()
111
+ const events: Event[] = data.events || []
112
+
113
+ for (const ev of events) {
114
+ if (!ev.metadata) continue
115
+ try {
116
+ const meta = JSON.parse(ev.metadata)
117
+ if (meta.stream === "coordinator" && meta.msg_type) {
118
+ allMessages.push({
119
+ stream_name: "coordinator",
120
+ msg_type: (meta.msg_type === "directive" ? "directive" : meta.msg_type) as MsgType,
121
+ message: ev.message,
122
+ timestamp: ev.timestamp,
123
+ for_streams: meta.for_streams,
124
+ })
125
+ }
126
+ } catch {
127
+ // ignore parse errors
128
+ }
129
+ }
130
+ } catch {
131
+ // ignore fetch errors
132
+ }
133
+ }
134
+
135
+ // Sort by timestamp
136
+ allMessages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
137
+ setMessages(allMessages)
138
+ setLoading(false)
139
+ }
140
+
141
+ fetchAll()
142
+ }, [childSessions, sessionNameMap, parentSessionId])
143
+
144
+ // Animate new messages
145
+ useEffect(() => {
146
+ if (!ref.current || messages.length === 0) return
147
+ const items = ref.current.querySelectorAll(".comm-msg")
148
+ if (items.length > 0) {
149
+ gsap.fromTo(
150
+ items,
151
+ { opacity: 0, x: -8 },
152
+ { opacity: 1, x: 0, duration: 0.3, stagger: 0.03, ease: "power2.out" }
153
+ )
154
+ }
155
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" })
156
+ }, [messages])
157
+
158
+ if (loading) {
159
+ return (
160
+ <div className="text-text-dim text-[10px] py-2">
161
+ loading stream comms...
162
+ </div>
163
+ )
164
+ }
165
+
166
+ if (messages.length === 0) {
167
+ return (
168
+ <div className="text-text-dim text-[10px] py-2">
169
+ no inter-stream messages yet
170
+ </div>
171
+ )
172
+ }
173
+
174
+ return (
175
+ <div ref={ref} className="max-h-80 overflow-y-auto space-y-0.5">
176
+ {messages.map((msg, i) => {
177
+ const style = MSG_TYPE_STYLES[msg.msg_type] || MSG_TYPE_STYLES.broadcast
178
+ const time = formatTime(msg.timestamp)
179
+ const isCoordinator = msg.stream_name === "coordinator"
180
+
181
+ return (
182
+ <div
183
+ key={i}
184
+ className={cn(
185
+ "comm-msg flex items-start gap-2 px-2 py-1.5 border-l-2 text-[11px] font-mono",
186
+ style.border,
187
+ isCoordinator ? "bg-purple/[0.04]" : "bg-bg/50"
188
+ )}
189
+ >
190
+ <span className="flex-shrink-0 text-text-dim w-11 tabular-nums">
191
+ {time}
192
+ </span>
193
+ <span className="flex-shrink-0">{style.icon}</span>
194
+ <span className={cn("flex-shrink-0 font-bold", style.text)}>
195
+ [{msg.stream_name}]
196
+ </span>
197
+ {isCoordinator && msg.for_streams && msg.for_streams.length > 0 && (
198
+ <span className="flex-shrink-0 text-text-dim">
199
+ {"\u2192"} {msg.for_streams.join(", ")}:
200
+ </span>
201
+ )}
202
+ <span className={cn("flex-1", isCoordinator ? "text-purple/80" : "text-text")}>
203
+ {msg.message}
204
+ </span>
205
+ </div>
206
+ )
207
+ })}
208
+ <div ref={bottomRef} />
209
+ </div>
210
+ )
211
+ }
212
+
213
+ function isValidMsgType(t: string): t is MsgType {
214
+ return ["broadcast", "finding", "blocker", "done", "directive"].includes(t)
215
+ }
216
+
217
+ function formatTime(ts: string): string {
218
+ try {
219
+ const d = new Date(ts + "Z")
220
+ return d.toLocaleTimeString("en-US", {
221
+ hour: "2-digit",
222
+ minute: "2-digit",
223
+ hour12: false,
224
+ })
225
+ } catch {
226
+ return "--:--"
227
+ }
228
+ }
@@ -0,0 +1,16 @@
1
+ export function TanukiLogo({ className = "" }: { className?: string }) {
2
+ return (
3
+ <svg
4
+ viewBox="0 0 168 128"
5
+ className={className}
6
+ fill="currentColor"
7
+ >
8
+ <g transform="translate(0,128) scale(0.1,-0.1)" stroke="none">
9
+ <path d="M113 1183 c-18 -37 -17 -212 1 -235 25 -34 48 -22 150 77 12 12 42 35 68 53 27 18 48 37 48 42 0 12 -55 50 -73 50 -9 0 -19 5 -22 10 -3 6 -17 10 -30 10 -13 0 -27 5 -30 10 -3 6 -27 10 -53 10 -41 0 -49 -3 -59 -27z m117 -30 c49 -18 70 -31 70 -44 0 -5 -26 -27 -57 -51 -32 -23 -63 -49 -70 -57 -9 -12 -14 -13 -24 -4 -15 15 -8 135 10 157 17 20 15 20 71 -1z" />
10
+ <path d="M1455 1200 c-3 -5 -17 -10 -30 -10 -13 0 -27 -4 -30 -10 -3 -5 -13 -10 -22 -10 -18 0 -73 -38 -73 -50 0 -5 21 -23 46 -41 25 -17 62 -46 82 -64 101 -93 114 -99 138 -67 18 23 19 198 2 235 -11 24 -19 27 -60 27 -26 0 -50 -4 -53 -10z m76 -67 c11 -30 12 -130 1 -137 -11 -6 -92 52 -92 65 0 5 -6 9 -13 9 -21 0 -48 35 -41 53 3 9 25 22 48 27 22 6 46 12 51 14 19 6 36 -7 46 -31z" />
11
+ <path d="M490 1002 c0 -4 -12 -13 -27 -19 -62 -27 -268 -227 -341 -330 -129 -182 -108 -278 103 -457 17 -14 36 -26 43 -26 6 0 12 -4 12 -8 0 -5 16 -14 35 -22 19 -8 35 -17 35 -22 0 -4 7 -8 15 -8 8 0 23 -4 33 -9 9 -5 31 -14 47 -21 17 -7 38 -16 47 -21 10 -5 29 -9 43 -9 14 0 33 -4 43 -9 20 -10 195 -31 262 -31 67 0 242 21 262 31 10 5 27 9 37 9 11 0 33 5 48 11 15 6 37 15 48 19 43 16 160 75 163 82 2 5 9 8 15 8 12 0 39 21 102 78 78 72 125 152 125 217 0 43 -33 120 -82 188 -71 99 -282 304 -340 330 -15 6 -28 15 -28 19 0 5 -27 8 -60 8 -60 0 -100 -16 -164 -65 -23 -18 -26 -27 -26 -83 0 -53 10 -93 30 -112 3 -3 17 -21 31 -40 15 -19 39 -48 53 -65 14 -16 32 -42 39 -57 6 -16 15 -28 19 -28 5 0 8 -9 8 -19 0 -11 5 -23 10 -26 14 -8 13 -71 -1 -97 -23 -43 -53 -65 -131 -97 -46 -19 -146 -26 -233 -16 -56 6 -72 11 -135 39 -28 13 -59 42 -77 74 -15 26 -17 88 -3 97 6 3 10 13 10 21 0 13 52 99 69 114 4 3 18 21 33 42 15 20 33 42 40 50 28 30 38 60 38 117 0 48 -4 62 -24 82 -45 45 -103 69 -167 69 -32 0 -59 -3 -59 -8z m121 -43 c57 -19 89 -56 89 -103 -1 -23 -6 -50 -13 -62 -19 -32 -71 -94 -79 -94 -5 0 -8 20 -8 44 0 76 -67 146 -127 131 -32 -8 -80 -61 -89 -98 -10 -46 4 -93 38 -126 17 -17 35 -28 39 -25 4 2 10 0 13 -5 4 -5 22 -7 41 -4 28 4 35 2 35 -11 0 -9 -4 -16 -10 -16 -5 0 -10 -8 -10 -18 0 -10 -4 -22 -10 -28 -5 -5 -12 -30 -16 -54 -4 -35 0 -55 18 -89 22 -43 81 -99 109 -105 8 -2 28 -9 44 -16 50 -22 257 -19 335 5 76 23 153 90 165 144 8 36 -2 108 -16 117 -5 3 -9 12 -9 21 0 9 -7 25 -17 35 -22 25 -10 33 27 18 61 -25 122 25 135 111 8 57 -11 101 -58 129 -46 29 -74 25 -113 -15 -38 -40 -48 -68 -42 -125 l3 -39 -28 27 c-46 44 -78 111 -75 156 3 34 10 45 44 71 61 47 119 47 190 0 76 -51 260 -238 315 -320 69 -104 77 -133 55 -199 -21 -62 -109 -159 -182 -202 -24 -14 -44 -30 -44 -35 0 -5 -6 -9 -12 -9 -7 0 -38 -12 -68 -26 -348 -164 -823 -106 -1080 133 -81 76 -120 136 -120 188 0 85 130 254 325 421 99 85 133 97 206 73z m-65 -147 c23 -29 17 -103 -10 -134 -29 -35 -56 -35 -90 -2 -20 21 -26 37 -26 70 0 24 5 44 10 44 6 0 10 7 10 15 0 37 79 43 106 7z m696 -5 c28 -35 23 -99 -8 -131 -34 -33 -61 -33 -90 2 -37 42 -26 144 16 156 27 8 65 -5 82 -27z" />
12
+ <path d="M725 557 c-31 -19 -45 -35 -45 -51 0 -18 48 -67 83 -83 15 -7 38 -18 52 -25 19 -9 31 -9 50 0 14 7 37 18 52 25 35 16 83 65 83 83 0 17 -15 33 -48 52 -28 16 -200 15 -227 -1z m184 -33 c36 -7 39 -17 12 -41 -40 -37 -51 -43 -71 -43 -33 0 -97 37 -103 59 -4 16 1 21 27 24 55 7 103 7 135 1z" />
13
+ </g>
14
+ </svg>
15
+ )
16
+ }