Skip to content

Commit ef3b589

Browse files
committed
feat(web): enhance sharing features with LLM-generated taglines
- Added support for LLM-generated taglines in share cards and analytics, improving user engagement. - Updated `ProfileShareSection` and share image generation to incorporate the new tagline feature. - Refactored `AnalysisClient` and related components to utilize the tagline, ensuring a cohesive user experience. - Introduced a new column for taglines in the `analysis_insights` table and updated relevant Supabase types. - Enhanced the overall structure of share templates to include taglines, providing a more personalized sharing experience.
1 parent f2d2621 commit ef3b589

14 files changed

Lines changed: 143 additions & 348 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ supabase/.temp/
3434
*.swp
3535
*.swo
3636
*~
37+
.cursor-memory
3738

3839
# OS
3940
.DS_Store

apps/web/src/app/analysis/[jobId]/AnalysisClient.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import Link from "next/link";
44
import { useEffect, useMemo, useState } from "react";
55
import { computeAnalysisInsights } from "@vibed/core";
6-
import type { AnalysisInsights, AnalysisMetrics, CommitEvent, VibeAxes } from "@vibed/core";
6+
import type { AnalysisInsights, AnalysisMetrics, CommitEvent } from "@vibed/core";
77
import { formatMetricLabel, formatMetricValue } from "@/lib/format-labels";
88
import { computeShareCardMetrics } from "@/lib/vcp/metrics";
99
import { isVibeAxes } from "@/lib/vcp/validators";
@@ -380,10 +380,6 @@ function fmtDate(iso: string | null): string {
380380
}
381381

382382

383-
function weekdayName(dow: number): string {
384-
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][dow] ?? "—";
385-
}
386-
387383
export default function AnalysisClient({ jobId }: { jobId: string }) {
388384
const [data, setData] = useState<ApiResponse | null>(null);
389385
const [error, setError] = useState<string | null>(null);

apps/web/src/app/api/share/story/[userId]/route.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ async function buildJobStory(
180180
metrics?: Array<{ label: string; value: string }>;
181181
headline?: string;
182182
subhead?: string;
183+
tagline?: string;
183184
})
184185
: null;
185186

@@ -191,6 +192,7 @@ async function buildJobStory(
191192
(vibeRow?.persona_id ?? insightsResult?.data?.persona_id ?? "balanced_builder") as string;
192193
const personaTagline =
193194
vibeRow?.persona_tagline ??
195+
shareTemplate?.tagline ??
194196
shareTemplate?.headline ??
195197
`${insightsResult?.data?.persona_confidence ?? "medium"} confidence`;
196198
const colors = shareTemplate?.colors
@@ -208,7 +210,7 @@ async function buildJobStory(
208210
value: metric.value,
209211
})) ?? [];
210212

211-
const highlight = shareTemplate?.subhead ?? shareTemplate?.headline;
213+
const highlight = shareTemplate?.tagline ?? shareTemplate?.subhead ?? shareTemplate?.headline;
212214
const topAxes = axes ? formatAxesList(axes) : [];
213215
const stats = [`${job.commit_count ?? 0} commits`, repoName];
214216

@@ -395,6 +397,7 @@ const renderStoryImage = async (story: StoryData, qrDataUrl: string) => {
395397
background: "rgba(255,255,255,0.15)",
396398
}}
397399
>
400+
{/* eslint-disable-next-line @next/next/no-img-element */}
398401
<img
399402
src={qrDataUrl}
400403
width={126}

apps/web/src/app/page.tsx

Lines changed: 42 additions & 297 deletions
Large diffs are not rendered by default.

apps/web/src/components/share/ProfileShareSection.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,27 +97,31 @@ export function ProfileShareSection({
9797
return shareOrigin; // Profile is at root
9898
}, [shareOrigin]);
9999

