replay-labs 0.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.
@@ -0,0 +1,147 @@
1
+ // Session map: the landing page for a session's labs.
2
+ // "This session contains N decisions. Start with X."
3
+
4
+ export function generateOverviewHtml({ goal, labs }) {
5
+ const rich = labs.filter((l) => l.rich);
6
+ const primary = rich[0] || labs[0];
7
+ const cards = labs.map((lab, index) => labCard(lab, index)).join("");
8
+ const hasReadyLabs = rich.length > 0;
9
+ const hasDecisionSignals = labs.length > 0;
10
+ const decisionWord = labs.length === 1 ? "decision" : "decisions";
11
+ const decisionNoun = labs.length === 1 ? "decision signal" : "decision signals";
12
+ const headline = hasReadyLabs
13
+ ? `This session contains <em>${labs.length} ${decisionWord}</em>. Start with ${escapeHtml(primary.module.name)}.`
14
+ : hasDecisionSignals
15
+ ? `Replay Labs found <em>${labs.length} ${decisionNoun}</em>, but no practice lab is ready yet.`
16
+ : "Replay did not find enough decision evidence in this session.";
17
+ const subcopy = hasReadyLabs
18
+ ? "Use the lab to practice the decision: diagnose it, test the failure mode, repair it, and transfer it to a future session."
19
+ : hasDecisionSignals
20
+ ? "Replay Labs found decision signals, but it did not find enough concrete code evidence to build a practice lab."
21
+ : "This session may still be useful to read, but Replay cannot honestly turn it into a decision map or lab yet.";
22
+ const foot = hasReadyLabs
23
+ ? `Ready labs use this session's diff and transcript. Pattern catalog: ${
24
+ rich.map((l) => `<a href="patterns/${l.module.id}.html">${escapeHtml(l.module.name)}</a>`).join(" · ")
25
+ }`
26
+ : hasDecisionSignals
27
+ ? "No lab link is shown because this session only had weak or indirect evidence."
28
+ : "No decision map is shown because Replay did not find enough decision evidence.";
29
+
30
+ return `<!doctype html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8" />
34
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
35
+ <title>Session map — Replay</title>
36
+ <link rel="preconnect" href="https://fonts.googleapis.com">
37
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
38
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
39
+ <style>
40
+ :root {
41
+ --bg: #0c0e11; --panel: #14171c; --panel2: #191d23;
42
+ --line: #242a32; --line2: #303845;
43
+ --ink: #e9e7e2; --muted: #98a1ac; --faint: #67707c;
44
+ --accent: #34d399; --accent-dim: #34d39922; --accent-ink: #052e1e;
45
+ --fail: #f87171; --warn: #fbbf24;
46
+ --sans: "Inter", ui-sans-serif, system-ui, sans-serif;
47
+ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
48
+ }
49
+ * { box-sizing: border-box; }
50
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans);
51
+ font-size: 15.5px; line-height: 1.6; -webkit-font-smoothing: antialiased; }
52
+ ::selection { background: var(--accent); color: var(--accent-ink); }
53
+ a { color: var(--accent); text-decoration: none; }
54
+ header.top { display: flex; align-items: center; padding: 14px 28px; border-bottom: 1px solid var(--line); }
55
+ .wordmark { font-family: var(--mono); font-weight: 700; font-size: 15px; }
56
+ .wordmark em { color: var(--accent); font-style: normal; }
57
+ .wordmark span { color: var(--faint); font-weight: 400; margin-left: 10px; font-size: 12px;
58
+ letter-spacing: .12em; text-transform: uppercase; }
59
+ main { max-width: 880px; margin: 0 auto; padding: 52px 24px 90px; }
60
+ .eyebrow { font-family: var(--mono); font-size: 11px; letter-spacing: .16em; text-transform: uppercase;
61
+ color: var(--faint); margin-bottom: 16px; }
62
+ .eyebrow b { color: var(--accent); font-weight: 700; }
63
+ h1 { font-size: 40px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.12; margin: 0 0 14px; max-width: 720px; }
64
+ h1 em { font-style: normal; color: var(--accent); }
65
+ .sub { color: var(--muted); font-size: 17px; max-width: 640px; margin: 0 0 10px; }
66
+ .goal { color: var(--faint); font-size: 13.5px; margin: 0 0 38px; }
67
+ .list { display: grid; gap: 14px; }
68
+ .lab-card { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 18px; align-items: center;
69
+ background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 22px 24px;
70
+ color: inherit; transition: border-color .15s, transform .15s; }
71
+ a.lab-card:hover { border-color: var(--accent); transform: translateY(-2px); text-decoration: none; }
72
+ .lab-card.primary { border-color: var(--accent); background: linear-gradient(135deg, #34d39910, var(--panel) 50%); }
73
+ .lab-card.locked { opacity: .62; }
74
+ .rank { font-family: var(--mono); font-size: 22px; font-weight: 700; color: var(--faint); }
75
+ .lab-card.primary .rank { color: var(--accent); }
76
+ .lab-card h3 { margin: 0 0 4px; font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
77
+ .lab-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
78
+ .tags { display: flex; gap: 8px; margin-top: 9px; flex-wrap: wrap; }
79
+ .tag { font-family: var(--mono); font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase;
80
+ border: 1px solid var(--line); border-radius: 5px; padding: 2px 8px; color: var(--faint); }
81
+ .tag.smell { color: var(--fail); border-color: #f8717133; }
82
+ .tag.start { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); font-weight: 700; }
83
+ .cta-col { text-align: right; font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--accent); white-space: nowrap; }
84
+ .lab-card.locked .cta-col { color: var(--faint); }
85
+ .foot { margin-top: 36px; color: var(--faint); font-size: 13px; }
86
+ .foot a { font-weight: 600; }
87
+ .recovery { display:flex; gap:10px; flex-wrap:wrap; margin: 24px 0 0; }
88
+ .recovery a { border:1px solid var(--line2); border-radius:8px; padding:9px 12px; color:var(--ink); font-weight:700; }
89
+ .recovery a.primary { background:var(--accent); border-color:var(--accent); color:var(--accent-ink); }
90
+ @media (max-width: 700px) {
91
+ h1 { font-size: 30px; }
92
+ .lab-card { grid-template-columns: 1fr; gap: 8px; padding: 18px; }
93
+ .rank { font-size: 16px; }
94
+ .cta-col { text-align: left; }
95
+ }
96
+ </style>
97
+ </head>
98
+ <body>
99
+ <header class="top"><div class="wordmark">replay labs<em>.</em><span>Session map</span></div></header>
100
+ <main>
101
+ <div class="eyebrow"><b>Session analyzed</b> · decisions sorted by available evidence</div>
102
+ <h1>${headline}</h1>
103
+ <p class="sub">${escapeHtml(subcopy)}</p>
104
+ <p class="goal">Session goal: ${escapeHtml(goal)}</p>
105
+ ${hasReadyLabs ? "" : `<div class="recovery"><a class="primary" href="/inbox">Choose another session</a><a href="/inbox">Back to inbox</a></div>`}
106
+ <div class="list">${cards}</div>
107
+ <p class="foot">${foot}</p>
108
+ </main>
109
+ </body>
110
+ </html>`;
111
+ }
112
+
113
+ function labCard(lab, index) {
114
+ const rank = String(index + 1).padStart(2, "0");
115
+ const m = lab.module;
116
+ const inner = `
117
+ <div class="rank">${rank}</div>
118
+ <div>
119
+ <h3>${escapeHtml(m.name)}</h3>
120
+ <p>${escapeHtml(firstSentence(m.why))}</p>
121
+ <div class="tags">
122
+ ${index === 0 && lab.rich ? '<span class="tag start">Start here</span>' : ""}
123
+ ${lab.generated ? '<span class="tag" style="color:var(--accent);border-color:#34d39944">generated</span>' : ""}
124
+ <span class="tag smell">smell: ${escapeHtml(m.challenge.smell)}</span>
125
+ <span class="tag">${escapeHtml(m.challenge.proof || "Transfer, not recall")}</span>
126
+ ${lab.rich && m.minutes ? `<span class="tag">≈ ${m.minutes} min</span>` : ""}
127
+ </div>
128
+ </div>
129
+ <div class="cta-col">${lab.rich ? "Enter lab →" : "Needs changed lines"}</div>`;
130
+ return lab.rich
131
+ ? `<a class="lab-card${index === 0 ? " primary" : ""}" href="labs/${m.id}.html">${inner}</a>`
132
+ : `<div class="lab-card locked">${inner}</div>`;
133
+ }
134
+
135
+ function firstSentence(text) {
136
+ // Split only on sentence-ending punctuation (not the dot in "Next.js").
137
+ const match = String(text).match(/^.+?[.!?](?=\s+[A-Z]|\s*$)/s);
138
+ return match ? match[0] : String(text);
139
+ }
140
+
141
+ function escapeHtml(value) {
142
+ return String(value)
143
+ .replaceAll("&", "&amp;")
144
+ .replaceAll("<", "&lt;")
145
+ .replaceAll(">", "&gt;")
146
+ .replaceAll('"', "&quot;");
147
+ }
@@ -0,0 +1,322 @@
1
+ // Pattern catalog: Refactoring-Guru-grade entries for AI-session decision patterns.
2
+ // Each entry teaches intent, the smell it prevents, three implementation tiers,
3
+ // when NOT to use it, and a review checklist the learner can carry forward.
4
+
5
+ const RUNTIME_ILLO = `<svg viewBox="0 0 560 170" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Server and browser zones separated by the client boundary">
6
+ <rect x="10" y="20" width="240" height="130" rx="12" fill="#191d23" stroke="#303845"/>
7
+ <text x="30" y="48" fill="#67707c" font-family="JetBrains Mono, monospace" font-size="11" letter-spacing="2">SERVER</text>
8
+ <text x="30" y="76" fill="#98a1ac" font-family="Inter, sans-serif" font-size="13">renders first · no window,</text>
9
+ <text x="30" y="96" fill="#98a1ac" font-family="Inter, sans-serif" font-size="13">no mic, no localStorage</text>
10
+ <rect x="310" y="20" width="240" height="130" rx="12" fill="#34d39912" stroke="#34d399"/>
11
+ <text x="330" y="48" fill="#34d399" font-family="JetBrains Mono, monospace" font-size="11" letter-spacing="2">BROWSER</text>
12
+ <rect x="330" y="62" width="64" height="24" rx="6" fill="#0f1216" stroke="#303845"/>
13
+ <text x="362" y="78" fill="#98a1ac" font-family="JetBrains Mono, monospace" font-size="11" text-anchor="middle">window</text>
14
+ <rect x="402" y="62" width="48" height="24" rx="6" fill="#0f1216" stroke="#303845"/>
15
+ <text x="426" y="78" fill="#98a1ac" font-family="JetBrains Mono, monospace" font-size="11" text-anchor="middle">mic</text>
16
+ <rect x="458" y="62" width="76" height="24" rx="6" fill="#0f1216" stroke="#303845"/>
17
+ <text x="496" y="78" fill="#98a1ac" font-family="JetBrains Mono, monospace" font-size="11" text-anchor="middle">storage</text>
18
+ <line x1="280" y1="10" x2="280" y2="160" stroke="#34d399" stroke-dasharray="5 5"/>
19
+ <rect x="228" y="118" width="104" height="24" rx="12" fill="#34d399"/>
20
+ <text x="280" y="134" fill="#052e1e" font-family="JetBrains Mono, monospace" font-size="11" font-weight="700" text-anchor="middle">'use client'</text>
21
+ <line x1="120" y1="115" x2="252" y2="74" stroke="#f87171" stroke-width="1.5"/>
22
+ <text x="160" y="84" fill="#f87171" font-family="Inter, sans-serif" font-size="16" font-weight="700">✗</text>
23
+ <text x="74" y="132" fill="#f87171" font-family="Inter, sans-serif" font-size="11.5">server code reaching for browser APIs</text>
24
+ </svg>`;
25
+
26
+ const SECRET_ILLO = `<svg viewBox="0 0 560 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Browser, API route, and provider with the secret held server-side">
27
+ <rect x="10" y="30" width="150" height="110" rx="12" fill="#191d23" stroke="#303845"/>
28
+ <text x="30" y="58" fill="#67707c" font-family="JetBrains Mono, monospace" font-size="11" letter-spacing="2">BROWSER</text>
29
+ <text x="30" y="84" fill="#98a1ac" font-family="Inter, sans-serif" font-size="13">every visitor</text>
30
+ <text x="30" y="103" fill="#98a1ac" font-family="Inter, sans-serif" font-size="13">reads the bundle</text>
31
+ <rect x="205" y="30" width="150" height="110" rx="12" fill="#34d39912" stroke="#34d399"/>
32
+ <text x="225" y="58" fill="#34d399" font-family="JetBrains Mono, monospace" font-size="11" letter-spacing="2">API ROUTE</text>
33
+ <rect x="225" y="72" width="110" height="26" rx="6" fill="#0f1216" stroke="#34d399"/>
34
+ <text x="280" y="89" fill="#34d399" font-family="JetBrains Mono, monospace" font-size="11" text-anchor="middle">REDACTED_KEY</text>
35
+ <text x="225" y="122" fill="#98a1ac" font-family="Inter, sans-serif" font-size="12">the key never leaves</text>
36
+ <rect x="400" y="30" width="150" height="110" rx="12" fill="#191d23" stroke="#303845"/>
37
+ <text x="420" y="58" fill="#67707c" font-family="JetBrains Mono, monospace" font-size="11" letter-spacing="2">PROVIDER</text>
38
+ <text x="420" y="84" fill="#98a1ac" font-family="Inter, sans-serif" font-size="13">bills whoever</text>
39
+ <text x="420" y="103" fill="#98a1ac" font-family="Inter, sans-serif" font-size="13">holds the key</text>
40
+ <line x1="160" y1="70" x2="200" y2="70" stroke="#34d399" stroke-width="1.5"/>
41
+ <line x1="355" y1="70" x2="395" y2="70" stroke="#34d399" stroke-width="1.5"/>
42
+ <path d="M 80 145 Q 280 185 480 145" fill="none" stroke="#f87171" stroke-width="1.5" stroke-dasharray="5 5"/>
43
+ <text x="280" y="194" fill="#f87171" font-family="Inter, sans-serif" font-size="12" text-anchor="middle">✗ browser → provider directly: the key ships to everyone</text>
44
+ </svg>`;
45
+
46
+ export const PATTERNS = {
47
+ "runtime-boundary": {
48
+ name: "Runtime Boundary",
49
+ illustration: RUNTIME_ILLO,
50
+ tagline: "Decide which environment owns a behavior before you design the component.",
51
+ intent:
52
+ "Code that depends on browser-only capabilities (speech, microphone, geolocation, camera, localStorage) must live where those capabilities exist. The boundary is a design decision, not a syntax detail.",
53
+ problem:
54
+ "Frameworks like Next.js render components on the server first. Browser globals do not exist there. Code that reaches for them works in quick demos and dies in server rendering, hydration, or another user's browser.",
55
+ smell: {
56
+ name: "Browser API leak",
57
+ copy: "Server-rendered code touches window, navigator, speechSynthesis, or localStorage. The tell: it 'works in dev' and fails on build, deploy, or someone else's machine."
58
+ },
59
+ tiers: [
60
+ {
61
+ label: "Naive",
62
+ verdict: "Crashes during server rendering. No boundary decided at all.",
63
+ code: `export default function Page() {
64
+ const recognition = new window.SpeechRecognition();
65
+ localStorage.setItem("goals", "[]");
66
+ }`
67
+ },
68
+ {
69
+ label: "Demo",
70
+ verdict: "Boundary decided. Honest for a single-user demo; still assumes every browser cooperates.",
71
+ code: `'use client';
72
+
73
+ export default function Page() {
74
+ const recognition = new window.SpeechRecognition();
75
+ localStorage.setItem("goals", "[]");
76
+ // works in Chrome, on my machine, with mic allowed
77
+ }`
78
+ },
79
+ {
80
+ label: "Production",
81
+ verdict: "Boundary + capability checks + designed failure states + a way to verify them.",
82
+ code: `'use client';
83
+ import { useEffect, useState } from "react";
84
+
85
+ type VoiceState = "ready" | "listening" | "unsupported" | "denied";
86
+
87
+ export default function VoiceCheckIn() {
88
+ const [state, setState] = useState<VoiceState>("ready");
89
+
90
+ useEffect(() => {
91
+ const Recognition =
92
+ window.SpeechRecognition ?? window.webkitSpeechRecognition;
93
+ if (!Recognition) { setState("unsupported"); return; }
94
+ }, []);
95
+
96
+ async function start() {
97
+ try {
98
+ await navigator.mediaDevices.getUserMedia({ audio: true });
99
+ setState("listening");
100
+ } catch {
101
+ setState("denied"); // a designed state, not a surprise
102
+ }
103
+ }
104
+
105
+ if (state === "unsupported") return <TypeInsteadFallback />;
106
+ if (state === "denied") return <MicHelp onRetry={start} />;
107
+ return <CheckInUI onStart={start} listening={state === "listening"} />;
108
+ }
109
+ // verify: e2e run with mic permission denied + a browser without SpeechRecognition`
110
+ }
111
+ ],
112
+ whenNotToUse: [
113
+ "The behavior has no browser dependency — keep it on the server and skip the client bundle cost.",
114
+ "Secrets or provider SDK calls are involved — that is a Secret Boundary problem; 'use client' makes it worse, not better.",
115
+ "Reliability matters more than browser-native speed — consider server-driven capture (e.g. telephony/realtime audio) instead of patching browser gaps."
116
+ ],
117
+ checklist: [
118
+ "Where does this code run first — server or browser?",
119
+ "Every browser global behind a capability check?",
120
+ "Unsupported browser: what does the user see?",
121
+ "Permission denied: what does the user see?",
122
+ "How is each failure state verified before shipping?"
123
+ ],
124
+ related: [
125
+ { slug: "secret-boundary", name: "Secret Boundary", live: true, copy: "Keep provider credentials behind API routes. The opposite pull from Runtime Boundary: this code must NOT reach the browser." },
126
+ { slug: "demo-persistence", name: "Demo Persistence", copy: "A JSON file is honest demo persistence — if the constraint is named and the production step is obvious." },
127
+ { slug: "model-output-protocol", name: "Model Output Protocol", copy: "When LLM text drives app state, define a small protocol instead of parsing prose casually." }
128
+ ]
129
+ },
130
+ "secret-boundary": {
131
+ name: "Secret Boundary",
132
+ illustration: SECRET_ILLO,
133
+ tagline: "A secret's home decides the architecture: anything the browser can read, every visitor owns.",
134
+ intent:
135
+ "Provider credentials (model APIs, telephony, payments) must live in a runtime users cannot inspect. API routes exist so the browser can trigger trusted work without ever holding the key.",
136
+ problem:
137
+ "Client code is published code. Bundlers inline anything client-reachable — including env vars with a public prefix — into JavaScript that every visitor, scraper, and competitor can read. A leaked provider key becomes someone else's free infrastructure, billed to you.",
138
+ smell: {
139
+ name: "Key in the bundle",
140
+ copy: "A credential referenced anywhere client-reachable code can see it. The tell: NEXT_PUBLIC_ on something that is not public, or a provider SDK imported into a client component."
141
+ },
142
+ tiers: [
143
+ {
144
+ label: "Naive",
145
+ verdict: "The key is compiled into the bundle. Every visitor owns your account.",
146
+ code: `"use client";
147
+ const KEY = process.env.NEXT_PUBLIC_ANTHROPIC_KEY;
148
+
149
+ export async function askCoach(prompt: string) {
150
+ return fetch("https://api.anthropic.com/v1/messages", {
151
+ method: "POST",
152
+ headers: { "x-api-key": KEY ?? "" },
153
+ body: JSON.stringify({ messages: [{ role: "user", content: prompt }] })
154
+ }).then((r) => r.json());
155
+ }`
156
+ },
157
+ {
158
+ label: "Demo",
159
+ verdict: "Boundary decided: the route owns the key. Honest for a demo; still trusts every input.",
160
+ code: `// app/api/chat/route.ts
161
+ import { NextResponse } from "next/server";
162
+
163
+ export async function POST(request: Request) {
164
+ const { prompt } = await request.json();
165
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
166
+ method: "POST",
167
+ headers: { "x-api-key": process.env.ANTHROPIC_API_KEY! },
168
+ body: JSON.stringify({ model: "claude-sonnet-4-6",
169
+ max_tokens: 300, messages: [{ role: "user", content: prompt }] })
170
+ });
171
+ return NextResponse.json(await res.json());
172
+ }`
173
+ },
174
+ {
175
+ label: "Production",
176
+ verdict: "Boundary + validation + safe errors + abuse limits. Strangers will find this endpoint.",
177
+ code: `// app/api/chat/route.ts
178
+ import { NextResponse } from "next/server";
179
+
180
+ export async function POST(request: Request) {
181
+ const { prompt } = await request.json().catch(() => ({}));
182
+ if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > 2000) {
183
+ return NextResponse.json({ error: "invalid prompt" }, { status: 400 });
184
+ }
185
+ try {
186
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
187
+ method: "POST",
188
+ headers: { "x-api-key": process.env.ANTHROPIC_API_KEY! },
189
+ body: JSON.stringify({ model: "claude-sonnet-4-6",
190
+ max_tokens: 300, messages: [{ role: "user", content: prompt }] })
191
+ });
192
+ if (!res.ok) throw new Error("provider " + res.status);
193
+ return NextResponse.json(await res.json());
194
+ } catch (error) {
195
+ console.error("chat route:", error); // details stay server-side
196
+ return NextResponse.json({ error: "temporarily unavailable" }, { status: 502 });
197
+ }
198
+ }
199
+ // still missing for scale: rate limiting per IP/user — name it before launch`
200
+ }
201
+ ],
202
+ whenNotToUse: [
203
+ "Truly public config (a maps tile key scoped to your domain) — some keys are designed to be public; check the provider's scoping story first.",
204
+ "Browser-capability work (mic, camera, geolocation) — that is Runtime Boundary; an API route cannot reach the user's hardware.",
205
+ "When a provider offers scoped, short-lived client tokens minted by your server — use that pattern instead of proxying every byte."
206
+ ],
207
+ checklist: [
208
+ "Could any client-reachable file see this credential?",
209
+ "Does the env var name promise publication (NEXT_PUBLIC_)?",
210
+ "Is request input validated before the provider call?",
211
+ "Do error responses leak provider details or status internals?",
212
+ "What happens when a stranger scripts this endpoint all night?"
213
+ ],
214
+ related: [
215
+ { slug: "runtime-boundary", name: "Runtime Boundary", live: true, copy: "Decide which environment owns a behavior. The mirror image: that code MUST reach the browser." },
216
+ { slug: "demo-persistence", name: "Demo Persistence", copy: "A JSON file is honest demo persistence — if the constraint is named." },
217
+ { slug: "model-output-protocol", name: "Model Output Protocol", copy: "When LLM text drives app state, define a small protocol instead of parsing prose casually." }
218
+ ]
219
+ }
220
+ };
221
+
222
+ export function generatePatternHtml(slug) {
223
+ const p = PATTERNS[slug];
224
+ if (!p) throw new Error(`Unknown pattern: ${slug}`);
225
+ return `<!doctype html>
226
+ <html lang="en">
227
+ <head>
228
+ <meta charset="utf-8" />
229
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
230
+ <title>${esc(p.name)} — Replay Patterns</title>
231
+ <link rel="preconnect" href="https://fonts.googleapis.com">
232
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
233
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
234
+ <style>
235
+ :root { color-scheme: dark; --bg:#0c0e11; --ink:#e9e7e2; --muted:#98a1ac; --line:#242a32;
236
+ --paper:#14171c; --soft:#191d23; --green:#34d399; --red:#f87171; --gold:#fbbf24; }
237
+ * { box-sizing:border-box; }
238
+ body { margin:0; background:var(--bg); color:var(--ink);
239
+ font-family: "Inter", ui-sans-serif, system-ui, sans-serif; line-height:1.6;
240
+ -webkit-font-smoothing: antialiased; }
241
+ .wrap { max-width: 880px; margin: 0 auto; padding: 36px 24px 80px; }
242
+ .crumb { color:var(--muted); font-size:13px; font-weight:700; }
243
+ .crumb a { color: var(--green); text-decoration: none; }
244
+ h1 { font-size: 40px; margin: 8px 0 4px; }
245
+ .tagline { font-size: 19px; color: var(--muted); margin: 0 0 28px; }
246
+ h2 { font-size: 22px; margin: 34px 0 10px; }
247
+ .card { background:var(--paper); border:1px solid var(--line); border-radius:10px; padding:18px; }
248
+ .smell { border-left: 4px solid var(--red); }
249
+ .smell b { color: var(--red); }
250
+ pre { margin:0; padding:14px; border-radius:8px; background:#24211d; color:#fff4df;
251
+ font-family: ui-monospace, Menlo, Consolas, monospace; font-size:13px; line-height:1.5;
252
+ overflow:auto; white-space:pre; }
253
+ .tier { margin-top: 14px; }
254
+ .tier-head { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
255
+ .badge { font-size:12px; font-weight:800; padding:3px 10px; border-radius:999px;
256
+ font-family: "JetBrains Mono", ui-monospace, monospace; letter-spacing:.06em; }
257
+ .b-naive { background:#f8717122; color:var(--red); }
258
+ .b-demo { background:#fbbf2422; color:var(--gold); }
259
+ .b-prod { background:#34d39922; color:var(--green); }
260
+ .verdict { color:var(--muted); font-size:14px; }
261
+ ul { margin: 8px 0 0; padding-left: 20px; }
262
+ li { margin: 6px 0; }
263
+ .grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:12px; margin-top:12px; }
264
+ .rel { text-decoration:none; color:inherit; display:block; }
265
+ .rel b { color: var(--green); display:block; margin-bottom:6px; }
266
+ .rel span { color:var(--muted); font-size:14px; }
267
+ .checklist li::marker { content: "☐ "; }
268
+ .cta { display:inline-block; margin-top:26px; background:var(--green); color:#052e1e; font-weight:750;
269
+ padding: 11px 16px; border-radius:8px; text-decoration:none; }
270
+ pre { border: 1px solid var(--line); background:#0f1216; }
271
+ h1 { font-weight: 800; letter-spacing: -0.02em; }
272
+ </style>
273
+ </head>
274
+ <body><div class="wrap">
275
+ <div class="crumb"><a href="../index.html">← session map</a> · <a href="../labs/${slug}.html">this pattern's lab</a> · Replay pattern catalog</div>
276
+ <h1>${esc(p.name)}</h1>
277
+ <p class="tagline">${esc(p.tagline)}</p>
278
+ ${p.illustration ? `<div class="card" style="padding:10px 14px">${p.illustration}</div>` : ""}
279
+
280
+ <h2>Intent</h2>
281
+ <div class="card"><p>${esc(p.intent)}</p></div>
282
+
283
+ <h2>Problem</h2>
284
+ <div class="card"><p>${esc(p.problem)}</p></div>
285
+
286
+ <h2>Smell: ${esc(p.smell.name)}</h2>
287
+ <div class="card smell"><p><b>${esc(p.smell.name)}.</b> ${esc(p.smell.copy)}</p></div>
288
+
289
+ <h2>Three tiers of the same decision</h2>
290
+ ${p.tiers.map((t, i) => `
291
+ <div class="tier">
292
+ <div class="tier-head">
293
+ <span class="badge ${i === 0 ? "b-naive" : i === 1 ? "b-demo" : "b-prod"}">${esc(t.label)}</span>
294
+ <span class="verdict">${esc(t.verdict)}</span>
295
+ </div>
296
+ <pre>${esc(t.code)}</pre>
297
+ </div>`).join("")}
298
+
299
+ <h2>When not to use it</h2>
300
+ <div class="card"><ul>${p.whenNotToUse.map((w) => `<li>${esc(w)}</li>`).join("")}</ul></div>
301
+
302
+ <h2>Review checklist</h2>
303
+ <div class="card"><ul class="checklist">${p.checklist.map((c) => `<li>${esc(c)}</li>`).join("")}</ul></div>
304
+
305
+ <h2>Related patterns</h2>
306
+ <div class="grid">
307
+ ${p.related.map((r) => r.live
308
+ ? `<a class="card rel" href="${esc(r.slug)}.html"><b>${esc(r.name)}</b><span>${esc(r.copy)} <em>Read the entry →</em></span></a>`
309
+ : `<div class="card rel"><b>${esc(r.name)}</b><span>${esc(r.copy)} <em>(lab coming next)</em></span></div>`).join("")}
310
+ </div>
311
+
312
+ <a class="cta" href="../labs/${slug}.html">Prove it in the lab →</a>
313
+ </div></body></html>`;
314
+ }
315
+
316
+ function esc(value) {
317
+ return String(value)
318
+ .replaceAll("&", "&amp;")
319
+ .replaceAll("<", "&lt;")
320
+ .replaceAll(">", "&gt;")
321
+ .replaceAll('"', "&quot;");
322
+ }
@@ -0,0 +1,68 @@
1
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+
4
+ export async function buildSessionBundle({
5
+ goal,
6
+ diff,
7
+ transcript,
8
+ diffPath,
9
+ transcriptPath,
10
+ outDir,
11
+ generate = false,
12
+ maxGenerated = 1
13
+ }) {
14
+ const { generateSessionLabs } = await import("./interaction.js");
15
+ const { generateOverviewHtml } = await import("./overview.js");
16
+ const { PATTERNS, generatePatternHtml } = await import("./patterns.js");
17
+ const destination = resolve(outDir);
18
+ const { labs } = await generateSessionLabs({
19
+ goal,
20
+ diff,
21
+ transcript,
22
+ diffPath,
23
+ transcriptPath,
24
+ generate,
25
+ cacheDir: resolve(destination, "generated"),
26
+ maxGenerated
27
+ });
28
+
29
+ await mkdir(resolve(destination, "labs"), { recursive: true });
30
+ await mkdir(resolve(destination, "patterns"), { recursive: true });
31
+
32
+ const files = [];
33
+ const expectedLabFiles = new Set(labs.filter((l) => l.rich).map((lab) => `${lab.module.id}.html`));
34
+ for (const entry of await readdir(resolve(destination, "labs")).catch(() => [])) {
35
+ if (entry.endsWith(".html") && !expectedLabFiles.has(entry)) {
36
+ await rm(resolve(destination, "labs", entry), { force: true });
37
+ }
38
+ }
39
+
40
+ for (const lab of labs.filter((l) => l.rich)) {
41
+ const labPath = resolve(destination, "labs", `${lab.module.id}.html`);
42
+ await writeFile(labPath, lab.html, "utf8");
43
+ files.push(labPath);
44
+ }
45
+ for (const slug of Object.keys(PATTERNS)) {
46
+ const patternPath = resolve(destination, "patterns", `${slug}.html`);
47
+ await writeFile(patternPath, generatePatternHtml(slug), "utf8");
48
+ files.push(patternPath);
49
+ }
50
+ const indexPath = resolve(destination, "index.html");
51
+ await writeFile(indexPath, generateOverviewHtml({ goal, labs }), "utf8");
52
+ files.push(indexPath);
53
+
54
+ return {
55
+ outDir: destination,
56
+ indexPath,
57
+ labs,
58
+ files
59
+ };
60
+ }
61
+
62
+ export function bundleSlug(value) {
63
+ return String(value || "session")
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9]+/g, "-")
66
+ .replace(/^-|-$/g, "")
67
+ .slice(0, 64) || "session";
68
+ }