total-recall-brain 3.35.0 β†’ 3.36.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 (66) hide show
  1. package/bin/total-recall.mjs +17 -4
  2. package/frontend/dist/assets/{index-BPrkIYkZ.js β†’ index-DZ4ypNEl.js} +135 -135
  3. package/frontend/dist/brand/plugins/phone.svg +20 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/src/App.tsx +3 -30
  6. package/frontend/src/api/plugins.ts +18 -0
  7. package/frontend/src/components/plugins/previews/CodeQualityPreview.tsx +78 -86
  8. package/frontend/src/components/plugins/previews/ComposableCliPreview.tsx +74 -84
  9. package/frontend/src/components/plugins/previews/DecisionPreview.tsx +158 -106
  10. package/frontend/src/components/plugins/previews/DesignPreview.tsx +109 -75
  11. package/frontend/src/components/plugins/previews/DomainsPreview.tsx +69 -88
  12. package/frontend/src/components/plugins/previews/ImageGeneratorPreview.tsx +286 -0
  13. package/frontend/src/components/plugins/previews/PhonePreview.tsx +490 -76
  14. package/frontend/src/components/plugins/previews/SigningPreview.tsx +164 -50
  15. package/frontend/src/components/plugins/previews/TextPreview.tsx +85 -86
  16. package/frontend/src/components/plugins/previews/UserAutomationsPreview.tsx +30 -0
  17. package/frontend/src/components/plugins/previews/index.tsx +6 -1
  18. package/frontend/src/pages/PluginsPage.tsx +603 -136
  19. package/package.json +2 -5
  20. package/scaffold/.agent/skills/plugins/SKILL.md +200 -6
  21. package/src/cli/plugin/create.mjs +156 -11
  22. package/src/cli/plugin/index.mjs +5 -0
  23. package/src/core/embeddings.mjs +2 -0
  24. package/src/core/plugin-loader.mjs +18 -3
  25. package/src/core/plugin-store.mjs +122 -11
  26. package/src/core/surface.mjs +29 -1
  27. package/src/core/vault.mjs +6 -2
  28. package/src/server/route-manifest.json +22 -2
  29. package/src/server/routes/plugins-mesh.mjs +9 -1
  30. package/src/server/routes/plugins.mjs +65 -1
  31. package/frontend/src/pages/AutomationsPage.spec.tsx +0 -61
  32. package/frontend/src/pages/AutomationsPage.tsx +0 -354
  33. package/plugins/creative-search/README.md +0 -33
  34. package/plugins/creative-search/cli.mjs +0 -415
  35. package/plugins/creative-search/config.mjs +0 -129
  36. package/plugins/creative-search/generator.mjs +0 -13
  37. package/plugins/creative-search/plugin.json +0 -61
  38. package/plugins/creative-search/ui/DESIGN.md +0 -50
  39. package/plugins/creative-search/ui/csearch-main.js +0 -314
  40. package/plugins/creative-search/ui/csearch-settings.js +0 -392
  41. package/plugins/creative-search/ui/index.js +0 -2
  42. package/plugins/creative-search/ui/tokens.css +0 -34
  43. package/plugins/dsh/README.md +0 -20
  44. package/plugins/dsh/cli.mjs +0 -232
  45. package/plugins/dsh/generator.mjs +0 -65
  46. package/plugins/dsh/plugin.json +0 -57
  47. package/plugins/dsh/ui/DESIGN.md +0 -48
  48. package/plugins/dsh/ui/dsh-main.js +0 -226
  49. package/plugins/dsh/ui/dsh-status.js +0 -265
  50. package/plugins/dsh/ui/index.js +0 -2
  51. package/plugins/dsh/ui/tokens.css +0 -32
  52. package/plugins/git-sentinel/README.md +0 -29
  53. package/plugins/git-sentinel/cli.mjs +0 -89
  54. package/plugins/git-sentinel/plugin.json +0 -18
  55. package/plugins/operator-alerts/README.md +0 -32
  56. package/plugins/operator-alerts/cli.mjs +0 -57
  57. package/plugins/operator-alerts/notify-core.mjs +0 -216
  58. package/plugins/operator-alerts/plugin.json +0 -20
  59. package/plugins/skill-manager/README.md +0 -57
  60. package/plugins/skill-manager/cli.mjs +0 -123
  61. package/plugins/skill-manager/plugin.json +0 -25
  62. package/plugins/skill-manager/route.mjs +0 -106
  63. package/plugins/system-monitor/README.md +0 -28
  64. package/plugins/system-monitor/cli.mjs +0 -80
  65. package/plugins/system-monitor/generator.mjs +0 -19
  66. package/plugins/system-monitor/plugin.json +0 -29
