stagent 0.1.10 → 0.1.12

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 (112) hide show
  1. package/README.md +58 -27
  2. package/package.json +3 -3
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -21
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/globals.css +0 -5
  18. package/src/app/playbook/[slug]/page.tsx +76 -0
  19. package/src/app/playbook/page.tsx +54 -0
  20. package/src/app/profiles/page.tsx +7 -4
  21. package/src/app/settings/page.tsx +2 -2
  22. package/src/app/tasks/page.tsx +5 -0
  23. package/src/components/costs/cost-dashboard.tsx +226 -320
  24. package/src/components/dashboard/activity-feed.tsx +6 -2
  25. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  26. package/src/components/notifications/notification-item.tsx +6 -3
  27. package/src/components/notifications/pending-approval-host.tsx +57 -11
  28. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  29. package/src/components/playbook/journey-card.tsx +110 -0
  30. package/src/components/playbook/playbook-action-button.tsx +22 -0
  31. package/src/components/playbook/playbook-browser.tsx +143 -0
  32. package/src/components/playbook/playbook-card.tsx +102 -0
  33. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  34. package/src/components/playbook/playbook-homepage.tsx +142 -0
  35. package/src/components/playbook/playbook-toc.tsx +90 -0
  36. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  37. package/src/components/playbook/related-docs.tsx +30 -0
  38. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  39. package/src/components/profiles/context-proposal-review.tsx +7 -3
  40. package/src/components/profiles/learned-context-panel.tsx +116 -8
  41. package/src/components/profiles/profile-detail-view.tsx +7 -19
  42. package/src/components/profiles/profile-form-view.tsx +0 -22
  43. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  44. package/src/components/settings/api-key-form.tsx +5 -43
  45. package/src/components/settings/auth-config-section.tsx +10 -6
  46. package/src/components/settings/auth-status-badge.tsx +8 -0
  47. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  48. package/src/components/settings/connection-test-control.tsx +63 -0
  49. package/src/components/settings/permissions-section.tsx +85 -75
  50. package/src/components/settings/permissions-sections.tsx +24 -0
  51. package/src/components/settings/presets-section.tsx +159 -0
  52. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  53. package/src/components/shared/app-sidebar.tsx +2 -0
  54. package/src/components/shared/command-palette.tsx +30 -0
  55. package/src/components/shared/light-markdown.tsx +134 -0
  56. package/src/components/workflows/loop-status-view.tsx +8 -4
  57. package/src/components/workflows/workflow-status-view.tsx +16 -9
  58. package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
  59. package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
  60. package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
  61. package/src/lib/agents/__tests__/sweep.test.ts +202 -0
  62. package/src/lib/agents/claude-agent.ts +104 -78
  63. package/src/lib/agents/learned-context.ts +32 -28
  64. package/src/lib/agents/learning-session.ts +234 -0
  65. package/src/lib/agents/pattern-extractor.ts +34 -64
  66. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  67. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
  68. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
  69. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
  70. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
  71. package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
  72. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
  73. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
  74. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
  75. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
  76. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
  77. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
  78. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
  79. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
  80. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
  81. package/src/lib/agents/profiles/registry.ts +0 -1
  82. package/src/lib/agents/profiles/sort.ts +7 -0
  83. package/src/lib/agents/profiles/types.ts +0 -1
  84. package/src/lib/agents/runtime/catalog.ts +1 -1
  85. package/src/lib/agents/runtime/claude.ts +66 -0
  86. package/src/lib/constants/settings.ts +1 -0
  87. package/src/lib/constants/task-status.ts +6 -0
  88. package/src/lib/data/seed-data/profiles.ts +0 -3
  89. package/src/lib/db/schema.ts +3 -0
  90. package/src/lib/docs/adoption.ts +105 -0
  91. package/src/lib/docs/journey-tracker.ts +21 -0
  92. package/src/lib/docs/reader.ts +102 -0
  93. package/src/lib/docs/types.ts +54 -0
  94. package/src/lib/docs/usage-stage.ts +60 -0
  95. package/src/lib/notifications/actionable.ts +18 -10
  96. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  97. package/src/lib/settings/budget-guardrails.ts +213 -85
  98. package/src/lib/settings/permission-presets.ts +150 -0
  99. package/src/lib/settings/runtime-setup.ts +71 -0
  100. package/src/lib/usage/__tests__/ledger.test.ts +29 -5
  101. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  102. package/src/lib/usage/ledger.ts +4 -2
  103. package/src/lib/usage/pricing-registry.ts +570 -0
  104. package/src/lib/usage/pricing.ts +15 -41
  105. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  106. package/src/lib/utils/learned-context-history.ts +150 -0
  107. package/src/lib/validators/__tests__/profile.test.ts +0 -15
  108. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  109. package/src/lib/validators/profile.ts +0 -1
  110. package/src/lib/validators/settings.ts +3 -9
  111. package/src/lib/workflows/__tests__/engine.test.ts +2 -0
  112. package/src/lib/workflows/engine.ts +20 -1
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { Image, ExternalLink } from "lucide-react";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { Card, CardHeader, CardContent } from "@/components/ui/card";
7
+ import type { DocSection, AdoptionEntry } from "@/lib/docs/types";
8
+
9
+ interface PlaybookCardProps {
10
+ section: DocSection;
11
+ adoption?: AdoptionEntry;
12
+ }
13
+
14
+ function adoptionDot(adoption?: AdoptionEntry) {
15
+ if (!adoption || adoption.depth === "none") {
16
+ return (
17
+ <span
18
+ className="h-2.5 w-2.5 rounded-full bg-muted-foreground/30"
19
+ title="Not explored"
20
+ />
21
+ );
22
+ }
23
+ if (adoption.depth === "light") {
24
+ return (
25
+ <span
26
+ className="h-2.5 w-2.5 rounded-full bg-amber-500"
27
+ title="Lightly explored"
28
+ />
29
+ );
30
+ }
31
+ return (
32
+ <span
33
+ className="h-2.5 w-2.5 rounded-full bg-emerald-500"
34
+ title="Deeply explored"
35
+ />
36
+ );
37
+ }
38
+
39
+ export function PlaybookCard({ section, adoption }: PlaybookCardProps) {
40
+ const isAppRoute =
41
+ section.route !== "cross-cutting" && section.route.startsWith("/");
42
+
43
+ return (
44
+ <Link
45
+ href={`/playbook/${section.slug}`}
46
+ className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
47
+ >
48
+ <Card className="surface-card glass-shimmer group h-full transition-colors hover:border-border hover:bg-accent/50 rounded-xl">
49
+ <CardHeader className="pb-2">
50
+ <div className="flex items-start justify-between gap-2">
51
+ <div className="flex items-center gap-2 min-w-0">
52
+ {adoptionDot(adoption)}
53
+ <h3 className="text-base font-medium truncate group-hover:text-primary transition-colors">
54
+ {section.title}
55
+ </h3>
56
+ </div>
57
+ {isAppRoute ? (
58
+ <Badge variant="secondary" className="shrink-0 text-xs">
59
+ {section.route}
60
+ </Badge>
61
+ ) : (
62
+ <Badge variant="outline" className="shrink-0 text-xs">
63
+ <ExternalLink className="h-3 w-3 mr-0.5" />
64
+ cross-cutting
65
+ </Badge>
66
+ )}
67
+ </div>
68
+ {/* Subtitle: feature count */}
69
+ <p className="text-sm text-muted-foreground">
70
+ {section.features.length} feature
71
+ {section.features.length !== 1 ? "s" : ""} covered
72
+ </p>
73
+ </CardHeader>
74
+
75
+ <CardContent className="space-y-3">
76
+ {/* Tags */}
77
+ <div className="flex flex-wrap gap-1.5">
78
+ {section.tags.slice(0, 4).map((tag) => (
79
+ <Badge key={tag} variant="outline" className="text-xs font-normal">
80
+ {tag}
81
+ </Badge>
82
+ ))}
83
+ {section.tags.length > 4 && (
84
+ <span className="text-xs text-muted-foreground">
85
+ +{section.tags.length - 4}
86
+ </span>
87
+ )}
88
+ </div>
89
+
90
+ {/* Footer */}
91
+ {section.screengrabCount > 0 && (
92
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
93
+ <Image className="h-3.5 w-3.5" />
94
+ {section.screengrabCount} screenshot
95
+ {section.screengrabCount > 1 ? "s" : ""}
96
+ </div>
97
+ )}
98
+ </CardContent>
99
+ </Card>
100
+ </Link>
101
+ );
102
+ }
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import Link from "next/link";
5
+ import ReactMarkdown from "react-markdown";
6
+ import remarkGfm from "remark-gfm";
7
+ import { ArrowLeft, ArrowRight, ExternalLink } from "lucide-react";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Badge } from "@/components/ui/badge";
10
+ import { PlaybookToc } from "./playbook-toc";
11
+ import { RelatedDocs } from "./related-docs";
12
+ import type { ParsedDoc, DocSection, AdoptionEntry } from "@/lib/docs/types";
13
+
14
+ interface PlaybookDetailViewProps {
15
+ doc: ParsedDoc;
16
+ relatedSections: DocSection[];
17
+ adoption: Record<string, AdoptionEntry>;
18
+ allSlugs: string[];
19
+ }
20
+
21
+ /** Convert a heading text to a slug-style ID */
22
+ function headingId(text: string): string {
23
+ return text
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, "-")
26
+ .replace(/(^-|-$)/g, "");
27
+ }
28
+
29
+ export function PlaybookDetailView({
30
+ doc,
31
+ relatedSections,
32
+ adoption,
33
+ allSlugs,
34
+ }: PlaybookDetailViewProps) {
35
+ const title =
36
+ (doc.frontmatter.title as string) || doc.slug.replace(/-/g, " ");
37
+ const tags = (doc.frontmatter.tags as string[]) || [];
38
+ const route = doc.frontmatter.route as string | undefined;
39
+ const category = doc.frontmatter.category as string | undefined;
40
+ const difficulty = doc.frontmatter.difficulty as string | undefined;
41
+
42
+ // Build a slug lookup from all known docs for rewriting .md links
43
+ const slugSet = useMemo(
44
+ () => new Set(allSlugs),
45
+ [allSlugs]
46
+ );
47
+
48
+ /** Custom link renderer for react-markdown */
49
+ function MarkdownLink({
50
+ href,
51
+ children,
52
+ }: {
53
+ href?: string;
54
+ children?: React.ReactNode;
55
+ }) {
56
+ if (!href) return <>{children}</>;
57
+
58
+ // Relative .md links → internal playbook links
59
+ if (href.includes(".md") || href.startsWith("./") || href.startsWith("../")) {
60
+ // Strip fragment, .md extension, and any relative path prefixes
61
+ const [pathPart, fragment] = href.split("#");
62
+ const slug = pathPart
63
+ .replace(/\.md$/, "")
64
+ .replace(/^(\.\.?\/)+/, "") // strip leading ./ or ../
65
+ .replace(/^(features|journeys)\//, ""); // strip directory prefix
66
+ if (slugSet.has(slug)) {
67
+ const hashSuffix = fragment ? `#${fragment}` : "";
68
+ return (
69
+ <Link
70
+ href={`/playbook/${slug}${hashSuffix}`}
71
+ className="text-primary underline underline-offset-4 hover:text-primary/80"
72
+ >
73
+ {children}
74
+ </Link>
75
+ );
76
+ }
77
+ }
78
+
79
+ // App routes → action buttons
80
+ if (href.startsWith("/") && !href.startsWith("/playbook")) {
81
+ return (
82
+ <Link
83
+ href={href}
84
+ className="inline-flex items-center gap-1.5 rounded-full bg-primary px-3 py-1 text-sm font-medium text-primary-foreground no-underline hover:bg-primary/90 transition-colors"
85
+ >
86
+ {children}
87
+ <ArrowRight className="h-3.5 w-3.5" />
88
+ </Link>
89
+ );
90
+ }
91
+
92
+ // External links
93
+ return (
94
+ <a
95
+ href={href}
96
+ target="_blank"
97
+ rel="noopener noreferrer"
98
+ className="text-primary underline underline-offset-4 hover:text-primary/80 inline-flex items-center gap-0.5"
99
+ >
100
+ {children}
101
+ <ExternalLink className="h-3 w-3" />
102
+ </a>
103
+ );
104
+ }
105
+
106
+ /** Custom heading renderer that adds IDs for TOC linking */
107
+ function H2({ children }: { children?: React.ReactNode }) {
108
+ const text = typeof children === "string" ? children : String(children);
109
+ return <h2 id={headingId(text)}>{children}</h2>;
110
+ }
111
+
112
+ function H3({ children }: { children?: React.ReactNode }) {
113
+ const text = typeof children === "string" ? children : String(children);
114
+ return <h3 id={headingId(text)}>{children}</h3>;
115
+ }
116
+
117
+ /** Custom image renderer that resolves relative screengrab paths */
118
+ function MarkdownImage({
119
+ src,
120
+ alt,
121
+ }: {
122
+ src?: string;
123
+ alt?: string;
124
+ }) {
125
+ if (!src) return null;
126
+
127
+ // Resolve ../screengrabs/ paths to /readme/ (images live in public/readme/)
128
+ let resolvedSrc = src;
129
+ if (src.startsWith("../screengrabs/")) {
130
+ resolvedSrc = `/readme/${src.replace("../screengrabs/", "")}`;
131
+ } else if (src.startsWith("./")) {
132
+ resolvedSrc = `/docs/${src.replace("./", "")}`;
133
+ }
134
+
135
+ return (
136
+ <span className="block my-6">
137
+ <img
138
+ src={resolvedSrc}
139
+ alt={alt || ""}
140
+ className="rounded-xl border border-border/50 max-w-full"
141
+ loading="lazy"
142
+ onError={(e) => {
143
+ (e.target as HTMLImageElement).style.display = "none";
144
+ }}
145
+ />
146
+ </span>
147
+ );
148
+ }
149
+
150
+ return (
151
+ <div className="space-y-6">
152
+ {/* Back nav */}
153
+ <div className="flex items-center gap-4">
154
+ <Button variant="ghost" size="sm" asChild>
155
+ <Link href="/playbook">
156
+ <ArrowLeft className="h-4 w-4 mr-1" />
157
+ Playbook
158
+ </Link>
159
+ </Button>
160
+ {route && route !== "cross-cutting" && (
161
+ <Button variant="outline" size="sm" asChild>
162
+ <Link href={route}>
163
+ Open {title}
164
+ <ArrowRight className="h-3.5 w-3.5 ml-1" />
165
+ </Link>
166
+ </Button>
167
+ )}
168
+ </div>
169
+
170
+ {/* Title + meta */}
171
+ <div className="space-y-2">
172
+ <h1 className="text-3xl font-bold tracking-tight">{title}</h1>
173
+ <div className="flex flex-wrap items-center gap-2">
174
+ {category && (
175
+ <Badge variant="secondary" className="text-xs">
176
+ {category}
177
+ </Badge>
178
+ )}
179
+ {difficulty && (
180
+ <Badge variant="outline" className="text-xs">
181
+ {difficulty}
182
+ </Badge>
183
+ )}
184
+ {tags.slice(0, 6).map((tag) => (
185
+ <Badge key={tag} variant="outline" className="text-xs font-normal">
186
+ {tag}
187
+ </Badge>
188
+ ))}
189
+ </div>
190
+ </div>
191
+
192
+ {/* Content layout: TOC sidebar + prose */}
193
+ <div className="flex gap-8">
194
+ {/* TOC sidebar — hidden on mobile */}
195
+ <aside className="hidden lg:block w-48 shrink-0">
196
+ <div className="sticky top-20">
197
+ <PlaybookToc body={doc.body} />
198
+ </div>
199
+ </aside>
200
+
201
+ {/* Prose content */}
202
+ <div className="flex-1 min-w-0">
203
+ <div className="prose dark:prose-invert max-w-none prose-headings:scroll-mt-20 prose-img:rounded-xl prose-img:border prose-img:border-border/50">
204
+ <ReactMarkdown
205
+ remarkPlugins={[remarkGfm]}
206
+ components={{
207
+ a: MarkdownLink as never,
208
+ h2: H2 as never,
209
+ h3: H3 as never,
210
+ img: MarkdownImage as never,
211
+ }}
212
+ >
213
+ {doc.body}
214
+ </ReactMarkdown>
215
+ </div>
216
+
217
+ {/* Related docs */}
218
+ <RelatedDocs sections={relatedSections} adoption={adoption} />
219
+ </div>
220
+ </div>
221
+ </div>
222
+ );
223
+ }
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import {
5
+ BookOpen,
6
+ Sparkles,
7
+ Rocket,
8
+ Crown,
9
+ ArrowRight,
10
+ } from "lucide-react";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Badge } from "@/components/ui/badge";
13
+ import { PlaybookBrowser } from "./playbook-browser";
14
+ import { AdoptionHeatmap } from "./adoption-heatmap";
15
+ import type {
16
+ DocManifest,
17
+ UsageStage,
18
+ AdoptionEntry,
19
+ JourneyCompletion,
20
+ } from "@/lib/docs/types";
21
+
22
+ interface PlaybookHomepageProps {
23
+ manifest: DocManifest;
24
+ stage: UsageStage;
25
+ adoption: Record<string, AdoptionEntry>;
26
+ journeyCompletions: Record<string, JourneyCompletion>;
27
+ hasUpdates: boolean;
28
+ }
29
+
30
+ const stageConfig: Record<
31
+ UsageStage,
32
+ {
33
+ icon: typeof BookOpen;
34
+ title: string;
35
+ subtitle: string;
36
+ ctaLabel: string;
37
+ ctaSlug: string;
38
+ }
39
+ > = {
40
+ new: {
41
+ icon: BookOpen,
42
+ title: "Welcome to Stagent",
43
+ subtitle:
44
+ "Your AI agent orchestrator. Start with the getting started guide or explore feature docs at your own pace.",
45
+ ctaLabel: "Getting Started",
46
+ ctaSlug: "getting-started",
47
+ },
48
+ early: {
49
+ icon: Sparkles,
50
+ title: "Keep Building",
51
+ subtitle:
52
+ "You've started creating tasks. Follow a guided journey to unlock more of what Stagent can do.",
53
+ ctaLabel: "Personal Use Guide",
54
+ ctaSlug: "personal-use",
55
+ },
56
+ active: {
57
+ icon: Rocket,
58
+ title: "Level Up",
59
+ subtitle:
60
+ "You're actively using Stagent. Explore advanced workflows, schedules, and multi-agent features.",
61
+ ctaLabel: "Power User Guide",
62
+ ctaSlug: "power-user",
63
+ },
64
+ power: {
65
+ icon: Crown,
66
+ title: "Master Mode",
67
+ subtitle:
68
+ "You've unlocked the full platform. Dive into cross-cutting guides and developer docs.",
69
+ ctaLabel: "Developer Guide",
70
+ ctaSlug: "developer",
71
+ },
72
+ };
73
+
74
+ export function PlaybookHomepage({
75
+ manifest,
76
+ stage,
77
+ adoption,
78
+ journeyCompletions,
79
+ hasUpdates,
80
+ }: PlaybookHomepageProps) {
81
+ const config = stageConfig[stage];
82
+ const Icon = config.icon;
83
+
84
+ const adoptedCount = Object.values(adoption).filter(
85
+ (a) => a.adopted
86
+ ).length;
87
+ const totalSections = manifest.sections.length;
88
+
89
+ return (
90
+ <div className="space-y-8">
91
+ {/* Hero */}
92
+ <div className="surface-panel rounded-2xl p-6 md:p-8">
93
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
94
+ <div className="flex items-start gap-4">
95
+ <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
96
+ <Icon className="h-6 w-6 text-primary" />
97
+ </div>
98
+ <div className="space-y-1">
99
+ <div className="flex items-center gap-2">
100
+ <h1 className="text-3xl font-bold tracking-tight">
101
+ {config.title}
102
+ </h1>
103
+ {hasUpdates && (
104
+ <Badge className="bg-primary/10 text-primary text-xs">
105
+ Updated
106
+ </Badge>
107
+ )}
108
+ </div>
109
+ <p className="text-base text-muted-foreground max-w-lg">
110
+ {config.subtitle}
111
+ </p>
112
+ {stage !== "new" && (
113
+ <p className="text-sm text-muted-foreground">
114
+ {adoptedCount} of {totalSections} features explored
115
+ </p>
116
+ )}
117
+ </div>
118
+ </div>
119
+ <Button asChild>
120
+ <Link href={`/playbook/${config.ctaSlug}`}>
121
+ {config.ctaLabel}
122
+ <ArrowRight className="h-4 w-4 ml-1" />
123
+ </Link>
124
+ </Button>
125
+ </div>
126
+ </div>
127
+
128
+ {/* Adoption Heatmap — shown for active/power users */}
129
+ {(stage === "active" || stage === "power") && (
130
+ <AdoptionHeatmap sections={manifest.sections} adoption={adoption} />
131
+ )}
132
+
133
+ {/* Full Browser */}
134
+ <PlaybookBrowser
135
+ sections={manifest.sections}
136
+ journeys={manifest.journeys}
137
+ adoption={adoption}
138
+ journeyCompletions={journeyCompletions}
139
+ />
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useRef } from "react";
4
+
5
+ interface TocItem {
6
+ id: string;
7
+ text: string;
8
+ level: number;
9
+ }
10
+
11
+ interface PlaybookTocProps {
12
+ body: string;
13
+ }
14
+
15
+ /** Extract ## and ### headings from markdown body */
16
+ function extractHeadings(body: string): TocItem[] {
17
+ const headingRegex = /^(#{2,3})\s+(.+)$/gm;
18
+ const items: TocItem[] = [];
19
+ let match;
20
+
21
+ while ((match = headingRegex.exec(body)) !== null) {
22
+ const level = match[1].length;
23
+ const text = match[2].trim();
24
+ const id = text
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "-")
27
+ .replace(/(^-|-$)/g, "");
28
+ items.push({ id, text, level });
29
+ }
30
+
31
+ return items;
32
+ }
33
+
34
+ export function PlaybookToc({ body }: PlaybookTocProps) {
35
+ const headings = extractHeadings(body);
36
+ const [activeId, setActiveId] = useState<string>("");
37
+ const observerRef = useRef<IntersectionObserver | null>(null);
38
+
39
+ useEffect(() => {
40
+ observerRef.current = new IntersectionObserver(
41
+ (entries) => {
42
+ for (const entry of entries) {
43
+ if (entry.isIntersecting) {
44
+ setActiveId(entry.target.id);
45
+ }
46
+ }
47
+ },
48
+ { rootMargin: "-80px 0px -60% 0px", threshold: 0 }
49
+ );
50
+
51
+ const elements = headings
52
+ .map((h) => document.getElementById(h.id))
53
+ .filter(Boolean) as HTMLElement[];
54
+
55
+ for (const el of elements) {
56
+ observerRef.current.observe(el);
57
+ }
58
+
59
+ return () => observerRef.current?.disconnect();
60
+ }, [headings]);
61
+
62
+ if (headings.length < 2) return null;
63
+
64
+ return (
65
+ <nav className="space-y-1" aria-label="Table of contents">
66
+ <h4 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-2">
67
+ On this page
68
+ </h4>
69
+ {headings.map((h) => (
70
+ <a
71
+ key={h.id}
72
+ href={`#${h.id}`}
73
+ className={`block text-sm py-0.5 transition-colors hover:text-foreground ${
74
+ h.level === 3 ? "pl-3" : ""
75
+ } ${
76
+ activeId === h.id
77
+ ? "text-primary font-medium"
78
+ : "text-muted-foreground"
79
+ }`}
80
+ onClick={(e) => {
81
+ e.preventDefault();
82
+ document.getElementById(h.id)?.scrollIntoView({ behavior: "smooth" });
83
+ }}
84
+ >
85
+ {h.text}
86
+ </a>
87
+ ))}
88
+ </nav>
89
+ );
90
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /** Small "Updated" dot badge for sidebar — shows when docs changed since last visit */
6
+ export function PlaybookUpdatedBadge() {
7
+ const [hasUpdates, setHasUpdates] = useState(false);
8
+
9
+ useEffect(() => {
10
+ fetch("/api/playbook/status")
11
+ .then((r) => (r.ok ? r.json() : null))
12
+ .then((data) => {
13
+ if (data?.hasUpdates) setHasUpdates(true);
14
+ })
15
+ .catch(() => {});
16
+ }, []);
17
+
18
+ if (!hasUpdates) return null;
19
+
20
+ return (
21
+ <span className="ml-auto flex h-2 w-2 rounded-full bg-primary" aria-label="Updated docs available" />
22
+ );
23
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { PlaybookCard } from "./playbook-card";
4
+ import type { DocSection, AdoptionEntry } from "@/lib/docs/types";
5
+
6
+ interface RelatedDocsProps {
7
+ sections: DocSection[];
8
+ adoption: Record<string, AdoptionEntry>;
9
+ }
10
+
11
+ export function RelatedDocs({ sections, adoption }: RelatedDocsProps) {
12
+ if (sections.length === 0) return null;
13
+
14
+ return (
15
+ <div className="space-y-3 pt-6 border-t border-border/50">
16
+ <h3 className="text-base font-medium text-muted-foreground uppercase tracking-wider">
17
+ Related Docs
18
+ </h3>
19
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
20
+ {sections.map((section) => (
21
+ <PlaybookCard
22
+ key={section.slug}
23
+ section={section}
24
+ adoption={adoption[section.slug]}
25
+ />
26
+ ))}
27
+ </div>
28
+ </div>
29
+ );
30
+ }