Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6c8c9c5
chore: stop ignoring .tmp/ (scratch demos land here)
nk-ag Jun 11, 2026
ddcedc6
docs: add audit poster restructure + dashboard restraint spec
nk-ag Jun 11, 2026
c8c6927
docs: revert /policies + /projects headings to plain title case
nk-ag Jun 11, 2026
f0240e2
style: calm site chrome + revert /policies + /projects to plain headings
nk-ag Jun 11, 2026
79ac068
feat(audit): restructure into 5-section flow with shareable poster
nk-ag Jun 11, 2026
dbb2cd2
style(audit): strip dead CSS + brutalist treatments + lock poster to …
nk-ag Jun 11, 2026
7af5d9d
style: drop body gradient + restructure how-to-improve as row list
nk-ag Jun 11, 2026
35f5185
docs: changelog entry for audit poster restructure
nk-ag Jun 11, 2026
356b042
style(audit): vertical centered poster + reorder tabs to projects/pol…
nk-ag Jun 11, 2026
94477d8
fix(audit): clamp poster section to viewport so scroll hint fits the …
nk-ag Jun 11, 2026
65e5370
fix(audit): score+rank no longer overlap; mono score font; calmer aut…
nk-ag Jun 11, 2026
8b1385d
fix(audit): capture poster as rendered, drop .capturing override
nk-ag Jun 11, 2026
bc82999
fix(audit): flatten score row + use real logo on poster
nk-ag Jun 11, 2026
8a5162f
fix(audit): swap html2canvas for html-to-image for clean PNG export
nk-ag Jun 11, 2026
a645249
fix(audit): lock html-to-image capture dims to live element so conten…
nk-ag Jun 11, 2026
99279bd
docs: changelog entry for poster capture width lock
nk-ag Jun 11, 2026
9c6d4da
docs: update dashboard.mdx for the new 5-section audit flow
nk-ag Jun 11, 2026
dfb7396
docs: changelog entry for dashboard.mdx audit-section update
nk-ag Jun 11, 2026
de018dd
fix(audit): capture poster from off-screen clone to drop flex-parent …
nk-ag Jun 11, 2026
3ab6eb1
style(nav): drop ━━ glyph prefix + audit slipping-count badge
nk-ag Jun 11, 2026
0308b73
fix: address CodeRabbit review — telemetry status, transient auth pro…
nk-ag Jun 11, 2026
575d8d2
fix(audit): correct CLI binary name in how-to-improve install commands
nk-ag Jun 11, 2026
0ff3767
feat(audit): invite-a-friend flow + spec for upstream /v0/invite endp…
nk-ag Jun 11, 2026
9d6f541
docs: update dashboard.mdx + changelog for invite-a-friend flow
nk-ag Jun 11, 2026
c564f21
fix(audit-invite): handle 401 → re-auth, sanitize upstream errors, dr…
nk-ag Jun 11, 2026
a28539a
style(projects): rebuild ProjectList with calm chrome — sharp borders…
nk-ag Jun 11, 2026
149e570
Revert "style(projects): rebuild ProjectList with calm chrome — sharp…
nk-ag Jun 11, 2026
0d596de
style(project): calm /project/[name] header + SessionsList chrome
nk-ag Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<mask>` for the "i" character, which html2canvas ignored). `html-to-image` serializes the live DOM into an SVG `<foreignObject>` 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 <slug>` — 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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### 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).

Expand Down
12 changes: 10 additions & 2 deletions __tests__/audit/share-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions __tests__/components/sessions-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe("SessionsList", () => {
it("renders sessions in table", () => {
const files = makeFiles(3);
render(<SessionsList files={files} projectName="test-project" />);
expect(screen.getByText("SessionId")).toBeInTheDocument();
expect(screen.getAllByText(/session id/i).length).toBeGreaterThan(0);
expect(screen.getByText(files[0].sessionId!)).toBeInTheDocument();
});

Expand All @@ -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 () => {
Expand All @@ -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(<SessionsList files={[]} projectName="test-project" />);
expect(screen.getByText("No sessions found")).toBeInTheDocument();
expect(screen.getByText(/no sessions found/i)).toBeInTheDocument();
});
});
183 changes: 183 additions & 0 deletions app/api/audit/invite/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<string>();
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 },
);
}
}
Loading