@@ -7,6 +7,9 @@ import {
7
7
  installPlugin,
8
8
  removePlugin,
9
9
  setPluginShared,
10
+ updatePluginBranding,
11
+ setPluginLocked,
12
+ deployPluginToStore,
10
13
  runPluginCommand,
11
14
  fetchPluginReadme,
12
15
  type PluginInfo,
@@ -15,13 +18,14 @@ import {
15
18
  type PeerNode
16
19
  } from "../api"
17
20
  import { renderMarkdown } from "../components/MarkdownUtils"
21
+ import { getPreviewComponent, PreviewFallback } from "../components/plugins/previews"
18
22
 
19
23
  interface PluginsPageProps {
20
24
  activeBrainId?: string | null
21
25
  }
22
26
 
23
27
  type Tab = "installed" | "bundled" | "mesh"
24
- type DetailTab = "run" | "readme" | "details"
28
+ type DetailTab = "preview" | "run" | "readme" | "details"
25
29
  type Alert = { type: "success" | "error"; message: string } | null
26
30
 
27
31
  const PEER_STATUS_LABEL: Record<PeerNode["status"], string> = {
@@ -36,6 +40,33 @@ const PEER_STATUS_LABEL: Record<PeerNode["status"], string> = {
36
40
  const mono: CSSProperties = { fontFamily: "var(--font-mono)", fontSize: "12px" }
37
41
  const muted: CSSProperties = { color: "var(--text-secondary)", fontSize: "13px", lineHeight: 1.5 }
38
42
 
43
+ const DEFAULT_BRANDING: Record<string, { icon: string; color: string; badge: string }> = {
44
+ phone: { icon: "πŸ“ž", color: "#10b981", badge: "Telephony" },
45
+ domains: { icon: "🌐", color: "#3b82f6", badge: "DNS" },
46
+ signing: { icon: "✍️", color: "#8b5cf6", badge: "Signing" },
47
+ "code-quality": { icon: "πŸ›‘οΈ", color: "#06b6d4", badge: "Quality Gates" },
48
+ "composable-cli": { icon: "πŸ’»", color: "#14b8a6", badge: "CLI Engine" },
49
+ decision: { icon: "βš–οΈ", color: "#a855f7", badge: "Decisions" },
50
+ design: { icon: "🎨", color: "#ec4899", badge: "Design System" },
51
+ text: { icon: "πŸ’¬", color: "#22c55e", badge: "Messaging" },
52
+ "creative-search": { icon: "πŸ”", color: "#f59e0b", badge: "Search" },
53
+ "git-sentinel": { icon: "🌿", color: "#84cc16", badge: "Git Health" },
54
+ "system-monitor": { icon: "πŸ“Š", color: "#6366f1", badge: "Telemetry" },
55
+ "operator-alerts": { icon: "πŸ””", color: "#ef4444", badge: "Alerts" },
56
+ dsh: { icon: "🐚", color: "#64748b", badge: "Remote Shell" },
57
+ }
58
+
59
+ function resolvePluginBranding(p: { id: string; branding?: { icon?: string | null; color?: string | null; badge?: string | null } }) {
60
+ const custom = p.branding || {}
61
+ const shortId = p.id.replace(/^tr-plugin-/, "")
62
+ const fallback = DEFAULT_BRANDING[shortId] || DEFAULT_BRANDING[p.id] || { icon: "🧩", color: "#3b82f6", badge: "Capability" }
63
+ return {
64
+ icon: custom.icon || fallback.icon,
65
+ color: custom.color || fallback.color,
66
+ badge: custom.badge || fallback.badge,
67
+ }
68
+ }
69
+
39
70
  function shortHash(sha: string | null | undefined) {
40
71
  return sha ? `${sha.slice(0, 12)}…` : "β€”"
41
72
  }
@@ -106,12 +137,19 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
106
137
  const [installGlobal, setInstallGlobal] = useState(false)
107
138
 
108
139
  const [detail, setDetail] = useState<PluginInfo | null>(null)
109
- const [detailTab, setDetailTab] = useState<DetailTab>("run")
140
+ const [detailTab, setDetailTab] = useState<DetailTab>("preview")
110
141
  const [subcommand, setSubcommand] = useState("")
111
142
  const [args, setArgs] = useState("")
112
143
  const [output, setOutput] = useState<{ text: string; ok: boolean } | null>(null)
113
144
  const [readme, setReadme] = useState("")
114
145
 
146
+ // Branding Customization Modal state
147
+ const [brandingOpen, setBrandingOpen] = useState(false)
148
+ const [brandingPlugin, setBrandingPlugin] = useState<PluginInfo | null>(null)
149
+ const [brandingIcon, setBrandingIcon] = useState("")
150
+ const [brandingColor, setBrandingColor] = useState("#3b82f6")
151
+ const [brandingBadge, setBrandingBadge] = useState("")
152
+
115
153
  const loadLocal = useCallback(async () => {
116
154
  setLoading(true)
117
155
  try {
@@ -195,9 +233,78 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
195
233
  loadLocal()
196
234
  }
197
235
 
198
- const openDetail = async (p: PluginInfo) => {
236
+ const doToggleLock = async (p: PluginInfo) => {
237
+ setBusy(p.id)
238
+ const nextLocked = !p.locked
239
+ const res = await setPluginLocked(p.id, nextLocked)
240
+ setBusy(null)
241
+ if (!res.success) {
242
+ setAlert({ type: "error", message: res.error || "Could not update lock status" })
243
+ return
244
+ }
245
+ if (res.plugin && detail?.id === p.id) setDetail(res.plugin)
246
+ setAlert({
247
+ type: "success",
248
+ message: nextLocked ? `${p.name} is locked against further editing.` : `${p.name} is unlocked for editing.`
249
+ })
250
+ loadLocal()
251
+ }
252
+
253
+ const doDeployStore = async (p: PluginInfo) => {
254
+ setBusy(p.id)
255
+ const res = await deployPluginToStore(p.id, true)
256
+ setBusy(null)
257
+ if (!res.success) {
258
+ setAlert({ type: "error", message: res.error || "Could not deploy plugin to store" })
259
+ return
260
+ }
261
+ if (res.plugin && detail?.id === p.id) setDetail(res.plugin)
262
+ setAlert({
263
+ type: "success",
264
+ message: `${p.name} deployed to the plugin store and locked as complete.`
265
+ })
266
+ loadLocal()
267
+ }
268
+
269
+ const openBrandingModal = (p: PluginInfo) => {
270
+ const branding = resolvePluginBranding(p)
271
+ setBrandingPlugin(p)
272
+ setBrandingIcon(branding.icon)
273
+ setBrandingColor(branding.color)
274
+ setBrandingBadge(branding.badge)
275
+ setBrandingOpen(true)
276
+ }
277
+
278
+ const saveBranding = async () => {
279
+ if (!brandingPlugin) return
280
+ if (brandingPlugin.locked) {
281
+ setAlert({ type: "error", message: "Plugin is locked. Unlock it before editing branding." })
282
+ setBrandingOpen(false)
283
+ return
284
+ }
285
+ setBusy(brandingPlugin.id)
286
+ const res = await updatePluginBranding(brandingPlugin.id, {
287
+ icon: brandingIcon.trim() || undefined,
288
+ color: brandingColor.trim() || undefined,
289
+ badge: brandingBadge.trim() || undefined
290
+ })
291
+ setBusy(null)
292
+ setBrandingOpen(false)
293
+ if (!res.success) {
294
+ setAlert({ type: "error", message: res.error || "Could not save branding" })
295
+ return
296
+ }
297
+ setAlert({ type: "success", message: `Updated branding for ${brandingPlugin.name}` })
298
+ await loadLocal()
299
+ if (detail?.id === brandingPlugin.id && res.plugin) {
300
+ setDetail(res.plugin)
301
+ }
302
+ }
303
+
304
+ const openDetail = async (p: PluginInfo, targetTab: DetailTab = "preview") => {
199
305
  setDetail(p)
200
- setDetailTab(p.cli ? "run" : "details")
306
+ const hasPreview = !!getPreviewComponent(p.id)
307
+ setDetailTab(hasPreview ? targetTab : (p.cli ? "run" : "details"))
201
308
  setSubcommand(p.cli?.subcommands?.[0]?.name || "")
202
309
  setArgs("")
203
310
  setOutput(null)
@@ -222,7 +329,6 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
222
329
  // eslint-disable-next-line react-hooks/exhaustive-deps
223
330
  }, [searchParams, installed])
224
331
 
225
- // Keep an open detail panel in sync after share/remove/reload.
226
332
  useEffect(() => {
227
333
  if (!detail) return
228
334
  const fresh = installed.find((p) => p.id === detail.id)
@@ -261,8 +367,8 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
261
367
  <div>
262
368
  <h1>Plugins</h1>
263
369
  <p>
264
- Plugins shape Total Recall for a particular use β€” memory categories, agent context, commands and scheduled jobs.
265
- Share them directly with other users through a hash-pinned HTTPS link. Your mesh peers can use the same shared plugins.
370
+ Plugins deliver modular capabilities into Total Recall and SSSS apps β€” memory categories, agent context, commands, and design-token UI.
371
+ Customizable branding, locked release status, and direct store deployment are managed on each card.
266
372
  </p>
267
373
  </div>
268
374
  <button className="btn btn-primary" onClick={() => { setInstallOpen(true); setAlert(null) }}>
@@ -302,33 +408,160 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
302
408
  </Empty>
303
409
  ) : (
304
410
  <div className="card-grid">
305
- {filteredInstalled.map((p) => (
306
- <div key={`${p.scope}:${p.id}`} className="card" style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
307
- <div style={{ display: "flex", justifyContent: "space-between", gap: "8px", alignItems: "baseline" }}>
308
- <button onClick={() => openDetail(p)} style={{ background: "none", border: "none", padding: 0, color: "var(--text-primary)", fontWeight: 600, fontSize: "15px", cursor: "pointer", textAlign: "left" }}>
309
- {p.name}
310
- </button>
311
- <span style={{ ...mono, color: "var(--text-tertiary)" }}>v{p.version}</span>
312
- </div>
313
- <div style={muted}>{p.description}</div>
314
- <UseCaseChips useCases={p.use_cases} />
315
- <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
316
- <span className="badge">{p.scope}</span>
317
- <span className="badge">{sourceLabel(p)}</span>
318
- {p.shared && <span className="badge badge-accent">shared</span>}
319
- {!p.valid && <span className="badge badge-error">invalid manifest</span>}
320
- {p.modified_since_install && <span className="badge badge-warning">changed since install</span>}
321
- </div>
322
- <div style={{ ...mono, color: "var(--text-tertiary)" }} title={p.sha256 || undefined}>sha256 {shortHash(p.sha256)}</div>
323
- <div style={{ display: "flex", gap: "8px", marginTop: "auto", flexWrap: "wrap" }}>
324
- <button className="btn btn-ghost btn-sm" onClick={() => openDetail(p)}>Open</button>
325
- <button className="btn btn-ghost btn-sm" disabled={busy === p.id || (!p.valid && !p.shared)} onClick={() => doShare(p, !p.shared)}>
326
- {p.shared ? "Stop sharing" : "Share"}
327
- </button>
328
- <button className="btn btn-ghost btn-sm" disabled={busy === p.id} onClick={() => doRemove(p)}>Remove</button>
411
+ {filteredInstalled.map((p) => {
412
+ const branding = resolvePluginBranding(p)
413
+ return (
414
+ <div
415
+ key={`${p.scope}:${p.id}`}
416
+ className="card"
417
+ style={{
418
+ display: "flex",
419
+ flexDirection: "column",
420
+ gap: "12px",
421
+ borderTop: `3px solid ${branding.color}`,
422
+ position: "relative"
423
+ }}
424
+ >
425
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "8px", alignItems: "flex-start" }}>
426
+ <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
427
+ <div
428
+ style={{
429
+ width: "36px",
430
+ height: "36px",
431
+ borderRadius: "8px",
432
+ background: `${branding.color}20`,
433
+ border: `1px solid ${branding.color}50`,
434
+ display: "flex",
435
+ alignItems: "center",
436
+ justifyContent: "center",
437
+ fontSize: "18px",
438
+ flexShrink: 0
439
+ }}
440
+ >
441
+ {branding.icon}
442
+ </div>
443
+ <div>
444
+ <button
445
+ onClick={() => openDetail(p, "preview")}
446
+ style={{
447
+ background: "none",
448
+ border: "none",
449
+ padding: 0,
450
+ color: "var(--text-primary)",
451
+ fontWeight: 600,
452
+ fontSize: "15px",
453
+ cursor: "pointer",
454
+ textAlign: "left"
455
+ }}
456
+ >
457
+ {p.name}
458
+ </button>
459
+ <div style={{ display: "flex", gap: "6px", alignItems: "center", marginTop: "2px" }}>
460
+ <span style={{ ...mono, color: "var(--text-tertiary)" }}>v{p.version}</span>
461
+ <span
462
+ className="badge"
463
+ style={{
464
+ fontSize: "10px",
465
+ background: `${branding.color}15`,
466
+ color: branding.color,
467
+ borderColor: `${branding.color}40`,
468
+ padding: "1px 6px"
469
+ }}
470
+ >
471
+ {branding.badge}
472
+ </span>
473
+ </div>
474
+ </div>
475
+ </div>
476
+ <div style={{ display: "flex", gap: "4px" }}>
477
+ {p.locked ? (
478
+ <span className="badge" style={{ background: "#2e1065", color: "#c084fc", borderColor: "#7e22ce" }} title="Editing locked">
479
+ πŸ”’ Locked
480
+ </span>
481
+ ) : (
482
+ <span className="badge" style={{ background: "#064e3b", color: "#6ee7b7", borderColor: "#059669" }} title="Editing enabled">
483
+ πŸ”“ Editable
484
+ </span>
485
+ )}
486
+ </div>
487
+ </div>
488
+
489
+ <div style={muted}>{p.description}</div>
490
+ <UseCaseChips useCases={p.use_cases} />
491
+
492
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap", fontSize: "11px" }}>
493
+ <span className="badge">{p.scope}</span>
494
+ <span className="badge">{sourceLabel(p)}</span>
495
+ {p.shared && <span className="badge badge-accent">shared</span>}
496
+ {p.store_deployed && <span className="badge badge-success">πŸͺ in store</span>}
497
+ {!p.valid && <span className="badge badge-error">invalid manifest</span>}
498
+ {p.modified_since_install && <span className="badge badge-warning">changed since install</span>}
499
+ </div>
500
+
501
+ <div style={{ ...mono, color: "var(--text-tertiary)", fontSize: "11px" }} title={p.sha256 || undefined}>
502
+ sha256 {shortHash(p.sha256)}
503
+ </div>
504
+
505
+ {/* Common Config Controls & Action Toolbar */}
506
+ <div style={{ display: "flex", gap: "6px", marginTop: "auto", flexWrap: "wrap", paddingTop: "8px", borderTop: "1px solid var(--border)" }}>
507
+ <button
508
+ className="btn btn-primary btn-sm"
509
+ onClick={() => openDetail(p, "preview")}
510
+ title="Open interactive component preview"
511
+ >
512
+ Preview
513
+ </button>
514
+ <button
515
+ className="btn btn-ghost btn-sm"
516
+ onClick={() => openDetail(p, "run")}
517
+ title="Run plugin CLI handler"
518
+ >
519
+ Run CLI
520
+ </button>
521
+ <button
522
+ className="btn btn-ghost btn-sm"
523
+ onClick={() => openBrandingModal(p)}
524
+ disabled={p.locked}
525
+ title={p.locked ? "Plugin is locked" : "Customize icon and branding"}
526
+ >
527
+ 🎨 Brand
528
+ </button>
529
+ <button
530
+ className="btn btn-ghost btn-sm"
531
+ onClick={() => doToggleLock(p)}
532
+ disabled={busy === p.id}
533
+ title={p.locked ? "Unlock editing" : "Lock editing when complete"}
534
+ >
535
+ {p.locked ? "πŸ”“ Unlock" : "πŸ”’ Lock"}
536
+ </button>
537
+ <button
538
+ className="btn btn-ghost btn-sm"
539
+ onClick={() => doDeployStore(p)}
540
+ disabled={busy === p.id || !p.valid}
541
+ title="Publish and deploy to the plugin store"
542
+ >
543
+ {p.store_deployed ? "βœ“ Store" : "πŸš€ Store"}
544
+ </button>
545
+ <button
546
+ className="btn btn-ghost btn-sm"
547
+ disabled={busy === p.id || (!p.valid && !p.shared)}
548
+ onClick={() => doShare(p, !p.shared)}
549
+ title="Share over mesh or direct link"
550
+ >
551
+ {p.shared ? "Unshare" : "Share"}
552
+ </button>
553
+ <button
554
+ className="btn btn-ghost btn-sm"
555
+ disabled={busy === p.id}
556
+ onClick={() => doRemove(p)}
557
+ title="Uninstall plugin"
558
+ >
559
+ Remove
560
+ </button>
561
+ </div>
329
562
  </div>
330
- </div>
331
- ))}
563
+ )
564
+ })}
332
565
  </div>
333
566
  )
334
567
  )}
@@ -337,32 +570,56 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
337
570
  loading ? <div style={muted}>Loading…</div> :
338
571
  filteredBundled.length === 0 ? <Empty title="No bundled plugins match" /> : (
339
572
  <>
340
- <p style={{ ...muted, marginBottom: "16px" }}>Shipped with this version of Total Recall, so they install on any node.</p>
573
+ <p style={{ ...muted, marginBottom: "16px" }}>Shipped with this version of Total Recall, installable on any node.</p>
341
574
  <div className="card-grid">
342
- {filteredBundled.map((p) => (
343
- <div key={p.id} className="card" style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
344
- <div style={{ display: "flex", justifyContent: "space-between", gap: "8px", alignItems: "baseline" }}>
345
- <span style={{ fontWeight: 600, fontSize: "15px" }}>{p.name}</span>
346
- <span style={{ ...mono, color: "var(--text-tertiary)" }}>v{p.version}</span>
347
- </div>
348
- <div style={muted}>{p.description}</div>
349
- <UseCaseChips useCases={p.use_cases} />
350
- <div style={{ ...muted, fontSize: "12px" }}>
351
- {[
352
- p.cli && `command: total-recall ${p.cli.command}`,
353
- p.tasks.length > 0 && `${p.tasks.length} scheduled task${p.tasks.length > 1 ? "s" : ""}`,
354
- p.categories.length > 0 && `${p.categories.length} memory categor${p.categories.length > 1 ? "ies" : "y"}`
355
- ].filter(Boolean).join(" Β· ")}
356
- </div>
357
- <div style={{ marginTop: "auto" }}>
358
- {p.installed
359
- ? <span className="badge badge-success">installed</span>
360
- : <button className="btn btn-primary btn-sm" disabled={busy === p.id} onClick={() => doInstall(p.id)}>
575
+ {filteredBundled.map((p) => {
576
+ const branding = resolvePluginBranding(p)
577
+ return (
578
+ <div key={p.id} className="card" style={{ display: "flex", flexDirection: "column", gap: "10px", borderTop: `3px solid ${branding.color}` }}>
579
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "8px", alignItems: "flex-start" }}>
580
+ <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
581
+ <div
582
+ style={{
583
+ width: "32px",
584
+ height: "32px",
585
+ borderRadius: "6px",
586
+ background: `${branding.color}20`,
587
+ display: "flex",
588
+ alignItems: "center",
589
+ justifyContent: "center",
590
+ fontSize: "16px"
591
+ }}
592
+ >
593
+ {branding.icon}
594
+ </div>
595
+ <div>
596
+ <span style={{ fontWeight: 600, fontSize: "15px" }}>{p.name}</span>
597
+ <div style={{ ...mono, color: "var(--text-tertiary)" }}>v{p.version}</div>
598
+ </div>
599
+ </div>
600
+ <span className="badge" style={{ background: `${branding.color}15`, color: branding.color }}>{branding.badge}</span>
601
+ </div>
602
+ <div style={muted}>{p.description}</div>
603
+ <UseCaseChips useCases={p.use_cases} />
604
+ <div style={{ ...muted, fontSize: "12px" }}>
605
+ {[
606
+ p.cli && `command: total-recall ${p.cli.command}`,
607
+ p.tasks.length > 0 && `${p.tasks.length} scheduled task${p.tasks.length > 1 ? "s" : ""}`,
608
+ p.categories.length > 0 && `${p.categories.length} memory categor${p.categories.length > 1 ? "ies" : "y"}`
609
+ ].filter(Boolean).join(" Β· ")}
610
+ </div>
611
+ <div style={{ marginTop: "auto", display: "flex", gap: "8px" }}>
612
+ {p.installed ? (
613
+ <span className="badge badge-success">installed</span>
614
+ ) : (
615
+ <button className="btn btn-primary btn-sm" disabled={busy === p.id} onClick={() => doInstall(p.id)}>
361
616
  {busy === p.id ? "Installing…" : "Install"}
362
- </button>}
617
+ </button>
618
+ )}
619
+ </div>
363
620
  </div>
364
- </div>
365
- ))}
621
+ )
622
+ })}
366
623
  </div>
367
624
  </>
368
625
  )
