| null;
+ user?: Pick | null;
+};
+
+export type ShareMoment = {
+ label: string;
+ headline: string;
+ detail: string;
+ inviteText: string;
+};
+
+function hasMeaningfulSpend(cost: number | null | undefined): cost is number {
+ return typeof cost === "number" && Number.isFinite(cost) && cost > 0;
+}
+
+function getMomentModelLabel(models: string[] | null | undefined): string | null {
+ if (!models || models.length === 0) return null;
+ if (models.some((model) => /claude-opus-4|opus/i.test(model))) return "Claude Opus";
+ if (models.some((model) => /claude-sonnet-4|sonnet/i.test(model))) return "Claude Sonnet";
+ if (models.some((model) => /claude-haiku-4|haiku/i.test(model))) return "Claude Haiku";
+ const first = models[0]!;
+ if (/^gpt-/i.test(first)) {
+ return first.replace(/^gpt/i, "GPT").replace(/-codex$/i, "-Codex");
+ }
+ if (/^o4/i.test(first)) return "o4";
+ if (/^o3/i.test(first)) return "o3";
+ return first;
+}
+
+export function buildShareMoment(post: ShareMomentPost): ShareMoment {
+ const usage = post.daily_usage;
+ const outputTokens = usage?.output_tokens ?? 0;
+ const spend = usage?.cost_usd;
+ const modelLabel = getMomentModelLabel(usage?.models);
+ const imageCount = post.images?.length ?? 0;
+ const kudosCount = post.kudos_count ?? 0;
+ const commentCount = post.comment_count ?? 0;
+
+ if (outputTokens >= 1_000_000) {
+ return {
+ label: "Output PR",
+ headline: `${formatTokens(outputTokens)} output shipped`,
+ detail: modelLabel
+ ? `${modelLabel} session with a seven-figure token day.`
+ : "Seven-figure output day.",
+ inviteText: "Think you can outship this?",
+ };
+ }
+
+ if (hasMeaningfulSpend(spend) && spend >= 100) {
+ return {
+ label: "Big Build Day",
+ headline: `$${formatCurrency(spend)} verified session`,
+ detail: modelLabel
+ ? `${modelLabel} carried the spend.`
+ : "High-intensity coding session.",
+ inviteText: "Challenge a teammate to beat this session.",
+ };
+ }
+
+ if ((usage?.models?.length ?? 0) >= 3) {
+ return {
+ label: "Toolkit Flex",
+ headline: `${usage?.models.length} models in one session`,
+ detail: modelLabel
+ ? `${modelLabel} led a multi-model build.`
+ : "Multi-model build log.",
+ inviteText: "Send this to another multi-model builder.",
+ };
+ }
+
+ if (imageCount > 0) {
+ return {
+ label: "Receipts Attached",
+ headline: `${imageCount} screenshot${imageCount === 1 ? "" : "s"} on the build log`,
+ detail: outputTokens > 0
+ ? `${formatTokens(outputTokens)} output with visible proof-of-work.`
+ : "Visual proof-of-work ready to share.",
+ inviteText: "Share the receipts with a friend.",
+ };
+ }
+
+ if (kudosCount + commentCount >= 3) {
+ return {
+ label: "Community Pull",
+ headline: `${kudosCount} kudo${kudosCount === 1 ? "" : "s"} · ${commentCount} comment${commentCount === 1 ? "" : "s"}`,
+ detail: "This session is already pulling people in.",
+ inviteText: "Bring another builder into the thread.",
+ };
+ }
+
+ if (outputTokens > 0) {
+ return {
+ label: "Build Log",
+ headline: `${formatTokens(outputTokens)} output tracked`,
+ detail: modelLabel
+ ? `${modelLabel} session logged on Straude.`
+ : "Session logged on Straude.",
+ inviteText: "Share this build log with a peer.",
+ };
+ }
+
+ return {
+ label: "Share Ready",
+ headline: "Session logged on Straude",
+ detail: "Turn this build into a public proof-of-work card.",
+ inviteText: "Invite another builder to log theirs.",
+ };
+}
+
+export function buildInviteUrl(origin: string, username: string | null | undefined) {
+ return new URL(username ? `/join/${username}` : "/", origin).toString();
+}
diff --git a/apps/web/lib/utils/post-share.ts b/apps/web/lib/utils/post-share.ts
index 3c5e639b..4d3d06d2 100644
--- a/apps/web/lib/utils/post-share.ts
+++ b/apps/web/lib/utils/post-share.ts
@@ -1,5 +1,6 @@
import type { DailyUsage, Post, User } from "@/types";
import { formatCurrency, formatTokens } from "./format";
+import { buildShareMoment } from "@/lib/share-moments";
type ShareablePost = Pick & {
user?: Pick | null;
@@ -48,12 +49,13 @@ export function getPostShareFilename(postId: string) {
export function buildPostShareText(post: ShareablePost) {
const title = post.title?.trim();
+ const moment = buildShareMoment(post);
const lines: string[] = [];
if (title) {
lines.push(title);
} else {
- lines.push("Tracked a Claude Code session");
+ lines.push(moment.headline);
}
const details: string[] = [];
@@ -85,6 +87,8 @@ export function buildPostShareText(post: ShareablePost) {
lines.push(details.join(" · "));
}
+ lines.push(moment.inviteText);
+
const username = post.user?.username;
lines.push(username ? `Tracked on Straude by @${username}` : "Tracked on Straude");
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 66f75000..096b4d17 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- **Strava-inspired share moments for session cards.** Feed posts now derive a share angle from activity data — Output PR, Big Build Day, Toolkit Flex, Receipts Attached, Community Pull, or Build Log — and surface it on the activity card and share panel. Generated social copy now includes the challenge/invite prompt, and the share panel adds a direct `/join/[username]` invite copy action so broadcast sharing can feed the referral loop. Product rationale captured in `docs/strava-growth-shareability.md`.
- **`CONTRIBUTING.md` at repo root.** Lightweight contribution guide inspired by Warp's, scaled down for a smaller project: TL;DR up top, mermaid flow diagram (bug → PR; feature → issue → PR), pointers to `docs/SETUP.md` / `docs/LOCAL_DEV.md` for environment setup, branch/commit conventions (`handle/short-description` prefix), and a testing bar that asks for regression tests on bug fixes and unit/e2e coverage on new behavior — without the heavier readiness-label and spec-PR gating Warp uses. Also covers code style (defers to `CLAUDE.md`), agent-assisted contributions, security disclosure via GitHub's private reporting, and the Contributor Covenant. Issue/repo links use the real `github.com/ohong/straude` remote.
- **CLI `--debug` flag (and `STRAUDE_DEBUG=1` env var).** Developer-facing diagnostics like the per-row token-normalization anomaly dump are now hidden by default — `straude push` only prints them when debug mode is on. Debug output goes to `stderr` so it doesn't interfere with piping. The previous unconditional `Warning: normalization anomalies detected …` line surprised users who had no way to act on it; we still surface aggregate anomaly counts (`anomalies_medium_low`, `anomalies_low_confidence`, `anomalies_unresolved`) in the `usage_pushed` PostHog event so we can monitor data quality without noisy CLI output. Shipped as `straude@0.1.23`.
- **Comment thread pagination.** Root comments limited to 20 initially with a "Load more comments" button to prevent large DOM renders.
diff --git a/docs/strava-growth-shareability.md b/docs/strava-growth-shareability.md
new file mode 100644
index 00000000..8aa9018c
--- /dev/null
+++ b/docs/strava-growth-shareability.md
@@ -0,0 +1,53 @@
+# Strava-Inspired Shareability Loop
+
+Straude should treat each coding session the way Strava treats a run or ride: an activity is not just a log row, it is a social object with status, proof, comparison, and a next action.
+
+## What Strava Users Share
+
+Based on Strava's public growth mechanics and the Latterly strategy write-up:
+
+- **Progress made visible**: routes, segment PRs, streaks, goals, and annual recaps turn private effort into a visible milestone.
+- **Social recognition**: kudos, comments, clubs, and leaderboards make the activity feel witnessed.
+- **Identity signaling**: sharing says "I am the kind of person who trains consistently," not just "I exercised."
+- **Friendly rivalry**: segments and leaderboards create natural challenges without requiring users to write copy.
+- **Fresh content loops**: every uploaded activity can become feed content, social content, club content, challenge progress, and lifecycle email content.
+
+The useful growth-loop framing from Kevin Kwok's Figma analysis is that durable companies sequence loops. For Straude, the individual activity loop should feed the public proof-of-work loop, which should feed the referral loop.
+
+## Applied To Straude
+
+Straude already has the raw activity object: daily usage with spend, output tokens, models, screenshots, kudos, comments, and profile identity. The missing layer is packaging those facts into repeatable "why this is worth sharing" moments.
+
+### Shipped In This Branch
+
+**Share moments** package every post with a Strava-like share angle:
+
+- Output PR: seven-figure output days.
+- Big Build Day: high-spend verified sessions.
+- Toolkit Flex: multi-model sessions.
+- Receipts Attached: posts with screenshots.
+- Community Pull: posts already receiving kudos/comments.
+- Build Log: the default proof-of-work moment.
+
+Each moment now appears in:
+
+- Feed activity cards, so users see which part of the session is socially interesting.
+- The share panel, so the card has a ready-made angle before choosing a theme or channel.
+- Generated share text, so X/native shares carry challenge-oriented copy.
+- A direct invite-link action, so a broadcast share can also become a friend/referral loop.
+
+Loop:
+
+```text
+Code with AI -> push to Straude -> share moment appears ->
+ user shares proof/invite -> non-user sees comparable artifact ->
+ joins through /join/[username] -> pushes their own session
+```
+
+## Next Feature Ideas
+
+- **Milestone share prompts**: after earning an achievement or breaking an output/spend personal record, open the share panel with that moment preselected.
+- **Challenge replies**: add a "Beat this session" link that creates a lightweight challenge page between two users.
+- **Weekly club-style recaps**: summarize followed users' biggest output PRs and invite the viewer to catch up.
+- **Share conversion dashboard**: track view -> signup -> first push by surface: post card, recap, profile, invite, CLI scorecard.
+- **Team segments**: private org leaderboards that make teams feel like Strava clubs.