diff --git a/.gitignore b/.gitignore index a02b96fa..d161b0e6 100644 --- a/.gitignore +++ b/.gitignore @@ -91,5 +91,5 @@ packages/*/assets/ # Local Mintlify dev server log (transient) mint-dev.log -# local scratch (dev logs, probe scripts) -/.tmp/ +# superpowers brainstorm session scratch +.superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 120e7a16..ee68af61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,32 @@ # Changelog +## 0.0.11-beta.9 — 2026-06-11 + +### Features +- Add `invite a friend` flow on `/audit#come-back-better`: new `InviteDialog` modal takes a comma/space/newline-separated list of friend emails (validates inline, hard-cap of 10 per submit, dedupes against the sender's own address), POSTs them to the new `/api/audit/invite` Next.js proxy route, which forwards to the api-server's `POST /v0/invite` endpoint with the user's Bearer token. Anonymous users get routed through `AuthDialog` first so we have a sender identity to Cc. Removes the placeholder `1 of 3 invited` perks progress bar — the perks copy now says invites are sent from failproof.ai + Cc'd to you. The upstream `/v0/invite` endpoint contract is handed over to the `FailproofAI/platform` team separately (#435). + +### Fixes +- Swap the `/audit` poster PNG export from `html2canvas` to `html-to-image`. html2canvas reimplements CSS in JavaScript and was producing broken dashed borders on the poster's outer rule (Canvas's `setLineDash` doesn't connect cleanly at corners) and a stray pink square cutting through the wordmark's "l" (the `/logo.svg` uses an SVG `` for the "i" character, which html2canvas ignored). `html-to-image` serializes the live DOM into an SVG `` and rasterizes it through the browser's native rendering engine, so dashed borders, SVG masks, gradients, and font metrics render exactly as they do on screen. Implementation in `app/audit/_components/audit-poster.tsx#captureCardBlob` (#435). +- Lock the html-to-image capture's `width`/`height` (and the matching inline `style` override) to the live poster element's `offsetWidth`/`offsetHeight`. html-to-image clones the node without its parent's flex context (the `.poster-section`'s `flex: 1` stretching), so the clone would collapse to intrinsic content width while the canvas inherited the original `offsetWidth` — content rendered anchored to the left of empty space (#435). +- Capture the poster from an off-screen clone instead of the live element. Even with explicit `width`/`height`, the live `.poster` carried its parent's flex context + `margin: 0 auto`, which html-to-image preserved during capture — the content ended up centered inside an oversized canvas, with the poster's left dashed border visible at canvas edge and the right meta clipped. The fix clones the node into a fresh `position: fixed; left: -10000px` wrapper with a fixed width matching `getBoundingClientRect().width`, captures the clone, then removes the wrapper. The clone has no flex parent and no margin auto, so the canvas dimensions match the poster exactly (#435). +- Drop the `━━` glyph prefix and the amber slipping-count badge (`182` etc.) from every navbar tab. The badge surfaced a number that was already prominent on the audit page itself and the prefix was decorative chrome left over from the brutalist redesign. `Navbar` no longer accepts `auditSlippingCount`; `app/layout.tsx` no longer reads the dashboard cache to derive the count (#435). +- Correct the CLI binary name in `how-to-improve-section.tsx`'s install commands. The per-policy install row and the `[install all]` bulk command both emitted `failproof policy add ` — the shipped binary is `failproofai`, so a copied command would fail with `command not found`. Updates the three call sites (header docstring, `bulkInstall()`, and the per-row install string), plus the matching example in `docs/dashboard.mdx#Audit` (#435). +- Calm the `/project/[name]` single-project page chrome. The header dropped the brutalist `━━ back to projects` link, the `● N sessions` green-dot count, the `section-h` display-font title, and the green-eyebrow `path` / `modified` definition list — replaced with a flat back chip, a mono `h1`, and a single inline meta line (`path … · modified … · sessions N`). `SessionsList` was rebuilt with the same calm chrome as the audit page: sharp 1px borders (no rounded corners), dim small-caps labels (`filter by` / `range` / `session id`), pink-outlined active chip, and JetBrains Mono throughout. The file icon goes from `text-primary` colored to dim. Empty state copy switches to the comment voice (`// no sessions found`). New CSS in `app/globals.css` under the `/project/[name]` and sessions-list blocks; tests updated to match the new copy / casing (#435). +- Address CodeRabbit review on the invite flow: `InviteDialog` now accepts an `onUnauthorized` callback and routes 401 responses back through `AuthDialog` (caller in `come-back-better-section.tsx` re-opens the auth flow) instead of dead-ending in a generic inline error. `/api/audit/invite` no longer forwards raw upstream exception text to either telemetry or the client response — surfaces a stable generic message and logs only the bounded `err.name` so internal hostnames / IPs / payload fragments stay server-side (#435). +- Address CodeRabbit review on the audit-poster + come-back-better paths. `audit-poster.tsx#handleShare`'s fallback telemetry no longer reports `status: "success"` when `fallbackMethod === "failed"` — share/capture analytics now correctly distinguish error vs. success. `come-back-better-section.tsx#refreshStatus` preserves the prior auth state on transient `/api/auth/status` failures (5xx, network blips) instead of downgrading to `anon` and clearing a valid reminder — it only falls through to `anon` on the very first probe (still `unknown`) so the cadence buttons unlock when the server is unreachable. Adds `:focus-visible` outlines to the new interactive controls (`.poster-share-btn`, `.cadence-btn`, `.invite-btn`, `.install-all-btn`, `.copy-icon-btn`, `.fix-install-btn`) so keyboard navigation has a perceptible focus indicator (#435). + +### Dependencies +- Add `html-to-image@^1.11.13` for the audit-poster PNG export. Replaces (but does not remove) `html2canvas` — the latter remains for any non-audit screenshot path still using it (#435). + +### Docs +- Update `docs/dashboard.mdx`'s `### Audit` section to describe the new 5-section flow (poster + strengths + quirks + how to improve + come back better), replacing the prior 6-section description (identity + show off + strengths + score+leaderboard + findings + prescribed policies+return loop). Calls out the html-to-image swap so the documented behaviour matches what users see when they click `download poster` (#435). +- Update `docs/dashboard.mdx`'s description of the `come back better` perks card to document the new `invite a friend` flow (modal → `/api/audit/invite` → upstream `/v0/invite` → one email per recipient with sender Cc'd) — replacing the prior "progress bar + invite a friend CTA" description (#435). + ## 0.0.11-beta.8 — 2026-06-11 +### Features +- Restructure `/audit` into a single-screen shareable poster + four below-fold sections (`strengths` / `quirks` / `how to improve` / `come back better`). The poster is the PNG-export region and now self-contains the wordmark, archetype index, audit date, score + rank, persona name + keywords + rarity, sigil tile, and a `audit yours → failproof.ai` footer — so screenshots and shares carry the brand without the surrounding dashboard chrome. `// how to improve` becomes a calm row list per prescribed policy (name in white, one-line description, command + copy button on the right) topped by an `[install all]` button that copies the combined `failproof policy add a b c …` command. `// come back better` adds a 3d/7d/14d/30d reminder-cadence picker and a perks card (mock data — invite tracking + entitlement lands in a follow-up). `/policies` and `/projects` revert to plain title-case English headings (`Policies` / `Configure Policies` / `Projects`) per commit a0a18415. Site-wide chrome strips down to a calm dark canvas — body gridline + noise overlays, hard-offset pink shadows, text-shadow stamps, gridline-on-card backgrounds, and the floating share dock all go away. Pink migrates from `#e4587d` → `#e4587c` across `app/globals.css`, `app/audit/audit-styles.css`, and the audit asset CSS files. Deleted: `identity-section`, `score-section`, `findings-section`, `policies-section`, `return-section`, `show-off-cta`, `share-dock`; new: `audit-poster`, `quirks-section`, `how-to-improve-section`, `come-back-better-section`, `src/audit/social-proof.ts` (seeded archetype rarity + score-rank bands). Design spec lives at `docs/superpowers/specs/2026-06-11-audit-poster-restructure-design.md`. + ### Fixes - Fix the `/audit` first-run audit failing on the first click (a retry worked) and stop capping how much it scans. The run was driven by a synchronous `/api/audit/run` POST that `triggerRun` (`app/audit/_components/rerun-button.tsx`) aborted after the 15s `DEFAULT_FETCH_TIMEOUT_MS` (`lib/fetch-with-timeout.ts`, sized for fast upstream calls), so a cold run (measured ~17s locally) timed out and dropped back to the empty state while the server kept running and warmed the caches — making the second click succeed. The run is now fire-and-forget: the POST starts `runAudit()` as a detached task in the long-lived server process and returns `202` immediately, the client polls `/api/audit/status` with **no duration cap** until it finishes (only a ~10-poll lost-connection backstop stops it), and a server-side run error is surfaced through the status endpoint (`app/api/audit/_state.ts` gains an `error` field + `finishRun(error)`, and its 5-min lock auto-expiry — which would have prematurely "finished" a long run — is removed along with the route's `maxDuration = 120`). The default scan window also drops from 30 days to the user's entire history, so an audit runs over every session regardless of how long it takes; the empty-state CTA and `run-progress` copy update from "10–30s" to "may take a while". New tests cover the fire-and-forget route, unbounded polling, and the run-state machine (`__tests__/api/audit-run-route.test.ts`, `__tests__/audit/rerun-button.test.ts`, `__tests__/api/audit-state.test.ts`) (#434). diff --git a/__tests__/audit/share-templates.test.ts b/__tests__/audit/share-templates.test.ts index 37f16f74..38a903e4 100644 --- a/__tests__/audit/share-templates.test.ts +++ b/__tests__/audit/share-templates.test.ts @@ -16,17 +16,25 @@ describe("share templates", () => { expect(LI_TEMPLATES).toHaveLength(5); }); - it("every template is personalised with score, grade, archetype and the site URL", () => { + it("every template is personalised with score, archetype and the site URL", () => { for (const t of [...X_TEMPLATES, ...LI_TEMPLATES]) { const out = t(ctx); expect(out).toContain("72"); expect(out).toContain("the cowboy"); - expect(out).toMatch(/\bB\b/); expect(out).toContain("befailproof.ai"); expect(out.length).toBeGreaterThan(40); } }); + it("does not surface the grade tier in copy (sounds bad at the low end)", () => { + const lowCtx: ShareCtx = { score: 41, arch: "the optimist", grade: "D", missing: 5 }; + for (const t of [...X_TEMPLATES, ...LI_TEMPLATES]) { + const out = t(lowCtx); + expect(out).not.toMatch(/\bD tier\b/i); + expect(out).not.toMatch(/\(D\)/i); + } + }); + it("handles the clean run (missing = 0) without dangling 'policies' phrasing", () => { for (const t of [...X_TEMPLATES, ...LI_TEMPLATES]) { const out = t(cleanCtx); diff --git a/__tests__/components/sessions-list.test.tsx b/__tests__/components/sessions-list.test.tsx index f02b33d1..d09e5005 100644 --- a/__tests__/components/sessions-list.test.tsx +++ b/__tests__/components/sessions-list.test.tsx @@ -49,7 +49,7 @@ describe("SessionsList", () => { it("renders sessions in table", () => { const files = makeFiles(3); render(); - expect(screen.getByText("SessionId")).toBeInTheDocument(); + expect(screen.getAllByText(/session id/i).length).toBeGreaterThan(0); expect(screen.getByText(files[0].sessionId!)).toBeInTheDocument(); }); @@ -61,7 +61,7 @@ describe("SessionsList", () => { const input = screen.getByLabelText("Filter by session ID"); await user.type(input, "00000000"); - expect(screen.getByText(/Showing.*of.*session/)).toBeInTheDocument(); + expect(screen.getByText(/showing.*of.*session/i)).toBeInTheDocument(); }); it("date preset filtering", async () => { @@ -84,11 +84,11 @@ describe("SessionsList", () => { await user.click(screen.getByText("Last Hour")); - expect(screen.getByText(/Showing.*of.*1.*session/)).toBeInTheDocument(); + expect(screen.getByText(/showing.*of.*1.*session/i)).toBeInTheDocument(); }); it("shows empty state", () => { render(); - expect(screen.getByText("No sessions found")).toBeInTheDocument(); + expect(screen.getByText(/no sessions found/i)).toBeInTheDocument(); }); }); diff --git a/app/api/audit/invite/route.ts b/app/api/audit/invite/route.ts new file mode 100644 index 00000000..3ccd76d1 --- /dev/null +++ b/app/api/audit/invite/route.ts @@ -0,0 +1,183 @@ +/** + * POST /api/audit/invite + * + * Browser-facing proxy for the api-server's POST /v0/invite — the user + * supplies a list of friend emails, the api-server composes invite emails + * (Cc'ing the sender so the recipient sees who invited them), and dispatches + * them through the same email infrastructure that backs the OTP flow. + * + * Auth: requires an active session — same cookie/refresh-token contract as + * /api/auth/reminder. Anonymous calls get 401 so the front-end can route to + * the AuthDialog before retrying. + * + * Validation: max 10 recipients per call, each must look like an email. + * Anything beyond that gets a 400 and never reaches upstream. + * + * Contract for the upstream endpoint is handed over to the platform team + * separately. + */ +import { NextRequest, NextResponse } from "next/server"; +import { whoAmI } from "@/lib/auth/auth-store"; +import { AuthApiError, sendInvites } from "@/lib/auth/api-server-client"; +import { initTelemetry, trackEvent } from "@/lib/telemetry"; + +export const dynamic = "force-dynamic"; + +const MAX_RECIPIENTS = 10; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface InviteBody { + to?: unknown; +} + +export async function POST(req: NextRequest): Promise { + await initTelemetry(); + const who = await whoAmI(); + if (!who) { + trackEvent("audit_invite_sent", { status: "unauthorized", source: "dashboard" }); + return NextResponse.json( + { code: "unauthorized", message: "Sign in before sending invites." }, + { status: 401 }, + ); + } + + let body: InviteBody = {}; + const raw = await req.text(); + if (raw.trim().length > 0) { + try { + const parsed = JSON.parse(raw); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + trackEvent("audit_invite_sent", { + status: "validation_error", + source: "dashboard", + reason: "not_an_object", + user_id: who.me.id, + }); + return NextResponse.json( + { code: "validation_error", message: "Request body must be a JSON object." }, + { status: 400 }, + ); + } + body = parsed as InviteBody; + } catch { + trackEvent("audit_invite_sent", { + status: "validation_error", + source: "dashboard", + reason: "malformed_json", + user_id: who.me.id, + }); + return NextResponse.json( + { code: "validation_error", message: "Request body is not valid JSON." }, + { status: 400 }, + ); + } + } + + if (!Array.isArray(body.to)) { + trackEvent("audit_invite_sent", { + status: "validation_error", + source: "dashboard", + reason: "missing_to", + user_id: who.me.id, + }); + return NextResponse.json( + { code: "validation_error", message: "`to` must be a list of email addresses." }, + { status: 400 }, + ); + } + + const normalised: string[] = []; + const seen = new Set(); + for (const entry of body.to) { + if (typeof entry !== "string") continue; + const e = entry.trim().toLowerCase(); + if (!e || !EMAIL_RE.test(e)) continue; + if (e === who.me.email.toLowerCase()) continue; // can't invite yourself + if (seen.has(e)) continue; + seen.add(e); + normalised.push(e); + } + + if (normalised.length === 0) { + trackEvent("audit_invite_sent", { + status: "validation_error", + source: "dashboard", + reason: "no_valid_recipients", + user_id: who.me.id, + input_count: Array.isArray(body.to) ? body.to.length : 0, + }); + return NextResponse.json( + { + code: "validation_error", + message: "Provide at least one valid email address (other than your own).", + }, + { status: 400 }, + ); + } + + if (normalised.length > MAX_RECIPIENTS) { + trackEvent("audit_invite_sent", { + status: "validation_error", + source: "dashboard", + reason: "too_many_recipients", + user_id: who.me.id, + input_count: normalised.length, + }); + return NextResponse.json( + { + code: "validation_error", + message: `Up to ${MAX_RECIPIENTS} recipients per invite batch. Please send the rest in a follow-up.`, + }, + { status: 400 }, + ); + } + + try { + const result = await sendInvites(who.auth.access_token, normalised); + trackEvent("audit_invite_sent", { + status: "success", + source: "dashboard", + user_id: who.me.id, + sent_count: result.sent.length, + failed_count: result.failed.length, + }); + return NextResponse.json(result, { status: 200 }); + } catch (err) { + if (err instanceof AuthApiError) { + trackEvent("audit_invite_sent", { + status: "failed", + source: "dashboard", + user_id: who.me.id, + error_code: err.code, + http_status: err.status, + recipient_count: normalised.length, + }); + const httpStatus = err.status >= 200 && err.status < 600 ? err.status : 504; + return NextResponse.json( + { + code: err.code, + message: err.message, + ...(err.retryAfterSecs !== undefined ? { retry_after_secs: err.retryAfterSecs } : {}), + }, + { status: httpStatus }, + ); + } + // Don't surface the raw upstream message to either telemetry or the + // client. Network/DNS errors can carry internal hostnames, IPs, or + // fragment payload bytes that have no business leaving the proxy. + // Log the error name only (bounded) and return a stable generic. + const errorName = err instanceof Error ? err.name : "unknown"; + trackEvent("audit_invite_sent", { + status: "failed", + source: "dashboard", + user_id: who.me.id, + error_code: "upstream_unreachable", + error_name: errorName.slice(0, 50), + recipient_count: normalised.length, + }); + return NextResponse.json( + { code: "upstream_unreachable", message: "Invite service is unreachable. Please try again in a moment." }, + { status: 502 }, + ); + } +} diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index bd9d9388..3da689fa 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -3,30 +3,31 @@ /** * Top-level client wrapper for /audit. * - * Composes the personality report: classify the agent into one of 8 - * archetypes, derive a score + tier, render the IdentitySection + - * ShowOff + Strengths + Score (with leaderboard) + Findings + Policies - * + Return-loop CTA. + * Composes the calm personality report: classify the agent into one of + * 8 archetypes, derive a score, and render the 5-section flow: * - * Empty / running states fall back to the existing EmptyState and - * RunProgress components. + * 01 AuditPoster — single-screen shareable poster + * 02 StrengthsSection — what it's great at + * 03 QuirksSection — what slipped through + * 04 HowToImproveSection — install / configure + * 05 ComeBackBetterSection — reminder + perks + * + * Empty / running states fall back to EmptyState and RunProgress. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getAuditResultAction } from "@/app/actions/get-audit-result"; import type { AuditResult, RunAuditOptions } from "@/src/audit/types"; import { classifyAgent } from "@/src/audit/archetypes"; -import { COHORT_SIZE, deriveScore, gradeFor, projectedScore, type Grade } from "@/src/audit/scoring"; +import { deriveScore, gradeFor, projectedScore } from "@/src/audit/scoring"; import { deriveStrengths } from "@/src/audit/strengths"; import { deriveFindings } from "@/src/audit/findings"; import { usePostHog } from "@/contexts/PostHogContext"; -import { IdentitySection } from "./identity-section"; -import { ShareDock } from "./share-dock"; +import { AuditPoster } from "./audit-poster"; import { StrengthsSection } from "./strengths-section"; -import { ScoreSection } from "./score-section"; -import { FindingsSection } from "./findings-section"; -import { PoliciesSection } from "./policies-section"; -import { ReturnSection } from "./return-section"; +import { QuirksSection } from "./quirks-section"; +import { HowToImproveSection } from "./how-to-improve-section"; +import { ComeBackBetterSection } from "./come-back-better-section"; import { ReportFooter } from "./report-footer"; import { EmptyState } from "./empty-state"; import { RunProgress } from "./run-progress"; @@ -58,11 +59,6 @@ interface Props { totalCatalogSize: number; } -function inferWindow(params: RunAuditOptions | undefined): string { - if (!params?.since) return "all time"; - return params.since; -} - function inferProjectName(result: AuditResult, override?: string): string { if (override && override.trim()) return override; // Pick the cwd that appears in the most examples — proxy for "your @@ -249,9 +245,7 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr startRerun(source)} @@ -263,9 +257,7 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr interface MainReportProps { result: AuditResult; cachedAt: string | null; - params: RunAuditOptions | undefined; projectFromUrl?: string; - totalCatalogSize: number; isRunning: boolean; rerunStatus: RerunStatus; onRerun: (source: RerunSource) => void; @@ -275,9 +267,7 @@ interface MainReportProps { function MainReport({ result, cachedAt, - params, projectFromUrl, - totalCatalogSize, isRunning, rerunStatus, onRerun, @@ -294,14 +284,10 @@ function MainReport({ const projectedGrade = gradeFor(projected); const strengths = useMemo(() => deriveStrengths(result), [result]); const findings = useMemo(() => deriveFindings(result), [result]); - // Renamed from `window` to avoid shadowing the browser global — any - // future `window.*` reference added inside MainReport would silently - // bind to a string and crash at runtime. - const scopeWindow = inferWindow(params); - // One pass over result.results derives both counts; score-section and - // return-section also need `missing` but they take it from us via props - // / `result` shape, so deduplicate the scan here. + // One pass over result.results: detectors triggered + missing prescribed + // policies. Both feed PostHog instrumentation; `missing` also feeds the + // poster's share-text template. const { detectorsTriggered, missing } = useMemo(() => { let detectorsTriggered = 0; let missing = 0; @@ -340,54 +326,37 @@ function MainReport({ detectorsTriggered, ]); - /** Identity hero ref — captured to PNG by the share buttons. */ - const identityFrameRef = useRef(null); + /** Poster ref — captured to PNG by the poster's share buttons. */ + const posterRef = useRef(null); return (
-
- - - - + + + onRerun("return_section")} /> - -
-
); } @@ -408,7 +377,6 @@ function ShellEmpty({ running, mode = "no-cache", rerunStatus, onDismissRerun, o return (
-
{running ? ( diff --git a/app/audit/_components/audit-poster.tsx b/app/audit/_components/audit-poster.tsx new file mode 100644 index 00000000..65acdfaf --- /dev/null +++ b/app/audit/_components/audit-poster.tsx @@ -0,0 +1,322 @@ +"use client"; + +/** + * Section 01 — AUDIT POSTER. The single-screen shareable above-the-fold + * artifact. Replaces the old IdentitySection + ScoreSection + ShareDock + * triumvirate. + * + * Layout (inside the PNG capture region): + * + * ┌─────────────────────────────────────────────────────────────┐ + * │ ▣ failproof_ai · audit № 01 of 08 · audited │ + * ├─────────────────────────────────────────────────────────────┤ + * │ │ + * │ /100 the optimist ▓░▓░▓░▓░ │ + * │ pace · conviction · forgetful ░▓░▓░▓░▓ │ + * │ // only N% of agents are this archetype │ + * │ │ + * ├─────────────────────────────────────────────────────────────┤ + * │ audit yours → failproof.ai │ + * └─────────────────────────────────────────────────────────────┘ + * + * Outside the capture region: three share buttons + scroll hint. + */ +import React, { forwardRef, useMemo, useState } from "react"; +import { pickArchetypeVariant, type ArchetypeKey } from "@/src/audit/archetypes"; +import { type Grade } from "@/src/audit/scoring"; +import { getArchetypeRarityPct, getScoreRank } from "@/src/audit/social-proof"; +import { copyOrDownloadCard, downloadCard, shareCardNative, shareCardToastMessage } from "@/lib/share-card"; +import { toast } from "@/app/components/toast"; +import { usePostHog } from "@/contexts/PostHogContext"; +import { Sigil } from "./sigil"; +import { X_TEMPLATES, LI_TEMPLATES, pickTemplate, type ShareCtx } from "./share-templates"; + +const SITE_URL = "https://befailproof.ai"; +const X_INTENT = (text: string) => + `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`; +const LI_INTENT = (text: string) => + `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(SITE_URL)}&summary=${encodeURIComponent(text)}`; + +interface Props { + archetypeKey: ArchetypeKey; + /** Stable seed for variant selection (project name is the natural fit). */ + seed: string; + score: number; + grade: Grade; + /** Count of unenabled prescribed policies — passed to the share-text + * templates, not rendered on the poster itself. */ + missing: number; + /** Audit timestamp (ISO string from cache). Rendered as ISO date in the + * top-right meta. UTC to keep the poster timezone-stable across shares. */ + auditedAt: string; +} + +export const AuditPoster = forwardRef(function AuditPoster( + { archetypeKey, seed, score, grade, missing, auditedAt }: Props, + posterRef, +) { + const archetype = useMemo( + () => pickArchetypeVariant(archetypeKey, seed), + [archetypeKey, seed], + ); + const rarityPct = getArchetypeRarityPct(archetypeKey); + const scoreRank = getScoreRank(score); + const indexLabel = String(archetype.index).padStart(2, "0"); + const auditedDate = useMemo(() => formatAuditedDate(auditedAt), [auditedAt]); + + const { capture } = usePostHog(); + const [busy, setBusy] = useState(null); + + const captureCardBlob = async (): Promise => { + const node = (posterRef as React.MutableRefObject)?.current; + if (!node) return null; + // Capture via html-to-image instead of html2canvas. The former + // serializes the DOM into an SVG and rasterizes + // it through the browser's native rendering engine — so dashed + // borders, the SVG logo, gradients, and font metrics render + // exactly as they do on screen. html2canvas reimplements CSS + // in JS and was producing broken dashes and a stray pink square + // on the logo's mask. + if (typeof document !== "undefined" && document.fonts?.ready) await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => r())); + // Clone the poster into an off-screen container with a fixed width + // so html-to-image captures a self-contained subtree. The live + // .poster sits inside a flex column (.poster-section's flex: 1) + // and uses margin: 0 auto for centering — capturing it directly + // inherits that parent context, which shifts content within an + // oversized canvas. The clone has no such context. + const liveRect = node.getBoundingClientRect(); + const captureWidth = Math.round(liveRect.width); + const captureHeight = Math.round(liveRect.height); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = [ + "position: fixed", + "left: -10000px", + "top: 0", + `width: ${captureWidth}px`, + `height: ${captureHeight}px`, + "padding: 0", + "margin: 0", + "background: var(--bg)", + "z-index: -1", + "pointer-events: none", + ].join(";"); + + const clone = node.cloneNode(true) as HTMLElement; + clone.style.cssText += [ + "", + `width: ${captureWidth}px`, + `height: ${captureHeight}px`, + "max-width: none", + "min-width: 0", + "flex: 0 0 auto", + "margin: 0", + ].join(";"); + + wrapper.appendChild(clone); + document.body.appendChild(wrapper); + + try { + const { toBlob } = await import("html-to-image"); + return await toBlob(clone, { + backgroundColor: "#0e0e11", + pixelRatio: 2, + cacheBust: true, + width: captureWidth, + height: captureHeight, + }); + } finally { + wrapper.remove(); + } + }; + + const filenameFor = (channel: "x" | "linkedin" | "download") => + `failproofai-${channel}-${grade.toLowerCase()}-${score}.png`; + + const handleShare = async (channel: "x" | "linkedin" | "download") => { + if (busy) return; + setBusy(channel); + capture("audit_card_share_clicked", { + channel, + source: "poster", + score, + grade, + missing_policies: missing, + }); + try { + const blob = await captureCardBlob().catch(() => null); + if (!blob) { + capture("audit_card_capture_completed", { + trigger: channel === "download" ? "download" : `share_${channel}`, + status: "error", + image_method: "failed", + source: "poster", + }); + toast(shareCardToastMessage("failed")); + return; + } + + if (channel === "download") { + const ok = downloadCard(blob, filenameFor(channel)); + const method = ok ? "download" : "failed"; + capture("audit_card_capture_completed", { + trigger: "download", + status: ok ? "success" : "error", + image_method: method, + source: "poster", + }); + toast(shareCardToastMessage(method)); + return; + } + + const shareCtx: ShareCtx = { + score, + arch: archetype.name.toLowerCase(), + grade, + missing, + }; + const shareText = channel === "x" + ? pickTemplate(X_TEMPLATES, seed, shareCtx) + : pickTemplate(LI_TEMPLATES, seed, shareCtx); + const native = await shareCardNative(blob, filenameFor(channel), shareText); + if (native) { + capture("audit_card_capture_completed", { + trigger: `share_${channel}`, + status: "success", + image_method: "native", + source: "poster", + }); + toast(shareCardToastMessage("native")); + return; + } + + const fallbackMethod = await copyOrDownloadCard(blob, filenameFor(channel)); + capture("audit_card_capture_completed", { + trigger: `share_${channel}`, + status: fallbackMethod === "failed" ? "error" : "success", + image_method: fallbackMethod, + source: "poster", + }); + toast(shareCardToastMessage(fallbackMethod)); + const intent = channel === "x" ? X_INTENT(shareText) : LI_INTENT(shareText); + globalThis.open(intent, "_blank", "noopener,noreferrer"); + } finally { + setBusy(null); + } + }; + + return ( +
+
+
+ + failproof_ai + · audit + + + № {indexLabel} + of 08 + · + audited {auditedDate} + +
+ +
+ {/* Sigil — visual anchor at the top of the centered stack */} +
+ +
+ + {/* Persona block — name + keywords + rarity, centered */} +
+

{archetype.name}

+
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ( + · + )} + + ))} +
+ {typeof rarityPct === "number" && ( +
+ {"// only"}{" "} + {rarityPct}%{" "} + of agents are this archetype +
+ )} +
+ + {/* Score block — heroic number + rank pill on the same baseline */} +
+ {score} + /100 + {scoreRank} +
+
+ +
+ + audit yours failproof.ai + +
+
+ +
+ + + +
+ + +
+ ); +}); + +/** UTC ISO date (YYYY-MM-DD) so the poster's date stays timezone-stable + * across the geographies it gets shared into. */ +function formatAuditedDate(iso: string): string { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } catch { + return iso; + } +} diff --git a/app/audit/_components/auth-dialog.tsx b/app/audit/_components/auth-dialog.tsx index c0555637..c85dbe3b 100644 --- a/app/audit/_components/auth-dialog.tsx +++ b/app/audit/_components/auth-dialog.tsx @@ -2,14 +2,15 @@ /** * Auth dialog — modal overlay shown when an unauthenticated user clicks - * "[ set a reminder ]". Two-step flow: + * a cadence button to set a reminder. Two-step flow: * * 1. Email entry → POST /api/auth/login-request * 2. OTP entry → POST /api/auth/login-verify * - * Styled to match the rest of the /audit page: pixel brackets, sharp pink - * accent, terminal-style frame. The dialog never sees the refresh token — - * the dashboard's API route writes it to ~/.failproofai/auth.json. + * Calm chrome to match the rest of /audit: 1px borders, plain + * lowercase copy, no corner crosshair frame. The dialog never sees + * the refresh token — the dashboard's API route writes it to + * ~/.failproofai/auth.json. */ import React, { useCallback, useEffect, useRef, useState } from "react"; @@ -23,15 +24,12 @@ export interface AuthedUser { interface Props { open: boolean; - /** Copy shown above the title, e.g. "oops — you are unknown." */ + /** Optional title. Defaults to "set a reminder". */ headline?: string; - /** Copy under the title explaining why we need auth right now. */ - reason?: string; onClose: () => void; /** Fired after successful verify. Caller decides what to do next. */ onAuthed: (user: AuthedUser) => void; - /** Telemetry tag identifying which CTA opened the dialog. Defaults to - * "unknown" so existing call sites continue to compile. */ + /** Telemetry tag identifying which CTA opened the dialog. */ source?: string; } @@ -52,8 +50,7 @@ function describeFetchError(err: unknown): string { export function AuthDialog({ open, - headline = "oops — you are unknown.", - reason = "verify yourself to continue.", + headline = "where to route the reminder?", onClose, onAuthed, source = "unknown", @@ -65,8 +62,6 @@ export function AuthDialog({ const emailInputRef = useRef(null); const codeInputRef = useRef(null); - // Reset internal state every time the dialog opens. Also fire the - // funnel-opened event so we can measure dismissal vs verification rates. useEffect(() => { if (open) { setStep({ kind: "email", error: null }); @@ -76,9 +71,6 @@ export function AuthDialog({ } }, [capture, open, source]); - // Fire dismissed when the dialog closes WITHOUT a successful verify. - // We piggyback on `open` flipping false instead of intercepting every - // close path so resend / step transitions don't double-count. const wasOpenRef = useRef(false); useEffect(() => { if (open) { @@ -94,7 +86,6 @@ export function AuthDialog({ wasOpenRef.current = false; }, [capture, open, source, step.kind]); - // Autofocus the right input as the step changes. useEffect(() => { if (!open) return; const t = setTimeout(() => { @@ -104,7 +95,6 @@ export function AuthDialog({ return () => clearTimeout(t); }, [open, step.kind]); - // ESC to close. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent): void => { @@ -114,7 +104,6 @@ export function AuthDialog({ return () => window.removeEventListener("keydown", onKey); }, [open, busy, onClose]); - // Resend countdown ticker. const resendActive = step.kind === "code" && step.resendIn > 0; useEffect(() => { if (!resendActive) return; @@ -129,9 +118,6 @@ export function AuthDialog({ const requestCode = useCallback( async (email: string, opts: { isResend?: boolean } = {}): Promise => { const { isResend = false } = opts; - // Show resend errors inline on the OTP step — the previously sent - // code is still usable. Only the first-send error path bounces back - // to the email step. const setError = (msg: string) => { if (isResend) { setStep((s) => (s.kind === "code" ? { ...s, error: msg } : s)); @@ -268,11 +254,6 @@ export function AuthDialog({ }} >
- - - - - -
━━ identity check

{headline}

{step.kind === "email" && ( <> -

{reason}

+

+ we'll send a one-time code to confirm. +

- {step.error}
}
@@ -329,14 +308,10 @@ export function AuthDialog({ {step.kind === "code" && ( <>

- we sent a code to {step.email}. -
- check your inbox — it expires in {Math.ceil(step.expiresIn / 60)} min. + code sent to {step.email}. + expires in {Math.ceil(step.expiresIn / 60)} min.

- {step.error}
}
diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx new file mode 100644 index 00000000..286b5899 --- /dev/null +++ b/app/audit/_components/come-back-better-section.tsx @@ -0,0 +1,316 @@ +"use client"; + +/** + * Section 05 — COME BACK BETTER. "build the habit." + * + * Two side-by-side cards: + * + * • Reminder — set a reminder cadence (3d / 7d / 14d / 30d). The cadence + * selection persists through /api/auth/reminder. Anon users get the + * AuthDialog first; authed-with-existing-reminder users see the next + * audit date and can reset. + * + * • Unlock perks — share with N friends to unlock pro features for a + * month. UI only — invite tracking + entitlement is a follow-up; the + * button opens the same X share intent the poster uses. + * + * Re-audit moves out of this section: a small inline "or re-audit now" + * link sits under the reminder card so the affordance survives without + * dominating the layout. + */ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { usePostHog } from "@/contexts/PostHogContext"; +import { isAbortError } from "@/lib/fetch-with-timeout"; +import { AuthDialog, type AuthedUser } from "./auth-dialog"; +import { InviteDialog } from "./invite-dialog"; + +interface Props { + isRunning: boolean; + onRerun: () => void; +} + +const DEFAULT_REMINDER_DAYS = 7; +const REMINDER_OPTIONS = [3, 7, 14, 30] as const; +type Cadence = typeof REMINDER_OPTIONS[number]; + +const PERKS_PERK = "share with 3 friends → unlock pro features for a month."; + +type AuthStatus = + | { kind: "unknown" } + | { kind: "anon" } + | { kind: "authed"; user: { id: string; email: string } }; + +interface Reminder { + next_audit_at: number; + user_email: string; + set_at: number; +} + +function daysUntil(unixSecs: number): number { + const nowSecs = Math.floor(Date.now() / 1000); + return Math.max(0, Math.ceil((unixSecs - nowSecs) / 86400)); +} + +function formatNextAudit(unixSecs: number): string { + const d = new Date(unixSecs * 1000); + return d.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +export function ComeBackBetterSection({ isRunning, onRerun }: Props) { + const { capture } = usePostHog(); + const [authStatus, setAuthStatus] = useState({ kind: "unknown" }); + const [reminder, setReminder] = useState(null); + const [cadence, setCadence] = useState(DEFAULT_REMINDER_DAYS); + const [dialogOpen, setDialogOpen] = useState(false); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [reminderBusy, setReminderBusy] = useState(false); + const ctaShownRef = useRef(false); + const lastRefreshAtRef = useRef(0); + + const refreshStatus = useCallback(async () => { + lastRefreshAtRef.current = Date.now(); + // Preserve current UI state on transient failures (5xx, network blips). + // Downgrading to anon on every error would clear a valid reminder mid- + // session on a single failed poll, forcing an unnecessary auth prompt. + // Only fall through to anon on the very first probe (still "unknown") + // so the cadence buttons unlock even if the server is unreachable. + const fallbackToAnonOnError = () => { + setAuthStatus((prev) => (prev.kind === "unknown" ? { kind: "anon" } : prev)); + }; + try { + const res = await fetch("/api/auth/status", { cache: "no-store" }); + if (!res.ok) { + fallbackToAnonOnError(); + return; + } + const body = (await res.json()) as { + authenticated?: boolean; + user?: { id: string; email: string }; + reminder?: Reminder | null; + }; + if (body.authenticated && body.user) { + setAuthStatus({ kind: "authed", user: body.user }); + setReminder(body.reminder ?? null); + } else { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + } catch { + fallbackToAnonOnError(); + } + }, []); + + useEffect(() => { + void refreshStatus(); + const REFRESH_MIN_INTERVAL_MS = 5_000; + const maybeRefresh = () => { + if (Date.now() - lastRefreshAtRef.current < REFRESH_MIN_INTERVAL_MS) return; + void refreshStatus(); + }; + const onFocus = () => maybeRefresh(); + const onVisibility = () => { + if (document.visibilityState === "visible") maybeRefresh(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refreshStatus]); + + useEffect(() => { + if (ctaShownRef.current) return; + if (authStatus.kind === "unknown") return; + ctaShownRef.current = true; + capture("audit_reminder_cta_shown", { + auth_state: authStatus.kind, + has_existing_reminder: reminder !== null, + source: "come_back_better_section", + }); + }, [authStatus, capture, reminder]); + + const persistReminder = useCallback( + async (inDays: number): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + setReminderBusy(true); + const res = await fetch("/api/auth/reminder", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ in_days: inDays }), + signal: controller.signal, + }); + if (!res.ok) { + if (res.status === 401) { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + capture("audit_reminder_saved", { + status: `http_${res.status}`, + source: "come_back_better_section", + cadence_days: inDays, + }); + return null; + } + const body = (await res.json()) as { reminder?: Reminder }; + capture("audit_reminder_saved", { + status: body.reminder ? "success" : "empty", + source: "come_back_better_section", + cadence_days: inDays, + }); + return body.reminder ?? null; + } catch (err) { + const kind = isAbortError(err) ? "timeout" : "error"; + capture("audit_reminder_saved", { + status: kind, + source: "come_back_better_section", + cadence_days: inDays, + }); + return null; + } finally { + clearTimeout(timer); + setReminderBusy(false); + } + }, + [capture], + ); + + const handleCadenceClick = useCallback( + async (next: Cadence) => { + setCadence(next); + capture("audit_reminder_cta_clicked", { + auth_state: authStatus.kind, + has_existing_reminder: reminder !== null, + cadence_days: next, + source: "come_back_better_section", + }); + if (authStatus.kind === "authed") { + const saved = await persistReminder(next); + if (saved) setReminder(saved); + return; + } + if (authStatus.kind === "anon") { + setDialogOpen(true); + } + }, + [authStatus, capture, persistReminder, reminder], + ); + + const handleAuthed = useCallback( + async (user: AuthedUser) => { + setAuthStatus({ kind: "authed", user }); + capture("audit_auth_completed", { + source: "come_back_better_section", + }); + const saved = await persistReminder(cadence); + if (saved) setReminder(saved); + }, + [cadence, capture, persistReminder], + ); + + const handleInvite = useCallback(() => { + capture("audit_perks_invite_clicked", { + source: "come_back_better_section", + auth_state: authStatus.kind, + }); + // Unauthed users go through the AuthDialog first so we have a sender + // identity to Cc on the invite email. + if (authStatus.kind !== "authed") { + setDialogOpen(true); + return; + } + setInviteDialogOpen(true); + }, [authStatus.kind, capture]); + + const handleRerunInline = useCallback(() => { + if (isRunning) return; + onRerun(); + }, [isRunning, onRerun]); + + const days = reminder ? daysUntil(reminder.next_audit_at) : 0; + + return ( +
+
+ + 05{"// come back better"} + +
+

build the habit

+ +
+ {/* Reminder card */} +
+
set a reminder
+
+ {reminder + ? `next audit set for ${formatNextAudit(reminder.next_audit_at)} · in ${days} day${days === 1 ? "" : "s"}.` + : "we'll nudge you when your next audit is due. pick the cadence:"} +
+
+ {REMINDER_OPTIONS.map((d) => ( + + ))} +
+ +
+ + {/* Perks card */} +
+
unlock failproof perks
+
{PERKS_PERK}
+ +
+ {"// invites are sent from failproof.ai, Cc'd to you, with a link to run their own audit."} +
+
+
+ + setInviteDialogOpen(false)} + onUnauthorized={() => { + // Session expired between probe and submit — flip back to anon + // and bounce through the AuthDialog so the user re-auths. + setAuthStatus({ kind: "anon" }); + setReminder(null); + setDialogOpen(true); + }} + /> + + setDialogOpen(false)} + onAuthed={(u) => { + setDialogOpen(false); + void handleAuthed(u); + }} + /> +
+ ); +} diff --git a/app/audit/_components/findings-section.tsx b/app/audit/_components/findings-section.tsx deleted file mode 100644 index 1a28f09c..00000000 --- a/app/audit/_components/findings-section.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"use client"; - -/** - * Section 04 — FINDINGS. "your agent has some quirks." - * - * Per-finding cards with four blocks: what happened / what this costs / - * evidence sample / the fix. Data sourced from `src/audit/findings.ts`. - */ -import React, { useState } from "react"; -import type { FindingCard } from "@/src/audit/findings"; -import { usePostHog } from "@/contexts/PostHogContext"; - -interface Props { - findings: FindingCard[]; -} - -export function FindingsSection({ findings }: Props) { - if (findings.length === 0) return null; - - return ( -
-
-
- ━━ findings{" "} - · ranked by impact -
-
- {findings.length} detector{findings.length === 1 ? "" : "s"} triggered -
-
-

your agent has some quirks.

- -
- {findings.map((f) => )} -
-
- ); -} - -function Finding({ f }: { f: FindingCard }) { - const { capture } = usePostHog(); - const [copied, setCopied] = useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(f.fix.install); - setCopied(true); - capture("audit_copy_clicked", { - source: "findings_section", - item_type: "single_policy_install_command", - policy_name: f.fix.slug, - finding_slug: f.sourceSlug, - }); - setTimeout(() => setCopied(false), 1500); - } catch { /* ignore */ } - }; - - return ( -
-
-
№{f.num}
-
{f.title}
-
- {f.count}× - occurrences -
-
-
- - policy{" "} - {f.policy} - - · - {f.projects} {f.projects === 1 ? "project" : "projects"} - · - last seen {f.lastSeen} - {f.alreadyEnabled && ( - <> - · - enforced - - )} -
-
-
-
what happened
-
{f.body}
-
-
-
what this costs
-
{f.cost}
-
-
-
evidence · sample
-
- {f.evidence.map((e, i) => { - if (e.kind === "comment") { - return
{e.text}
; - } - if (e.kind === "err") { - return
{e.text}
; - } - return ( -
- - {e.text} -
- ); - })} -
-
-
-
the fix
-
- {f.fix.slug} -
{f.fix.desc}
- {f.fix.alsoCoveredBy && ( -
- also covered by{" "} - {f.fix.alsoCoveredBy} -
- )} - - ${f.fix.install}{" "} - - {copied ? "copied" : "click to copy"} - - -
-
-
-
- ); -} diff --git a/app/audit/_components/how-to-improve-section.tsx b/app/audit/_components/how-to-improve-section.tsx new file mode 100644 index 00000000..97898111 --- /dev/null +++ b/app/audit/_components/how-to-improve-section.tsx @@ -0,0 +1,187 @@ +"use client"; + +/** + * Section 04 — HOW TO IMPROVE. Calm row list, one per prescribed + * policy: + * + * $ failproofai policy add [📋] + * + * + * A single "install all" button at the section header copies the + * combined install command for every prescribed policy. + */ +import React, { useMemo, useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { type Grade, tierName } from "@/src/audit/scoring"; +import { usePostHog } from "@/contexts/PostHogContext"; + +interface Props { + result: AuditResult; + projected: number; + projectedGrade: Grade; +} + +const DETECTOR_TO_PRIMARY_POLICY: Record = { + "redundant-cd-cwd": "warn-repeated-tool-calls", + "prefer-edit-over-read-cat": "block-read-outside-cwd", + "prefer-edit-over-sed-awk": "warn-repeated-tool-calls", + "prefer-write-over-heredoc": "block-env-files", + "sleep-polling-loop": "warn-background-process", + "find-from-root": "block-read-outside-cwd", + "git-commit-no-verify": "warn-git-amend", + "reread-after-edit": "warn-repeated-tool-calls", +}; + +const POLICY_DESC: Record = { + "warn-repeated-tool-calls": "warns when the same tool is called 3+ times with identical parameters.", + "block-read-outside-cwd": "denies any file read outside the project root, including symlinks.", + "block-env-files": "blocks reads and writes of .env files at the tool layer.", + "block-secrets-write": "blocks writes to .pem, id_rsa, credentials.json, and other secret-key files.", + "warn-background-process": "warns before starting nohup / & / screen / tmux processes.", + "warn-git-amend": "warns before amending git commits.", + "require-ci-green-before-stop": "requires CI checks to pass on HEAD before the agent declares the task done.", +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +interface FixRow { + name: string; + desc: string; + hits: number; +} + +function buildFixes(result: AuditResult): FixRow[] { + const enabledSet = new Set(result.enabledBuiltinNames ?? []); + const buckets = new Map(); + + for (const row of result.results) { + if (row.hits === 0) continue; + + let target: string; + if (row.source === "audit-detector") { + const mapped = DETECTOR_TO_PRIMARY_POLICY[shortName(row.name)]; + if (!mapped) continue; + target = mapped; + } else if (row.source === "builtin" && !row.enabledInConfig) { + target = shortName(row.name); + } else { + continue; + } + + if (enabledSet.has(target)) continue; + buckets.set(target, (buckets.get(target) ?? 0) + row.hits); + } + + return [...buckets.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([name, hits]) => ({ + name, + desc: POLICY_DESC[name] ?? "enable this builtin policy to close the gap.", + hits, + })); +} + +function bulkInstall(fixes: FixRow[]): string { + if (fixes.length === 0) return ""; + if (fixes.length === 1) return `failproofai policy add ${fixes[0]!.name}`; + return `failproofai policy add ${fixes.map((f) => f.name).join(" ")}`; +} + +export function HowToImproveSection({ result, projected, projectedGrade }: Props) { + const { capture } = usePostHog(); + const fixes = useMemo(() => buildFixes(result), [result]); + const installAllCmd = useMemo(() => bulkInstall(fixes), [fixes]); + const [copiedAll, setCopiedAll] = useState(false); + + if (fixes.length === 0) return null; + + const handleInstallAll = async () => { + try { + await navigator.clipboard.writeText(installAllCmd); + setCopiedAll(true); + capture("audit_copy_clicked", { + source: "how_to_improve_section_install_all", + item_type: "bulk_install_command", + policy_count: fixes.length, + }); + setTimeout(() => setCopiedAll(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+ + 04{"// how to improve"} + + +
+

install or configure

+
+ enable all {fixes.length === 1 ? "one" : fixes.length} → projected{" "} + {projected} · {tierName(projectedGrade).toLowerCase()} +
+ +
+ {fixes.map((f, i) => ( + + ))} +
+
+ ); +} + +function FixRow({ fix, idx }: { fix: FixRow; idx: number }) { + const { capture } = usePostHog(); + const [copied, setCopied] = useState(false); + const install = `failproofai policy add ${fix.name}`; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(install); + setCopied(true); + capture("audit_copy_clicked", { + source: "how_to_improve_section", + item_type: "single_policy_install_command", + policy_name: fix.name, + policy_rank: idx + 1, + }); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+
{fix.name}
+
{fix.desc}
+
+
+ {install} + +
+
+ ); +} diff --git a/app/audit/_components/identity-section.tsx b/app/audit/_components/identity-section.tsx deleted file mode 100644 index 9c2df894..00000000 --- a/app/audit/_components/identity-section.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -/** - * Section 01 — IDENTITY. The hero. Big archetype name with hard-offset - * stamp shadow, sigil to the right, keywords strip, "common in / primary - * risk" meta grid, and the closing one-liner. - * - * Layout uses the ported `.archetype-frame` / `.arch-mast` / `.arch-body` - * classes from audit-styles.css. Data sources from `src/audit/archetypes.ts`. - * - * The variant copy (tagline / keywords / common / risk / closing) is - * picked deterministically from a multi-variant catalog using the `seed` - * prop — typically the inferred project name. Same seed → same persona - * blurb across renders; different seeds → different copy. So two users - * who both land on "the optimist" see different language for it. - * - * Exposes a `frameRef` forwarded onto the `.archetype-frame` element so - * the ShowOff "make poster" action can capture it via html2canvas. - */ -import React, { forwardRef, useMemo } from "react"; -import { ARCHETYPES, pickArchetypeVariant, type ArchetypeKey } from "@/src/audit/archetypes"; -import { type Grade } from "@/src/audit/scoring"; -import { Sigil } from "./sigil"; - -interface Props { - archetypeKey: ArchetypeKey; - secondaryKey: ArchetypeKey; - toolCalls: number; - sessions: number; - /** "30d", "7d", etc. shown in the target line; "all time" otherwise. */ - window: string; - /** Stable seed for variant selection (project name is the natural fit). */ - seed: string; -} - -export const IdentitySection = forwardRef(function IdentitySection( - { archetypeKey, secondaryKey, toolCalls, sessions, window, seed }: Props, - frameRef, -) { - // `pickArchetypeVariant` re-hashes the seed string via djb2 + 4 mix - // passes per axis. Deterministic over (archetypeKey, seed) so memoize. - const archetype = useMemo( - () => pickArchetypeVariant(archetypeKey, seed), - [archetypeKey, seed], - ); - const secondary = secondaryKey !== archetypeKey ? ARCHETYPES[secondaryKey] : null; - - return ( -
-
- ┌ identity - v1.0 ┐ - └ № {archetype.index} / 08 - archetype ┘ - -
-
-
- ━━ identity · your agent's archetype -
-
- detected from{" "} - {toolCalls.toLocaleString()} - {" "}tool calls - / - {sessions} - {" "}sessions - / - {window} - - live - -
-
-
-
- № {archetype.index} of 08 -
-
archetype
-
-
- -
-
-

{archetype.name}

-

{archetype.tagline}

- - {secondary && ( -
- with - {secondary.name.replace("the ", "")} - tendencies -
- )} - -
- {archetype.keywords.map((k, i) => ( - - {k} - {i < archetype.keywords.length - 1 && ( - · - )} - - ))} -
- -
-
- common in - {archetype.common} -
-
- primary risk - {archetype.risk} -
-
- -
— {archetype.closing}
-
- - -
-
-
- ); -}); diff --git a/app/audit/_components/invite-dialog.tsx b/app/audit/_components/invite-dialog.tsx new file mode 100644 index 00000000..53956ede --- /dev/null +++ b/app/audit/_components/invite-dialog.tsx @@ -0,0 +1,227 @@ +"use client"; + +/** + * Invite dialog — modal that takes a comma/newline-separated list of friend + * emails and POSTs them to /api/audit/invite. The api-server composes the + * actual invite email (Cc'ing the sender) using the same email infrastructure + * that backs the OTP flow. + * + * Anonymous users get bounced through the AuthDialog by the caller; this + * component assumes the session is already established. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { usePostHog } from "@/contexts/PostHogContext"; +import { toast } from "@/app/components/toast"; + +interface Props { + open: boolean; + onClose: () => void; + /** Source tag for PostHog so we can split "invited from come-back-better" + * from any future entry points. */ + source: string; + /** Called when the proxy returns 401 (session expired between probe and + * submit). The parent should re-open the AuthDialog so the user can + * re-authenticate; without this, a 401 dead-ends with an inline error. */ + onUnauthorized?: () => void; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MAX_RECIPIENTS = 10; + +function parseEmails(input: string): { valid: string[]; invalid: string[] } { + const tokens = input + .split(/[\s,;]+/) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + const valid: string[] = []; + const invalid: string[] = []; + const seen = new Set(); + for (const t of tokens) { + if (seen.has(t)) continue; + seen.add(t); + if (EMAIL_RE.test(t)) valid.push(t); + else invalid.push(t); + } + return { valid, invalid }; +} + +export function InviteDialog({ open, onClose, source, onUnauthorized }: Props): React.ReactElement | null { + const { capture } = usePostHog(); + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (open) { + setValue(""); + setBusy(false); + setError(null); + capture("audit_invite_dialog_opened", { source }); + } + }, [capture, open, source]); + + useEffect(() => { + if (!open) return; + const t = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(t); + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape" && !busy) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, busy, onClose]); + + const { valid, invalid } = useMemo(() => parseEmails(value), [value]); + + const submit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (busy) return; + if (valid.length === 0) { + setError("add at least one valid email address."); + return; + } + if (valid.length > MAX_RECIPIENTS) { + setError(`up to ${MAX_RECIPIENTS} emails at a time. send the rest in a follow-up.`); + return; + } + setBusy(true); + setError(null); + capture("audit_invite_submitted", { + source, + recipient_count: valid.length, + had_invalid: invalid.length > 0, + }); + try { + const res = await fetch("/api/audit/invite", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ to: valid }), + }); + const body = (await res.json().catch(() => ({}))) as { + sent?: string[]; + failed?: string[]; + code?: string; + message?: string; + }; + if (res.status === 401) { + // Session expired between probe and submit — route back through auth. + // Without this, repeated submits would dead-end with the same 401. + if (onUnauthorized) { + onClose(); + onUnauthorized(); + } else { + setError("session expired. please sign in again."); + } + return; + } + if (!res.ok) { + const msg = body.message ?? "couldn't send invites."; + setError(msg); + return; + } + const sent = body.sent?.length ?? 0; + const failed = body.failed?.length ?? 0; + toast( + failed > 0 + ? `📨 sent ${sent}, ${failed} bounced — copy the bounce and try again.` + : `📨 sent ${sent} ${sent === 1 ? "invite" : "invites"}. thanks for spreading the word.`, + ); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(`network error: ${message}`); + } finally { + setBusy(false); + } + }, + [busy, valid, invalid, capture, source, onClose, onUnauthorized], + ); + + if (!open) return null; + + return ( +
{ + if (!busy && e.target === e.currentTarget) onClose(); + }} + > +
+ + +

+ invite friends to audit theirs +

+

+ paste emails separated by commas, spaces, or newlines. you'll be Cc'd on every invite so they know it's from you. +

+ + +