@@ -468,97 +725,307 @@ export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
468
725
  </div>
469
726
  )}
470
727
 
471
- {detail && (
472
- <div role="dialog" aria-modal="true" aria-label={detail.name} onClick={closeDetail}
473
- style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", justifyContent: "flex-end", zIndex: 1000 }}>
474
- <div onClick={(e) => e.stopPropagation()}
475
- style={{ width: "100%", maxWidth: "640px", height: "100%", overflowY: "auto", background: "var(--bg-secondary)", borderLeft: "1px solid var(--border)", padding: "24px", display: "flex", flexDirection: "column", gap: "16px" }}>
476
- <div style={{ display: "flex", justifyContent: "space-between", gap: "12px" }}>
477
- <div>
478
- <div style={{ fontSize: "18px", fontWeight: 700 }}>{detail.name}</div>
479
- <div style={{ ...mono, color: "var(--text-tertiary)" }}>{detail.id} Β· v{detail.version}</div>
480
- </div>
481
- <button className="btn btn-ghost btn-sm" onClick={closeDetail} aria-label="Close">Close</button>
728
+ {/* Branding Customization Modal */}
729
+ {brandingOpen && brandingPlugin && (
730
+ <div role="dialog" aria-modal="true" aria-label="Customize Plugin Branding" onClick={() => setBrandingOpen(false)}
731
+ style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000, padding: "16px" }}>
732
+ <div className="card" onClick={(e) => e.stopPropagation()} style={{ width: "100%", maxWidth: "480px", display: "flex", flexDirection: "column", gap: "16px" }}>
733
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
734
+ <div style={{ fontWeight: 700, fontSize: "16px" }}>Customize Branding: {brandingPlugin.name}</div>
735
+ <button className="btn btn-ghost btn-sm" onClick={() => setBrandingOpen(false)}>βœ•</button>
482
736
  </div>
483
- <div style={muted}>{detail.description}</div>
484
- {!detail.valid && (
485
- <div className="alert alert-error">
486
- {detail.errors.map((e) => <div key={e}>{e}</div>)}
737
+
738
+ {brandingPlugin.locked && (
739
+ <div className="alert alert-warning" style={{ fontSize: "12px" }}>
740
+ πŸ”’ This plugin is marked complete and locked against editing. Unlock it to change branding.
487
741
  </div>
488
742
  )}
489
- <div role="tablist" style={{ display: "flex", gap: "8px" }}>
490
- {detail.cli && <button role="tab" aria-selected={detailTab === "run"} className={`btn btn-sm ${detailTab === "run" ? "btn-primary" : "btn-ghost"}`} onClick={() => setDetailTab("run")}>Run</button>}
491
- <button role="tab" aria-selected={detailTab === "details"} className={`btn btn-sm ${detailTab === "details" ? "btn-primary" : "btn-ghost"}`} onClick={() => setDetailTab("details")}>Details</button>
492
- <button role="tab" aria-selected={detailTab === "readme"} className={`btn btn-sm ${detailTab === "readme" ? "btn-primary" : "btn-ghost"}`} onClick={() => setDetailTab("readme")}>README</button>
493
- </div>
494
743
 
495
- {detailTab === "run" && detail.cli && (
496
- <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
497
- <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
498
- <select className="select" value={subcommand} onChange={(e) => setSubcommand(e.target.value)} aria-label="Subcommand" style={{ paddingRight: "32px" }}>
499
- {(detail.cli.subcommands.length ? detail.cli.subcommands : [{ name: "" }]).map((s) => (
500
- <option key={s.name} value={s.name}>{s.name || "(default)"}</option>
501
- ))}
502
- </select>
503
- <input className="input" value={args} onChange={(e) => setArgs(e.target.value)} placeholder="extra arguments" aria-label="Arguments" style={{ flex: 1, minWidth: "160px" }} />
504
- <button className="btn btn-primary" onClick={runCommand} disabled={busy === `run:${detail.id}` || !detail.valid}>
505
- {busy === `run:${detail.id}` ? "Running…" : "Run"}
506
- </button>
744
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
745
+ <label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>Icon (Emoji or Symbol)</label>
746
+ <div style={{ display: "flex", gap: "8px" }}>
747
+ <input
748
+ className="input"
749
+ value={brandingIcon}
750
+ onChange={(e) => setBrandingIcon(e.target.value)}
751
+ style={{ width: "80px", textAlign: "center", fontSize: "18px" }}
752
+ disabled={brandingPlugin.locked}
753
+ />
754
+ <div style={{ display: "flex", gap: "4px", flexWrap: "wrap", alignItems: "center" }}>
755
+ {["πŸ“ž", "🌐", "✍️", "πŸ›‘οΈ", "πŸ’»", "βš–οΈ", "🎨", "πŸ’¬", "πŸ”", "🌿", "πŸ“Š", "πŸ””", "🐚"].map((emoji) => (
756
+ <button
757
+ key={emoji}
758
+ type="button"
759
+ className="btn btn-ghost btn-sm"
760
+ style={{ padding: "4px 8px", fontSize: "14px" }}
761
+ disabled={brandingPlugin.locked}
762
+ onClick={() => setBrandingIcon(emoji)}
763
+ >
764
+ {emoji}
765
+ </button>
766
+ ))}
507
767
  </div>
508
- <div style={{ ...muted, fontSize: "12px" }}>
509
- {detail.cli.subcommands.find((s) => s.name === subcommand)?.description}
510
- {" "}Same as <code style={mono}>npx total-recall {detail.cli.command} {subcommand} {args}</code>. Runs in a separate process.
511
- </div>
512
- {output && (
513
- <pre style={{ ...mono, margin: 0, padding: "14px", borderRadius: "var(--radius-sm)", background: "var(--bg-primary)", border: `1px solid ${output.ok ? "var(--border)" : "var(--error)"}`, whiteSpace: "pre-wrap", maxHeight: "420px", overflow: "auto" }}>
514
- {output.text}
515
- </pre>
516
- )}
517
768
  </div>
518
- )}
769
+ </div>
770
+
771
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
772
+ <label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>Theme Color (Hex)</label>
773
+ <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
774
+ <input
775
+ type="color"
776
+ value={brandingColor}
777
+ onChange={(e) => setBrandingColor(e.target.value)}
778
+ style={{ width: "42px", height: "38px", border: "none", cursor: "pointer", background: "none" }}
779
+ disabled={brandingPlugin.locked}
780
+ />
781
+ <input
782
+ className="input"
783
+ value={brandingColor}
784
+ onChange={(e) => setBrandingColor(e.target.value)}
785
+ style={{ flex: 1, fontFamily: "var(--font-mono)" }}
786
+ disabled={brandingPlugin.locked}
787
+ />
788
+ </div>
789
+ </div>
790
+
791
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
792
+ <label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>Domain Badge Text</label>
793
+ <input
794
+ className="input"
795
+ value={brandingBadge}
796
+ onChange={(e) => setBrandingBadge(e.target.value)}
797
+ placeholder="e.g. Telephony, Quality, DNS"
798
+ disabled={brandingPlugin.locked}
799
+ />
800
+ </div>
519
801
 
520
- {detailTab === "details" && (
521
- <div>
522
- <Fact label="Scope">{detail.scope}{detail.linked ? " (linked folder)" : ""}</Fact>
523
- <Fact label="Source">{sourceLabel(detail)} β€” <span style={mono}>{detail.source.ref}</span></Fact>
524
- <Fact label="Installed">{detail.installed_at ? new Date(detail.installed_at).toLocaleString() : "no install record"}</Fact>
525
- <Fact label="Content sha256"><span style={mono}>{detail.sha256 || "β€”"}</span></Fact>
526
- {detail.modified_since_install && <Fact label="At install"><span style={mono}>{detail.installed_sha256}</span></Fact>}
527
- <Fact label="Files">{detail.file_count ?? "β€”"} Β· {formatBytes(detail.size_bytes)}</Fact>
528
- <Fact label="Shared">{detail.shared ? "yes" : "no"}</Fact>
529
- {detail.shared && <Fact label="Public link">{detail.share_url ? <input className="input" aria-label="Public plugin share link" readOnly value={detail.share_url} onFocus={(e) => e.currentTarget.select()} /> : "Set TR_PUBLIC_BASE_URL to your public HTTPS origin to create a link."}</Fact>}
530
- <Fact label="Use cases">{detail.use_cases.join(", ") || "β€”"}</Fact>
531
- {detail.author && <Fact label="Author">{detail.author}</Fact>}
532
- {detail.license && <Fact label="License">{detail.license}</Fact>}
533
- <Fact label="Location"><span style={mono}>{detail.dir}</span></Fact>
534
- <Fact label="Agent context">{detail.has_generator ? "adds a block to compiled instructions" : "β€”"}</Fact>
535
- <Fact label="Memory categories">
536
- {detail.categories.length ? detail.categories.map((c) => c.name).join(", ") : "β€”"}
537
- </Fact>
538
- <Fact label="Scheduled tasks">
539
- {detail.tasks.length === 0 ? "β€”" : (
540
- <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
541
- {detail.tasks.map((t) => (
542
- <div key={t.command}>
543
- <span style={mono}>{t.schedule}</span> {t.command} β€” {t.intent}
544
- <div style={{ color: "var(--text-tertiary)", fontSize: "12px" }}>last run: {t.last_run || "not yet"}</div>
802
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "8px" }}>
803
+ <button type="button" className="btn btn-ghost" onClick={() => setBrandingOpen(false)}>Cancel</button>
804
+ <button
805
+ type="button"
806
+ className="btn btn-primary"
807
+ onClick={saveBranding}
808
+ disabled={brandingPlugin.locked || busy !== null}
809
+ >
810
+ Save Branding
811
+ </button>
812
+ </div>
813
+ </div>
814
+ </div>
815
+ )}
816
+
817
+ {/* Plugin Detail & Interactive Preview Drawer */}
818
+ {detail && (
819
+ <div role="dialog" aria-modal="true" aria-label={detail.name} onClick={closeDetail}
820
+ style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", justifyContent: "flex-end", zIndex: 1000 }}>
821
+ <div onClick={(e) => e.stopPropagation()}
822
+ style={{ width: "100%", maxWidth: "680px", height: "100%", overflowY: "auto", background: "var(--bg-secondary)", borderLeft: "1px solid var(--border)", padding: "24px", display: "flex", flexDirection: "column", gap: "16px" }}>
823
+
824
+ {/* Drawer Header */}
825
+ {(() => {
826
+ const branding = resolvePluginBranding(detail)
827
+ const PreviewComponent = getPreviewComponent(detail.id)
828
+ return (
829
+ <>
830
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "12px" }}>
831
+ <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
832
+ <div
833
+ style={{
834
+ width: "44px",
835
+ height: "44px",
836
+ borderRadius: "10px",
837
+ background: `${branding.color}20`,
838
+ border: `1px solid ${branding.color}50`,
839
+ display: "flex",
840
+ alignItems: "center",
841
+ justifyContent: "center",
842
+ fontSize: "22px",
843
+ flexShrink: 0
844
+ }}
845
+ >
846
+ {branding.icon}
847
+ </div>
848
+ <div>
849
+ <div style={{ fontSize: "18px", fontWeight: 700 }}>{detail.name}</div>
850
+ <div style={{ display: "flex", gap: "8px", alignItems: "center", marginTop: "2px" }}>
851
+ <span style={{ ...mono, color: "var(--text-tertiary)" }}>{detail.id} Β· v{detail.version}</span>
852
+ <span className="badge" style={{ background: `${branding.color}15`, color: branding.color, borderColor: `${branding.color}40`, fontSize: "11px" }}>
853
+ {branding.badge}
854
+ </span>
855
+ {detail.locked && <span className="badge" style={{ background: "#2e1065", color: "#c084fc" }}>πŸ”’ Locked</span>}
545
856
  </div>
546
- ))}
857
+ </div>
858
+ </div>
859
+ <button className="btn btn-ghost btn-sm" onClick={closeDetail} aria-label="Close">Close</button>
860
+ </div>
861
+
862
+ <div style={muted}>{detail.description}</div>
863
+
864
+ {!detail.valid && (
865
+ <div className="alert alert-error">
866
+ {detail.errors.map((e) => <div key={e}>{e}</div>)}
547
867
  </div>
548
868
  )}
549
- </Fact>
550
- <div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
551
- <button className="btn btn-ghost btn-sm" disabled={busy === detail.id || (!detail.valid && !detail.shared)} onClick={() => doShare(detail, !detail.shared)}>
552
- {detail.shared ? "Stop sharing" : "Share"}
553
- </button>
554
- <button className="btn btn-ghost btn-sm" disabled={busy === detail.id} onClick={() => doRemove(detail)}>Remove</button>
555
- </div>
556
- </div>
557
- )}
558
869
 
559
- {detailTab === "readme" && (
560
- <div style={{ fontSize: "14px", lineHeight: 1.6 }}>{readme ? renderMarkdown(readme) : <span style={muted}>Loading…</span>}</div>
561
- )}
870
+ {/* Drawer Navigation Tabs */}
871
+ <div role="tablist" style={{ display: "flex", gap: "8px", borderBottom: "1px solid var(--border)", paddingBottom: "10px" }}>
872
+ {PreviewComponent && (
873
+ <button
874
+ role="tab"
875
+ aria-selected={detailTab === "preview"}
876
+ className={`btn btn-sm ${detailTab === "preview" ? "btn-primary" : "btn-ghost"}`}
877
+ onClick={() => setDetailTab("preview")}
878
+ >
879
+ Interactive Preview
880
+ </button>
881
+ )}
882
+ {detail.cli && (
883
+ <button
884
+ role="tab"
885
+ aria-selected={detailTab === "run"}
886
+ className={`btn btn-sm ${detailTab === "run" ? "btn-primary" : "btn-ghost"}`}
887
+ onClick={() => setDetailTab("run")}
888
+ >
889
+ Run CLI
890
+ </button>
891
+ )}
892
+ <button
893
+ role="tab"
894
+ aria-selected={detailTab === "details"}
895
+ className={`btn btn-sm ${detailTab === "details" ? "btn-primary" : "btn-ghost"}`}
896
+ onClick={() => setDetailTab("details")}
897
+ >
898
+ Details & Config
899
+ </button>
900
+ <button
901
+ role="tab"
902
+ aria-selected={detailTab === "readme"}
903
+ className={`btn btn-sm ${detailTab === "readme" ? "btn-primary" : "btn-ghost"}`}
904
+ onClick={() => setDetailTab("readme")}
905
+ >
906
+ README
907
+ </button>
908
+ </div>
909
+
910
+ {/* Tab 1: 100% Functional Interactive Preview */}
911
+ {detailTab === "preview" && (
912
+ <div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
913
+ {PreviewComponent ? (
914
+ <PreviewComponent />
915
+ ) : (
916
+ <PreviewFallback pluginId={detail.id} />
917
+ )}
918
+ </div>
919
+ )}
920
+
921
+ {/* Tab 2: CLI Command Runner */}
922
+ {detailTab === "run" && detail.cli && (
923
+ <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
924
+ <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
925
+ <select className="select" value={subcommand} onChange={(e) => setSubcommand(e.target.value)} aria-label="Subcommand" style={{ paddingRight: "32px" }}>
926
+ {(detail.cli.subcommands.length ? detail.cli.subcommands : [{ name: "" }]).map((s) => (
927
+ <option key={s.name} value={s.name}>{s.name || "(default)"}</option>
928
+ ))}
929
+ </select>
930
+ <input className="input" value={args} onChange={(e) => setArgs(e.target.value)} placeholder="extra arguments" aria-label="Arguments" style={{ flex: 1, minWidth: "160px" }} />
931
+ <button className="btn btn-primary" onClick={runCommand} disabled={busy === `run:${detail.id}` || !detail.valid}>
932
+ {busy === `run:${detail.id}` ? "Running…" : "Run"}
933
+ </button>
934
+ </div>
935
+ <div style={{ ...muted, fontSize: "12px" }}>
936
+ {detail.cli.subcommands.find((s) => s.name === subcommand)?.description}
937
+ {" "}Same as <code style={mono}>npx total-recall {detail.cli.command} {subcommand} {args}</code>. Runs in a separate process.
938
+ </div>
939
+ {output && (
940
+ <pre style={{ ...mono, margin: 0, padding: "14px", borderRadius: "var(--radius-sm)", background: "var(--bg-primary)", border: `1px solid ${output.ok ? "var(--border)" : "var(--error)"}`, whiteSpace: "pre-wrap", maxHeight: "420px", overflow: "auto" }}>
941
+ {output.text}
942
+ </pre>
943
+ )}
944
+ </div>
945
+ )}
946
+
947
+ {/* Tab 3: Plugin Facts & Config Controls */}
948
+ {detailTab === "details" && (
949
+ <div>
950
+ <Fact label="Scope">{detail.scope}{detail.linked ? " (linked folder)" : ""}</Fact>
951
+ <Fact label="Source">{sourceLabel(detail)} β€” <span style={mono}>{detail.source.ref}</span></Fact>
952
+ <Fact label="Installed">{detail.installed_at ? new Date(detail.installed_at).toLocaleString() : "no install record"}</Fact>
953
+ <Fact label="Content sha256"><span style={mono}>{detail.sha256 || "β€”"}</span></Fact>
954
+ {detail.modified_since_install && <Fact label="At install"><span style={mono}>{detail.installed_sha256}</span></Fact>}
955
+ <Fact label="Files">{detail.file_count ?? "β€”"} Β· {formatBytes(detail.size_bytes)}</Fact>
956
+ <Fact label="Shared">{detail.shared ? "yes" : "no"}</Fact>
957
+ <Fact label="Lock Status">{detail.locked ? "πŸ”’ Locked (Completed)" : "πŸ”“ Unlocked (Editable)"}</Fact>
958
+ <Fact label="Store Status">{detail.store_deployed ? "πŸͺ Published in Plugin Store" : "Not yet deployed to store"}</Fact>
959
+ {detail.shared && <Fact label="Public link">{detail.share_url ? <input className="input" aria-label="Public plugin share link" readOnly value={detail.share_url} onFocus={(e) => e.currentTarget.select()} /> : "Set TR_PUBLIC_BASE_URL to your public HTTPS origin to create a link."}</Fact>}
960
+ <Fact label="Use cases">{detail.use_cases.join(", ") || "β€”"}</Fact>
961
+ {detail.author && <Fact label="Author">{detail.author}</Fact>}
962
+ {detail.license && <Fact label="License">{detail.license}</Fact>}
963
+ <Fact label="Location"><span style={mono}>{detail.dir}</span></Fact>
964
+ <Fact label="Agent context">{detail.has_generator ? "adds a block to compiled instructions" : "β€”"}</Fact>
965
+ <Fact label="Memory categories">
966
+ {detail.categories.length ? detail.categories.map((c) => c.name).join(", ") : "β€”"}
967
+ </Fact>
968
+ <Fact label="Scheduled tasks">
969
+ {detail.tasks.length === 0 ? "β€”" : (
970
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
971
+ {detail.tasks.map((t) => (
972
+ <div key={t.command}>
973
+ <span style={mono}>{t.schedule}</span> {t.command} β€” {t.intent}
974
+ <div style={{ color: "var(--text-tertiary)", fontSize: "12px" }}>last run: {t.last_run || "not yet"}</div>
975
+ </div>
976
+ ))}
977
+ </div>
978
+ )}
979
+ </Fact>
980
+
981
+ {/* Config Actions Toolbar inside drawer */}
982
+ <div style={{ display: "flex", gap: "8px", marginTop: "16px", flexWrap: "wrap" }}>
983
+ <button
984
+ className="btn btn-ghost btn-sm"
985
+ onClick={() => openBrandingModal(detail)}
986
+ disabled={detail.locked}
987
+ >
988
+ 🎨 Customize Branding
989
+ </button>
990
+ <button
991
+ className="btn btn-ghost btn-sm"
992
+ onClick={() => doToggleLock(detail)}
993
+ disabled={busy === detail.id}
994
+ >
995
+ {detail.locked ? "πŸ”“ Unlock Editing" : "πŸ”’ Lock When Complete"}
996
+ </button>
997
+ <button
998
+ className="btn btn-primary btn-sm"
999
+ onClick={() => doDeployStore(detail)}
1000
+ disabled={busy === detail.id || !detail.valid}
1001
+ >
1002
+ {detail.store_deployed ? "βœ“ Deployed to Store" : "πŸš€ Deploy to Plugin Store"}
1003
+ </button>
1004
+ <button
1005
+ className="btn btn-ghost btn-sm"
1006
+ disabled={busy === detail.id || (!detail.valid && !detail.shared)}
1007
+ onClick={() => doShare(detail, !detail.shared)}
1008
+ >
1009
+ {detail.shared ? "Stop sharing" : "Share"}
1010
+ </button>
1011
+ <button
1012
+ className="btn btn-ghost btn-sm"
1013
+ disabled={busy === detail.id}
1014
+ onClick={() => doRemove(detail)}
1015
+ >
1016
+ Remove
1017
+ </button>
1018
+ </div>
1019
+ </div>
1020
+ )}
1021
+
1022
+ {/* Tab 4: Markdown Readme */}
1023
+ {detailTab === "readme" && (
1024
+ <div style={{ fontSize: "14px", lineHeight: 1.6 }}>{readme ? renderMarkdown(readme) : <span style={muted}>Loading…</span>}</div>
1025
+ )}
1026
+ </>
1027
+ )
1028
+ })()}
562
1029
  </div>
563
1030
  </div>
564
1031
  )}