100+
const shareTagline = insight ?? personaTagline ?? "";
101+
100102
const shareText = useMemo(() => {
101103
const metricsLine = `${totalRepos} repos · ${totalCommits.toLocaleString()} commits · ${clarity}% clarity`;
102-
return `My Unified VCP: ${personaName}\n${personaTagline ?? ""}\n${metricsLine}\n#VCP`;
103-
}, [personaName, personaTagline, totalRepos, totalCommits, clarity]);
104+
return `My Unified VCP: ${personaName}\n${shareTagline}\n${metricsLine}\n#VCP`;
105+
}, [personaName, shareTagline, totalRepos, totalCommits, clarity]);
104106

105107
const shareCaption = useMemo(() => {
106-
return `My Unified VCP: ${personaName}${personaTagline ?? ""}. ${totalRepos} repos, ${totalCommits.toLocaleString()} commits. #VCP`;
107-
}, [personaName, personaTagline, totalRepos, totalCommits]);
108+
const taglineSegment = shareTagline ? ` — ${shareTagline}` : "";
109+
return `My Unified VCP: ${personaName}${taglineSegment}. ${totalRepos} repos, ${totalCommits.toLocaleString()} commits. #VCP`;
110+
}, [personaName, shareTagline, totalRepos, totalCommits]);
108111

109112
const shareImageTemplate: ShareImageTemplate = useMemo(() => {
110113
return {
111114
colors,
112115
headline: `Unified VCP: ${personaName}`,
113116
subhead: personaTagline ?? `${personaConfidence} confidence`,
117+
tagline: shareTagline || `${personaConfidence} confidence`,
114118
metrics: shareCardMetrics,
115119
persona_archetype: {
116120
label: personaName,
117121
archetypes: topAxes.slice(0, 3).map((a) => `${a.name}: ${a.score}`),
118122
},
119123
};
120-
}, [personaName, personaTagline, personaConfidence, colors, shareCardMetrics, topAxes]);
124+
}, [personaName, personaTagline, personaConfidence, colors, shareCardMetrics, topAxes, shareTagline]);
121125

122126
return (
123127
<div className="space-y-4">

apps/web/src/components/share/share-image.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,15 @@ export function createShareSvg(
114114
const metricsChars = format === "og" ? 72 : format === "square" ? 60 : 46;
115115

116116
const headlineWrapped = wrapTextLines(template.headline, headlineChars, 2);
117-
const subheadWrapped = wrapTextLines(template.subhead, subheadChars, 2);
117+
const taglineText = template.tagline ?? template.subhead;
118+
const taglineWrapped = wrapTextLines(taglineText, subheadChars, 2);
118119
const metricsWrapped = wrapTextLines(metricsText, metricsChars, format === "story" ? 2 : 1);
119120

120121
const headlineLines = headlineWrapped.lines.map((l, idx) =>
121122
escapeXml(idx === headlineWrapped.lines.length - 1 && headlineWrapped.truncated ? withEllipsis(l) : l)
122123
);
123-
const subheadLines = subheadWrapped.lines.map((l, idx) =>
124-
escapeXml(idx === subheadWrapped.lines.length - 1 && subheadWrapped.truncated ? withEllipsis(l) : l)
124+
const taglineLines = taglineWrapped.lines.map((l, idx) =>
125+
escapeXml(idx === taglineWrapped.lines.length - 1 && taglineWrapped.truncated ? withEllipsis(l) : l)
125126
);
126127
const metricsLines = metricsWrapped.lines.map((l, idx) =>
127128
escapeXml(idx === metricsWrapped.lines.length - 1 && metricsWrapped.truncated ? withEllipsis(l) : l)
@@ -131,8 +132,8 @@ export function createShareSvg(
131132
format === "story" ? Math.round(cfg.height * 0.34) : cfg.pad + Math.round(cfg.headlineSize * 0.9);
132133

133134
const headlineY = startY;
134-
const subheadY = headlineY + Math.round(cfg.headlineSize * 0.95) + 18;
135-
const metricsY = subheadY + Math.round(cfg.subheadSize * 0.95) + 18;
135+
const taglineY = headlineY + Math.round(cfg.headlineSize * 0.95) + 18;
136+
const metricsY = taglineY + Math.round(cfg.subheadSize * 0.95) + 18;
136137
const metaY =
137138
format === "story" ? Math.round(cfg.height * 0.72) : metricsY + Math.round(cfg.metricsSize * 0.95) + 28;
138139
const hashY = metaY + Math.round(cfg.metaSize * 1.6);
@@ -144,7 +145,7 @@ export function createShareSvg(
144145
const headlineTspans = headlineLines
145146
.map((line, idx) => `<tspan x="${x}" dy="${idx === 0 ? 0 : Math.round(cfg.headlineSize * 1.1)}">${line}</tspan>`)
146147
.join("");
147-
const subheadTspans = subheadLines
148+
const taglineTspans = taglineLines
148149
.map((line, idx) => `<tspan x="${x}" dy="${idx === 0 ? 0 : Math.round(cfg.subheadSize * 1.25)}">${line}</tspan>`)
149150
.join("");
150151
const metricsTspans = metricsLines
@@ -163,8 +164,8 @@ export function createShareSvg(
163164
<text x="${x}" y="${headlineY}" font-size="${cfg.headlineSize}" font-weight="700" fill="#FFFFFF" font-family="Space Grotesk, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
164165
${headlineTspans}
165166
</text>
166-
<text x="${x}" y="${subheadY}" font-size="${cfg.subheadSize}" fill="rgba(255,255,255,0.85)" font-family="Space Grotesk, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
167-
${subheadTspans}
167+
<text x="${x}" y="${taglineY}" font-size="${cfg.subheadSize}" fill="rgba(255,255,255,0.85)" font-family="Space Grotesk, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
168+
${taglineTspans}
168169
</text>
169170
<text x="${x}" y="${metricsY}" font-size="${cfg.metricsSize}" fill="rgba(255,255,255,0.85)" font-family="Space Grotesk, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
170171
${metricsTspans}

apps/web/src/components/share/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface ShareImageTemplate {
6767
colors: ShareCardColors;
6868
headline: string;
6969
subhead: string;
70+
tagline?: string | null;
7071
metrics: ShareCardMetric[];
7172
persona_archetype: {
7273
label: string;

apps/web/src/components/vcp/unified/UnifiedMethodologySection.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,13 @@
22

33
import Link from "next/link";
44
import { cn } from "@/lib/utils";
5-
import type { VibeAxes } from "@vibed/core";
6-
import { AXIS_METADATA, AXIS_ORDER } from "../constants";
75
import { formatMatchedRule, AXIS_LEGEND } from "@/lib/format-labels";
86

97
interface UnifiedMethodologySectionProps {
10-
/** Persona name */
11-
personaName: string;
12-
/** Confidence level */
13-
confidence: string;
148
/** Matched rules from detectVibePersona (string array like ["A>=70", "C>=65"]) */
159
matchedRules: string[];
1610
/** Caveats from detectVibePersona */
1711
caveats: string[];
18-
/** User's vibe axes */
19-
axes: VibeAxes;
2012
/** Additional class names */
2113
className?: string;
2214
}
@@ -27,11 +19,8 @@ interface UnifiedMethodologySectionProps {
2719
* Accepts data directly from detectVibePersona output
2820
*/
2921
export function UnifiedMethodologySection({
30-
personaName,
31-
confidence,
3222
matchedRules,
3323
caveats,
34-
axes,
3524
className,
3625
}: UnifiedMethodologySectionProps) {
3726
return (

apps/web/src/inngest/functions/analyze-repo.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ function computeEpisodeSummary(events: CommitEvent[]): Array<{
152152
*/
153153
interface NarrativeResult {
154154
narrative: AnalysisReport["narrative"];
155+
tagline: string | null;
155156
inputTokens: number;
156157
outputTokens: number;
157158
}
@@ -227,9 +228,10 @@ export async function generateNarrativeWithLLM(params: {
227228
"- Never infer intent, skill, or code quality. Avoid speculation and motivational language.",
228229
"- Every claim must cite at least one specific metric name and value (e.g. burstiness_score=0.42).",
229230
"- Each section must include evidence: 2-6 commit SHAs that support the section.",
231+
"- Provide a concise tagline (<=60 characters) that describes the developer's vibe. This will be displayed on the share card and must remain observational.",
230232
"",
231233
"Output must be STRICT JSON with this schema:",
232-
'{"summary":"...","sections":[{"title":"...","content":"...","evidence":["sha", "..."]}],"highlights":[{"metric":"...","value":"...","interpretation":"..."}]}',
234+
'{"summary":"...","tagline":"...","sections":[{"title":"...","content":"...","evidence":["sha", "..."]}],"highlights":[{"metric":"...","value":"...","interpretation":"..."}]}',
233235
].join("\n");
234236

235237
const userPrompt = [
@@ -307,6 +309,7 @@ export async function generateNarrativeWithLLM(params: {
307309

308310
const obj = parsed as {
309311
summary?: unknown;
312+
tagline?: unknown;
310313
sections?: unknown;
311314
highlights?: unknown;
312315
};
@@ -335,8 +338,13 @@ export async function generateNarrativeWithLLM(params: {
335338
highlights.push({ metric: hi.metric, value: hi.value, interpretation: hi.interpretation });
336339
}
337340

341+
// Enforce 60-char limit as specified in the prompt
342+
const rawTagline = typeof obj.tagline === "string" ? obj.tagline.trim() : null;
343+
const tagline = rawTagline ? rawTagline.slice(0, 60) : null;
344+
338345
return {
339346
narrative: { summary: obj.summary, sections, highlights },
347+
tagline,
340348
inputTokens: response.inputTokens,
341349
outputTokens: response.outputTokens,
342350
};
@@ -1044,6 +1052,7 @@ export const analyzeRepo = inngest.createFunction(
10441052
const llmResolution = await resolveLLMConfig(userId, repoId);
10451053
let llmNarrative: AnalysisReport["narrative"] | null = null;
10461054
let llmModelUsed: string | null = null;
1055+
let llmTagline: string | null = null;
10471056
const llmKeySource: LLMKeySource = llmResolution.source;
10481057
const llmConfig: LLMConfig | null = llmResolution.config;
10491058

@@ -1070,6 +1079,7 @@ export const analyzeRepo = inngest.createFunction(
10701079
if (result) {
10711080
llmNarrative = result.narrative;
10721081
llmModelUsed = candidate;
1082+
llmTagline = result.tagline;
10731083

10741084
// Record successful usage with token counts
10751085
await recordLLMUsage({
@@ -1137,22 +1147,28 @@ export const analyzeRepo = inngest.createFunction(
11371147
);
11381148
if (reportError) throw new Error(`Failed to upsert report: ${reportError.message}`);
11391149

1150+
const personaTaglineFallback = insights.persona.description ?? "";
1151+
const finalTagline =
1152+
llmTagline?.trim()?.length ? llmTagline.trim() : personaTaglineFallback;
1153+
insights.share_template.tagline = finalTagline;
1154+
11401155
// Save legacy insights
11411156
const { error: insightsError } = await supabase.from("analysis_insights").upsert(
1142-
{
1143-
job_id: jobId,
1144-
insights_json: insights,
1145-
generator_version: ANALYZER_VERSION,
1146-
persona_id: insights.persona.id,
1147-
persona_label: insights.persona.label,
1148-
persona_confidence: insights.persona.confidence,
1149-
tech_signals: insights.tech_signals,
1150-
share_template: insights.share_template,
1151-
persona_delta: insights.persona_delta,
1152-
sources: insights.sources,
1153-
},
1154-
{ onConflict: "job_id" }
1155-
);
1157+
{
1158+
job_id: jobId,
1159+
insights_json: insights,
1160+
generator_version: ANALYZER_VERSION,
1161+
persona_id: insights.persona.id,
1162+
persona_label: insights.persona.label,
1163+
persona_confidence: insights.persona.confidence,
1164+
tech_signals: insights.tech_signals,
1165+
share_template: insights.share_template,
1166+
persona_delta: insights.persona_delta,
1167+
sources: insights.sources,
1168+
tagline: finalTagline,
1169+
},
1170+
{ onConflict: "job_id" }
1171+
);
11561172
if (insightsError) throw new Error(`Failed to upsert insights: ${insightsError.message}`);
11571173

11581174
// Save vibe v2 insights

apps/worker/src/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ function computeEpisodeSummary(events: CommitEvent[]): Array<{
243243
interface NarrativeResult {
244244
narrative: AnalysisReport["narrative"];
245245
model: string;
246+
tagline: string | null;
246247
}
247248

248249
/**
@@ -292,8 +293,9 @@ async function generateNarrativeWithLLM(params: {
292293
"Never infer intent, skill, or code quality. Avoid speculation and motivational language.",
293294
"Every claim must cite at least one specific metric name and value (e.g. burstiness_score=0.42) or a specific commit subject line provided.",
294295
"Each section must include evidence: 2-6 commit SHAs that support the section.",
296+
"Provide a concise tagline (<=60 characters) that describes the developer's vibe. This tagline will be displayed on share cards and should remain observational.",
295297
"Output must be STRICT JSON with this schema:",
296-
'{"summary":"...","sections":[{"title":"...","content":"...","evidence":["sha", "..."]}],"highlights":[{"metric":"...","value":"...","interpretation":"..."}]}',
298+
'{"summary":"...","tagline":"...","sections":[{"title":"...","content":"...","evidence":["sha", "..."]}],"highlights":[{"metric":"...","value":"...","interpretation":"..."}]}',
297299
].join("\n");
298300

299301
const userPrompt = [
@@ -375,6 +377,7 @@ async function generateNarrativeWithLLM(params: {
375377

376378
const obj = parsed as {
377379
summary?: unknown;
380+
tagline?: unknown;
378381
sections?: unknown;
379382
highlights?: unknown;
380383
};
@@ -407,9 +410,14 @@ async function generateNarrativeWithLLM(params: {
407410
}
408411
if (!highlightsValid) continue;
409412

413+
// Enforce 60-char limit as specified in the prompt
414+
const rawTagline = typeof obj.tagline === "string" ? obj.tagline.trim() : null;
415+
const tagline = rawTagline ? rawTagline.slice(0, 60) : null;
416+
410417
return {
411418
narrative: { summary: obj.summary, sections, highlights },
412419
model,
420+
tagline,
413421
};
414422
} catch (error) {
415423
console.warn(`LLM model ${model} failed, trying next:`, error);
@@ -567,6 +575,12 @@ async function processJob(jobId: string, config: WorkerConfig): Promise<void> {
567575
);
568576
if (reportError) throw new Error(`Failed to upsert report: ${reportError.message}`);
569577

578+
// Authoritative tagline: LLM-generated if available, else persona description
579+
const personaTaglineFallback = insights.persona.description ?? "";
580+
const llmTagline = llmResult?.tagline?.trim();
581+
const finalTagline = llmTagline?.length ? llmTagline : personaTaglineFallback;
582+
insights.share_template.tagline = finalTagline;
583+
570584
const { error: insightsError } = await supabase.from("analysis_insights").upsert(
571585
{
572586
job_id: jobId,
@@ -577,6 +591,7 @@ async function processJob(jobId: string, config: WorkerConfig): Promise<void> {
577591
persona_confidence: insights.persona.confidence,
578592
tech_signals: insights.tech_signals as unknown as Json,
579593
share_template: insights.share_template as unknown as Json,
594+
tagline: finalTagline,
580595
persona_delta: insights.persona_delta as unknown as Json,
581596
sources: insights.sources,
582597
},

0 commit comments

Comments
 (0)