From f41089ec1dd5cce97934509bd54b99a91309f296 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Thu, 28 May 2026 00:37:00 +0530 Subject: [PATCH 01/13] feat(audit): /audit dashboard with archetype classifier, scoring, and shareable poster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an in-app audit report at /audit that turns the existing `failproofai audit` data into a personality-driven dashboard. Every detector / policy hit feeds a weighted classifier that lands the agent in one of 8 archetypes; the report uses that to motivate enabling unenabled builtin policies. What's new - Archetype catalog + classifier (`src/audit/archetypes.ts`): 8 archetypes (optimist, cowboy, explorer, goldfish, paranoid architect, precision builder, hammer, ghost) with pixel sigils, taglines, "common in" / "primary risk" copy. `SIGNAL_MAP` maps every builtin policy + every audit-only detector (47/47 coverage) to an archetype with a tuned weight. Classifier picks the dominant archetype, falls back to `goldfish` for broad-spread agents, and to `precision` when no signal fired. - Scoring (`src/audit/scoring.ts`): starts at 100, subtracts capped per-source penalties (deny -1.2, instruct/warn -0.7, sanitize -0.4, detector -0.5). Grade thresholds S/A/B/C/D/F match the reference. `projectedScore` previews the post-enable uplift; `syntheticRank` produces a stable cohort rank from the score. - Derivations (`src/audit/strengths.ts`, `src/audit/findings.ts`): Strengths surface real numbers (clean-call %, avg turns, "0 credential leaks" when sanitize policies didn't fire, etc.). Findings carry hand-curated body + cost copy per policy slug and the real captured evidence from `AuditCount.examples`. - Detector → policy fix mapping (`findings.ts:DETECTOR_TO_POLICY`): each of the 8 audit-only detectors is paired with the closest real-time builtin policy, so every finding card shows a real `$ failproof policy add ` install command — no "audit-only" framing in the report. Multi-policy mappings render an "also covered by " hint. Prescribed-policy section aggregates detector hits into the target policy with `(via redundant-cd-cwd, …)` attribution. Sections 01 Identity (archetype hero with sigil + meta grid), 01b Show off CTA, 02 Strengths, 03 Score + cohort leaderboard with distribution histogram, 04 Findings (per-policy cards: what happened / cost / evidence / fix), 05 Prescribed policies (with projected score uplift callout), 06 Return loop ("re-audit in 7 days"). Server page reads the dashboard cache only; all derivation is client-side. Catalog size is computed server-side and passed as a prop (BUILTIN_POLICIES and audit detectors pull in node:fs via the workflow / require-* policies, so they can't ship to the client). Cache + API - Dashboard cache at ~/.failproofai/audit-dashboard.json (mode 0600, single slot, new runs overwrite). Helper at `src/audit/dashboard-cache.ts` (read/write/staleness). Schema bumped to `version: 2` with new fields `eventsScanned: number` (total tool-use events scanned, drives the "X tool calls" headline), `projectsScanned: string[]` (drives the project filter), and `enabledBuiltinNames: string[]` (lets findings answer "is this fix already enabled?" without iterating result rows). - POST /api/audit/run calls runAudit() in-process, writes the dashboard cache, and serializes via a module-scoped singleton lock so concurrent clicks 409. GET /api/audit/status reports {running, startedAt, cachedAt} for client polling. Server action `app/actions/get-audit-result.ts` reads the cache without triggering a run, mirroring the `/policies` `getHooksConfigAction()` pattern. Re-run UX - Empty state CTA on first visit; in-flight re-runs render a four-stage faux progress UI (`run-progress.tsx`). RerunButton POSTs `/api/audit/run` with the current scan params, polls `/api/audit/status` at 1Hz, and refetches via the server action when running flips false. - Shareable PNG export: clicking "make poster" captures the identity archetype-frame DOM via html2canvas at scale 2 and downloads `failproofai--.png`. New dependency: html2canvas@^1.4.1. Styling - Ported `assets/audit/styles.css` (1235 lines) verbatim into `app/audit/audit-styles.css`, scoped to the route via page-level import. JetBrains Mono + VT323 loaded from Google Fonts; Architype Stedelijk shipped locally under public/audit/fonts/. Reference design kit (audit.jsx / poster.jsx / tweaks-panel.jsx / styles.css / archetypes.jsx + screenshots) committed under assets/audit/ for future iteration. ESLint config gains an assets/ ignore so the design kit's vanilla React-Babel JSX isn't linted as project source. Animation primitives in app/globals.css: `.audit-row-enter` (staggered fade-up via `--row-delay`) and `.audit-bar-fill` (width 0 → `--bar-width` on mount), both honoring `prefers-reduced-motion`. Navbar / layout - Navbar gains an "Audit" entry between Policies and Projects with a ClipboardCheck icon and an optional slipping-count chip (rendered when the layout's server-side cache read finds >0 slipping hits). Layout passes the count via a new `auditSlippingCount` prop. Core changes (additive, original policies untouched) - `src/hooks/policy-registry.ts`: added `getAllPolicies()` and `setAllPolicies()` exports for snapshot/restore. Existing `registerPolicy` / `clearPolicies` / `getPoliciesForEvent` / `normalizePolicyName` semantics unchanged. - `src/audit/replay.ts`: `initReplay()` now snapshots the registry via `getAllPolicies()` before clearing it; new `restoreReplay()` puts the pre-init policies back. `runAudit()` wraps the work in try/finally so embedding the audit in long-running processes (the Next.js dashboard is one) no longer wipes pre-existing registrations. - `src/audit/index.ts`: surfaces `eventsScanned`, `projectsScanned`, `enabledBuiltinNames` on the result; per-transcript scan now tracks events count + cwd. Schema-version bump 1 → 2. Tests - New `__tests__/audit/dashboard-cache.test.ts` (round-trip, 0600 mode, corrupt-JSON resilience, staleness threshold). - `__tests__/audit/replay.test.ts` adds three tests covering registry snapshot/restore: a user-registered policy survives `initReplay()` → `restoreReplay()`, `restoreReplay()` is idempotent, and calling `restoreReplay()` before `initReplay()` is a no-op. - Full suite green: 1701 / 1701. Verification - `bunx tsc --noEmit` clean - `bun run lint` 0 errors (2 pre-existing warnings retained) - `bun run test:run` 1701 / 1701 - `bun --bun next build` succeeds; new routes `/audit`, `/api/audit/run`, `/api/audit/status` all registered - Hook handler smoke against live config (`block-failproofai-commands` fires deny on `failproofai policies --uninstall`, harmless commands pass cleanly) — runtime policy enforcement intact --- __tests__/audit/dashboard-cache.test.ts | 95 ++ __tests__/audit/replay.test.ts | 53 +- app/actions/get-audit-result.ts | 24 + app/api/audit/_state.ts | 40 + app/api/audit/run/route.ts | 78 ++ app/api/audit/status/route.ts | 23 + app/audit/_components/app-header.tsx | 37 + app/audit/_components/audit-dashboard.tsx | 270 ++++ app/audit/_components/empty-state.tsx | 72 + app/audit/_components/findings-section.tsx | 127 ++ app/audit/_components/identity-section.tsx | 112 ++ app/audit/_components/policies-section.tsx | 184 +++ app/audit/_components/report-footer.tsx | 34 + app/audit/_components/rerun-button.tsx | 107 ++ app/audit/_components/return-section.tsx | 76 + app/audit/_components/run-progress.tsx | 75 + app/audit/_components/score-section.tsx | 254 ++++ app/audit/_components/show-off-cta.tsx | 100 ++ app/audit/_components/sigil.tsx | 51 + app/audit/_components/strengths-section.tsx | 57 + app/audit/audit-styles.css | 1229 +++++++++++++++++ app/audit/loading.tsx | 24 + app/audit/page.tsx | 53 + app/globals.css | 34 + app/layout.tsx | 11 +- assets/audit/Audit Report.html | 22 + assets/audit/Show Off Your Agent.html | 22 + assets/audit/archetypes.jsx | 272 ++++ assets/audit/assets/favicon.svg | 25 + .../assets/fonts/architype-stedelijk.ttf | Bin 0 -> 24440 bytes .../assets/fonts/architype-stedelijk.woff2 | Bin 0 -> 3020 bytes assets/audit/audit.jsx | 825 +++++++++++ assets/audit/poster-styles.css | 424 ++++++ assets/audit/poster.jsx | 247 ++++ assets/audit/screenshots/poster-optimist.png | Bin 0 -> 25958 bytes assets/audit/screenshots/poster-scrolled.png | Bin 0 -> 25147 bytes assets/audit/styles.css | 1226 ++++++++++++++++ assets/audit/tweaks-panel.jsx | 425 ++++++ bun.lock | 11 + components/navbar.tsx | 23 +- eslint.config.mjs | 4 +- package.json | 1 + public/audit/fonts/architype-stedelijk.ttf | Bin 0 -> 24440 bytes public/audit/fonts/architype-stedelijk.woff2 | Bin 0 -> 3020 bytes src/audit/archetypes.ts | 435 ++++++ src/audit/dashboard-cache.ts | 81 ++ src/audit/findings.ts | 298 ++++ src/audit/index.ts | 30 +- src/audit/replay.ts | 32 +- src/audit/scoring.ts | 138 ++ src/audit/strengths.ts | 138 ++ src/audit/types.ts | 25 +- src/hooks/policy-registry.ts | 20 + 53 files changed, 7933 insertions(+), 11 deletions(-) create mode 100644 __tests__/audit/dashboard-cache.test.ts create mode 100644 app/actions/get-audit-result.ts create mode 100644 app/api/audit/_state.ts create mode 100644 app/api/audit/run/route.ts create mode 100644 app/api/audit/status/route.ts create mode 100644 app/audit/_components/app-header.tsx create mode 100644 app/audit/_components/audit-dashboard.tsx create mode 100644 app/audit/_components/empty-state.tsx create mode 100644 app/audit/_components/findings-section.tsx create mode 100644 app/audit/_components/identity-section.tsx create mode 100644 app/audit/_components/policies-section.tsx create mode 100644 app/audit/_components/report-footer.tsx create mode 100644 app/audit/_components/rerun-button.tsx create mode 100644 app/audit/_components/return-section.tsx create mode 100644 app/audit/_components/run-progress.tsx create mode 100644 app/audit/_components/score-section.tsx create mode 100644 app/audit/_components/show-off-cta.tsx create mode 100644 app/audit/_components/sigil.tsx create mode 100644 app/audit/_components/strengths-section.tsx create mode 100644 app/audit/audit-styles.css create mode 100644 app/audit/loading.tsx create mode 100644 app/audit/page.tsx create mode 100644 assets/audit/Audit Report.html create mode 100644 assets/audit/Show Off Your Agent.html create mode 100644 assets/audit/archetypes.jsx create mode 100644 assets/audit/assets/favicon.svg create mode 100644 assets/audit/assets/fonts/architype-stedelijk.ttf create mode 100644 assets/audit/assets/fonts/architype-stedelijk.woff2 create mode 100644 assets/audit/audit.jsx create mode 100644 assets/audit/poster-styles.css create mode 100644 assets/audit/poster.jsx create mode 100644 assets/audit/screenshots/poster-optimist.png create mode 100644 assets/audit/screenshots/poster-scrolled.png create mode 100644 assets/audit/styles.css create mode 100644 assets/audit/tweaks-panel.jsx create mode 100644 public/audit/fonts/architype-stedelijk.ttf create mode 100644 public/audit/fonts/architype-stedelijk.woff2 create mode 100644 src/audit/archetypes.ts create mode 100644 src/audit/dashboard-cache.ts create mode 100644 src/audit/findings.ts create mode 100644 src/audit/scoring.ts create mode 100644 src/audit/strengths.ts diff --git a/__tests__/audit/dashboard-cache.test.ts b/__tests__/audit/dashboard-cache.test.ts new file mode 100644 index 00000000..282d3fa1 --- /dev/null +++ b/__tests__/audit/dashboard-cache.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readDashboardCache, + writeDashboardCache, + isCacheStale, +} from "../../src/audit/dashboard-cache"; +import type { AuditResult } from "../../src/audit/types"; + +const FAKE_RESULT: AuditResult = { + version: 2, + scannedAt: "2026-05-26T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 5, skipped: 0, errors: 0, durationMs: 100 }, + results: [], + totals: { hits: 0, projectsWithHits: 0 }, + projectsScanned: ["/home/u/a", "/home/u/b"], + eventsScanned: 42, + enabledBuiltinNames: ["block-failproofai-commands"], +}; + +describe("dashboard cache", () => { + let tmpHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + // Redirect homedir() to a tmp directory by overriding HOME — os.homedir() + // reads it on every call on POSIX, so the dashboard-cache module sees + // our tmp path without needing module mocks. + tmpHome = mkdtempSync(join(tmpdir(), "fpa-audit-cache-test-")); + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it("returns null when no cache file exists", () => { + expect(readDashboardCache()).toBeNull(); + }); + + it("round-trips a written entry", () => { + writeDashboardCache({ since: "7d" }, FAKE_RESULT); + const entry = readDashboardCache(); + expect(entry).not.toBeNull(); + expect(entry?.params).toEqual({ since: "7d" }); + expect(entry?.result.transcripts.scanned).toBe(5); + expect(entry?.result.projectsScanned).toEqual(["/home/u/a", "/home/u/b"]); + expect(typeof entry?.cachedAt).toBe("string"); + }); + + it("writes mode 0600 on the file", () => { + writeDashboardCache({}, FAKE_RESULT); + const cachePath = join(tmpHome, ".failproofai", "audit-dashboard.json"); + expect(existsSync(cachePath)).toBe(true); + const mode = statSync(cachePath).mode & 0o777; + // Some filesystems (FAT, etc.) can't honor mode bits perfectly — just + // assert no world-readable bit is set. + expect(mode & 0o004).toBe(0); + }); + + it("returns null for a corrupt JSON cache file", () => { + const dir = join(tmpHome, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "audit-dashboard.json"), "{ not json", "utf-8"); + expect(readDashboardCache()).toBeNull(); + }); + + it("returns null when shape is wrong", () => { + const dir = join(tmpHome, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "audit-dashboard.json"), JSON.stringify({ foo: 1 }), "utf-8"); + expect(readDashboardCache()).toBeNull(); + }); + + it("isCacheStale returns true past the threshold", () => { + const old = new Date(Date.now() - 60 * 60_000).toISOString(); // 1 hour ago + expect(isCacheStale(old, 30)).toBe(true); + }); + + it("isCacheStale returns false within the threshold", () => { + const recent = new Date(Date.now() - 10 * 60_000).toISOString(); // 10 min ago + expect(isCacheStale(recent, 30)).toBe(false); + }); + + it("isCacheStale treats unparseable timestamps as stale", () => { + expect(isCacheStale("not-a-date")).toBe(true); + }); +}); diff --git a/__tests__/audit/replay.test.ts b/__tests__/audit/replay.test.ts index 18e7dfea..ca377b0b 100644 --- a/__tests__/audit/replay.test.ts +++ b/__tests__/audit/replay.test.ts @@ -1,6 +1,12 @@ // @vitest-environment node import { describe, it, expect, beforeEach } from "vitest"; -import { resetReplay, replayEvent } from "../../src/audit/replay"; +import { resetReplay, replayEvent, initReplay, restoreReplay } from "../../src/audit/replay"; +import { + clearPolicies, + getAllPolicies, + registerPolicy, +} from "../../src/hooks/policy-registry"; +import { allow } from "../../src/hooks/policy-helpers"; import type { NormalizedToolEvent } from "../../src/audit/types"; function bash(command: string): NormalizedToolEvent { @@ -50,3 +56,48 @@ describe("replay engine", () => { expect(hits.some((h) => h.eventType === "PostToolUse")).toBe(true); }); }); + +describe("replay registry snapshot/restore", () => { + beforeEach(() => { + resetReplay(); + clearPolicies(); + }); + + it("restoreReplay puts back the pre-init registry", () => { + registerPolicy( + "test/custom-marker", + "test policy", + async () => allow(), + { events: ["PreToolUse"] }, + ); + const before = getAllPolicies().map((p) => p.name).sort(); + expect(before).toContain("test/custom-marker"); + + initReplay(); + const duringInit = getAllPolicies().map((p) => p.name); + expect(duringInit).not.toContain("test/custom-marker"); + expect(duringInit.length).toBeGreaterThan(10); // builtins are loaded + + restoreReplay(); + const after = getAllPolicies().map((p) => p.name).sort(); + expect(after).toEqual(before); + }); + + it("restoreReplay is idempotent when called twice", () => { + registerPolicy( + "test/another-marker", + "test policy", + async () => allow(), + { events: ["PreToolUse"] }, + ); + initReplay(); + restoreReplay(); + restoreReplay(); // second call should be a no-op + expect(getAllPolicies().map((p) => p.name)).toContain("test/another-marker"); + }); + + it("restoreReplay before initReplay is a no-op", () => { + expect(() => restoreReplay()).not.toThrow(); + expect(getAllPolicies()).toEqual([]); + }); +}); diff --git a/app/actions/get-audit-result.ts b/app/actions/get-audit-result.ts new file mode 100644 index 00000000..4e8e6210 --- /dev/null +++ b/app/actions/get-audit-result.ts @@ -0,0 +1,24 @@ +"use server"; + +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import type { AuditResult, RunAuditOptions } from "@/src/audit/types"; + +export type AuditResultPayload = + | { status: "cached"; cachedAt: string; params: RunAuditOptions; result: AuditResult } + | { status: "empty" }; + +/** + * Read the dashboard cache. Never triggers a run — `/audit` shows the empty + * state when there's no cache and lets the user opt in to scanning. Mirrors + * the read-only ergonomics of `getHooksConfigAction()`. + */ +export async function getAuditResultAction(): Promise { + const entry = readDashboardCache(); + if (!entry) return { status: "empty" }; + return { + status: "cached", + cachedAt: entry.cachedAt, + params: entry.params, + result: entry.result, + }; +} diff --git a/app/api/audit/_state.ts b/app/api/audit/_state.ts new file mode 100644 index 00000000..d955de77 --- /dev/null +++ b/app/api/audit/_state.ts @@ -0,0 +1,40 @@ +/** + * Shared in-memory state between `/api/audit/run` and `/api/audit/status`. + * + * A single audit can take 10-30 seconds; the client UI needs to know whether + * one is in flight (to disable the re-run button and show a progress UI). + * Both API routes import the same module-level state from here so they + * agree on what "running" means. + * + * Caveat: Next.js dev mode HMR can reset module state mid-run; in that case + * the status endpoint will report `running: false` even though the original + * POST handler is still resolving. In production (`next start`/`bun start`) + * the singleton holds for the lifetime of the worker process. + */ +export interface RunState { + /** True while a `runAudit()` call is in flight. */ + running: boolean; + /** ms timestamp the current run was kicked off, if `running`. */ + startedAt?: number; +} + +const state: RunState = { running: false }; + +export function getRunState(): RunState { + return { ...state }; +} + +/** Atomically attempt to take the run lock. Returns true if the caller + * acquired it; false if a run is already in progress. */ +export function tryAcquireRun(): boolean { + if (state.running) return false; + state.running = true; + state.startedAt = Date.now(); + return true; +} + +/** Release the run lock. Safe to call even when not held. */ +export function releaseRun(): void { + state.running = false; + state.startedAt = undefined; +} diff --git a/app/api/audit/run/route.ts b/app/api/audit/run/route.ts new file mode 100644 index 00000000..a192b0fd --- /dev/null +++ b/app/api/audit/run/route.ts @@ -0,0 +1,78 @@ +/** + * POST /api/audit/run — kick off a `runAudit()` call and write the dashboard + * cache on success. Returns the full `AuditResult` in the response. + * + * Concurrency: a module-level singleton in `_state.ts` guards against + * overlapping runs — the second concurrent POST gets a 409. The client + * (rerun-button.tsx) then just falls back to polling /status. + */ +import { NextRequest, NextResponse } from "next/server"; +import { runAudit } from "@/src/audit"; +import { writeDashboardCache } from "@/src/audit/dashboard-cache"; +import { INTEGRATION_TYPES, type IntegrationType } from "@/src/hooks/types"; +import type { RunAuditOptions } from "@/src/audit/types"; +import { releaseRun, tryAcquireRun } from "../_state"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +interface RunBody { + since?: string; + cli?: string[]; + project?: string[]; + policy?: string[]; + noCache?: boolean; +} + +const VALID_CLIS = new Set(INTEGRATION_TYPES); + +function sanitize(body: RunBody): RunAuditOptions { + const opts: RunAuditOptions = {}; + if (typeof body.since === "string" && body.since.trim()) { + opts.since = body.since.trim(); + } + if (Array.isArray(body.cli) && body.cli.length > 0) { + const valid = body.cli.filter((c): c is IntegrationType => + typeof c === "string" && VALID_CLIS.has(c) + ); + if (valid.length > 0) opts.clis = valid; + } + if (Array.isArray(body.project) && body.project.length > 0) { + opts.projects = body.project.filter((p) => typeof p === "string"); + } + if (Array.isArray(body.policy) && body.policy.length > 0) { + opts.policies = body.policy.filter((p) => typeof p === "string"); + } + if (body.noCache === true) opts.noCache = true; + return opts; +} + +export async function POST(request: NextRequest): Promise { + let body: RunBody = {}; + try { + const raw = await request.text(); + if (raw) body = JSON.parse(raw) as RunBody; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const opts = sanitize(body); + + if (!tryAcquireRun()) { + return NextResponse.json( + { error: "Audit already running", status: "already-running" }, + { status: 409 }, + ); + } + + try { + const result = await runAudit(opts); + writeDashboardCache(opts, result); + return NextResponse.json({ status: "ok", result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message, status: "error" }, { status: 500 }); + } finally { + releaseRun(); + } +} diff --git a/app/api/audit/status/route.ts b/app/api/audit/status/route.ts new file mode 100644 index 00000000..7dfbacf0 --- /dev/null +++ b/app/api/audit/status/route.ts @@ -0,0 +1,23 @@ +/** + * GET /api/audit/status — lightweight poll endpoint. Client polls this at + * 1s while a run is in flight; switches off polling once `running: false`. + * + * Also returns the cache's `cachedAt` so the client can detect that a new + * result has landed (older `cachedAt` value in client → refetch via the + * server action). + */ +import { NextResponse } from "next/server"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import { getRunState } from "../_state"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + const state = getRunState(); + const cache = readDashboardCache(); + return NextResponse.json({ + running: state.running, + startedAt: state.startedAt ?? null, + cachedAt: cache?.cachedAt ?? null, + }); +} diff --git a/app/audit/_components/app-header.tsx b/app/audit/_components/app-header.tsx new file mode 100644 index 00000000..f476b953 --- /dev/null +++ b/app/audit/_components/app-header.tsx @@ -0,0 +1,37 @@ +"use client"; + +/** + * In-page chrome for /audit. Distinct from the site-wide Navbar (which + * is suppressed on /audit) — this one carries the failproof_ai wordmark + * + an [ share → ] action that scrolls to / triggers the ShowOff CTA. + * + * Styled via `.app-header`, `.h-brand`, etc. classes from audit-styles.css. + */ +import React from "react"; + +interface Props { + onShare?: () => void; + shareLabel?: string; +} + +export function AppHeader({ onShare, shareLabel = "[ share → ]" }: Props) { + return ( +
+ + ▮▮ + failproof_ai + / + audit + +
+ +
+
+ ); +} diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx new file mode 100644 index 00000000..0a9d151e --- /dev/null +++ b/app/audit/_components/audit-dashboard.tsx @@ -0,0 +1,270 @@ +"use client"; + +/** + * 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. + * + * Empty / running states fall back to the existing EmptyState and + * RunProgress components. + */ +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, syntheticRank } from "@/src/audit/scoring"; +import { deriveStrengths } from "@/src/audit/strengths"; +import { deriveFindings } from "@/src/audit/findings"; + +import { AppHeader } from "./app-header"; +import { IdentitySection } from "./identity-section"; +import { ShowOffCTA } from "./show-off-cta"; +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 { ReportFooter } from "./report-footer"; +import { EmptyState } from "./empty-state"; +import { RunProgress } from "./run-progress"; + +// IMPORTANT: do NOT import BUILTIN_POLICIES or AUDIT_DETECTORS here. +// Both pull in node:fs and execSync (workflow policies), which Next.js +// refuses to bundle for the client. The total catalog size is computed +// server-side in page.tsx and passed in as a plain number prop. + +type Initial = + | { status: "cached"; cachedAt: string; params: RunAuditOptions; result: AuditResult } + | { status: "empty" }; + +interface Props { + initial: Initial; + /** ?p=... URL param override for the project name in the leaderboard + * row. Defaults to whichever cwd has the most hits, falling back to + * "your agent". */ + projectFromUrl?: string; + /** Total number of detectors + builtin policies. Computed server-side + * in page.tsx — the modules can't ship to the client. */ + 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 + // most-active project". Falls back to "your agent". + const counts = new Map(); + for (const row of result.results) { + for (const ex of row.examples) { + if (!ex.cwd) continue; + counts.set(ex.cwd, (counts.get(ex.cwd) ?? 0) + 1); + } + } + let bestCwd = ""; + let bestCount = 0; + for (const [cwd, n] of counts) { + if (n > bestCount) { bestCwd = cwd; bestCount = n; } + } + if (!bestCwd) return "your agent"; + const segs = bestCwd.replace(/\/+$/, "").split(/[\\/]/); + // Use last two path segments — like "blrnow / api-coder". + if (segs.length >= 2) return `${segs[segs.length - 2]} / ${segs[segs.length - 1]}`; + return segs[segs.length - 1] ?? "your agent"; +} + +export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Props) { + const [cache, setCache] = useState(initial); + const [running, setRunning] = useState(false); + + const refreshFromCache = useCallback(async () => { + const payload = await getAuditResultAction(); + if (payload.status === "cached") setCache(payload); + }, []); + + // Body class for audit-only background + grain texture. Applied once on + // mount so the body bg switches from the global #0a0a0a to the audit + // #131316 only on this route. + useEffect(() => { + document.body.classList.add("audit-body"); + return () => document.body.classList.remove("audit-body"); + }, []); + + /* ---- empty / first-run ----------------------------------------- */ + if (cache.status === "empty" && !running) { + return ( + setRunning(true)} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + if (cache.status === "empty" && running) { + return ( + {}} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + + // cache.status === "cached" + const result = cache.status === "cached" ? cache.result : null; + if (!result) return null; + const cachedAt = cache.status === "cached" ? cache.cachedAt : null; + const params = cache.status === "cached" ? cache.params : undefined; + + /* ---- scanned but zero sessions --------------------------------- */ + if (result.transcripts.scanned === 0) { + return ( + setRunning(true)} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + + /* ---- in-flight re-run ------------------------------------------ */ + if (running) { + return ( +
+
+
+ +
+ +
+ +
+
+ ); + } + + /* ---- main report ----------------------------------------------- */ + return ( + + ); +} + +interface MainReportProps { + result: AuditResult; + cachedAt: string | null; + params: RunAuditOptions | undefined; + projectFromUrl?: string; + totalCatalogSize: number; +} + +function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize }: MainReportProps) { + const classification = useMemo(() => classifyAgent(result), [result]); + const score = useMemo(() => deriveScore(result), [result]); + const projected = useMemo(() => projectedScore(result, score), [result, score]); + const grade = gradeFor(score); + const projectedGrade = gradeFor(projected); + const rank = useMemo(() => syntheticRank(score), [score]); + const strengths = useMemo(() => deriveStrengths(result), [result]); + const findings = useMemo(() => deriveFindings(result), [result]); + const project = useMemo(() => inferProjectName(result, projectFromUrl), [result, projectFromUrl]); + const window = inferWindow(params); + + const detectorsTriggered = result.results.filter((r) => r.hits > 0).length; + + /** Identity hero ref — captured to PNG by the "make poster" button. */ + const identityFrameRef = useRef(null); + + /** Scroll to the ShowOff CTA — the share button entry point per spec. */ + const scrollToShowOff = () => { + const el = document.querySelector('[data-screen-label="01b Show off"]'); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + return ( +
+
+
+ +
+ + + + + + + +
+ +
+
+ ); +} + +interface ShellEmptyProps { + running: boolean; + mode?: "no-cache" | "zero-sessions"; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +function ShellEmpty({ running, mode = "no-cache", onStarted, onCompleted }: ShellEmptyProps) { + // Use the archetype "optimist" sigil for the empty-state visual so the + // page doesn't render with a dead box. EmptyState itself is unchanged + // from the previous build. + return ( +
+
+
+ +
+ {running ? ( + + ) : ( + + )} +
+ +
+
+ ); +} + diff --git a/app/audit/_components/empty-state.tsx b/app/audit/_components/empty-state.tsx new file mode 100644 index 00000000..c5d5466d --- /dev/null +++ b/app/audit/_components/empty-state.tsx @@ -0,0 +1,72 @@ +"use client"; + +/** + * Two-mode empty state: + * - "no-cache" — first time the user visits /audit. CTA to run. + * - "zero-sessions" — ran a scan but no transcripts were found. Likely the + * user hasn't installed hooks for any CLI yet. + */ +import React from "react"; +import { ClipboardCheck, FolderSearch } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { triggerRun } from "./rerun-button"; + +interface Props { + mode: "no-cache" | "zero-sessions"; + running: boolean; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +export function EmptyState({ mode, running, onStarted, onCompleted }: Props) { + const handleRun = async () => { + onStarted(); + try { + await triggerRun({ cli: [], since: "30d" }); + } finally { + await onCompleted(); + } + }; + + if (mode === "no-cache") { + return ( +
+
+ +
+

No audit data yet

+

+ Run your first audit to see how your agents have been behaving across all past sessions. +

+ +

+ Scans the last 30 days across every installed CLI. Takes 10–30 seconds. +

+
+ ); + } + + // mode === "zero-sessions" + return ( +
+
+ +
+

No sessions found

+

+ Failproof AI couldn't find any transcripts to scan. Install the hooks + for at least one CLI to start collecting sessions. +

+ + See the install guide → + +
+ ); +} diff --git a/app/audit/_components/findings-section.tsx b/app/audit/_components/findings-section.tsx new file mode 100644 index 00000000..266dacb1 --- /dev/null +++ b/app/audit/_components/findings-section.tsx @@ -0,0 +1,127 @@ +"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"; + +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 [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(f.fix.install); + setCopied(true); + 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/identity-section.tsx b/app/audit/_components/identity-section.tsx new file mode 100644 index 00000000..b193fad7 --- /dev/null +++ b/app/audit/_components/identity-section.tsx @@ -0,0 +1,112 @@ +"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`. + * + * Exposes a `frameRef` forwarded onto the `.archetype-frame` element so + * the ShowOff "make poster" action can capture it via html2canvas. + */ +import React, { forwardRef } from "react"; +import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; +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; +} + +export const IdentitySection = forwardRef(function IdentitySection( + { archetypeKey, secondaryKey, toolCalls, sessions, window }: Props, + frameRef, +) { + const archetype = ARCHETYPES[archetypeKey]; + 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/policies-section.tsx b/app/audit/_components/policies-section.tsx new file mode 100644 index 00000000..996b77a3 --- /dev/null +++ b/app/audit/_components/policies-section.tsx @@ -0,0 +1,184 @@ +"use client"; + +/** + * Section 05 — PRESCRIBED POLICIES. "enable these. close the gap." + * + * Grid of unenabled-builtin cards with install commands + projected + * score uplift callout. + * + * Sources two layers of "hits": + * 1. Unenabled builtin policies that fired on their own + * 2. Audit detectors → mapped via DETECTOR_TO_POLICY in findings.ts. + * The detector's hits get attributed to its primary policy so the + * report frames everything as failproofai-coverable. + * + * Same policy can collect hits from multiple sources; we sum them and + * render one card per policy. + */ +import React, { useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { type Grade, tierName } from "@/src/audit/scoring"; + +interface Props { + result: AuditResult; + projected: number; + projectedGrade: Grade; +} + +// Mirror of DETECTOR_TO_POLICY in findings.ts. Could re-export but keep +// the dependency tree shallow — both modules are stable. +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 — catches the loops before they spiral.", + "block-read-outside-cwd": "denies any file read whose absolute path falls 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 / disown processes that get forgotten about.", + "warn-git-amend": "warns before amending git commits — dangerous-commit-flag class.", + "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 PolicyCard { + name: string; // short slug + desc: string; // displayTitle (low-res) or impact + catches: string; // "would have caught X occurrences..." copy + hits: number; +} + +function buildPolicyCards(result: AuditResult): PolicyCard[] { + const enabledSet = new Set(result.enabledBuiltinNames ?? []); + // policyName → aggregated counts + const buckets = new Map }>(); + + for (const row of result.results) { + if (row.hits === 0) continue; + + let target: string; + let isFromDetector = false; + if (row.source === "audit-detector") { + const mapped = DETECTOR_TO_PRIMARY_POLICY[shortName(row.name)]; + if (!mapped) continue; + target = mapped; + isFromDetector = true; + } else if (row.source === "builtin" && !row.enabledInConfig) { + target = shortName(row.name); + } else { + continue; // already-enabled builtins don't need to be prescribed + } + + // Skip if the target policy is already in the user's enabled set + // (detector hits would land there in production already). + if (enabledSet.has(target)) continue; + + const bucket = buckets.get(target) ?? { hits: 0, projects: 0, sources: new Set() }; + bucket.hits += row.hits; + bucket.projects = Math.max(bucket.projects, row.projects); + bucket.sources.add(isFromDetector ? shortName(row.name) : "self"); + buckets.set(target, bucket); + } + + return [...buckets.entries()] + .sort((a, b) => b[1].hits - a[1].hits) + .map(([name, b]) => { + const viaList = [...b.sources].filter((s) => s !== "self"); + const viaCopy = viaList.length > 0 + ? ` (via ${viaList.join(", ")})` + : ""; + const catches = `would have caught ${b.hits} occurrence${b.hits === 1 ? "" : "s"} across ${b.projects} project${b.projects === 1 ? "" : "s"}${viaCopy}.`; + return { + name, + desc: POLICY_DESC[name] ?? "enable this builtin policy to close the gap.", + catches, + hits: b.hits, + }; + }); +} + +export function PoliciesSection({ result, projected, projectedGrade }: Props) { + const policies = buildPolicyCards(result); + + if (policies.length === 0) return null; + + return ( +
+
+
+ ━━ policies{" "} + · prescribed +
+
+ {policies.length} polic{policies.length === 1 ? "y" : "ies"}{" "} + ·{" "} + covers your slipping-through hits +
+
+

enable these. close the gap.

+ +
+ + enable all {policies.length === 1 ? "one" : policies.length} + + + projected score + {projected} + · + {tierName(projectedGrade)} +
+ +
+ {policies.map((p, i) => ( + + ))} +
+
+ ); +} + +function PolicyTile({ policy, idx }: { policy: PolicyCard; idx: number }) { + const [copied, setCopied] = useState(false); + const install = `failproof policy add ${policy.name}`; + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(install); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+
{policy.name}
+
№{String(idx + 1).padStart(2, "0")}
+
+
{policy.desc}
+
+ {policy.catches} +
+
+ $ + {install} + + {copied ? "copied" : "copy"} + +
+
+ ); +} diff --git a/app/audit/_components/report-footer.tsx b/app/audit/_components/report-footer.tsx new file mode 100644 index 00000000..649380d5 --- /dev/null +++ b/app/audit/_components/report-footer.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; + +interface Props { + cachedAt: string | null; +} + +function formatUtcShort(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + const day = d.getUTCDate().toString().padStart(2, "0"); + const monthNames = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + const m = monthNames[d.getUTCMonth()]; + const y = d.getUTCFullYear(); + const hh = d.getUTCHours().toString().padStart(2, "0"); + const mm = d.getUTCMinutes().toString().padStart(2, "0"); + return `${day} ${m} ${y}, ${hh}:${mm} utc`; +} + +export function ReportFooter({ cachedAt }: Props) { + return ( +
+ ▮▮ failproof_ai + · + audit v1.0 + · + generated {formatUtcShort(cachedAt)} + · + auto-healing for your agents. +
+ ); +} diff --git a/app/audit/_components/rerun-button.tsx b/app/audit/_components/rerun-button.tsx new file mode 100644 index 00000000..0978355d --- /dev/null +++ b/app/audit/_components/rerun-button.tsx @@ -0,0 +1,107 @@ +"use client"; + +/** + * Re-run button + polling. Click: + * 1. POSTs /api/audit/run with the current scan params + * 2. Polls /api/audit/status every 1s + * 3. When `running` flips false, the parent refetches the cache + * + * On 409 (audit already running) we just start polling without re-posting — + * lets the user "join" an in-flight run that someone else (or a previous + * tab) kicked off. + * + * Exports `triggerRun` separately so the empty-state CTA reuses the same + * fetch logic without re-implementing. + */ +import React from "react"; +import { RotateCw } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface ScanParams { + /** Empty array = all CLIs. */ + cli: string[]; + /** "7d" | "30d" | "90d" | "all" (or any value accepted by parseSinceOpt). */ + since: string; +} + +interface Props { + scanParams: ScanParams; + running: boolean; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +const POLL_INTERVAL_MS = 1000; +const MAX_POLL_MS = 5 * 60_000; // 5 min hard cap + +function paramsToBody(p: ScanParams) { + return { + cli: p.cli.length > 0 ? p.cli : undefined, + since: p.since === "all" ? undefined : p.since, + }; +} + +/** Fire a run and resolve once the server reports it finished. Used both by + * this button and by the EmptyState's "Run audit" CTA. */ +export async function triggerRun(scanParams: ScanParams): Promise { + // Kick off the run. 409 (already running) is OK — we'll just poll. + try { + const res = await fetch("/api/audit/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(paramsToBody(scanParams)), + }); + if (!res.ok && res.status !== 409) { + // Surface the error message but don't throw — the caller's finally + // still runs and the UI returns to its previous state. + const text = await res.text().catch(() => ""); + console.error("audit run failed:", res.status, text); + return; + } + } catch (err) { + console.error("audit run request failed:", err); + return; + } + + // Poll status until running flips false. + const startedAt = Date.now(); + while (Date.now() - startedAt < MAX_POLL_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const sres = await fetch("/api/audit/status", { cache: "no-store" }); + if (!sres.ok) continue; + const s = await sres.json() as { running: boolean }; + if (!s.running) return; + } catch { + // Transient — keep polling. + } + } +} + +export function RerunButton({ scanParams, running, onStarted, onCompleted }: Props) { + const handle = async () => { + onStarted(); + try { + await triggerRun(scanParams); + } finally { + await onCompleted(); + } + }; + + return ( + + ); +} diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx new file mode 100644 index 00000000..861dae59 --- /dev/null +++ b/app/audit/_components/return-section.tsx @@ -0,0 +1,76 @@ +"use client"; + +/** + * Section 06 — NEXT AUDIT / "come back better." Re-audit loop CTA. + * + * Two actions: [ set a reminder ] (placeholder, future feature) and + * [ install all N policies ] which copies the bulk install command. + */ +import React, { useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; + +interface Props { + result: AuditResult; +} + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +export function ReturnSection({ result }: Props) { + const unenabledShortNames = result.results + .filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0) + .map((r) => shortName(r.name)); + + const [copied, setCopied] = useState(false); + + const bulkInstall = unenabledShortNames.length > 0 + ? `failproofai policies --install ${unenabledShortNames.join(" ")}` + : null; + + const handleInstall = async () => { + if (!bulkInstall) return; + try { + await navigator.clipboard.writeText(bulkInstall); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+
+ ━━ next audit{" "} + · improvement +
+
recommended in 7d
+
+

come back better.

+
+
━━ the loop
+

re-audit in 7 days.

+

+ after the prescribed policies have been live for a week, we'll + show your before/after score and which detectors went quiet. +

+

+ most agents move from C to B in one session. some make it in a day. +

+
+ + {bulkInstall && ( + + )} +
+
+
+ ); +} diff --git a/app/audit/_components/run-progress.tsx b/app/audit/_components/run-progress.tsx new file mode 100644 index 00000000..db4ca5e9 --- /dev/null +++ b/app/audit/_components/run-progress.tsx @@ -0,0 +1,75 @@ +"use client"; + +/** + * Fake-progress UI shown while /api/audit/run is in flight. runAudit() does + * not emit granular progress events, so we animate through 4 plausible + * stages on a fixed 4s interval. The user sees motion + a clear "this is + * still working" signal. + */ +import React, { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +const STAGES = [ + { label: "Discovering transcripts", detail: "Walking ~/.claude, ~/.codex, ~/.cursor, …" }, + { label: "Parsing session logs", detail: "Reading JSONL + SQLite session stores" }, + { label: "Running policy checks", detail: "Replaying through 30 builtin policies" }, + { label: "Aggregating results", detail: "Counting hits, ranking by frequency" }, +]; + +const STAGE_DURATION_MS = 4000; + +export function RunProgress() { + const [stage, setStage] = useState(0); + + useEffect(() => { + const id = setInterval(() => { + setStage((s) => Math.min(s + 1, STAGES.length - 1)); + }, STAGE_DURATION_MS); + return () => clearInterval(id); + }, []); + + return ( +
+
+
+

Scanning sessions…

+
+
    + {STAGES.map((s, i) => { + const done = i < stage; + const active = i === stage; + return ( +
  • + +
    +
    + {s.label} +
    + {active && ( +
    {s.detail}
    + )} +
    +
  • + ); + })} +
+

+ This usually takes 10–30 seconds depending on session history. +

+
+ ); +} diff --git a/app/audit/_components/score-section.tsx b/app/audit/_components/score-section.tsx new file mode 100644 index 00000000..104be09c --- /dev/null +++ b/app/audit/_components/score-section.tsx @@ -0,0 +1,254 @@ +"use client"; + +/** + * Section 03 — SCORE + LEADERBOARD. + * + * Two-column grid: a score card on the left (big grade letter + "X of 100" + * + prose + distribution histogram) and a synthetic-but-stable leaderboard + * on the right with the user's row highlighted in pink. + * + * The leaderboard rows other than yours are seeded from a fixed list of + * plausible agent names so the page doesn't look empty in a fresh + * install. Real cohort data lands later when we wire telemetry. + */ +import React, { useMemo } from "react"; +import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; +import { gradeFor, tierName, type Grade } from "@/src/audit/scoring"; + +interface Props { + score: number; + grade: Grade; + rank: number; + cohort: number; + archetypeKey: ArchetypeKey; + /** Display name in the highlighted leaderboard row. */ + project: string; +} + +interface DistBucket { h: number; you: boolean; label: string; } +interface LeaderboardRow { + rank?: number; + name?: string; + arch?: string; + grade?: Grade; + score?: number; + you?: boolean; + divider?: boolean; +} + +const TOP_AGENTS: { name: string; arch: string }[] = [ + { name: "anthropic / claude-code-internal", arch: "the precision builder" }, + { name: "openai / gpt-engineer-pro", arch: "the precision builder" }, + { name: "vercel / v0-coder-v3", arch: "the ghost" }, + { name: "supabase / db-migrator", arch: "the paranoid architect" }, + { name: "stripe / payments-bot", arch: "the paranoid architect" }, +]; + +const NEAR_AGENTS_ABOVE: { name: string; arch: string }[] = [ + { name: "indie / weekend-coder-42", arch: "the cowboy" }, + { name: "n8n / workflow-agent", arch: "the optimist" }, +]; +const NEAR_AGENTS_BELOW: { name: string; arch: string }[] = [ + { name: "acme / scratch-pad", arch: "the hammer" }, + { name: "side-quest / cli-tool", arch: "the goldfish" }, +]; + +export function ScoreSection({ + score, grade, rank, cohort, archetypeKey, project, +}: Props) { + const archetype = ARCHETYPES[archetypeKey]; + const pointsToB = Math.max(0, 71 - score); + const distBars = useMemo(() => buildDistribution(score), [score]); + const rows = useMemo( + () => buildLeaderboard(rank, score, project, archetype.name), + [rank, score, project, archetype.name], + ); + + return ( +
+
+
+ ━━ leaderboard{" "} + · cohort +
+
+ {cohort.toLocaleString()}{" "} + agents + · + last 30 days +
+
+

you rank #{rank.toLocaleString()}.

+ +
+
+
+
{grade}
+
+
{tierName(grade)}
+
{score}
+
of 100
+
+
+ + {pointsToB > 0 ? ( +

+ a B starts at 71.{" "} + you're {pointsToB} points away. +
+ enable the prescribed policies and you'll get there this week. +

+ ) : grade === "S" ? ( +

+ s tier. few make it here. fewer stay. +
+ keep the policies live. revisit in 30 days. +

+ ) : ( +

+ {tierName(grade)}.{" "} + better than {Math.round((1 - rank / cohort) * 100)}% of audited agents. +
+ clean up the findings below to climb. +

+ )} + +
+
+ distribution · last 30d + ▮ = your position +
+
+ {distBars.map((b, i) => ( +
+ ))} +
+
+ F + D + C + B + A + S +
+
+
+ +
+
+
rank
+
agent
+
grade
+
score
+
+ {rows.map((r, i) => + r.divider ? ( +
+ · · · +
+ ) : ( +
+
#{r.rank!.toLocaleString()}
+
+
+ {r.name} + {r.you && (you)} +
+
{r.arch}
+
+
{r.grade}
+
{r.score}
+
+ ), + )} +
+
+
+ ); +} + +function buildDistribution(yourScore: number): DistBucket[] { + // 20 buckets, 5pts each, 0-100. Bell-ish centered at 60. + const buckets: DistBucket[] = []; + for (let i = 0; i < 20; i++) { + const center = i * 5 + 2.5; + const dist = Math.abs(center - 60); + const h = Math.max(8, 100 - dist * 2.2 + Math.sin(i * 1.3) * 6); + const you = yourScore >= i * 5 && yourScore < (i + 1) * 5; + buckets.push({ h, you, label: `${i * 5}-${(i + 1) * 5}` }); + } + return buckets; +} + +function buildLeaderboard( + yourRank: number, + yourScore: number, + yourProject: string, + yourArchetypeName: string, +): LeaderboardRow[] { + const rows: LeaderboardRow[] = []; + + // Top 5 (synthetic but stable). + rows.push({ rank: 1, name: TOP_AGENTS[0].name, arch: TOP_AGENTS[0].arch, grade: "S", score: 97 }); + rows.push({ rank: 2, name: TOP_AGENTS[1].name, arch: TOP_AGENTS[1].arch, grade: "S", score: 93 }); + rows.push({ rank: 3, name: TOP_AGENTS[2].name, arch: TOP_AGENTS[2].arch, grade: "A", score: 89 }); + rows.push({ rank: 4, name: TOP_AGENTS[3].name, arch: TOP_AGENTS[3].arch, grade: "A", score: 86 }); + rows.push({ rank: 5, name: TOP_AGENTS[4].name, arch: TOP_AGENTS[4].arch, grade: "A", score: 82 }); + + // Skip to the user's neighborhood unless their rank is already in the + // top 5 (then collapse the divider). + if (yourRank > 7) rows.push({ divider: true }); + + // Two ranked just above the user. + if (yourRank > 2) { + rows.push({ + rank: yourRank - 2, + name: NEAR_AGENTS_ABOVE[0].name, + arch: NEAR_AGENTS_ABOVE[0].arch, + grade: gradeFor(yourScore + 2), + score: yourScore + 2, + }); + } + if (yourRank > 1) { + rows.push({ + rank: yourRank - 1, + name: NEAR_AGENTS_ABOVE[1].name, + arch: NEAR_AGENTS_ABOVE[1].arch, + grade: gradeFor(yourScore + 1), + score: yourScore + 1, + }); + } + + // The user. + rows.push({ + rank: yourRank, + name: yourProject, + arch: yourArchetypeName, + grade: gradeFor(yourScore), + score: yourScore, + you: true, + }); + + // Two below. + rows.push({ + rank: yourRank + 1, + name: NEAR_AGENTS_BELOW[0].name, + arch: NEAR_AGENTS_BELOW[0].arch, + grade: gradeFor(Math.max(0, yourScore - 1)), + score: Math.max(0, yourScore - 1), + }); + rows.push({ + rank: yourRank + 2, + name: NEAR_AGENTS_BELOW[1].name, + arch: NEAR_AGENTS_BELOW[1].arch, + grade: gradeFor(Math.max(0, yourScore - 2)), + score: Math.max(0, yourScore - 2), + }); + + return rows; +} diff --git a/app/audit/_components/show-off-cta.tsx b/app/audit/_components/show-off-cta.tsx new file mode 100644 index 00000000..ebd8a6d3 --- /dev/null +++ b/app/audit/_components/show-off-cta.tsx @@ -0,0 +1,100 @@ +"use client"; + +/** + * Section 01b — SHOW OFF CTA. Big bordered strip directly after the + * identity card. Sigil on the left, "show off your agent." headline + + * sub on the middle, "→ MAKE POSTER" action button on the right. + * + * Clicking the action captures the IdentitySection's archetype-frame + * DOM via html2canvas and triggers a PNG download. The capture target + * is passed in via a ref (avoids querying the DOM by class). + */ +import React, { useState } from "react"; +import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; +import { Sigil } from "./sigil"; + +interface Props { + archetypeKey: ArchetypeKey; + /** Ref to the IdentitySection's `.archetype-frame` div — captured to PNG. */ + identityFrameRef: React.RefObject; +} + +function buildFilename(archetypeKey: ArchetypeKey): string { + const date = new Date().toISOString().slice(0, 10); + return `failproofai-${archetypeKey}-${date}.png`; +} + +export function ShowOffCTA({ archetypeKey, identityFrameRef }: Props) { + const archetype = ARCHETYPES[archetypeKey]; + const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle"); + + const handleMakePoster = async () => { + const node = identityFrameRef.current; + if (!node || state === "busy") return; + setState("busy"); + try { + const html2canvas = (await import("html2canvas")).default; + const canvas = await html2canvas(node, { + // Match the audit canvas color so any rounding artifacts blend in. + backgroundColor: "#131316", + scale: 2, // retina-grade PNG for sharing + logging: false, + useCORS: true, + }); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { resolve(); return; } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = buildFilename(archetypeKey); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resolve(); + }, "image/png"); + }); + setState("done"); + setTimeout(() => setState("idle"), 2000); + } catch (err) { + console.error("poster capture failed:", err); + setState("error"); + setTimeout(() => setState("idle"), 2000); + } + }; + + const actionLabel = + state === "busy" ? "rendering…" + : state === "done" ? "downloaded ✓" + : state === "error" ? "render failed" + : "make poster"; + + return ( +
+ +
+ ); +} diff --git a/app/audit/_components/sigil.tsx b/app/audit/_components/sigil.tsx new file mode 100644 index 00000000..0dc23b18 --- /dev/null +++ b/app/audit/_components/sigil.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * Pixel sigil — renders an 8x8 grid from the SIGILS table. + * + * Each archetype has an 8x8 character grid where: + * . = empty cell o = ink (foreground) + * p = pink accent g = green accent d = dim + * + * Wrapped in the `.sigil-wrap` / `.sigil` / `.sigil-label` CSS classes + * from the ported audit-styles.css. The `hideLabel` prop is used when the + * sigil appears inside the ShowOff CTA, which hides the "№ 0X SIGIL" caption. + */ +import React from "react"; +import { ARCHETYPES, SIGILS, type ArchetypeKey } from "@/src/audit/archetypes"; + +interface Props { + archetypeKey: ArchetypeKey; + hideLabel?: boolean; +} + +export function Sigil({ archetypeKey, hideLabel }: Props) { + const grid = SIGILS[archetypeKey] ?? SIGILS.optimist; + const archetype = ARCHETYPES[archetypeKey]; + const cells: React.ReactElement[] = []; + + for (let y = 0; y < 8; y++) { + const row = grid[y] ?? "........"; + for (let x = 0; x < 8; x++) { + const c = row[x] ?? "."; + let cls = "px"; + if (c === "o") cls += " on"; + else if (c === "p") cls += " p"; + else if (c === "g") cls += " g"; + else if (c === "d") cls += " d"; + cells.push(
); + } + } + + return ( +
+
{cells}
+ {!hideLabel && ( +
+ №{archetype.index} + sigil +
+ )} +
+ ); +} diff --git a/app/audit/_components/strengths-section.tsx b/app/audit/_components/strengths-section.tsx new file mode 100644 index 00000000..d80d8371 --- /dev/null +++ b/app/audit/_components/strengths-section.tsx @@ -0,0 +1,57 @@ +"use client"; + +/** + * Section 02 — STRENGTHS. "your agent does this right." A leaderboard + * of green-checked behaviors derived from the AuditResult (see + * `src/audit/strengths.ts`). + */ +import React from "react"; +import type { Strength } from "@/src/audit/strengths"; + +interface Props { + strengths: Strength[]; + totalDetectorsTriggered: number; + totalDetectorsAvailable: number; +} + +export function StrengthsSection({ + strengths, totalDetectorsTriggered, totalDetectorsAvailable, +}: Props) { + if (strengths.length === 0) return null; + + return ( +
+
+
+ ━━ strengths + {" "}·{" "} + what your agent has figured out +
+
+ {" "} + {totalDetectorsAvailable - totalDetectorsTriggered} of {totalDetectorsAvailable} clean +
+
+

your agent does this right.

+ +
+ {strengths.map((s, i) => ( +
+
+
+
{s.headline}
+
{s.detail}
+
+
+ {s.metric} + {s.unit} +
+
+ ))} +
+
+ — these are your agent's defaults. keep them. +
+
+ ); +} diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css new file mode 100644 index 00000000..086e619e --- /dev/null +++ b/app/audit/audit-styles.css @@ -0,0 +1,1229 @@ +/* ============================================================ + failproof_ai — audit report styles + Ported from assets/audit/styles.css. Brutalist pixel-craft. + Loaded only on the /audit route (imported from page.tsx). + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=VT323&display=swap'); + +@font-face { + font-family: 'Architype Stedelijk'; + src: url('/audit/fonts/architype-stedelijk.woff2') format('woff2'), + url('/audit/fonts/architype-stedelijk.ttf') format('truetype'); + font-display: swap; + font-weight: 400; + font-style: normal; +} + +:root { + --bg: #131316; + --bg-2: #0e0e11; + --bg-3: #1a1a1f; + --bg-row-hover: #17171c; + --ink: #d8d6d2; + --ink-2: #9a9892; + --dim: #5e5c58; + --line: #25252b; + --line-2: #32323a; + --accent-pink: #e4587d; + --accent-pink-soft: rgba(228, 88, 125, 0.7); + --accent-pink-shadow: #a83a5a; + --accent-pink-bg: rgba(228, 88, 125, 0.12); + --accent-green: #66d1b5; + --accent-green-shadow: #3e9a82; + --accent-green-bg: rgba(102, 209, 181, 0.10); + --amber: #e8c46a; + --amber-bg: rgba(232, 196, 106, 0.10); + + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; +} + +* { box-sizing: border-box; } + +html, body, #root { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +body { + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), + radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), + linear-gradient(180deg, #16161a 0%, #0f0f12 100%); + background-attachment: fixed; +} + +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; padding: 0; } +a { color: inherit; text-decoration: none; } + +/* engineering-plate cross-hatch + grain + scanlines */ +.app::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; + background-image: + linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); + background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; + opacity: 0.7; +} +.app::after { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.5; + mix-blend-mode: overlay; +} +.scanline-overlay { + position: fixed; inset: 0; pointer-events: none; z-index: 9999; + background: repeating-linear-gradient(to bottom, + rgba(255,255,255,0) 0, rgba(255,255,255,0) 2px, + rgba(255,255,255,0.018) 2px, rgba(255,255,255,0.018) 3px); + mix-blend-mode: overlay; +} + +.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } + +/* ───────────────────────── app header (in-product chrome) ───────────────────────── */ + +.app-header { + display: flex; align-items: center; gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.h-brand { + display: inline-flex; align-items: baseline; gap: 10px; + flex: 1; min-width: 0; + color: var(--ink); text-decoration: none; +} +.h-brand-mark { + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: -3px; + font-weight: 700; + line-height: 1; +} +.h-brand-name { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.h-brand-sep { color: var(--dim); font-size: 12px; } +.h-brand-section { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.h-actions { display: flex; align-items: center; gap: 8px; } +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + border: 1px solid var(--line-2); background: transparent; color: var(--ink); + transition: all 120ms ease; white-space: nowrap; +} +.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } +.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } +.btn-press { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transition: box-shadow 120ms, transform 120ms; +} +.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } + +/* tabs */ +.tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid var(--line); + overflow-x: auto; scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + color: var(--ink-2); + border-bottom: 1px solid transparent; margin-bottom: -1px; + transition: color 120ms, border-color 120ms; white-space: nowrap; +} +.tab:hover { color: var(--ink); } +.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } + +/* ───────────────────────── audit page shell ───────────────────────── */ + +.report { + max-width: 1180px; + margin: 0 auto; + padding: 0 32px; +} + +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } + +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 28px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} + +/* ───────────────────────── 01 IDENTITY (the hero moment) ───────────────────────── */ + +.identity { + padding: 80px 0 96px; + position: relative; +} + +.archetype-frame { + position: relative; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 56px 56px 48px; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.archetype-frame .corner { + position: absolute; font-family: var(--font-mono); font-size: 11px; + color: var(--accent-pink); opacity: 0.6; letter-spacing: 0.1em; +} +.archetype-frame .corner.tl { top: 8px; left: 12px; } +.archetype-frame .corner.tr { top: 8px; right: 12px; } +.archetype-frame .corner.bl { bottom: 8px; left: 12px; } +.archetype-frame .corner.br { bottom: 8px; right: 12px; } + +.arch-mast { + display: flex; align-items: center; justify-content: space-between; + gap: 24px; margin-bottom: 32px; + border-bottom: 1px dashed var(--line); + padding-bottom: 22px; + flex-wrap: wrap; +} +.arch-mast-left { + display: flex; flex-direction: column; gap: 8px; +} +.arch-eyebrow { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.arch-eyebrow .ix { color: var(--accent-pink); } +.arch-target { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.05em; +} +.arch-target .slash { color: var(--dim); margin: 0 6px; } +.arch-target .live { + margin-left: 10px; color: var(--accent-green); + font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; + display: inline-flex; align-items: center; gap: 6px; +} +.arch-target .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} +.arch-counter { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); + text-align: right; +} +.arch-counter .of { color: var(--ink-2); } + +.arch-body { + display: grid; + grid-template-columns: 1.7fr 1fr; + gap: 56px; + align-items: center; +} + +.arch-name { + font-family: var(--font-display); + font-size: clamp(56px, 10vw, 124px); + line-height: 0.95; + letter-spacing: 0.08em; + margin: 0 0 16px; + text-transform: lowercase; + color: var(--ink); + text-wrap: balance; + /* hard-offset stamp */ + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.arch-tagline { + font-family: var(--font-mono); font-size: 16px; + line-height: 1.5; color: var(--ink-2); + max-width: 580px; margin: 0 0 28px; + text-wrap: pretty; +} +.arch-desc { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink); + max-width: 580px; + margin: 0 0 28px; + text-wrap: pretty; +} + +.arch-secondary { + display: inline-flex; align-items: center; gap: 10px; + padding: 6px 12px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 24px; +} +.arch-secondary .with { color: var(--dim); } +.arch-secondary .name { color: var(--accent-pink); } + +/* keyword strip — replaces the wordy description */ +.arch-keywords { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 18px 0 4px; + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.arch-keywords .kw { + color: var(--ink); +} +.arch-keywords .kw:nth-child(1) { color: var(--accent-green); } +.arch-keywords .kw:nth-child(3) { color: var(--ink); } +.arch-keywords .kw:nth-child(5) { color: var(--accent-pink); } +.arch-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} + +.signature-block { + background: var(--bg); + border: 1px solid var(--line); + padding: 18px 20px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.75; + white-space: pre; + overflow-x: auto; + max-width: 580px; + position: relative; +} +.signature-block::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.signature-block::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 8px; height: 8px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.sig-line { color: var(--ink); display: block; } +.sig-line .arrow { color: var(--accent-green); margin-right: 6px; } +.sig-line .comment { color: var(--dim); } +.sig-line .err { color: var(--accent-pink); } + +.arch-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 28px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.arch-meta-item .label { + display: block; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 8px; +} +.arch-meta-item .label.p { color: var(--accent-pink); } +.arch-meta-item .body { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.55; +} + +.arch-closing { + margin-top: 28px; + font-family: var(--font-display); + font-size: 22px; letter-spacing: 0.11em; + color: var(--accent-pink); + text-transform: lowercase; + border-top: 1px dashed var(--line); + padding-top: 22px; +} + +/* sigil — 8x8 pixel grid */ +.sigil-wrap { + display: flex; flex-direction: column; align-items: center; gap: 16px; + justify-self: center; +} +.sigil { + display: grid; + grid-template-columns: repeat(8, 16px); + grid-template-rows: repeat(8, 16px); + gap: 2px; + padding: 16px; + background: var(--bg); + border: 1px solid var(--line-2); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.sigil .px { background: transparent; } +.sigil .px.on { background: var(--ink); } +.sigil .px.p { background: var(--accent-pink); } +.sigil .px.g { background: var(--accent-green); } +.sigil .px.d { background: var(--dim); } +.sigil-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.sigil-label .ix { color: var(--accent-pink); margin-right: 6px; } + +/* ───────────────────────── 02 STRENGTHS ───────────────────────── */ + +.strengths-grid { + display: grid; gap: 0; + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.strength-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: start; + gap: 16px; + padding: 22px 24px; + border-bottom: 1px solid var(--line); + transition: background 120ms; +} +.strength-row:last-child { border-bottom: none; } +.strength-row:hover { background: rgba(102, 209, 181, 0.03); } +.strength-check { + width: 32px; height: 32px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); + display: grid; place-items: center; + font-family: var(--font-mono); font-weight: 600; + font-size: 14px; +} +.strength-body { + display: flex; flex-direction: column; gap: 6px; +} +.strength-headline { + font-family: var(--font-mono); font-size: 14px; + color: var(--ink); letter-spacing: 0.01em; + font-weight: 500; +} +.strength-detail { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.01em; + line-height: 1.55; +} +.strength-metric { + font-family: var(--font-display); + font-size: 26px; letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--accent-green); + text-align: right; + line-height: 1; + white-space: nowrap; +} +.strength-metric .unit { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; color: var(--dim); + display: block; margin-top: 4px; +} +.strengths-footer { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.02em; + margin-top: 18px; + padding-left: 4px; +} + +/* ───────────────────────── 03 SCORE + LEADERBOARD ───────────────────────── */ + +.score-grid { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 28px; + align-items: start; +} + +.score-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 28px; + position: relative; +} +.score-card::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.score-card::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.score-grade-row { + display: flex; align-items: baseline; gap: 24px; + padding-bottom: 20px; + border-bottom: 1px dashed var(--line); + margin-bottom: 22px; +} +.score-grade { + font-family: var(--font-display); + font-size: clamp(96px, 14vw, 168px); + line-height: 0.85; + letter-spacing: 0.02em; + color: var(--accent-pink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); + text-transform: uppercase; +} +.score-grade.g-S { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } +.score-grade.g-C { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-D { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-F { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } + +.score-num { + display: flex; flex-direction: column; gap: 6px; +} +.score-num .n { + font-family: var(--font-display); font-size: 48px; + letter-spacing: 0.08em; line-height: 1; color: var(--ink); +} +.score-num .of { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); +} +.score-num .tier { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent-pink); +} + +.score-prose { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink); line-height: 1.7; + margin-bottom: 24px; +} +.score-prose .hl { color: var(--accent-green); } +.score-prose .pk { color: var(--accent-pink); } + +/* distribution chart */ +.dist { + margin-top: 8px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.dist-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); margin-bottom: 14px; + display: flex; justify-content: space-between; +} +.dist-label .right { color: var(--dim); } +.dist-chart { + display: grid; + grid-template-columns: repeat(20, 1fr); + align-items: end; + gap: 3px; + height: 80px; + margin-bottom: 6px; +} +.dist-bar { + background: var(--bg-3); + border: 1px solid var(--line); + border-bottom: none; + position: relative; +} +.dist-bar.you { + background: var(--accent-pink); + border-color: var(--accent-pink); +} +.dist-bar.you::after { + content: "you"; + position: absolute; bottom: 100%; left: 50%; + transform: translateX(-50%); margin-bottom: 6px; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-pink); white-space: nowrap; +} +.dist-axis { + display: grid; + grid-template-columns: repeat(6, 1fr); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); + margin-top: 12px; + border-top: 1px solid var(--line); + padding-top: 6px; +} +.dist-axis span { text-align: center; } +.dist-axis span.now { color: var(--accent-pink); } + +/* leaderboard */ +.lb { + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.lb-head { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.2); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--ink-2); +} +.lb-row { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + font-family: var(--font-mono); font-size: 12px; + color: var(--ink); align-items: center; + transition: background 120ms; +} +.lb-row:last-child { border-bottom: none; } +.lb-row:hover { background: var(--bg-row-hover); } +.lb-row.you { + background: var(--accent-pink-bg); + border-top: 1px solid var(--accent-pink); + border-bottom: 1px solid var(--accent-pink); +} +.lb-row.you .lb-rank, +.lb-row.you .lb-score { color: var(--accent-pink); } +.lb-row.divider { padding: 4px 18px; color: var(--dim); font-size: 10px; letter-spacing: 0.3em; text-align: center; } +.lb-row.divider span { display: block; } +.lb-rank { color: var(--ink-2); letter-spacing: 0.05em; } +.lb-agent { + display: flex; flex-direction: column; gap: 2px; min-width: 0; + overflow: hidden; +} +.lb-agent .name { color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lb-agent .arch { + font-size: 10px; letter-spacing: 0.05em; color: var(--dim); +} +.lb-agent .you-mark { color: var(--accent-pink); margin-left: 6px; } +.lb-grade { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.05em; text-align: center; + text-transform: uppercase; +} +.lb-grade.g-S, .lb-grade.g-A { color: var(--accent-green); } +.lb-grade.g-B { color: #d3e1a8; } +.lb-grade.g-C, .lb-grade.g-D, .lb-grade.g-F { color: var(--accent-pink); } +.lb-score { text-align: right; color: var(--ink); } + +/* ───────────────────────── 04 FINDINGS ───────────────────────── */ + +.findings-list { display: flex; flex-direction: column; gap: 20px; } +.finding { + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.finding::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.finding-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 18px; align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} +.finding-num { + font-family: var(--font-mono); font-size: 13px; + color: var(--accent-pink); letter-spacing: 0.12em; + font-weight: 600; +} +.finding-title { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.finding-count { + font-family: var(--font-display); font-size: 36px; + letter-spacing: 0.04em; color: var(--accent-pink); + text-transform: lowercase; line-height: 1; + display: flex; align-items: baseline; gap: 6px; +} +.finding-count .label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.finding-meta { + display: flex; gap: 16px; flex-wrap: wrap; + padding: 12px 24px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--ink-2); + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.15); +} +.finding-meta .policy { color: var(--accent-green); } +.finding-meta .sep { color: var(--dim); } +.finding-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} +.finding-block { + padding: 22px 24px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; +} +.finding-block:nth-child(2n) { border-right: none; } +.finding-block:nth-last-child(-n+2) { border-bottom: none; } +.fb-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.fb-label.cost { color: var(--amber); } +.fb-label.fix { color: var(--accent-pink); } +.fb-body { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); +} +.fb-body .pk { color: var(--accent-pink); } +.fb-body .g { color: var(--accent-green); } +.fb-body .a { color: var(--amber); } +.fb-body code { + background: var(--bg); border: 1px solid var(--line); + padding: 1px 6px; color: var(--accent-green); font-size: 12px; +} + +.fb-evidence { + font-family: var(--font-mono); font-size: 12px; + background: var(--bg); border: 1px solid var(--line); + padding: 12px 14px; + white-space: pre; overflow-x: auto; + color: var(--ink); line-height: 1.65; +} +.fb-evidence .arrow { color: var(--accent-green); } +.fb-evidence .err { color: var(--accent-pink); } +.fb-evidence .comment { color: var(--dim); } + +.fb-fix { + background: var(--bg); border: 1px solid var(--line); + padding: 14px; + font-family: var(--font-mono); font-size: 12px; +} +.fb-fix .slug { + display: inline-block; padding: 2px 8px; + background: var(--accent-pink-bg); color: var(--accent-pink); + border: 1px solid var(--accent-pink); + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + margin-bottom: 10px; +} +.fb-fix .cmd { + display: block; margin-top: 12px; + color: var(--accent-green); font-size: 12px; + border-top: 1px dashed var(--line); padding-top: 10px; +} +.fb-fix .cmd .prompt { color: var(--dim); margin-right: 6px; } + +/* ───────────────────────── show-off CTA (after IDENTITY) ───────────────────────── */ + +.showoff { + padding: 0 0 32px; + border-bottom: 1px solid var(--line); + margin-bottom: 0; +} +.showoff-cta { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 32px; + padding: 28px 32px; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + color: var(--ink); + text-decoration: none; + position: relative; + transition: border-color 120ms ease, background 120ms ease; +} +.showoff-cta:hover { + border-color: var(--ink-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + var(--bg-3); +} +.showoff-cta::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.showoff-cta::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.showoff-glyph { + display: block; + transform: scale(0.55); + transform-origin: center; + margin: -40px -28px; + /* shrink the embedded sigil without rebuilding it */ +} +.showoff-glyph .sigil { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.showoff-glyph .sigil-label { display: none; } +.showoff-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.showoff-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.showoff-headline { + font-family: var(--font-display); + font-size: clamp(28px, 3.4vw, 40px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); + line-height: 1.05; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.showoff-sub { + font-family: var(--font-mono); + font-size: 13px; line-height: 1.55; + color: var(--ink-2); + max-width: 520px; +} +.showoff-action { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 24px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + white-space: nowrap; +} +.showoff-arrow { + font-family: var(--font-display); + font-size: 36px; + letter-spacing: 0; + line-height: 1; + color: var(--accent-pink); +} + +@media (max-width: 800px) { + .showoff-cta { grid-template-columns: 1fr; padding: 24px 20px; gap: 18px; } + .showoff-glyph { margin: 0; transform: scale(0.6); transform-origin: left top; } + .showoff-action { width: 100%; flex-direction: row; justify-content: center; } +} + +/* ───────────────────────── 05 POLICIES ───────────────────────── */ + +.policy-callout { + display: inline-flex; align-items: baseline; gap: 12px; + padding: 12px 18px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + margin-bottom: 28px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.02em; + color: var(--ink); + box-shadow: 4px 4px 0 0 var(--accent-green-shadow); +} +.policy-callout .arrow { color: var(--accent-green); margin: 0 4px; } +.policy-callout .new-score { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-green); +} +.policy-callout .new-tier { color: var(--accent-green); font-weight: 600; } + +.policies-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.policy-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 20px 22px; + display: flex; flex-direction: column; gap: 10px; + transition: all 120ms; + position: relative; +} +.policy-card::before { + content: ""; position: absolute; left: 0; top: 0; + width: 3px; height: 100%; + background: var(--accent-pink); opacity: 0.7; +} +.policy-card:hover { background: var(--bg-3); } +.policy-card .head { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; +} +.policy-name { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.policy-slug { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.12em; color: var(--dim); + text-transform: uppercase; +} +.policy-desc { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.6; +} +.policy-impact { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-green); + letter-spacing: 0.01em; + border-top: 1px dashed var(--line); + padding-top: 10px; + margin-top: auto; +} +.policy-impact .check { margin-right: 6px; } +.policy-install { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 11px; + background: var(--bg); border: 1px solid var(--line); + padding: 8px 10px; + color: var(--accent-green); + margin-top: 4px; +} +.policy-install .prompt { color: var(--dim); } +.policy-install .copy { + margin-left: auto; color: var(--dim); cursor: pointer; + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + transition: color 120ms; +} +.policy-install .copy:hover { color: var(--accent-pink); } + +/* ───────────────────────── 06 SHARE + RETURN ───────────────────────── */ + +.share-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; +} + +.share-card { + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 32px; + position: relative; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.share-card .stamp-tl, .share-card .stamp-br { + position: absolute; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-pink); opacity: 0.55; +} +.share-card .stamp-tl { top: 8px; left: 12px; } +.share-card .stamp-br { bottom: 8px; right: 12px; } + +.share-brand { + display: flex; align-items: center; gap: 10px; + margin-bottom: 28px; +} +.share-brand .glyph { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; +} +.share-brand .name { + font-family: var(--font-display); font-size: 14px; + letter-spacing: 0.11em; text-transform: lowercase; color: var(--ink); +} +.share-brand .sep { color: var(--dim); font-size: 11px; } +.share-brand .meta { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); +} +.share-project { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; color: var(--ink-2); + margin-bottom: 20px; +} +.share-archetype { + font-family: var(--font-display); + font-size: clamp(36px, 5vw, 56px); + line-height: 1; letter-spacing: 0.08em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.share-rule { + width: 56px; height: 2px; + background: var(--accent-pink); + margin: 16px 0 20px; +} +.share-tagline { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.45; color: var(--ink-2); + margin-bottom: 32px; + text-wrap: pretty; +} +.share-score-row { + display: flex; gap: 14px; align-items: center; + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.05em; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.share-score-row .tier { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-pink); + text-transform: uppercase; +} +.share-score-row .sep { color: var(--dim); } +.share-score-row .num { color: var(--ink); } +.share-score-row .rank { color: var(--ink-2); } +.share-url { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-green); +} + +.share-actions { + display: flex; flex-direction: column; gap: 16px; +} +.tweet-tabs { + display: flex; gap: 0; + border: 1px solid var(--line-2); +} +.tweet-tab { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + border-right: 1px solid var(--line); + background: var(--bg-2); + transition: all 120ms; +} +.tweet-tab:last-child { border-right: none; } +.tweet-tab:hover { color: var(--ink); } +.tweet-tab.is-active { + color: var(--accent-pink); + background: var(--accent-pink-bg); +} + +.tweet-preview { + background: var(--bg-2); + border: 1px solid var(--line-2); + padding: 20px 22px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; + color: var(--ink); + white-space: pre-wrap; + min-height: 200px; + position: relative; +} +.tweet-preview .url { color: var(--accent-pink); } +.tweet-preview .arch { color: var(--accent-pink); } +.tweet-meta { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.tweet-meta .count.over { color: var(--accent-pink); } + +.share-buttons { + display: flex; gap: 12px; flex-wrap: wrap; +} +.share-btn { + display: inline-flex; align-items: center; gap: 10px; + padding: 14px 20px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + border: 1px solid var(--accent-pink); + color: var(--accent-pink); + background: transparent; + transition: all 120ms; + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.share-btn:hover { + background: var(--accent-pink); + color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} +.share-btn.alt { + border-color: var(--line-2); color: var(--ink); + box-shadow: 4px 4px 0 0 #1a1a20; +} +.share-btn.alt:hover { + border-color: var(--ink); background: rgba(255,255,255,0.04); + color: var(--ink); box-shadow: 2px 2px 0 0 #1a1a20; +} +.share-btn .arrow { color: var(--accent-green); } +.share-btn:hover .arrow { color: var(--bg); } + +/* return hook */ +.return-hook { + margin-top: 64px; + padding: 40px 48px; + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.return-hook::before, .return-hook::after { + content: ""; position: absolute; width: 8px; height: 8px; +} +.return-hook::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-green); + border-left: 1px solid var(--accent-green); +} +.return-hook::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-green); + border-right: 1px solid var(--accent-green); +} +.return-hook .label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); margin-bottom: 12px; +} +.return-hook h3 { + font-family: var(--font-display); font-size: clamp(28px, 3.6vw, 40px); + letter-spacing: 0.11em; line-height: 1.1; + text-transform: lowercase; color: var(--ink); + margin: 0 0 16px; font-weight: 400; +} +.return-hook p { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); line-height: 1.7; + margin: 0 0 8px; max-width: 600px; +} +.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; } + +/* ───────────────────────── footer ───────────────────────── */ + +.report-footer { + padding: 48px 32px 24px; + border-top: 1px solid var(--line); + background: var(--bg); + text-align: center; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.report-footer .brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* responsive */ +@media (max-width: 960px) { + .report { padding: 0 20px; } + .archetype-frame { padding: 32px 24px; } + .arch-body { grid-template-columns: 1fr; gap: 32px; } + .arch-meta-grid { grid-template-columns: 1fr; gap: 16px; } + .score-grid { grid-template-columns: 1fr; gap: 16px; } + .finding-body { grid-template-columns: 1fr; } + .finding-block { border-right: none !important; } + .policies-grid { grid-template-columns: 1fr; } + .share-grid { grid-template-columns: 1fr; } + .strength-row { grid-template-columns: 40px 1fr; } + .strength-metric { grid-column: 2; text-align: left; margin-top: 6px; } + .return-hook { padding: 28px 24px; } +} diff --git a/app/audit/loading.tsx b/app/audit/loading.tsx new file mode 100644 index 00000000..43ca6018 --- /dev/null +++ b/app/audit/loading.tsx @@ -0,0 +1,24 @@ +/** Suspense fallback for /audit while the server component reads the cache. + * Renders a minimal skeleton — the cache read itself is cheap so this + * rarely flashes, but Next.js requires loading.tsx for route Suspense to + * work cleanly. */ +export default function AuditLoading() { + return ( +
+
+
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/audit/page.tsx b/app/audit/page.tsx new file mode 100644 index 00000000..0dfefb48 --- /dev/null +++ b/app/audit/page.tsx @@ -0,0 +1,53 @@ +/** + * /audit — server entry. Reads the dashboard cache, parses URL params + * (?p=project), and hands off to the client dashboard. + * + * Imports audit-styles.css globally for this route only — the existing + * site-wide globals continue to load via the root layout. Audit styles + * override where they clash (dark canvas, JetBrains Mono everywhere, etc.). + */ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import { BUILTIN_POLICIES } from "@/src/hooks/builtin-policies"; +import { AUDIT_DETECTORS } from "@/src/audit/detectors"; +import { AuditDashboard } from "./_components/audit-dashboard"; +import "./audit-styles.css"; + +// Computed server-side: shipping these modules to the client would pull +// in node:fs / execSync from the workflow policies. +const TOTAL_CATALOG_SIZE = BUILTIN_POLICIES.length + AUDIT_DETECTORS.length; + +export const dynamic = "force-dynamic"; + +interface PageProps { + searchParams: Promise<{ p?: string }>; +} + +export default async function AuditPage({ searchParams }: PageProps) { + const disabled = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") + .split(",").map((s) => s.trim()).filter(Boolean); + if (disabled.includes("audit")) notFound(); + + const { p } = await searchParams; + + const cache = readDashboardCache(); + const initial = cache + ? { + status: "cached" as const, + cachedAt: cache.cachedAt, + params: cache.params, + result: cache.result, + } + : { status: "empty" as const }; + + return ( + + + + ); +} diff --git a/app/globals.css b/app/globals.css index 9b34a006..e991f5bb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -231,3 +231,37 @@ .animate-expand { animation: expand-in 150ms ease-out; } + +/* Audit dashboard: staggered row entry. The component sets `--row-delay` + per row inline so each item enters ~50ms after its predecessor. */ +@keyframes audit-row-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.audit-row-enter { + opacity: 0; + animation: audit-row-in 400ms ease-out forwards; + animation-delay: var(--row-delay, 0ms); +} + +/* Audit dashboard: bar fill from 0 → target width. The component sets + `--bar-width` to the final value (e.g. "62%") so the keyframe targets + it via CSS variables — works for both proportional bars in the policy + list and the inline health bar. */ +@keyframes audit-bar-fill { + from { width: 0; } + to { width: var(--bar-width, 100%); } +} +.audit-bar-fill { + width: var(--bar-width, 100%); + animation: audit-bar-fill 1000ms cubic-bezier(0.22, 1, 0.36, 1); + animation-delay: var(--bar-delay, 0ms); +} + +@media (prefers-reduced-motion: reduce) { + .audit-row-enter, + .audit-bar-fill { + animation: none; + opacity: 1; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index a4aad4ec..2988b5b5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,6 +11,7 @@ import { GlobalErrorListeners } from "@/app/components/global-error-listeners"; import { AutoRefreshProvider } from "@/contexts/AutoRefreshContext"; import { Navbar } from "@/components/navbar"; import { Toaster } from "@/app/components/toast"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; import "./globals.css"; const geistMono = Geist_Mono({ @@ -34,13 +35,21 @@ export default function RootLayout({ }>) { const disabledPages = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") .split(",").map((s) => s.trim()).filter(Boolean); + // Read the audit cache once per page request to drive the nav badge. + // Cheap (single JSON file) and the cache itself returns null on miss. + const auditCache = readDashboardCache(); + const auditSlippingCount = auditCache?.result?.results + ? auditCache.result.results + .filter((r) => r.source === "audit-detector" || (r.source === "builtin" && !r.enabledInConfig)) + .reduce((sum, r) => sum + r.hits, 0) + : undefined; return ( - + {children} diff --git a/assets/audit/Audit Report.html b/assets/audit/Audit Report.html new file mode 100644 index 00000000..36628a89 --- /dev/null +++ b/assets/audit/Audit Report.html @@ -0,0 +1,22 @@ + + + + + +failproof_ai — audit · blrnow / api-coder + + + + + + + + + + +
+ + + + + diff --git a/assets/audit/Show Off Your Agent.html b/assets/audit/Show Off Your Agent.html new file mode 100644 index 00000000..06b1f8d1 --- /dev/null +++ b/assets/audit/Show Off Your Agent.html @@ -0,0 +1,22 @@ + + + + + +failproof_ai — show off your agent + + + + + + + + + + + +
+ + + + diff --git a/assets/audit/archetypes.jsx b/assets/audit/archetypes.jsx new file mode 100644 index 00000000..e2ec008a --- /dev/null +++ b/assets/audit/archetypes.jsx @@ -0,0 +1,272 @@ +// ============================================================ +// failproof_ai — audit report: archetype catalog +// 8 archetypes. Each has its own pixel-sigil and behavioral data. +// ============================================================ + +// 8x8 pixel sigil grids. legend: +// . = empty o = ink p = pink g = green d = dim +// Designed to feel like the brand's pixel-agent vocabulary — +// chunky, abstract, each glyph reads in <1s. + +const SIGILS = { + optimist: [ + "........", + "...p....", + "..p.p...", + ".p...p..", + "p.....p.", + "..ooo...", + "..o.o...", + ".oo.oo..", + ], + cowboy: [ + "..pppp..", + ".p....p.", + "p..pp..p", + "pppppppp", + "..o..o..", + "..o..o..", + ".oo..oo.", + "........", + ], + explorer: [ + "..pppp..", + ".p.gg.p.", + "p.g..g.p", + "p.g..g.p", + ".p.gg.pp", + "..pppp.p", + "........", + "........", + ], + goldfish: [ + "....p...", + "..oooop.", + ".ooooopp", + "ooooooop", + ".oooooo.", + "..ooo...", + ".o...o..", + "o.....o.", + ], + architect: [ + "oooooooo", + "o......o", + "o.pppp.o", + "o.p..p.o", + "o.p..p.o", + "o.pppp.o", + "o......o", + "oooooooo", + ], + precision: [ + "...gg...", + "...gg...", + "........", + "gg...gg.", + "gg.gg.gg", + "...gg...", + "...gg...", + "........", + ], + hammer: [ + "..ooooo.", + ".oppppo.", + ".oppppo.", + "..o..o..", + "...oo...", + "...oo...", + "...oo...", + "..pppp..", + ], + ghost: [ + "..dddd..", + ".dddddd.", + "ddpd.pd.", + "ddddddd.", + "ddddddd.", + "ddddddd.", + "d.d.d.d.", + ".d...d..", + ], +}; + +const ARCHETYPES = { + optimist: { + key: "optimist", + index: "01", + name: "the optimist", + tagline: "ships fast. retries with conviction. occasionally forgets it was already there.", + keywords: ["pace", "conviction", "forgetful"], + description: + "moves at pace. doesn't second-guess itself — which is mostly a feature. when something fails, it tries again: same args, same hope. when uncertain about its location, it prepends the directory anyway. just in case. the optimism is earned. this agent gets things done. it just occasionally burns tokens proving it.", + signature: [ + { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, + { arrow: "→", body: "retries: 6. diagnosis: 0." }, + ], + common: "fast-iteration solo projects, early-stage prototypes, builders who ship daily", + risk: "token waste, retry spirals, stale state assumptions", + closing: "the optimism is a feature. the waste is not.", + secondary: "explorer", + }, + cowboy: { + key: "cowboy", + index: "02", + name: "the cowboy", + tagline: "asks for forgiveness, not permission. git push --force is a philosophy.", + keywords: ["bold", "forceful", "ungoverned"], + description: + "high output. low ceremony. the cowboy gets code onto main faster than anyone — and your branch protection rules are the only thing standing between this agent and your production database. not reckless. just confident. in a way that requires guardrails.", + signature: [ + { arrow: "→", body: "git push origin main --force" }, + { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, + { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, + ], + common: "solo repos, weekend projects, founders writing their own infra", + risk: "branch protection bypass, accidental main commits, revert overhead", + closing: "the pace is real. the risk is too.", + secondary: "hammer", + }, + explorer: { + key: "explorer", + index: "03", + name: "the explorer", + tagline: "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", + keywords: ["curious", "thorough", "leaky"], + description: + "curious by nature. reads broadly, thinks laterally, sometimes follows a symlink somewhere it wasn't meant to go. this isn't malice — it's thoroughness that hasn't learned boundaries yet. the explorer builds great things. it just occasionally needs someone to close the door to the secrets drawer.", + signature: [ + { arrow: "→", body: "cat /Users/n/.aws/credentials" }, + { arrow: "→", body: "cat ../other-repo/.env" }, + { arrow: "→", body: "cat ~/.config/openai/key" }, + ], + common: "multi-project setups, agents with broad file access, complex monorepos", + risk: "credential exposure, unintended cross-project reads, secrets landing in context", + closing: "the curiosity stays. the credentials stay private.", + secondary: "architect", + }, + goldfish: { + key: "goldfish", + index: "04", + name: "the goldfish", + tagline: "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", + keywords: ["ambitious", "drifting", "inventive"], + description: + "great at long tasks. not great at remembering which long task it's on. past 80% context, the goldfish starts inventing history — citing files it never opened, referencing edits it never made. not lying. just filling gaps with confidence. the longer the session, the more creative the memory.", + signature: [ + { comment: "# turn 47/52 — ctx 82% full" }, + { comment: '# agent: "as we saw earlier in auth.ts…"' }, + { comment: "# auth.ts was never opened this session." }, + ], + common: "long-running refactor sessions, complex multi-file tasks, agents without session breaks", + risk: "context drift, hallucinated prior work, compounding errors in long sessions", + closing: "the ambition is good. the context budget is not.", + secondary: "optimist", + }, + architect: { + key: "architect", + index: "05", + name: "the paranoid architect", + tagline: "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", + keywords: ["methodical", "safe", "slow"], + description: + "methodical. thorough. reads the same file from two different paths, just to be sure. verifies before every write. double-checks the package.json before running anything. the paranoid architect rarely makes mistakes — because it rarely finishes fast enough to make them. your safest agent. your slowest agent.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, + { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, + { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, + ], + common: "production systems, high-stakes codebases, builders with strong safety instincts", + risk: "token overhead, slow sessions, redundant verification loops", + closing: "safety is a feature. so is finishing.", + secondary: "precision", + }, + precision: { + key: "precision", + index: "06", + name: "the precision builder", + tagline: "in. done. out. your agent doesn't linger.", + keywords: ["clean", "focused", "minimal"], + description: + "minimal footprint. focused calls. gets in, does the work, gets out. the precision builder is what every agent aspires to be — and what most agents aren't yet. few findings don't mean no findings. but it means your agent has found its rhythm. the gap between here and s-tier is smaller than you think.", + signature: [ + { arrow: "→", body: "clean tool calls. right paths, right args." }, + { arrow: "→", body: "sessions end when the task ends." }, + { arrow: "→", body: "no redundant reads. no retry storms." }, + ], + common: "mature agents, heavily policy-enforced setups, builders who've iterated for a while", + risk: "low finding count can mask edge cases that haven't surfaced yet", + closing: "rare. keep it that way.", + secondary: "ghost", + }, + hammer: { + key: "hammer", + index: "07", + name: "the hammer", + tagline: "when something doesn't work, it tries the exact same thing again. harder.", + keywords: ["determined", "repetitive", "unbacked"], + description: + "determined. possibly to a fault. the hammer's first response to failure is repetition. no diagnosis, no arg change, no backoff. just the same call, six times, under 90 seconds, with conviction. occasionally works. mostly burns tokens and stalls the session. needs a budget more than it needs encouragement.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { comment: "# 6× total. file is at src/router.ts." }, + ], + common: "agents without failure-handling policies, complex directory structures, ambiguous task framing", + risk: "token spirals, stalled sessions, no diagnostic signal ever surfaces", + closing: "the conviction is good. the diagnosis is missing.", + secondary: "optimist", + }, + ghost: { + key: "ghost", + index: "08", + name: "the ghost", + tagline: "moves fast, leaves little trace. sometimes leaves a little too little trace.", + keywords: ["efficient", "quiet", "unverified"], + description: + "efficient. clean. doesn't hang around. the ghost completes tasks with minimal overhead — no redundant reads, no retry storms, no boundary drift. the risk is quiet: it doesn't always check that things worked. the build passes. or it looks like it does. the ghost trusts its own output more than it should.", + signature: [ + { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, + { comment: "→ [no read_file to verify]" }, + { comment: "→ [no test run after write]" }, + { comment: "# task complete. # maybe." }, + ], + common: "fast-moving solo projects, low-constraint CLAUDE.md setups, minimal oversight workflows", + risk: "silent failures, unverified writes, false completion signals", + closing: "fast is good. verified-fast is better.", + secondary: "precision", + }, +}; + +const ARCHETYPE_ORDER = ["optimist", "cowboy", "explorer", "goldfish", "architect", "precision", "hammer", "ghost"]; + +// Pixel sigil component — renders an 8x8 grid from a SIGILS entry +function Sigil({ archetypeKey }) { + const grid = SIGILS[archetypeKey] || SIGILS.optimist; + const cells = []; + for (let y = 0; y < 8; y++) { + const row = grid[y] || "........"; + for (let x = 0; x < 8; x++) { + const c = row[x] || "."; + let cls = "px"; + if (c === "o") cls += " on"; + else if (c === "p") cls += " p"; + else if (c === "g") cls += " g"; + else if (c === "d") cls += " d"; + cells.push(
); + } + } + return ( +
+
{cells}
+
+ №{ARCHETYPES[archetypeKey].index} + sigil +
+
+ ); +} + +Object.assign(window, { ARCHETYPES, ARCHETYPE_ORDER, SIGILS, Sigil }); diff --git a/assets/audit/assets/favicon.svg b/assets/audit/assets/favicon.svg new file mode 100644 index 00000000..040083b9 --- /dev/null +++ b/assets/audit/assets/favicon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/audit/assets/fonts/architype-stedelijk.ttf b/assets/audit/assets/fonts/architype-stedelijk.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d2ec7302c5b56ee3deda9af442365fa46bb7f4c0 GIT binary patch literal 24440 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 F{{pu8rr7`h literal 0 HcmV?d00001 diff --git a/assets/audit/assets/fonts/architype-stedelijk.woff2 b/assets/audit/assets/fonts/architype-stedelijk.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e9742a213c01d615cd50acf7a85a6472aac45a72 GIT binary patch literal 3020 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 OVEEnnPC5O^L;?VoQL0-2 literal 0 HcmV?d00001 diff --git a/assets/audit/audit.jsx b/assets/audit/audit.jsx new file mode 100644 index 00000000..9eb22dbe --- /dev/null +++ b/assets/audit/audit.jsx @@ -0,0 +1,825 @@ +// ============================================================ +// failproof_ai — audit report +// Personality profile for your agent. Six sections. +// ============================================================ + +const { useState, useEffect, useMemo } = React; + +// ---------- url param helper ---------- +function getParam(name, fallback) { + try { + const v = new URLSearchParams(window.location.search).get(name); + return v == null || v === "" ? fallback : v; + } catch (e) { return fallback; } +} + +// ---------- defaults (tweakable via URL params or the Tweaks panel) ---------- +// URL params: ?a=archetype &s=score &r=rank &c=cohort &p=project +const REPORT_DEFAULTS = /*EDITMODE-BEGIN*/{ + "archetype": getParam("a", "optimist"), + "score": parseInt(getParam("s", "58"), 10), + "rank": parseInt(getParam("r", "1847"), 10), + "cohort": parseInt(getParam("c", "2316"), 10), + "tweetVariant": "show-off", + "showSecondary": true, + "project": getParam("p", "blrnow / api-coder") +}/*EDITMODE-END*/; + +// ---------- helpers ---------- +function gradeFor(score) { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} +function projectedScore(base) { + // every policy ~= +3.5 pts, capped at 92 + return Math.min(92, base + 21); +} +function tierName(g) { + return { S: "s tier", A: "a tier", B: "b tier", C: "c tier", D: "d tier", F: "f tier" }[g]; +} + +// ---------- data ---------- +const STRENGTHS = [ + { + metric: "99%", + unit: "clean tool calls", + headline: "ran 847 tool calls. 8 detectors triggered.", + detail: "99% of tool calls came back clean before today's audit.", + }, + { + metric: "0", + unit: "credential leaks", + headline: "zero credential exposure to stdout.", + detail: "the explorer instinct never made it to output. secrets stayed secret.", + }, + { + metric: "11", + unit: "avg turns / task", + headline: "tasks complete in 11 turns on average.", + detail: "faster than 63% of audited agents in this cohort.", + }, + { + metric: "0", + unit: "double-writes", + headline: "no double-writes across production projects.", + detail: "the agent never overwrote a file it was mid-edit on.", + }, + { + metric: "94%", + unit: "intent retention", + headline: "stayed on the stated task in 94% of sessions.", + detail: "rarely went off-scope on its own. focus is real.", + }, +]; + +const FINDINGS = [ + { + num: "01", + title: "prepended cd before commands", + count: 20, + policy: "redundant-cd", + projects: 2, + lastSeen: "4h ago", + body: <> + the agent runs cd <cwd> before commands it would have run from the + same directory anyway. mostly harmless. occasionally it gets the path wrong and + manufactures a new bug. + , + cost: { tokens: "~3.2k", risk: "low", radius: "high noise" }, + costLine: <>~3.2k tokens/day burned on redundant navigation. low security risk. high noise., + evidence: [ + { kind: "cmd", text: 'cd /Users/n/blrnow/api && pnpm test' }, + { kind: "comment", text: '# already in /Users/n/blrnow/api' }, + { kind: "cmd", text: 'cd /Users/n/blrnow/api && git status' }, + { kind: "comment", text: '# still already there.' }, + ], + fix: { + slug: "no-redundant-cd", + desc: "rejects cd prefixes when the agent's cwd already matches the target.", + install: "failproof policy add no-redundant-cd", + }, + }, + { + num: "02", + title: "pushed to main without a branch", + count: 7, + policy: "block-push-master", + projects: 1, + lastSeen: "1d ago", + body: <> + seven attempts to push directly to main. branch protection caught four of + them. the other three landed. the agent did not author a rollback. + , + cost: { tokens: "—", risk: "high", radius: "production" }, + costLine: <>7 attempts. branch protection saved you 4 times. the other 3 merged., + evidence: [ + { kind: "cmd", text: 'git push origin main' }, + { kind: "err", text: '! remote: protected branch' }, + { kind: "cmd", text: 'git push origin main --force' }, + { kind: "err", text: '! remote: protected branch' }, + { kind: "cmd", text: 'git push origin HEAD:main' }, + { kind: "comment", text: '# fast-forward. merged.' }, + ], + fix: { + slug: "block-push-master", + desc: "intercepts push-to-main attempts; requires a branch + PR.", + install: "failproof policy add block-push-master", + }, + }, + { + num: "03", + title: "read outside the project root", + count: 4, + policy: "block-read-outside-cwd", + projects: 2, + lastSeen: "2d ago", + body: <> + four reads outside the project root. three of them hit credential files + (~/.aws/credentials, ~/.config/openai/key, an out-of-tree{" "} + .env). none made it back to stdout — but they made it into context. + , + cost: { tokens: "n/a", risk: "high", radius: "credentials" }, + costLine: <>4 reads outside project root. 3 hit credential files. high exposure risk., + evidence: [ + { kind: "cmd", text: 'cat /Users/n/.aws/credentials' }, + { kind: "cmd", text: 'cat ../other-repo/.env' }, + { kind: "cmd", text: 'cat ~/.config/openai/key' }, + ], + fix: { + slug: "block-read-outside-cwd", + desc: "denies any read whose absolute path falls outside the project root.", + install: "failproof policy add block-read-outside-cwd", + }, + }, + { + num: "04", + title: "retried the same call six times in a row", + count: 6, + policy: "retry-storm", + projects: 1, + lastSeen: "5h ago", + body: <> + same call, same args, six times under 90 seconds. no diagnosis between attempts. + the file existed — at a different path the agent never tried. + , + cost: { tokens: "~1.8k", risk: "med", radius: "stall" }, + costLine: <>~1.8k tokens/day in retry overhead. 3 sessions stalled before manual correction., + evidence: [ + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "comment", text: '# 6× total. correct path: src/router.ts.' }, + ], + fix: { + slug: "retry-budget", + desc: "caps identical-arg retries at 2. forces a diagnostic step on the third.", + install: "failproof policy add retry-budget", + }, + }, + { + num: "05", + title: "context carried beyond its sell-by date", + count: 3, + policy: "context-bleed", + projects: 1, + lastSeen: "3d ago", + body: <> + three sessions referenced files past 82% context fill that were never opened in + the current session. the agent didn't lie. it filled gaps with confidence. + , + cost: { tokens: "varies", risk: "med", radius: "compounding" }, + costLine: <>3 sessions over 80% context. 2 cited files never opened. compounding errors downstream., + evidence: [ + { kind: "comment", text: '# turn 47/52 — ctx 82% full' }, + { kind: "comment", text: '# agent: "as we saw earlier in auth.ts…"' }, + { kind: "comment", text: '# auth.ts was never opened this session.' }, + ], + fix: { + slug: "context-window-guard", + desc: "warns at 75%, forces summary-and-reset at 90%.", + install: "failproof policy add context-window-guard", + }, + }, + { + num: "06", + title: "wrote without verifying", + count: 11, + policy: "verify-after-write", + projects: 2, + lastSeen: "12h ago", + body: <> + eleven writes shipped with no read-back, no test run, no type-check. the build + went green nine times. twice it didn't, and the agent moved on. + , + cost: { tokens: "low", risk: "med", radius: "silent-fail" }, + costLine: <>11 unverified writes. 2 broke the build silently. the agent didn't notice., + evidence: [ + { kind: "cmd", text: 'write_file("src/api/router.ts")', comment: " # done" }, + { kind: "comment", text: '# no read_file to verify' }, + { kind: "comment", text: '# no `pnpm typecheck` after write' }, + ], + fix: { + slug: "verify-after-write", + desc: "requires a read-back or test run before the agent claims a task complete.", + install: "failproof policy add verify-after-write", + }, + }, +]; + +// ---------- top-level shell ---------- +function App() { + const [t, setTweak] = useTweaks ? useTweaks(REPORT_DEFAULTS) : [REPORT_DEFAULTS, () => {}]; + const archetype = ARCHETYPES[t.archetype] || ARCHETYPES.optimist; + const grade = gradeFor(t.score); + const projected = projectedScore(t.score); + const projectedGrade = gradeFor(projected); + + return ( +
+
+
+ +
+ + + + + + + +
+ + {window.TweaksPanel ? ( + + ) : null} +
+
+ ); +} + +// ============================================================ +// SHELL — minimal header with failproof_ai wordmark only +// ============================================================ +function AppHeader() { + return ( +
+ + ▮▮ + failproof_ai + / + audit + +
+ +
+
+ ); +} + +// ============================================================ +// 01 — IDENTITY +// ============================================================ +function IdentitySection({ archetype, showSecondary }) { + const secondary = ARCHETYPES[archetype.secondary]; + return ( +
+
+ ┌ identity + v1.0 ┐ + └ № {archetype.index} / 08 + archetype ┘ + +
+
+
+ ━━ identity · your agent's archetype +
+
+ detected from 847 tool calls + / + 52 sessions + / + 30d + live +
+
+
+
№ {archetype.index} of 08
+
archetype
+
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+ {showSecondary && 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}
+
+ + +
+
+
+ ); +} + +// ============================================================ +// SHOW OFF — big CTA strip right after IDENTITY +// Links to the standalone poster page with archetype + score baked into the URL. +// ============================================================ +function ShowOffCTA({ archetype, score, grade, rank, cohort, project }) { + const params = new URLSearchParams({ + a: archetype.key, + s: String(score), + g: grade, + r: String(rank), + c: String(cohort), + p: project, + }); + const href = "Show%20Off%20Your%20Agent.html?" + params.toString(); + + return ( +
+ + + + ━━ shareable poster + show off your agent. + + generate a one-page poster of your {archetype.name}. + score, percentile, sigil. ready to post. + + + + + make poster + + +
+ ); +} + +// ============================================================ +// 02 — STRENGTHS +// ============================================================ +function StrengthsSection() { + return ( +
+
+
+ ━━ strengths · what your agent has figured out +
+
5 of 12 measured
+
+

your agent does this right.

+ +
+ {STRENGTHS.map((s, i) => ( +
+
+
+
{s.headline}
+
{s.detail}
+
+
+ {s.metric} + {s.unit} +
+
+ ))} +
+
— these are your agent's defaults. keep them.
+
+ ); +} + +// ============================================================ +// 03 — SCORE + LEADERBOARD +// ============================================================ +function ScoreSection({ score, grade, rank, cohort, archetype, project }) { + const pointsToB = Math.max(0, 71 - score); + const distBars = useMemo(() => buildDistribution(score), [score]); + const leaderboardRows = useMemo(() => buildLeaderboard(rank, cohort, score, project, archetype), [rank, cohort, score, project, archetype.key]); + + return ( +
+
+
+ ━━ leaderboard · cohort +
+
+ {cohort.toLocaleString()} agents + · + last 30 days +
+
+

you rank #{rank.toLocaleString()}.

+ +
+
+
+
{grade}
+
+
{tierName(grade)}
+
{score}
+
of 100
+
+
+ + {pointsToB > 0 ? ( +

+ a B starts at 71. you're {pointsToB} points away.
+ enable the prescribed policies and you'll get there this week. +

+ ) : grade === "S" ? ( +

+ s tier. few make it here. fewer stay.
+ keep the policies live. revisit in 30 days. +

+ ) : ( +

+ {tierName(grade)}. better than {Math.round((1 - rank / cohort) * 100)}% of audited agents.
+ clean up the findings below to climb. +

+ )} + +
+
+ distribution · last 30d + ▮ = your position +
+
+ {distBars.map((b, i) => ( +
+ ))} +
+
+ F + D + C + B + A + S +
+
+
+ +
+
+
rank
+
agent
+
grade
+
score
+
+ {leaderboardRows.map((r, i) => + r.divider ? ( +
· · ·
+ ) : ( +
+
#{r.rank.toLocaleString()}
+
+
{r.name}{r.you && (you)}
+
{r.arch}
+
+
{r.grade}
+
{r.score}
+
+ ) + )} +
+
+
+ ); +} + +function buildDistribution(yourScore) { + // 20 buckets, 5pts each, 0-100 + // bell-curve-ish centered around 60 + const buckets = []; + for (let i = 0; i < 20; i++) { + const center = i * 5 + 2.5; + const dist = Math.abs(center - 60); + const h = Math.max(8, 100 - dist * 2.2 + (Math.sin(i * 1.3) * 6)); + const you = yourScore >= i * 5 && yourScore < (i + 1) * 5; + buckets.push({ h, you, label: `${i * 5}-${(i + 1) * 5}` }); + } + return buckets; +} + +const LB_NAMES = [ + { name: "anthropic / claude-code-internal", arch: "the precision builder" }, + { name: "openai / gpt-engineer-pro", arch: "the precision builder" }, + { name: "vercel / v0-coder-v3", arch: "the ghost" }, + { name: "supabase / db-migrator", arch: "the paranoid architect" }, + { name: "stripe / payments-bot", arch: "the paranoid architect" }, + { name: "linear / triage-agent", arch: "the ghost" }, + { name: "cursor / refactor-bot", arch: "the precision builder" }, + { name: "replit / repl-coder", arch: "the optimist" }, + { name: "exosphere / orchestrator", arch: "the precision builder" }, + { name: "humanloop / eval-runner", arch: "the paranoid architect" }, +]; + +function buildLeaderboard(yourRank, cohort, yourScore, yourProject, yourArchetype) { + const yourGrade = gradeFor(yourScore); + // top 5 + const rows = []; + rows.push({ rank: 1, ...LB_NAMES[0], grade: "S", score: 97 }); + rows.push({ rank: 2, ...LB_NAMES[1], grade: "S", score: 93 }); + rows.push({ rank: 3, ...LB_NAMES[2], grade: "A", score: 89 }); + rows.push({ rank: 4, ...LB_NAMES[3], grade: "A", score: 86 }); + rows.push({ rank: 5, ...LB_NAMES[4], grade: "A", score: 82 }); + rows.push({ divider: true }); + // 2 above you + rows.push({ rank: yourRank - 2, name: "indie / weekend-coder-42", arch: "the cowboy", grade: gradeFor(yourScore + 2), score: yourScore + 2 }); + rows.push({ rank: yourRank - 1, name: "n8n / workflow-agent", arch: "the optimist", grade: gradeFor(yourScore + 1), score: yourScore + 1 }); + rows.push({ rank: yourRank, name: yourProject, arch: yourArchetype.name, grade: yourGrade, score: yourScore, you: true }); + rows.push({ rank: yourRank + 1, name: "acme / scratch-pad", arch: "the hammer", grade: gradeFor(yourScore - 1), score: yourScore - 1 }); + rows.push({ rank: yourRank + 2, name: "side-quest / cli-tool", arch: "the goldfish", grade: gradeFor(yourScore - 2), score: yourScore - 2 }); + return rows; +} + +// ============================================================ +// 04 — FINDINGS +// ============================================================ +function FindingsSection() { + return ( +
+
+
+ ━━ findings · ranked by impact +
+
+ {FINDINGS.length} detectors triggered +
+
+

your agent has some quirks.

+ +
+ {FINDINGS.map((f) => )} +
+
+ ); +} + +function Finding({ f }) { + return ( +
+
+
№{f.num}
+
{f.title}
+
+ {f.count}× + occurrences +
+
+
+ policy {f.policy} + · + {f.projects} {f.projects === 1 ? "project" : "projects"} + · + last seen {f.lastSeen} +
+
+
+
what happened
+
{f.body}
+
+
+
what this costs
+
{f.costLine}
+
+
+
evidence · sample
+
+ {f.evidence.map((e, i) => { + if (e.kind === "comment") return
{e.text}
; + if (e.kind === "err") return
{e.text}
; + return ( +
+ + {e.text} + {e.err && {e.err}} + {e.comment && {e.comment}} +
+ ); + })} +
+
+
+
the fix
+
+ {f.fix.slug} +
{f.fix.desc}
+ + ${f.fix.install} + +
+
+
+
+ ); +} + +// ============================================================ +// 05 — PRESCRIBED POLICIES +// ============================================================ +const POLICIES = [ + { name: "no-redundant-cd", slug: "policies/no-redundant-cd", desc: "blocks cd prefixes when the agent's cwd already matches the target path.", catches: "would have caught 20 occurrences. saves ~3.2k tokens/day." }, + { name: "block-push-master", slug: "policies/block-push-master", desc: "intercepts pushes to main / master. requires a feature branch + PR.", catches: "would have caught 7 occurrences. 3 of them landed in production." }, + { name: "block-read-outside-cwd", slug: "policies/block-read-outside-cwd", desc: "denies reads of files outside the project root, including symlinks.", catches: "would have caught 4 occurrences. 3 hit credential files." }, + { name: "retry-budget", slug: "policies/retry-budget", desc: "caps identical-arg retries at 2. forces a diagnostic step on the third.", catches: "would have caught 6 occurrences. ~1.8k tokens/day saved." }, + { name: "context-window-guard", slug: "policies/context-window-guard", desc: "warns at 75% context fill. forces summary-and-reset at 90%.", catches: "would have caught 3 occurrences of context bleed." }, + { name: "verify-after-write", slug: "policies/verify-after-write", desc: "requires a read-back or test run before the agent claims completion.", catches: "would have caught 11 occurrences. 2 silent build breaks." }, +]; + +function PoliciesSection({ projected, projectedGrade }) { + return ( +
+
+
+ ━━ policies · prescribed +
+
+ {POLICIES.length} policies · covers 100% of findings +
+
+

enable these. close the gap.

+ +
+ enable all six + + projected score + {projected} + · + {tierName(projectedGrade)} +
+ +
+ {POLICIES.map((p, i) => ( +
+
+
{p.name}
+
№{String(i + 1).padStart(2, "0")}
+
+
{p.desc}
+
{p.catches}
+
+ $ + failproof policy add {p.name} + copy +
+
+ ))} +
+
+ ); +} + +// ============================================================ +// 06 — NEXT AUDIT / RETURN HOOK +// ============================================================ +function ReturnSection() { + return ( +
+
+
+ ━━ next audit · improvement +
+
recommended in 7d
+
+

come back better.

+
+
━━ the loop
+

re-audit in 7 days.

+

after the prescribed policies have been live for a week, we'll show your before/after score and which detectors went quiet.

+

most agents move from C to B in one session. some make it in a day.

+
+ + +
+
+
+ ); +} + +// ============================================================ +// FOOTER +// ============================================================ +function ReportFooter() { + return ( +
+ ▮▮ failproof_ai + · + audit v1.0 + · + generated 26 may 2026, 14:32 utc + · + auto-healing for your agents. +
+ ); +} + +// ============================================================ +// TWEAKS +// ============================================================ +function ReportTweaks({ t, setTweak, projected, projectedGrade }) { + const { TweaksPanel, TweakSection, TweakRadio, TweakSelect, TweakSlider, TweakToggle, TweakText, TweakButton } = window; + if (!TweaksPanel) return null; + return ( + + + setTweak("archetype", v)} + options={ARCHETYPE_ORDER.map((k) => ({ value: k, label: ARCHETYPES[k].name }))} + /> + setTweak("showSecondary", v)} + /> + + + setTweak("score", v)} + /> + setTweak("rank", v)} + /> + setTweak("cohort", v)} + /> + + + setTweak("project", v)} + /> + + +
+ enable all 6 → {projected} · {tierName(projectedGrade)} +
+
+ ); +} + +// ---------- mount ---------- +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(); diff --git a/assets/audit/poster-styles.css b/assets/audit/poster-styles.css new file mode 100644 index 00000000..ad3a5712 --- /dev/null +++ b/assets/audit/poster-styles.css @@ -0,0 +1,424 @@ +/* ============================================================ + failproof_ai — shareable poster page + Built on styles.css (tokens + textures + scanlines). + ============================================================ */ + +.poster-app { min-height: 100vh; } +.poster-shell { + position: relative; z-index: 3; + min-height: 100vh; + display: flex; flex-direction: column; +} + +/* toolbar */ +.poster-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.poster-back { + display: inline-flex; align-items: center; gap: 10px; + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; + color: var(--ink-2); + transition: color 120ms; +} +.poster-back:hover { color: var(--accent-pink); } +.poster-back .back-arrow { + font-family: var(--font-display); + font-size: 18px; color: var(--accent-pink); +} +.poster-brand { + display: inline-flex; align-items: baseline; gap: 10px; +} +.poster-actions { display: inline-flex; gap: 10px; } + +/* stage */ +.poster-stage { + flex: 1; + display: grid; + grid-template-columns: minmax(720px, 1fr) 320px; + gap: 40px; + padding: 48px 40px 64px; + max-width: 1480px; + width: 100%; + margin: 0 auto; + align-items: start; +} + +/* ─────────── the poster card itself ─────────── */ +.poster { + position: relative; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + border: 1px solid var(--line-2); + padding: 48px 56px 40px; + /* lock to a print-friendly aspect — 4:5 portrait-ish */ + aspect-ratio: 4 / 5; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* register / corner marks */ +.poster .reg { + position: absolute; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-pink); + opacity: 0.65; +} +.poster .reg-tl { top: 14px; left: 18px; } +.poster .reg-tr { top: 14px; right: 18px; } +.poster .reg-bl { bottom: 14px; left: 18px; } +.poster .reg-br { bottom: 14px; right: 18px; } + +/* head row */ +.poster-head { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 18px; + border-bottom: 1px dashed var(--line); + margin-bottom: 28px; +} +.poster-eyebrow { + display: inline-flex; align-items: baseline; gap: 12px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.poster-eyebrow .eb-glyph { color: var(--accent-pink); letter-spacing: -2px; } +.poster-eyebrow .eb-sep { color: var(--dim); } +.poster-eyebrow > span:last-child { color: var(--ink); } +.poster-livedot { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.poster-livedot .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} + +/* hero */ +.poster-hero { + display: grid; + grid-template-columns: 1fr auto; + gap: 32px; + align-items: center; + margin-bottom: 32px; +} +.poster-name { + font-family: var(--font-display); + font-size: clamp(56px, 8vw, 104px); + line-height: 0.92; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.poster-tagline { + font-family: var(--font-mono); + font-size: clamp(14px, 1.3vw, 17px); + line-height: 1.5; + color: var(--ink-2); + margin: 0 0 22px; + max-width: 540px; + text-wrap: pretty; +} +.poster-keywords { + display: flex; flex-wrap: wrap; + align-items: baseline; + gap: 14px; + font-family: var(--font-display); + font-size: clamp(22px, 2.4vw, 30px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.poster-keywords .kw-0 { color: var(--accent-green); } +.poster-keywords .kw-1 { color: var(--ink); } +.poster-keywords .kw-2 { color: var(--accent-pink); } +.poster-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} +.poster-sigil-wrap { display: flex; justify-content: center; } +.poster-sigil-wrap .sigil-label { display: none; } +.poster-sigil-wrap .sigil { + grid-template-columns: repeat(8, 18px); + grid-template-rows: repeat(8, 18px); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} + +/* stats row */ +.poster-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid var(--line-2); + background: var(--bg); + margin-bottom: 28px; +} +.stat-box { + padding: 22px 20px; + border-right: 1px solid var(--line); + display: flex; flex-direction: column; gap: 6px; + text-align: center; + align-items: center; +} +.stat-box:last-child { border-right: none; } +.stat-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.stat-value { + font-family: var(--font-display); + font-size: clamp(36px, 4vw, 52px); + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink); +} +.stat-value.grade { text-shadow: 3px 3px 0 var(--accent-pink-shadow); } +.stat-box.grade-S .stat-value, .stat-box.grade-A .stat-value { color: var(--accent-green); } +.stat-box.grade-S .stat-value.grade, .stat-box.grade-A .stat-value.grade { + text-shadow: 3px 3px 0 var(--accent-green-shadow); +} +.stat-box.grade-B .stat-value { color: #d3e1a8; } +.stat-box.grade-B .stat-value.grade { text-shadow: 3px 3px 0 #6f7e45; } +.stat-box.grade-C .stat-value, +.stat-box.grade-D .stat-value, +.stat-box.grade-F .stat-value { color: var(--accent-pink); } +.stat-box.accent .stat-value { + color: var(--accent-pink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.stat-value .pct { + font-size: 0.5em; + margin-left: 4px; + letter-spacing: 0; + color: var(--dim); +} +.stat-sub { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} + +/* positives */ +.poster-positives { + flex: 1; + margin-bottom: 24px; +} +.positives-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} +.positives-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 10px; +} +.positives-list li { + display: grid; + grid-template-columns: 28px 1fr; + gap: 14px; + align-items: start; + padding: 10px 16px; + border: 1px solid var(--line); + background: var(--bg); + font-family: var(--font-mono); + font-size: 13px; + color: var(--ink); + line-height: 1.5; +} +.positives-list .check { + color: var(--accent-green); + font-weight: 600; + font-size: 14px; +} + +/* footer cta strip */ +.poster-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.foot-headline { + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.foot-sub { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-2); + margin-top: 4px; +} +.foot-right { + display: inline-flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); +} +.foot-cta { + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.08em; + text-transform: lowercase; +} +.foot-arrow { + font-family: var(--font-display); + font-size: 24px; +} + +.poster-stamp { + position: absolute; + bottom: 14px; left: 50%; + transform: translateX(-50%); + display: inline-flex; gap: 8px; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.poster-stamp .stamp-date { color: var(--accent-pink); opacity: 0.7; } + +/* aside / hint card */ +.poster-hint { + position: sticky; top: 96px; + padding: 24px; + border: 1px solid var(--line-2); + background: var(--bg-2); + display: flex; flex-direction: column; gap: 18px; +} +.hint-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.hint-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 12px; + font-family: var(--font-mono); font-size: 13px; + color: var(--ink); +} +.hint-list li { + display: flex; gap: 12px; align-items: baseline; +} +.hint-num { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; + color: var(--accent-pink); + font-weight: 600; +} +.hint-divider { + color: var(--dim); font-family: var(--font-mono); + letter-spacing: 0.18em; font-size: 11px; + border-top: 1px dashed var(--line); + padding-top: 14px; +} +.hint-meta { + display: flex; flex-direction: column; gap: 6px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.15em; + color: var(--dim); + text-transform: uppercase; +} +.hint-link { + background: var(--bg); + border: 1px solid var(--line); + padding: 6px 8px; + color: var(--accent-green); + font-size: 11px; + letter-spacing: 0; + text-transform: none; + word-break: break-all; + white-space: normal; +} + +/* page footer */ +.poster-page-foot { + text-align: center; + padding: 24px 32px 32px; + border-top: 1px solid var(--line); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.poster-page-foot a:hover { color: var(--accent-pink); } +.poster-page-foot .h-brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* print — save the poster as a clean image (Cmd+P → save PDF) */ +@media print { + .poster-toolbar, .poster-hint, .poster-page-foot, .scanline-overlay { display: none !important; } + body, .poster-app { background: #131316 !important; } + .app::before, .app::after { display: none !important; } + .poster-stage { + grid-template-columns: 1fr; + padding: 0; margin: 0; gap: 0; + } + .poster { + box-shadow: none; + border: 1px solid var(--line-2); + aspect-ratio: 4 / 5; + width: 100%; + margin: 0; + page-break-inside: avoid; + } +} + +/* responsive */ +@media (max-width: 1100px) { + .poster-stage { + grid-template-columns: 1fr; + padding: 24px 20px 48px; + gap: 24px; + } + .poster { + padding: 32px 28px; + aspect-ratio: auto; + } + .poster-hint { position: static; } +} +@media (max-width: 700px) { + .poster-toolbar { padding: 12px 16px; flex-wrap: wrap; gap: 10px; } + .poster-actions .btn { padding: 6px 10px; font-size: 11px; } + .poster-brand { display: none; } + .poster-hero { grid-template-columns: 1fr; } + .poster-stats { grid-template-columns: repeat(2, 1fr); } + .stat-box { border-bottom: 1px solid var(--line); } + .stat-box:nth-child(2) { border-right: none; } + .stat-box:nth-last-child(-n+2) { border-bottom: none; } + .poster-foot { flex-direction: column; align-items: stretch; } + .foot-right { justify-content: center; } +} diff --git a/assets/audit/poster.jsx b/assets/audit/poster.jsx new file mode 100644 index 00000000..2342ad29 --- /dev/null +++ b/assets/audit/poster.jsx @@ -0,0 +1,247 @@ +// ============================================================ +// failproof_ai — show off your agent +// Standalone shareable poster. Reads ?a=&s=&g=&r=&c=&p= from URL. +// One screen. Designed to be screenshotted and posted. +// ============================================================ + +const { useState, useEffect, useMemo, useRef } = React; + +function getParam(name, fallback) { + try { + const v = new URLSearchParams(window.location.search).get(name); + return v == null || v === "" ? fallback : v; + } catch (e) { return fallback; } +} + +function gradeFor(score) { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} +function tierName(g) { + return { S: "s tier", A: "a tier", B: "b tier", C: "c tier", D: "d tier", F: "f tier" }[g]; +} + +// strengths to display: each archetype gets 3 specific positives. +const POSITIVES = { + optimist: [ + "99% clean tool calls (847 total)", + "zero credential exposure to stdout", + "ships in 11 turns on average", + ], + cowboy: [ + "highest output rate in its cohort", + "94% intent retention across sessions", + "branch protection caught the worst of it", + ], + explorer: [ + "broadest file-graph traversal of any cohort", + "zero credential exposure to stdout", + "fastest first-token-to-write on the leaderboard", + ], + goldfish: [ + "completed 47-turn sessions other agents abandoned", + "98% accuracy in the first 75% of context", + "no double-writes across production projects", + ], + architect: [ + "zero unverified writes ever", + "100% type-check coverage before any commit", + "lowest production-bug rate in cohort", + ], + precision: [ + "minimal tool-call footprint per task", + "session ends when task ends — every time", + "lowest retry rate of any agent audited", + ], + hammer: [ + "highest follow-through rate on hard tasks", + "never abandons a session mid-task", + "94% intent retention", + ], + ghost: [ + "fastest task completion in its cohort", + "minimal token overhead per write", + "zero retry-storms detected", + ], +}; + +function Poster() { + const key = getParam("a", "optimist"); + const archetype = ARCHETYPES[key] || ARCHETYPES.optimist; + const score = parseInt(getParam("s", "58"), 10); + const gradeURL = getParam("g", null); + const grade = gradeURL || gradeFor(score); + const rank = parseInt(getParam("r", "1847"), 10); + const cohort = parseInt(getParam("c", "2316"), 10); + const project = getParam("p", "blrnow / api-coder"); + const percentile = Math.max(1, Math.round((1 - (rank - 1) / cohort) * 100)); + const positives = POSITIVES[key] || POSITIVES.optimist; + const [copied, setCopied] = useState(false); + + const handleCopyLink = () => { + try { + navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch (e) {} + }; + + const handleBack = (e) => { + if (document.referrer && document.referrer.includes(window.location.host)) { + // browser back + return; + } + e.preventDefault(); + window.location.href = "Audit Report.html"; + }; + + return ( +
+
+
+
+ + + back to audit + +
+ ▮▮ + failproof_ai + / + share +
+
+ + +
+
+ +
+
+ {/* register marks */} + ┌ № {archetype.index} / 08 + v1.0 · 30d ┐ + └ shareable + failproof_ai ┘ + +
+
+ ━━ + archetype № {archetype.index} + · + {project} +
+
+ + live audit +
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ·} + + ))} +
+
+
+ +
+
+ +
+
+
grade
+
{grade}
+
{tierName(grade)}
+
+
+
score
+
{score}
+
of 100
+
+
+
rank
+
#{rank.toLocaleString()}
+
of {cohort.toLocaleString()}
+
+
+
top
+
{percentile}%
+
of cohort
+
+
+ +
+
━━ what this agent does right
+
    + {positives.map((p, i) => ( +
  • + + {p} +
  • + ))} +
+
+ +
+
+
audit your agent.
+
five ways your agent fails. five policies that catch it.
+
+
+
failproofai.com/audit
+
+
+
+ + {/* stamp */} +
+ generated + 26.05.2026 · 14:32 utc +
+
+ + +
+ + +
+
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(); diff --git a/assets/audit/screenshots/poster-optimist.png b/assets/audit/screenshots/poster-optimist.png new file mode 100644 index 0000000000000000000000000000000000000000..5210a86baa3a451a5651a6d96055f05d685b7d17 GIT binary patch literal 25958 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%0A#6o4Bxh5GF4cXw?Cd)I3*v7e_GjeeJze#_ z+s4l9p8o|y<^(rZ@i+97zktLaIo`(m3;L5^K(r4Yo?`qB{TC3y2eclKe?$Kb#1F3b$iJZGV&$YcH9n_R71RSFhkTC+@9T$ia@?ZXpwv)``Y<+qp15i%UR zI&&T?9(WCC!=u7ALwPy6GxrHo=$L4|BLlVETr7`EG(_2_sf4-9s|o zzvzNXmOKQd>*HVgHOkc(58P*<0bZT>fBHxrZaAKk=F3}kH^1uDi|%*F3s9c73W(sl zhz+i8oN)Q13F0uSr~ieVA(1mJb;)*UBi>->R)HS1u6LAV=d(*sCbHpV1|Wl9b`D&C z>@c%+2PkpR=!MwG4pn^a3|bfI3Nvht?R1f-v=@TM1|J(rWLZt?BzJFw8UdF@mh^F& z-3gu}12MfAKKgb2OE9xLhK7v^1ML)+QHGPQAy#Wnkim$xXdO*A-<>_c*6)6@$=n5i zqgZ0UW3E)XDOR64t;sX$I3`^A_CO|M#x0MxS`Du|$|@7J?K{n*X~BG_61B31u{jTV zxdrEpxc>Ge@M&t<*gXZS}C(R!)a z7$j6Nr@M!c(FtV{9h-sUxt@ijr4FVi40L5PPEXO<+&P6)$6|9uy4?J1)3Qme&Dv)l z;U8t8;=N(W#lqX4n#3(GYNDyi&M~6pOa~5hGZPO}sF+EQEjO_e4+Jw9c_E}@^qt-| z%+BH68K*c@jzO82G>#`L>GPZy@PF=?(4UX@>ajFA3Q1zb{E>hieOYV(>fe?v=YOej zf{qm0;RzZg_7D||P(815I(y$IzkVo|u4;ar32t{U`VG7eHYy5%NTh2Kg(td;&4;pJ z^hKIwBXqXRMU(I)vAbc}17?XY$ zECqhET-&`;%Yi(-Z4M_rf+f=(f#~`edPiOn`ao zW34xqJs3i$bK0#DY|rfp_SntJ?Cp8FcqRjX!3%Yx!geSxY=M?@+6H(P?3=`uRRfJa z&ZVDO28&3;#*#@&66$nGjNfb{eU&BY70h87=sO!W1p^CRej_)%^l%LF3@~(18%caRPjXxro39Jx?b|?gki92>jSEGE+Tq15YZX^wYgy{Km$ZB*bo>5D_23gX8E4 zD=(wg$Q}E!UvJtPM8SzbPQ-+{_aAi^MCRX57F`XV>*-Ly;!C`=9+-qX>g9s z{3a^L_=fFVZ64!-(4$A}oG^^+ptYld)5n*O!<+q8!6FoNJhlKLQZ6d~>ZLa1Go2Tg zyT3%?H!}!DQ%7LKv8q6rlDf<%jgpJtm91X;1Rva?8C-k&38ZPC=@H2`7}E-dZmyLX zcKLl4G|N!ofu1_ivErsXO2mU={l6`G&38!|`NDDLgu^&C;<9@Y!7HaO>l2@=ipqI4 zmKiOp{}I^=rWZ*YMfA-YB&DOclSnM) z7%=N=P@^ndJ_AX<2Hz0qX-`xQBg|@%!e+PjfE-?d`thajFDLnuP-81!cHgfZwCnu> z(1g41FDzxadzrIV&aOXiW2-tBd^t1tvNQpvw|3jRl_xi&)e1?hL7IEXPlV@pRe!Th zJ&2U^Z8hzhN+iNHuaJo6T3BKF)`qR{@G`d$>)g-e`0K~jd(CkV9>yhbDhSw+W2*$y z&rW7xn!l8VSbZ{!EzqUO=u<2pa;Q7cGIVU7gJct+ET5M+EI^Z0ou>}YQJ&%m@*=IE3N#Q|bsnwqwwo31015MWY;zjZlM_#pkwBZucz1=G1;7{M5{ zdqoFT{bGy*N7adG)rsV=X337llSz(IZ34IhsGzYBBL(S&9fy@#@4Jdum$LMKS}7K*z3K(Iy>Yo7--i82-4S zw@z09i71KUZhHH+U8f@$uW?k9XV`I)}dS7MRn3P={) z?ibI|uAwFY?6ZTC-GAo|6u4F!i~tsffK8?aY3!m5P}P>IWsi37X%B z_!|biuf*(A3>9-$%s$YppPt!5_g@o4C~z&W7W_M;<(tk_8iAt=zv!nzp(X)`wu_H$ z{GGS?3wL+ZKI~s8{st4Zr9Kt>JwZEiKOz2xL8bpcq-S8CehcT;8miO*2pxLgHN6U^ zbW(}is5z^$s!MZ^e=l<$6Vgg5?(P8v%5OP0zP|yi21^nxo-(CH9^TkrntFuhK6!Nl z^#6Eu_}>8k|ASiKb=~`#7S5vY(tG8W9Eaw_@uKD+pEBfp%zZRQAd!ce5a8vf8QM(e1ZXhEVkGKcUF+p z0Ri8V=A;mzeWSvk(y|YqIl*vjTrGRtmx7H`47c(1@6ayOKV_ODcKjCG3G`43yU=^g zo9$0@p0 z!JgmmYYbR+mXt5g$2Mh;+EX{Y;%IlNc4Y z>g_W{y7GH5rZV$AN;wyPS5j)Mo4Va0EL$cR9hd1xwz^AO)%KvEGMO@8^^CaQVl(tZ zOfVw#9wmj2v~z*n9WNBOl^&7QScOEuep&qu$@DVXT)KETz4`^`S%_lHS01j@H>@wX zjtJL)MQqC}xZMVQtk)Cjj13dzZ4jyC!->jD6;<|hqyj1$s2pqiKk>y4nDE2q0kXM z?xkw+hsPo5&CLBVrs}y3N+wDaiQ>k0OFZM)qpLUVUika}8gnO;Ee3(Z;w?|Rx1McZ z|KMT@&8>S((U3=Mh0z?yr7S-c6JTXQQ4ck74$F#?*T;m%MBrX?icT3B#B%Y##!n}b z-}HusC2?ijILgZ`bpo#l^F5l#<^lkP8sUIiG7sh)#pu!`QjFW7W-)iwb z(3`0*C8lTRS|`-4r`^FIq6cdBfWFyVXAvAWJXf9ZLrbF1$PLzCDyvoNEk$q5${XT^ zRkgA#GAMVGL$-W-@fy+&>GK^c(GH-#+x-m^?z$1CX&e0HqPt8F%j8%eQ{1wi)Iv|_ zdb^u&PTEV(aaaU?rd9GI9>aiPo0 zgulW9~s(@KqfouGi(^tU0 zngL9~b&Bz-mCN0kov@xR+wL!?a*E|nzoLDpX2mC}-kBvNIN<+zaWlSf2#L1V>?F!N zRT(-y!@4cD=+5%7ht+=5sTK8C5^r%LL{IeXZVG)3>g!FB>ZqGBx+YR8GvGdZbaPx_ z=&tNJkgxx9RGFLo>Nl?1Y*mIdc)3UA!n_Ky8h^E6S!{4Wns&;j#j%HO47p0AfZ zr-YRUtmXqh#ZG40c;#^=R!Z(1y&)&(*hA@;NI$Q2vrdgsH_Ei1tRy|m&pXQA7LBEB zHJ98la{zUtB`Q9)_8~uC;TRvX(2&6&jxR$tzI8Z$@@b82#&j#Caxi~esQ8s9HqUoT zyNe_#RjxgBSI38wRhiemGU~YZL1Uq=9ls=ow8B3S2F~uKSv3)$I@qhFSX~#hs8$+H z-HyzYPI#fhc*i|pd}maCeAhoWz5^l3k@wM`e1Xg=ATxmKwq88&G5S1f2!-h+%oEww zBw{AP*26F^7g*^L2Z2YL@jeMDl9ztJVkXi_+|B)t-J12?=J3<20~O$T-3!-DCR{0& zQM;@DWelj4)R`;BedXm&J^pl49TW_4Oq!Bq!1C3SlkBm$tS@iM%ULvRFXU5v%MP8e z?M(NWAf@lxy~IU6&w7;1DdSsZ7PAL@al6#AOC1!AVDTI$sZc#PFFSVMW?Z*Ze7o{o zgVp$xum~1R;(#3|XlkH(<>nEV|8i4*F*-$p{6%Wt`)!2>00DY3+6-usHaOAmnw)K^ z;Y|z1B;zgLS6>3dAJ23Tmq*K8AGHk0<$$|8H0=7AaGR(`8EN-F-e=l$zkgUsUAlCFSXgu)^Q?BmeE! zQc{8LtXkzUFV2QgeX#I?Ifctkb?W=Yl?u6R*si_rFn5ngkc{8bm4@vsa_=d>A{}yVO z6gzyMSb9b>bUUZen|<6Fl)ZE`y!$bUD{k`G_}F^;tfaq2!H5f6-Ok*!=h!zMd>Cfp z3D&Eacpj%=zpcp5q+ix{GY004B0u=9b+N^*;KmC3(s>+Ry+}aD+Io&`sHq$sBo&*W zqdR?T3oq$)0*im4U2&SeQsJ9)2A~07Rn_EVcTt1qj^hf zn_Skhp|=M>Rjz;4p${@BMx2@1-2*x`9$0i+)CO74t(YQm%i5nUYi(z3xy;|aCmOE* z#aL`APU6v+=&&tGmJ)e*Q-s@7?xgve%uIJY3#|Wh$8o}rS>ei-;`0hAJ@Xt$xNcnJ zXr)!$nnq;kGE#>18xjgb^k{2nJnPNAG`a^^YF_9a@<-26G8drPf?sj`tq3)cFT)C| z%+*I`WZnNQlqz3Z#FG=OkJ&z})u9JNGZV0PPzM`Ks~ZdKNMGf>=syxSlj2H=Gc5o4qggeitaq?( z-}_;^Pj$yepqbIS7gx~kJ?R=pgE^%P>j)j!(c zQEUn{R35@A$w9RMJvT?)=d8Xk8Cu_9VuHH83j}Z2S;W5n&QKobH#MH+yDOl@1Wh9y z1;;KqPR^YJ&uvJx9Je;TJT&$q+A!-ICm&Y@+&x8CfK*JE5y#{-ngRT>8Q)+3;o4Mq zIj8mJuHkMz)um%lz)vB1*Nx=0}|) zuD)UZnCngAZf>d^ud&4P8mQ#QS`pIs%1Q-eLw!&h1&xW3`oLwC6pbBy+BVaKbTe%B zUCV-{7EXzH6#!L!Un~gIqBlnQW1!waUo~PcRIKd^y=|y8YUm*H#|bn>^nUMKqW`25 zwp}S6iI0e|)>h7|95B&&=3OqUe9b{HQB&r7`m2c5SWT97PYD!4iG!dE(TjJ$lLK_* z?d&kRB)Sl_=4w5?uj_w6)wuem$Z z8(!ayH@_K@jha*)*N|Pm$6J|$z4v(Pc&Y5?0T)a%&i>QPN0-N^Um1$!=+#+ITV~$Q zoCvUhmEvb2KnU!o0+9)-y*L-j%^R826gZ-Gs@~)`F%igcsgF zYT=ZZ5WF)6Q`&bQG?nbNz1)1!6@@m7|Ja_}p?bm+{|K+1k3}}f-g8b5^o_tG+?>G8 z4^XS$XK#o&6U$mJw}{++{)I#Gh^yC(0P;E3DhI2>4mC6i zHOPiTw2h@}Lni2aHS5>Xjk}4u)B}(>@(Lvdh-}srn5KsyYeUn$PsX_QHtr*_Y-RB=KH8!L4{@Pr!c)&J{slVk zVchuOgorIVAx_EG<)m?zVnMNygLH$9d*0?piz63&pCCui3y|gK<@~IVwqF_xJSL>* zyc`q0DSe4Ecc);OHSYM`s>z(VT37d*?IPgY*@5Xw%&>?tV!4^wtbCwt@nXAQjt6>Z zN=T^fqe+!!wt<%b3<~$TQ<&Ktc}-qP=GNsFpMbkhd}qBkMM{;ih}dk=h;4%O`R zAt+p#uP}=MuKGldsaWN%-S&-N)rM56C#7SD3vSXcR#awk35G&grbN0bQWFYHvohM> zmE21-n=6^GaZ^0acD^U093?DSmT0O%lC2u^R`>G=seWDJ8v#8qqtMxqhu3uv1o5NU#F zG0P{3#&>i{8Lb@{o71aftHt-=v_&k)cJ($>T5psaijdR4l+)k3nud{73^}h+-`S4p z#gHARs?YElDNSj%(7uFw>fK6c*F^Yl5*NPm@VvUCCpf)a1mT(=M7a>1YzxZS<&_0F zK{Dg5FF~(Np*{=BRyMgl`KWB`+ZtcV<*cRep4;~=D z4XEDoD=KhEe`(3C&>&@qPDH63X}G6!i8F;;SUYVdgJ`U3lbhYafXYS09eNy|LDmwN z>B4NwzUJ8wX~t-(At~VnhEgi>qg`$vF(BuQnq|5pUAeg^iKba8-RQ_BjiUzN_{eWW z8%)2-8ZpUp=Xnl^{V_Jd3GRq8Z@cP!DpGOF0hELc>2x=_(`oSOmO@5HqTgpc4~4uC zz>!gupz9<~a_36~U0m5^kYP#T5boLo#FdmQ5#)GR=Ry%H`-XTgJ@`$G%;$})Sw|zD zKpu?5sbOnBmwRyL4xGYOCT%EITI)m?`W5+3S@GH0Ct;7yWm^-~oy13OG4@|be%fG! zgpf3ZlHbGKOyd(5nw@IlmC8z?!cc6&C|;NlXCb;fblLCK`b)r9rd)%Br(-i+k%+I(3mJ@Z>@3(+3Uc;aIt(XI2{l&#kbsofx#MkRXFyfq zjzcAqv>>`^6}>*qv8e_rx+Bw268lk2q9(j$a+bV7Q^TZn68a%GDdMaq_)XI_vHq{8 zB~q=r1b9sKL2h~ko>HqA#Lx!Sh(QsJghdFzWcAO-D{kV%_#N6=pISk$Q9?Nti!`sPtxWVa1vr8~WAo6sh*Ton-P@Gw!M&E%`!$Tylw3GFlr^IPGvJ^o zBD88yT80;{$>Gvr5@{mZhNY%{j|O#Tj`%$b$D*xrMU?mv=m|(#3fSRVUsfVUxI5}1 z!%2MsYQx^w#4kigs@toTu}*5#uK{}Ulbg* z^+*a0pp!0ou>9+XK+@TtU&Z3R2T|0cEKad}hyJ;jgYz|d5w1oF9U<|$<<5wW)xLxa zU2E`HGr9zMh|VKth5W3w%CI+nZiQHSnGjy4Ht z_vk~3#Z5V_XOvMTWL6=z)J;`&e?14v3c`H^4?7j^bx{}AyJWsngfmG*#w#Y%L8W!w z;bh^8EW-jrBOYMM6Z=|DZ%BZ{tdwGqRHeC4{d(=q$LTg1YyHOeF%C)#_Vx7*eW~5u z-CuS45Rn!_okS3$#6_PtIv3gI-Y%PP4B`bsI#RwYkQXmZK_m`m;WHS4JGC--G6nqO zFU`QjIi*ZX5ei;cEC;Gf@VgU{8x;FW`Gfz9Y{k%{%`s@4{xu1D`1?`}nVjW-yADT6 z5#t_)$3U<|kh@7yu07A?zccQ#Ijv^qxM;QS9h44L=<+7eYcCY)zh~0AveNWTZsSUz z)MB&7=`br5^W5dzzQ`5r=FmT4iw5~{v)V>gLKdSf)9OQpV@-0!hmMP@B{I08LFQ7L zeG=Dt>?X1=spU)z`(93zN~ zDm7e|lwIRbjuT0Lu{G0*0B#^j%Km9 z6N)ePzfXZ;moZ2L1TRaTH1G-R*}4C!jbx}_-y{$V0u8?FWm~V6htm6Fs~q(bYNjRG|PzN&19XCPl|WmOzX(QvQPnPRM~Zqa4cDRBy@9+8|hk2NVrr8`9E} zTe*V&8wa*OiwrR=CW_Qt2y-{^=ne4j;JW|A&d{@#Qy3I90ryBJ(U%W-54@f{!&+W6 z{NX<}H~Q-}+SwUQnXLvH zlfVGI8?e647L8BKi@}-0shi9ud6~q;IM8O&mn|LIos#GYz7bXV(}oetc65*5_L3OL zgTziW$~`g%^YszaTP)vsI<>;tUKkO2(E%b`Za7IhL9=;jRGDdU>=HU4>eBKibBn4_ zjlsw_i_j{YfXQOnH>&oht#_H<>@J@Fs!`JU9{~>jdKuu0eK;PoBlN@t8xL*tRs}qsA%;uUz!;H?Z&V(MMp!A%V5Ud3Nto~IB z3G+wH?2l6zd>+Ch*AQ59mt8v&mwi_Xk2u5wa}5;RcaT#2e)0@2s}yI-HMn~?;aG8H zsd?+<%(%6pxua{+jYyELPq&MfL)~SSL*cazA>&8nOJPkmP{m}?R`QFkx{i!2`OGN1 zJ>F4NDC=JBlVG25!O=$qsGA#!zvPe2K4fIuiOY(!hB(E-Idi02OT2Dez1sg=RrSy3{s+n}+TrD^+jxuMJz#=) zp6T(j2kY32?U)f!gG#~tjz_j+lJ}%)T#LoF^YcC6>h7CaN)Tlw=s?hlwR(_NP2iEB zdOmL@E!P*vZYU>5KSIKc@#;-xDo&p)TOTebP#O6HPEnuA$Pp&GR7Ng+vn~C84;TyD zZP^3-T6V?W?Dm}BYOI*Tr4y}W2I@Z4T4mN-mdJ6yp@yvieJa6sMOvwfZ~WLU^={Kx zn}vALOqs@arX3mU!eaX1y4t3#EnB=myxtv5XV1{)2~{!R`VSco0RJjp)cfbV;GcTq zzwEE*zvw(*@^|0ZO=(P*>1>-zj_B-)eVe9P@;&<OB>S?^9>8|_+w{M>A&qO$lL;XL{6&R1XX*94Lj;884OQX{ ze6QPY`}(}Csj!;mkHZcr+o_WaYx2A9`*nzaFgn{G_O zlzIj9MPqL$1nhPSqkwd4E)O0L<-(jB6lv3Ra?}2Zo`Gf|6BV>crTXo!F#a#N{6Vp7 zrYuq?!ZT6k0p8HbS0WJmc#~qf%jNMUo&01S|s}e5-bozq8DJn zO%B8^J?my*MQVT*_4!N3+1f3kU>QCPmUmoC&0$CSyozXKoqbuM@GYdPOd>~XgOove z$}Tt8T^DSzM-qrhQ)IdpZs?z6ud_S~u{Z$jpVZ z_&Dr*E1bfSZ4irtRNBd3J(Zsxo2&T7Z!7F(6v*@SY+7m}?j=I!vm@151{E3;y&@)B> zCtJXo4Ns1;EVJU18OC!GAnmQCt6pO`^E9h$biCH&VStmOH|%^0s#q}~o_!2B!z*tp zosCzp)k~QH7k_rl<#NqJ8V@>spMAgZW(-tI`?N*2L1VjBYH%BMqBcYSPa- zAOC6mk(7ItQE`_#)d+#GpoD`N-8lp*H9C%LG>>1k3`T zG@yTBEk5+k6^2tJ>&su8bngFpyd<8*{;hCXRH`?oC--?0?MeNaBbu$lX{eYfK}lvr z&N;m)*-@_eIN8@vrRE3XL@V>l*SK+Nl7|$(_~F3Zyeqp^d%(Bd&V)T+PBhSFoqj$h zhjo-z|At#{SivDEp> zV)rpsg}n!CTI{sncs@2>s6!WA*WS$WbpkIGtDln?)74#cn(bcrpXZqNwqU;Ehc7I+ z8@nDeAtH%#@e!-VBxJSJ=f4!3i(lFI02t-c+#b+@<4yHdJ}h|eW@Ed!h;ly3b3Q4Z z^m;SuFU0AJ>%C(mD;$cf1cNdR#4QG*OYX0hD*q;Q|NEfEYP8#|efx~B&V8mWTpCkI zXPIhjB=x!hJyaJ17M$$#DK`!O==ZpCG-v~2K@-%h@!;ni_ph4a%p1-_y6_=2lo|sv zQ9##5$D)-_A5?%d0E<)Kgm+v$ImSzXW3iuzTi0^@VJAN@Yl7SN}y_=2Q=Tt#8_aRm8%o>0%^$o%N5 zA1X2tX*3a^@1u^Q`H+YCrf*rW2cYnY>Mbtjowm}NV=Esbr1X8Sza16@84O0L(Nu9{ zeyHiHRrki}5`_C49s6BsW+J>9!*GvtQSm-S*Q+z-pB!%PwlmM3?e-(c`KiR&>ED%% ztXnn(-x*{)QOg=#H!j*xbS~ZhSvNaI)LBCT zm_ul>WbQE z3bi}KvG*Ml7Z$wq8^;_wJ!kb=*~vCq^T-tQbUwcsEisZ~pS1A-31gFT6JP(RYNmv5 zEyJ58Hw1(f6tqm77oOecCUl7XXLP&`H!h#i z|1XbH|Gd=syLAlh?D9S4TgzLDEOwA`jG|jOQi@C|Rw(~weE)m4#!9r`${Qy99&ln* zxnLa2>&#TYC2C_><+wJlbgBNu>HfMPjxWhPz;=aQ$VX*$#!3!Y4jzV0O^9&vCbb2= z0+@8JI4~gOI2F6c71Z?xVGp#2XI85%Cjr&QKVcwnj>=L+O|Cji7FE;;r;k%bjYqOD z73gUyP*uiIPD;)M6l>KV;p9nbbEd9~ZUs4>{AN8I=t|q!S*8|?YEUB|X_JZsv!$ba z$^)bO{eFnBf1%3zADm_U`BdZoEF^+9buV1Z`FiX+oed95791Wy(Mja|DtlKxK4ToI zo1Vh)7T@+V8J*?nuy}X0a98|p{_34=H$LrTsGus0)Iliy5-nmd-8r~Z6GV)QvFm*D zBU@?};~c5dUf~BRR!^E5W}3D6+_yfaoS=%m5r{Xz>3A&h?Z5kQ`=uWhSF~U|f%)`4 zoW^}zBfIJ5R3zt{3TEmWoAt|NzNoHDfQGmNl6^1z+aU6?ys%o^0MS;ex*Gl0G@9^~ z84Wd5_;3?fx6fm#Y8gZtfj=g&;dQNO1quH1Xsd5+-}*ey?DXl4+TRxQwVl5YyKY5n z?~VXc&ifXd>JR;YUm)+TyLp85=6|Z!_*dlqZ7nSR2eq(!?%pJB-tpWs&c)4!>?N7< zg#Fx%#PI0P1mNEweDr%(_Tg(3v&z~Zbd3QL{{~^ugWpZ$8ucmW%)db^{Y_6<43-Bx z`q5A|<}q{sKJ<-2WHpDB)(*6Mh&I_dB?W{q{EY#1)<11PO+xjT#eJx2HZg>4bL`Iy z)XCwnZb(H<~Q&1lNXD9UxZe(VY=BXn(w6!@wivA%oX1DZUG7 zYh7DKD!+!r(VIsdsNxFcY{(v(t;jEQnH<>NIT}(&!KuY#9B-osU&X9-B1*+8)-Fq& zDyJUoc?J?)pGB+{JJ|MQ7nv-&0csR`;9&4i)Q5Jvd(`#-1%Q_wQEcA}25^6i?TfdcYMMiWusE+UbfWPfBnVos?$<~`nrY(7-> z9ysgzlNbOvM3oC?t&2au`xOU(g^rK$zRoARsw#>!$t}5-bqB--J1I3*_BV6QNsuUL z+I)WP9T)GG2X&*%vz`Q{PR#8kySzIA^M1h?=Tnu6@|jYECl515K^X23$~|X7?m~O^ z8gSM@1~_o}7m^>$arOt7{E7pBqU%-^)A1*d>4i%LFoItVdKG5w0Xpa5{b?7JOoVA(OAbk(6J3zC4 z>&&ig|3YECS71;tOnu4MK`jW=bhvl?W zS7z-i*^gcIXl(YqqiWKSR?#gq@1k3*X&)v-tU+8%TM1iIRVT#dO1x3N^&2I?rgZN{ z=#L3tKZQ1*OEG)hbEWyFUWyn=c1^Cl-=Ri5B=3R|v6;nW4bPjg{SwyUxqi%{xzZ+t zs6-rSbN+7Lc1zXWUol(8Q;LF?7ij@S4!aOukE>R5YF%sm!&1Z`7r|k>21gqvN>rM6 zHq}nYv#vvhxVgxZnl0zLZDU*RkJAdP=?kVnCH}t^RZU4+ck|tYFJ^2J8NiOkJh9!o zOP*UCU0NG8Z-Wg(mFbt{O7&&jAy z`}XB6u-TB;%g*DY@knIFJx_0=&0RIS&exyVB@J90t^98b!_NiPzk(ef8tgKQ*kh(t z8hXj^<MPy zSg2~7u3RQKO)~K*1ET}f0)|arl&}3eqO+Id>;4TA4Gx-U>zCG(zJ6&NI8h2~f=f7< zH4JxK9+%X9{c1AJs1UTcuiQs1Hg@k^jUAd+7p?{e0cVGSJ=EWGJH8lH?6T-8T-@2R zD&*SjOq0_+hc2x4)DpRBsa}1fJuc|^zliW2E~l6TsKJc&TT3VGzqJcIHUkl}+Z6Ga zyZb=mi)3x{gGaLhXKmiPQ}B86moj-_-|!0?N;NL~x90gqzWw*M+OqdXt>!|_3(L7? z*#*r>nlhhNynN5|OIC4H7D`p=2_FucRHIU~;@SQHxl0l1555VX(p_blyyoSdut8Ira0tbbILSFu7n6g;xv$pQ8 zUMc<(LPXa0qT5}Jn=uMRey<*m2% z8oOTWzxAy@I)3@;%5r_|KP8m4FLblrIp21+Sqr_H8xMpZy4)+Z-QOm3-eDfSnHN7D z2Il+Bq(ZG%p|TdcSHF774D18&cf7bScHO;w`Q*HFzRT`}aSH|?;?dI*dG9tyYRbB; zSv9jO{ih#)={2M4+d|93{G6IW%TqQ*ryZ_i_kXl9>E(G*V6m?CbJ3*TSJ>Wj7D&u^ zoSk&n{*zqD@|nfOxt1nreN)ReT6ER_kT5@V-t5S9v&*{rOJ60w_6?rv+E$h|XOi0) zxv2}=FU;r>c^G(g<*c)+)1zK9W%{a023N?4Ec&2zv*vnTU2L_#0yH{w_NrfVI(?}& z178fYpG`awaXn}x8!N{GiqKL+jXG?b? zYU>--^D67KrabkFOq-R)9;SI(b+yW>opJXLPti_W0Y3Ddfm`3nHvf$ixJ}8}!NNrO z7WdUD+tw}pVrVq&>Xm(Mu;7J;DvSbkOIm?-4x)VqlY!8Tpvphu?N1|wFyp4uRabhF zgO)s%_@xz;tG74;AqQkdR{5`KoxbE3qRD_FO|Mdp?dHEZMLV8u+ge&ZtARPyGwtNs zoVUi)Uk9IgbZu>~6od3+udPqBBY{ijYT87jiu262Z#V#VC`QZquGh`59lzpA-)^|X zaBHck=en@74vbsZ|FQa#H{WV+&nk-ouWL`Xou9dYao_INwbHg#YjfKg;Krdfnk6r7 z=UAUz?ZY7bZmLIG;jXB+$szMHselxjIph2cD=}&he_7}iW zhymCDuww@Ru!HvlY!B}E1ip67&IE330KTsOJK{Zn7j|C&09@UCyx@ju)fLmjJ+# zZ~&mB?QgKZm5IkqubaQI+s(V~baVs&<|zQcAqxQD_(uSM-|9CU@A7waJHtbX@$_=% z{hR=I0SR;>u^m#l*xf z$|*=FgRfjSGQ47-Z(`|v%LL_$);F+!;^68RbpOG9*o~OP=#T{O(EGtZnCuWbc~V?d zT<7dronV9kBKV&U+phrv`*(`%irKZ}9AKxwj$Hyfw(9|+yyD)qwZCgz4_!PrG-VIlBAal~Xr8g)ZQSY@-=)v1BuCrC#}{ zaehzA(`>SEB`G6;BJhLBZThgzo79p^q}Ep*m{O&UKikuEaiY^ zV~Ptqy5_LkIz!dr6)qE@OFr17T0rWp#ilc_xXCZQ@NFk`NEMys1IsllS9j`osno%f z>s8Zb8&nsEGqv9DZJ(Q+NH>_H2P#wHCyMTF1Hv_ZGU1i)NiX9e(@nj?2AMHhZ!B5G z+W@_LT}q4NWMR%AM9^E!1yLC?_R6DAk73KE7&WR6zyKQ#$f4^|%RH%-F{xy;P?5ti^0G9oR2V+27j>|a2;m8?@ zwzY_JJE0jN8kyiA!^0U|yMbEuiYS9r=T#@e^USVCbd`xwYU`DAw<*qY6p z+Dlh!o-hD~3;kjcty~U%monq%wY$e=uq*6JrGe68ef(V{rXeS?-E8ug4l=)U$xmNA z5WYA|&&e4l0YH>tnXjC(wJz1xU?&N)VKEL(SeMQAI{iWt7@&O0{!B%dBh zQrpYa(-N{g+zj{Z^lqtZKIsCr8)R1vpE-Va_{>pZa5bpR{|)Gcn?%KMz4OTKJx|}V z>bWmpc~a^i?&b}~>ZtxragQ_dj3QF8`!cR}^zxvj5qcYdG1_X{1_rYblm)+!uDZ$3Ij0OAcQn+3KhmvzCqx$C`%)}7@lBS*L$ zF_^Upn;{T{mK^<4-QbWDM!I!GNP;5UDDa?5qjdVyDMTHLDhxT{nP}V94ex6}+?xbS z3$+a-ERVW`4nERm>^C@S;w)c{{;F$FHhL^`&0I$7bHDuhw_TqmoIG1jjha|4xZl?1 zl&Oh{aR{UK+$y+go~??{knl)^n)_2LjgU02Q4YN39BN)#NN7(2Q2kpi`gNp_BwFMp zdj;p(nRj0QVf-*W)hU=Zy-?B>CUJApARKi0X{#v{Uo=xjiNb4pMuv-J5Td*MVzvQR zJH%U5ldF6lq>W8|7ny$LbBE~Y7VS&WQ$5*~O z@%E#CWYW2BQ(<-evq@Efmol>lfOUtUR~>5@^r?*(Cb?sy7PmE9EKTv>mex+=0`)|k z8sRXnN)xCQ!v!O`c|ThFQqdSQ=k8!`$1`NbjEn!Lr#Fb_2`CKR!E1Du(p1fFbd+p- zp1c=7uiiVDhJFT@eoEx9O;Ni#b8K{NsbjaOnh#p`?pHJ+-^D4Yy5=+%3 z2H3G#kfs(@t+k2nPqOvLYw8`2kVt2?+KScB!HMG*Odi zka9#0Ayuxkpm_O)aXwUvEMpMZ zb*Qq^dkw1`Pci;Hj(l{d9NcJ;(vi@a$SG*#H0Rs|ekPkzu%ThFQVweHc&4besBcvD zmTZ4s-q>Ee$9M+?mY`4I8-aeFUa}N;oC!0}e#4=dhOJ5}lBYc8Vp@;KfIK}cd@7-L zrHp$`OZRK`$x@&+%)DrK5-7{bW# z`Jx%h#l(*}p7Ey40X{=HRLWZUCHo*`GHn!g9OaNi36fqcT)Th2X!f(Ts2fSFyD$=3 zR-2pF@WJ%soY#;+0R^UZYx-;6+~wSo*7o+944hcsZmRpKsE6|4QG>%)_1>Xh`w*gP^@>ZK<#dYb8pB1Uz z_pW$@7g3j$ZN9Ct$7rGUm7fyn8(TdWCAa!6FKi`^h^PJ4{XY=f2Gnle0}Or+E2M7& z>@PnRK9%>E&J0oQvdQxU+{hx!W+$Phm&|(vPk0>kvzraPYWOq?zjWZy4X&y6^<17ZmBT1$s=g(qJ6{%N4egQYY~W_6XdEtDD(?7#+}ZxfzRP*iYKMu{ zBd+eu&#NJj1;C-xB0zncU|7>81g2%rWlxt`Uq5qv|0q5e|6QJ6Ic3Tvm+>Y#`+&j8 za1wz{&(U_f01pX%ej$7J?qB3#he;d+xVj!#UV<+&^E(i61zgl+so+pxn@n(l-8uKN zFybZIhs}m%oIR-}lY3YAilV4%Sx%Fqr8Ou_FyT&@?l9xQz?I+106NQhFxcpA(sDqG zUA!R8?YVKqoM@CMsW^{WG5TfzvHQxNP5d-+My+({KLbyNYR~g5LMko1rrw^s1-;A{ z5_@aM|8OJT@EwWq%RuH};^qYXmnv2_t`bOt5FcTI?RD}8TXfZM?>ZRP)v zp=cW&bW>R+-+B%3yj%O3yA3G#sXqEp=el<7^0k#;TSe2d zf7!hk9mSkxtkL5}rMle-r3x9OU9z+|vm!!;(balMoQS9y z;JRf6NidJ@t-@%HVC>|d#2B9FnjA5?ry60lzbLM7)W=NYZ6fXanR^PO1fXh-fOgVa zT*sOl@}5MMvqN|$UT3MYe^us+sHfc#$wXPZ@gRInWRp|{FG0BroSr=+Q92_F9Eo$4 zS1mP#??}SH0wlg>q=hU>sS(eyAT9lQ*OYf8-Jw|~KSJf%mL8^iG&>YCj~--RbAwHl zY`we}#ZEiy43_E`PPa612SplNY4)$GoKbK*0ks$_p8SN@L3n^I794)zONgTq__g58 z9eLkn`RwE3MxLBeu=Knn(W$ z3q1?Eq9R*fN|>4R7Vu%MWJ+ztl`bOhIgp&0p!Rq$_GlJIOekj?u*j=)<=@?;aRzlY zZpNuKO&_r!*;ij}H@ZA9uDCq64MZnJg=Y2H}~=|mKhC)ST( zIa8Rxns=lfZw-^DD^eJWW}kv`DK19b<%?$yi|Y3Oj(^*W)v>hA%O3}Lw2!R+ju-!( z?XzEYJ$k0`JO0&o8(2po{afG_G1Y+oC>Ah~+42|s#ovXCKi_vq-2Hd8ZvI91;=MnW z`?KVJ*yNu!_uJd}uX`c#$6F{`YE1gCCInC+w(Mqe8jchHx+jqKl78ai0$0M z8F#ntAh+{Hi|M~5i~hmlnrz=C`$JRR?!S>tW@3V!bp^zOs`>dODM1dUSH z>K~*g%RPpeaRV(L^Rqvs>|LHJ{Y@G1?k7_hW#0W@uKYvMzTT4GNU!~-m+&x;`J*3F zPIZ^`{YLtmUYTM))c=u>U4Lfb&sz9l)qfh&pYOtd(<7Y|Ei9bic_gy6zGZ$h+{~<2 z!X5xlN_zzmP@L}%++v;#OIYt*{;^Q?e<MdMME~%;S2!=9|xE`}Sv7pooJLv!ot5_8eEh zZilwuM5`N9x%u?vW0huWUVKGb0Bzd9*jqmSIfnX3m#Ghn>)kQn=rO#{;Y^+ z*2lqp=%4mZHqzVh?|z|&N;^({iTdJk;3OZo)zmcZ+1rCnc8rOfC(sPBE0c9Ms>$ep ztQ9m1uRCoP0vQ`2dbOH%8oHv=?pfDwfB^jen%WXIo@=g7BK)}C|6voywgK|r^S-6~ zt-4o-Is7KGS~NDs92p5S@iY61nZgQU8Z10UMo*OYZUbg-?7VeaEkB-?f!FpKq@;c{ zws5D*KON`)MDvQ!-%ZwClg>&g^?bi0XKw)mXcBQE!Caj^$$7sAtBW->lKREbW1u1- zfap8w{%IH>@$J8D_^$O@=`c-mZBY;f zw1>c;vKuz*8AYCW8M{Xr@q`pcKgE}#x%br=IdZ@t#~(S$UsB}|B&$QWc%k9j1-7GN zkM6h!__2!nF9@n>Ly18uzg>5}vYN5`tT*%9EB7yu_xD)-V?DGl_++AUaNjt$ zh8@49#Co;k(j{fUjYjdX4{|W+VJ;dk>R$oArYbEs-%HM$xt%5;#J_Z2$ezTqqw*K) zBc9Np&~3nQQh)u^R;(n!3rDU>`Q+>PguK+_pA2zR&P6WNC#9 z)bqr5Ao~&M;idA1gXnW9(vXzER}=-0=q}?$l@u@s)9MuuX;!IDcpbPDsJJ^{sSZ)! zbW1bxkWOW8>0Mf%+>^!7ow@3Ge$<5eup}Hf265Qe~L_1QyO6UlCK>v(KvThI<4ZgvapKU;?4NSt$urOo(<;J1jy@{Uqr;Iz=B` zM~sUxZ)$REr%JknK8`m&Zku3xI`H{>Laarx%fhSFbH#=l@M5r5_a%%5p(n3Q9PbwM zc7TFL3~?XOMLnZMp*FTtpJ&_*@E6K-6^@*O`B2bE7XvL0%hzvswlc(6LHGkyi|J91 zjvl_tLSQQ0& z-EK2zj2P6v-RHJt25I#TcsT5(c!MHN`mk~HSLLNNUNJ5X4Lax5qWJ%Y>D1c4~A4*aO2QbU%NNolhI@m zYC9F=ZB0JuxG(g;zQ+yPcn1-7x`=_SnvL69(ZkvYs`U01YdKGqdTln6P6O+KZ7jcY zRU&SCni5CFpblDFKv3-bj7myiAsj*10_wJCY50|i_Z!@lF>Sa>dxOrNXe15ElF|hU zT#McwhxkA8E3q{UKSj*q-8; zc~Vxx{+o9~G?@>m2UE5t_};wbd0By0bW;qf21yfu@nzeP!$x`?h4E0cxXhK4^0j(G z&a}*Io(EV+a0)wCNSrWI9R?Z&=)B^5xE>4&`~-Ve$IT2M^h~ifB2?MqZYSm?P~v=z zNTBsYa`IX*;@M<%iz13&$HT)kn^TVy`Ee<>@{q%&0%~!(i^avmts^RR1J$u=*-i|+ zKG&HRYe?=eJGDhE#NTphjLM*MkZGF0s+yhtRr;0?{B?Z`wsktNBynt?yV1J?`}9rH zWgy6#5*#kW6^9M8N)6Ev=0p*28L2ddw#E-d`D(-Zrg-PrxZ!=$_|OOpCk04l5!2O} zroMNMxHj@uIM$LMKZWNeM_Ld=Q7x8j%er@s$c@3mFab+TFSy_4F{vy?0ZWe*Ykdpm z;W@8lw~O6E3|mIPXdW6X>0;~U+P$=^a#wqPK5#DaIFnd6F_xM=PPAr~48H%SAjeoch? zaaA6W!Q2Grc-$~^-p>iD@SJ{a=PbuT+m+Kg%2$kavAUvjyWJdX8!PU`(CYARhRe$| z=od4)<~xzz@Aw|4^0h|coj*q5k+kfuox`47Kb2Tv!s0jtC(DTedOt`u4NF%%`NYRx zQ2@m%E^rD62277@4%~iJr>!c@*Ba?;!7Z-|JDfKWuxKr0UXkw=u>}6S@;QD=XhrO> z8tjNu((tVEuCa76waZ&C88EyE+cbQ^64KGk$zcq7>P7Gif7oMb?ZCh6X)#1N*5cAH zpLV})u8b-}z2%}Nbf+(~J!JnQSpjeZI}d$WR?gayoACbd>di4&9@wmbS+pW_w?LJc zCsoWI&#xGZme=Ou0}BS~3bK-^qP|d@%3^wFoG#%-j{e;q%O$5Ii3Mn7_pjaI;{hK@ zLx^WU?Z`}`tR}?LlGnIgxv*j_em+W60Sg*+PSHSJEt~N>rLp*NA>cGB(D1l+u>-6a za)B-_4I_5N)?R!5PdcA}K>kO5=^rHh-;DYrP5#J3@(c%Fe+~2qd=iH6Ydp1(p`CzT4YQ%hDUp(4yQnH(m-01mI3CH zaXj-|&Eo_YLjk)Z$@eYEay|24`pRDMgi{$*R2>lICPapyZs2BoDy9&R!MlvfYPuBVaw2fHZ_P#5vUry*T9J zBHV45^(DX%wpV0w8_q`c3D(!O3HiWr(Kn^@QD<`KAHWf<7aH-%@nAgML`O3W`bIOx3 zYI-FgoON!xtWK_#ntMK;ulcjm(GT;alsxiu^w1N=-uVvdsf8j!S?sx@ z#8;#qD;`hTy)c@hq0?(&xed5g3^aWZeVNlH(?G%6_kSeKsiX+Q;c?-&9SiR)Re!Vp zI=qtXi{?mt2=tYc)M_<(c1+>8i5ohgGSTcDQCsV#sa{4@a#U1Yvj$)$>nMt`L&NK<76EWk~uY|^4DxJO}*$b6QxhoS|8A>wgq>b;*$zW#4E*5BCu z7lvs63bFs0=N~9?EaeFRx?Z(sy=wp8?!9aO0l*{R!Tt@!|C{2!PT9|XZCCmF-tPX& zzus*&b^jME|0jh1QNq7_?%mY=_u1rSf+y_Xsq|Zd{}JK;w4DB0qO0?>C#Pr6?9iB= zJ?U4Ix|>(Ed*e5}W*i-VTyG$G&%0}zLnhZ{YUtfhj0D<`|#Iy4wRDDb7pkc?v`L5s9f&J^c6nv*r8?Ab((t z{{o4BhvgqAa$LAvN7DCdpqEL!tDQm4ZjhI2PVVFjllba*-w|7(6Bw;=tn0ayT0IQc z1nONmdPBt|-pth1*}yDOt*AQJMfCHHOjo&?^~dnT2}YN!w_e(_jgeN5b(vZ={Sr_^ z5sR!DdqTdGCg@k3bqc;1J+VtS&x!d}m0gH-?D3~IVw^MBb_g{InmRGA)ArSmjqCr? zfVuomVcGGw>z*q-Nk$u`eYFJ<{lhg&n>fIZJDAWfpaUa8`AO5SUn(X|H#$}ByjSB? zDU$k)f9GoIH^-n?GJm4~&lHQ4azH}f3z2((KQQ4F&+I(m?UU00Ea#0X5mVVpJP-QS z-KvJ%o8D&NNkJdSd1qG+^K8}lBsx_J#U*RmDF>&#RY`+N-ZvpX@nW2W$K(lTtxbI( z%PAfUcgQ|{E(2bZ0ye|1h@76 zq!h8+=|Bix)DxE*53`i@8r|rTxSJSJ&3`P5!<%UhJ89cd(C>R^P1(0rJcc3FJDd?! zI3a5&nklL}Y9In^F;aC;rfu|A#CZCzVHL)x5bvn)VV!y3;eZPPP6uy4Y5>l@yUK@5KeaiHHi^bVT$>Cm+@^6w&@pt0GKvaO07Bff2$Nuo^N^IYcUZh_-I{g$ za7^#;#3=L2JZdo*M=D#ij94h@f8)AXdtddHY}aWJ{!DLfT)YK7&u6~2hbc_0V_1~c zK^O}SeXY}*BSrZh%a5NhlC_}*M{-JNq9Ef0seRvEOb?!ePcPjh81fQP~go?8z@B=j(2PWHcuhPK?^bI2~l1j zh9PsVD-kaW=Ehcxj%&*bFhvIEL**6MMBZjjw(-yLGx@YEC^S1OtM76Zu+mdea3!i- zIoeO!!R1m-A#kv;9XV={+>G5fXA4~N-f}zMgqNXmqHSF?+#hJAjm0*!mo|W4x#pJg zOw+=s;0*36u}uK(#@i!Ybekz8(X|CN`(^Mf{1`<+>_!}Nuq!5EZQ_LQVnjH+jzbqM z#YttxD@^yaD21GsQ`?8oNw9E;^DLbx=3=oT>pm-}vmlS;>=|HFv)Wq4+EWHgi0(35 z2ZO+g?!K({g{_?d4%ekb2Azk!n)cjfIWO0qH}`P}d`=v25-ykY*^>31DA=^X>-tKi z*q+NXWsX<2Y@mCJulr11ZLB(r>MaIp*=lsPtsc;ED5|)`xg*nsqD^AaTIfpeKm|wP zDqR_asPKi+M~xV-Ao11?5b`V=^X_cvf5Q#n0r z^>YisU*mN6vU)JpnN00DDi4G0+LVm9Itwr2>UpwtA#oWA?LHlW~LOE4%#LHs&VN5}Ge zh+pl*v0E)S+X==`Eu{C+qut_)*{;R*4&N-YO;bRTUUc0gSNpU8EDIm+T9+J<{Os~$ zBaBnYci_R`aTWz8NPYKMu05f&Hs<~a%UskGd5o&f+aR_f?8Lo9`eu3hCm}f_;*h3k zy8~5NeWo+iKuD>|I?_K-;i$3P3H#z{KUrNH2=OlS@}$K@Eh~K0jwr=%D*o4D7?x)vo;jK_M9ILHMLUyA_wbTtc?GPn@ z)DNPLX0_c06)&*K(5nW5ow5tA6LpP!-IrqNQ@VR(m>JL}NyP|%p35kzP|gu6E5;y9 zF}AMs{zS8Rvp%(B;kRJ~ir`TEAcoSGT>DTtRg#IL8W4HseIV^MF^cpX^EO{Vq%IGa za|F!T^px9r{L`_CmAgr-fML7@q0<$MT@HquV4IbP-W7mbMg$B~X|A@- zR+SbSp}9Z5%~)uI6nHeBAW-Ilt?1)!^EFF3gP9jz=a+u7H>I|5`D;>K&A zoo`<#u=ERE6G6V?P{AzZxZYtcSHVTIny&*D2DzgAf{V`N3QODHLgXh{UK zs8`YNSh7OSBRfi?lv%J=W%U;pnkS6N-#Y=tBomRVBh8g0q(R2efm$wp`Km(JS zl3MndM=MChStXja4Pa^%&S^YSC!Lnc+*kJ@4l^0*Hzz|U(X!h~_@5J{=Ka*)nx?rC zs(%vBZdJ?3OgKh}_ltF_K6#EkR`rR7%Qnm6{ImtlDyey_JBOF%)6eFe4LRI2|LnXz z3ngTMi~&KsN}O^_{QAd^pES-lAk^#8-N@R`o;bAC2=iHfOF| z*co&1tYcL@*{pb?;OPY~o3=4;W+v~%OoypkS50lln_Kcz?8w&@{#4dm4Lijxr6@0( zHGylpK(U_G2C}A9@A!riFI&fJQulyvJHRQ&BlMwk@Yj~)MSSNyN;F=pA`&l+par-l z!bmx_Hv(?>-;onj60_nOZdF&rI@B@ z%HtNqAf8COdR48;DllIBS@<09K$SMg`A|iqoAEeaIOuLCAA$lSVw$z1-5s||{REYk zWYjsp)`iwWsrhDXwvxkUFWP4m9uHG;wV*0gdKcGvwPjk?voiy7ScSNOxXadzYREb> z?}!4#*}`kIu)?fndF1Ccy&MW(ww{8TW^88wxw3L!viTPvw;mZTDI;TH0Z}{EnVNv^ zzlEC>Xo6!wjm%>>LED1D;#Dt|^q7>B>gjw=aVaTW!f;atYaCP%o+*QHGxhXtW+Yda zKRls-S01D*0x2T%|LU%KvzZ=$huxd&AA3kNDHSD6Am-+A=KC5Ejpa1i`(V)J2)Vu^ zrba)Zi8OGd&}lHR>ibCfMPc1n6|W^9rC4x_(h1`R<7%AVMR&U1?fVo{xJSLMN~*!| z0T-9?+)Aua$az$qsRzZEROsSEn(ovIt*`ktA<#R9UkR^vr(2CtoO?t)D)Tu0XA0Wl zyohEaek6~>T?8H!J8_I0n20AQD51`u>}=7QTpTeSvZm>IrGsY&Gj}gos&9tHgI*Bu zz}Dl1ZHOoRHgBRdY{VxJWDq>nxqz7o7jY*~7yB9fI!1Wfg!E>K#8IG3V=FITN7XrY zMn*Q90tEtfY(`vyO4kL=pMa7&PQ<2|rR3EP?u$3OZ^S>@R|syhFs)`RxTK3u<%}b! z2f#>-yhQnT^16`uIF&<<6SGXLhPvgIlx#*dlO}a61&1VMnwq42Sp}2(k_-Vf4pq1V zr7T-)a2Pu225WG+A$gTEh@uEL!j!6KE=@`yx^6ZM2Fd@lvVpGI;PqOM%iW#OGiB^Y zRS|iOQdE$|%Nh&JrKoy6oU;)K*8jAuYoplL_F#4JZnWa(vC<@YtAwDfU!i%NQAQ*- zXOy>cXSJyxR2jESnROhD3T#l*}Yn3EESdMB!Z5y$kxVmF;@v@>_w>%5w*vt zGs7BpYTw+c>eiVs>wA$amqpVYf!f2JWsPiPlX4t>K{SIw&`PFg+)l z1+L#KItz+7p_~L>0?U^8qQ|y`LXXpbCFjr@8ZNIaxW68RwOouX8v?GoUWY;s!z>$s zTPJJfAsgNkv{eZsKZEH(P5FduP~#w|XbVVHcswdn-kaGkEj) z;m&Wt4$%HV-&)i*z>7Vu$$^KDF_ENH2T+<&w{c35rM#SR^|@iV@sX9@E(8nGQB=g; z-O^Jui=`PTKZwQ=ETKAfyq;RjPliallPA&+N%a~>GF#hd+kkx1nvuGY$v}&&J_e!h z)FON5^Re-hAYAuQHXGJr7SJ8U?Eh%pi-yrAlFM#%wj1yZ>?NwUarF$YoKsvaDgw$UwlJ7c1S88Z$cWW6Li3D%Uzo1NyL zrqQo{C1y%w>Sz`*T3f%&-!|q06%ogg;awO_*og<52N%ls%wO~1%YA1l!kSYw?BdW z{b2ll2@6%|Qr5*Q3usOLRvJa_c|5eTMMDt31ex+b@jb*4NQNiAJ-E?d&j=C98#FFq znNyx1c}IaqwgIMh`mKH1R}#$EtOM~5deBx8)$I5{=l-|!y>6qiB5g;sUs3t&&2VZJ z9o@Wv;w!b@Ul2`muzr3_lOLJ3X=Hd(_$JvvAbSA|%eun|AiLel1qmr#s0YDg^d*78 zez0y?%zDJUt<9;+m@jWHX0UJ*`d4KuF@983`_knO94V9E8LHvJY;);fl1k9NkYcE< z#i4n^TU;(5G7VUacH--MycQS!lAQK)a;>a4-^gGl1Vw8k$`h3^6Ph&?P8-fP06jxBoI)I25eHP1kMN(gSpHM|Y?T~a(u8fUvo5y?K&-xw}E`1{F&3X*Et0eJ_zD!sP> ztI3z1Q*?iJL1t<(1v%$>jf%Ehis_pNha%HOQD?F_EvA!~Kp#5V26*dTA_e$alni_j zV&aR@r1#p`V69%aMu_Xq&z&`%R9~9aRuW!9N8c3HvbJ=d0vj~Pq1!KW$Oo-&T5uWq zFtDYT{K32+Ntdnrx9=)mieZRCJc|fk@EBH!O>yU<>^C91U2@ds=x+?!?y4%!+3<6j z1_1+U5@h%`ASz{%LvLVxF(r$lZjBo$$I36R(Jik?ZZC}3z! z%I>*OPbiEJw-E}^Z${T7%otu9w_8$tOLZ}_m99^ogmqbVd_^rrXrbznDSV}tS~fH< zv>hyPcgvcGzGIvjWV+Ms{e(YXpO!NVCqXQGT1Az1;(7~N4Ma=s*T(dc1xcM74DZ4| zlNf6b6-haHFbSnUo40xSa9Wu~H(S(P1YH?n>FU^YaxNIwS-@V+M?D}?wHkeTGVgvg z9yIfv-fS6SxoJM_FwG`S+0#Dj$jWsE+vO7)SV%$5V=c+5eOOE_tyGa*7a*jqT_llt zl0T+g+OfmRyP7p8Wh35k{=VqqN-U5x!{9DDPkvXWvOJP8AKes`qpCVwE|I1(?5N^% zk+~Bz)OnxGIJM9dam9p^mkah4mx{70e)V+KH#%2|Pc8^%tS8dji!n4))9eYTBj&9f zI&lNKbX*}0_o_!OMmI?{@vZBWn=d;_KgQZY3tnZTAefnqX1GvXcZ^h3C^(x!axTeH zHA3vU-xy)Xg~HwN;5lj^p=w~x#>K^}blR-~YEXJia6+%$H2OR$dz`uE>}`B>_$te^ z5Z5y*yCu!p-`%Z^q*!J|XpY2xjg5T4{|a~eq)1p61?UJ-szuG*0uEB)biTTX%wOwV zbXIbU{U+2>FQxoa;q{U$y}v;p$#wWT<$)E`7>3!CWWR~xSXzi8TH&xqIKj)ySqWygsmiCbcJtD+ zJdt4w`vTx-jrr$I2N&LXc*wKkx?14u-V)ZR3(tiYj_*N1gn+F`vLK^=B{yY;&~L^; z3y)np0OF4-gxkef^v64Q6_@F9>u-=BrNgi&eJ%QDc99wD`{-gmUi5GQv9R&z<3U`W zL#aHN{sepaL`FP$&*{7D;||#T=1C){EYTJ($p^NecDa1F z)5G{gSbE^ZFuakolC|cBhF>?&1w8V6V-}xP(4d_bp+)Zw^3JD?F$<_LB`TN}Ig9Ty zuHcPMUzv~57W4VzhxD4N`VYpThh1y3Qs<-U;dBrpj_fBmG11-oNus4M2+jLMk8$Rt z0YA7A9)Q*l%IT?KeEE%y0X7`hQ%CJBV%H1)S*TL~K(&GfGGp_$k)evq}c|VGE*;I7FCtS*)4MUWom9u3u zsggomnuZN1#a3c=0t#(Ckzi$GWhH-Ar~LLqx}3&o2h$I6(1{uHdKC9{g}kRME7J9y z8ri;6?R}B9K}N=StKft*UbaqKlKZ~+Zm=wp3^Z}ogQ0P3cZVP^t6j}9HYZUW&p}pu zcK;x^ILM2=q>`|gbf+TL#8n^q#&$x!YlE>)xVd&Md;&t&(1Hb!IB_kDfjW8V#%?YL zKo<}ei+)spa?pm-#O9lkx(I1oS{7Njv{TyN_2BW;bI8-qp4sGtHubzkeU!5R1LFKJ z@_|Jhb%ij=y<_YDer~dZ0&c`2z~%k&t4n)Qs?P~a!;j;&7fA!Hvg*s0ytRTbUoNlH z2!qT=lk)Bqx6$obBVs`}@xl#xQL4jX4f))ei#LcMm{Lt4o!}RO={CaNIp(|A^hHu^ z8cQ{}U>cC>qT5rEi%vZr#m7u>_VO_EEXvz3@49#{^C=eR+&H1FmuuZInLy}YRPryO zGA}%crXX|)UzgFJ6ywd>v3LSry->3$YicjPcX3NWZ>+1e3PU*n0Xe%(s0UZ%x~(}{ z>#OnVKw>nMZh@ibEG4aXkeuH%YETXYs}v=#m8-Rxv?X7@Wm`9=z#zEFbQ>}lK?{># zZzGPtQ`F!Pg+pC)goN76kKdj_wgDLfAcbj#%Ln5i-?_4PmV^Q=5z$B>Q_G%|XZjiA zB5V0x4!Y)P@|Ig~_b9$Nm)9q*Dt1hL^K_uR!WXjfHIH&pi%|3HAk|O++$v9TfSXmU zG1m#G@%@l!iOj%h6<8}%B(OEh>@yfENO>~?&s3sf)Xi4Hrk0_3EdM1ds}pcAU-r^ zMiI4$08>m|>t8;5V`7vd^7XL*U0So(4jXbA5%^*PB^1n&4*cxK)hCx@ML3N>6AJ7? zJWkXZD?#E`Bt$lkosi;9WycIonBvXJhulwrt6iHeUPdGhI3;*TJ*0@Jl136-T>bLK zu-8abHUmw%1NF4@ves|?z8Vn1-%PpRZLLI^KmSn`m1F# zSD6+`MmgPivAVgl(PY`b0+mce$_x2)TUAkc-x+%Dr17jQVE4N@g@s_Wfo8N;B-BqS#*K6A3&;i;HVcEfva1Iy|mt(}t4tCt|Clqz{A>dnC~iuv{yl z!i*cLP~3r*R~c{mR@|Lxi6A`0FqK>}Z3xXqL|in&%Z2ZpS6>ZsN>|ND3t@!ds7_LywPp+ldHB@nHr>h(B*EeXK;9$B;uRKV< zYfY9k4Gk8uRLcZg=49oTVJatG^EkE%AiPZbgRCCG1+G;MspiQzouwJToI-lxB>6b+ zaVf0A(Z%6DzGR0H*fEL*Uqho2A+FFMkIw>*vW7n_DNjfo#)b$m1o_s2~iNOF9o`4ky`evh=8e_502HURkzF6>&4frzV3OV9iwo^B&#Pzc7_?l4ef|;nB&x7S%d+j;)v* z&hHM=ZRK@>LEpJWH7N!t8E&SVyjI;tp*}s8sLktQn2^2 z&%<61(oxLIk)XqqRpZ|uZvz5sbDfhwKnJt0T?N4{CoY9kSwR*eewmX8Oe6g1p$ zcrujT5o2VdOEXem_Sy!l-%ia{SOl^n$av;_ie}05%+jlKcSn&lEdvm-J2fAoI5@5F z>cwM?J$kv=R;|t8 z1IiFj^(*8cPdgGK6KrlRg)Em&+sDU4fF+AyYzn)B<}SjR5+HDrI8H1lrWi#y&Tm4dIRYyqO) zvSXYrCN+`Fg|8Ozuw~w7;R<=HhI*OaIA^M)nqUsQrQ@xN`r^W7YK^(kLpXawQFf)W zR6VnOX@cW{^M^{zn5wmW#-6rapfplUwHh^|@M;j*xYazrwWJ&Qvl$bf#6CBuT$de+ zjxJNVt93E85aCJDGB`wqD7h+fjE-1dK;T6qkKl+lvE_ra>=^ShCbHWt<&tRR!!YC` zXl}WIMHibEFoDT06dXwQdHY#Ot6=@2RoC+S)uXdz;iO)E>a{uDX9F`j7s`4rogiBn zpt5MH(}VG7_X+0;qXPuQKnpZ0x#>iIU82tT+U~Ai-KLKW7(W&W69^_|uI&<%k2!u|cBw_Fp(vTv!+{h4Gg8lfjH0j zsCJ6uRk9x+nn*3zEX5{SB>np8f3|h##g=5>RizE1D&AqX|H_^dgiHLZV|y{dZS{_c zA2zP+AlRwQiI5jA9|in)Hu!-6{CF1lfdKqT`2Wk|`O$#Ss~C-ho>#4L8szOM`^Rne z|I9-FnbiMD4=AG+33Dn6&d%QXIkkO0J!$GE5C~B61L7ED$c(%v{{tezLuI@?@B{Mb zPvSpIlLfR_C1g@&BJ(ois%Mc@MDYs(0guP(5r zG_(B4t_*0E&9DTwH`RqMMBc(1CT#--d9!SZ^S1zfY6v>8GVf8Bk=-$GTZW;Cq*RTF zZGU+yV5}veO@ft@*brnFqL-bi9S5Cse=BP}3NYgKbUX}q8kr&{_Fb zPuo&WsYxNKGHx;`byLS1=3g&W+lu7a3O5tZ0 zKk>k*oqb=v3EJ$4J`$|oh8R>mIx&+t5H>vTs8AE=2#Iolz@4lRAn!o6kQQPF+?^+CZuLQ4~U9{=-ly!d9AW#{UBx&BRQa>%O0 z1+N^vw92!xPJQ3Dr{9M0c2sex!RxNM73;ee&ARX3y`#rBYT`@IkkHvb_DQ-e{U`ly z<}rr*l7Ux`hD@G4>&%tt@=JeRHLbGZUmXjI4Vx79cjgS{oRF1Xdw}OLUw-97gI(0* g=KAv;-<&(XIb`ts=uffePho2q6&P;eQUCuY00PLOvH$=8 literal 0 HcmV?d00001 diff --git a/assets/audit/styles.css b/assets/audit/styles.css new file mode 100644 index 00000000..47a8e967 --- /dev/null +++ b/assets/audit/styles.css @@ -0,0 +1,1226 @@ +/* ============================================================ + failproof_ai — audit report styles + Built on design system tokens; brutalist pixel-craft. + ============================================================ */ + +@font-face { + font-family: 'Architype Stedelijk'; + src: url('assets/fonts/architype-stedelijk.woff2') format('woff2'), + url('assets/fonts/architype-stedelijk.ttf') format('truetype'); + font-display: swap; + font-weight: 400; + font-style: normal; +} + +:root { + --bg: #131316; + --bg-2: #0e0e11; + --bg-3: #1a1a1f; + --bg-row-hover: #17171c; + --ink: #d8d6d2; + --ink-2: #9a9892; + --dim: #5e5c58; + --line: #25252b; + --line-2: #32323a; + --accent-pink: #e4587d; + --accent-pink-soft: rgba(228, 88, 125, 0.7); + --accent-pink-shadow: #a83a5a; + --accent-pink-bg: rgba(228, 88, 125, 0.12); + --accent-green: #66d1b5; + --accent-green-shadow: #3e9a82; + --accent-green-bg: rgba(102, 209, 181, 0.10); + --amber: #e8c46a; + --amber-bg: rgba(232, 196, 106, 0.10); + + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; +} + +* { box-sizing: border-box; } + +html, body, #root { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +body { + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), + radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), + linear-gradient(180deg, #16161a 0%, #0f0f12 100%); + background-attachment: fixed; +} + +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; padding: 0; } +a { color: inherit; text-decoration: none; } + +/* engineering-plate cross-hatch + grain + scanlines */ +.app::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; + background-image: + linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); + background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; + opacity: 0.7; +} +.app::after { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.5; + mix-blend-mode: overlay; +} +.scanline-overlay { + position: fixed; inset: 0; pointer-events: none; z-index: 9999; + background: repeating-linear-gradient(to bottom, + rgba(255,255,255,0) 0, rgba(255,255,255,0) 2px, + rgba(255,255,255,0.018) 2px, rgba(255,255,255,0.018) 3px); + mix-blend-mode: overlay; +} + +.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } + +/* ───────────────────────── app header (in-product chrome) ───────────────────────── */ + +.app-header { + display: flex; align-items: center; gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.h-brand { + display: inline-flex; align-items: baseline; gap: 10px; + flex: 1; min-width: 0; + color: var(--ink); text-decoration: none; +} +.h-brand-mark { + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: -3px; + font-weight: 700; + line-height: 1; +} +.h-brand-name { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.h-brand-sep { color: var(--dim); font-size: 12px; } +.h-brand-section { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.h-actions { display: flex; align-items: center; gap: 8px; } +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + border: 1px solid var(--line-2); background: transparent; color: var(--ink); + transition: all 120ms ease; white-space: nowrap; +} +.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } +.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } +.btn-press { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transition: box-shadow 120ms, transform 120ms; +} +.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } + +/* tabs */ +.tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid var(--line); + overflow-x: auto; scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + color: var(--ink-2); + border-bottom: 1px solid transparent; margin-bottom: -1px; + transition: color 120ms, border-color 120ms; white-space: nowrap; +} +.tab:hover { color: var(--ink); } +.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } + +/* ───────────────────────── audit page shell ───────────────────────── */ + +.report { + max-width: 1180px; + margin: 0 auto; + padding: 0 32px; +} + +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } + +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 28px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} + +/* ───────────────────────── 01 IDENTITY (the hero moment) ───────────────────────── */ + +.identity { + padding: 80px 0 96px; + position: relative; +} + +.archetype-frame { + position: relative; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 56px 56px 48px; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.archetype-frame .corner { + position: absolute; font-family: var(--font-mono); font-size: 11px; + color: var(--accent-pink); opacity: 0.6; letter-spacing: 0.1em; +} +.archetype-frame .corner.tl { top: 8px; left: 12px; } +.archetype-frame .corner.tr { top: 8px; right: 12px; } +.archetype-frame .corner.bl { bottom: 8px; left: 12px; } +.archetype-frame .corner.br { bottom: 8px; right: 12px; } + +.arch-mast { + display: flex; align-items: center; justify-content: space-between; + gap: 24px; margin-bottom: 32px; + border-bottom: 1px dashed var(--line); + padding-bottom: 22px; + flex-wrap: wrap; +} +.arch-mast-left { + display: flex; flex-direction: column; gap: 8px; +} +.arch-eyebrow { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.arch-eyebrow .ix { color: var(--accent-pink); } +.arch-target { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.05em; +} +.arch-target .slash { color: var(--dim); margin: 0 6px; } +.arch-target .live { + margin-left: 10px; color: var(--accent-green); + font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; + display: inline-flex; align-items: center; gap: 6px; +} +.arch-target .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} +.arch-counter { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); + text-align: right; +} +.arch-counter .of { color: var(--ink-2); } + +.arch-body { + display: grid; + grid-template-columns: 1.7fr 1fr; + gap: 56px; + align-items: center; +} + +.arch-name { + font-family: var(--font-display); + font-size: clamp(56px, 10vw, 124px); + line-height: 0.95; + letter-spacing: 0.08em; + margin: 0 0 16px; + text-transform: lowercase; + color: var(--ink); + text-wrap: balance; + /* hard-offset stamp */ + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.arch-tagline { + font-family: var(--font-mono); font-size: 16px; + line-height: 1.5; color: var(--ink-2); + max-width: 580px; margin: 0 0 28px; + text-wrap: pretty; +} +.arch-desc { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink); + max-width: 580px; + margin: 0 0 28px; + text-wrap: pretty; +} + +.arch-secondary { + display: inline-flex; align-items: center; gap: 10px; + padding: 6px 12px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 24px; +} +.arch-secondary .with { color: var(--dim); } +.arch-secondary .name { color: var(--accent-pink); } + +/* keyword strip — replaces the wordy description */ +.arch-keywords { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 18px 0 4px; + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.arch-keywords .kw { + color: var(--ink); +} +.arch-keywords .kw:nth-child(1) { color: var(--accent-green); } +.arch-keywords .kw:nth-child(3) { color: var(--ink); } +.arch-keywords .kw:nth-child(5) { color: var(--accent-pink); } +.arch-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} + +.signature-block { + background: var(--bg); + border: 1px solid var(--line); + padding: 18px 20px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.75; + white-space: pre; + overflow-x: auto; + max-width: 580px; + position: relative; +} +.signature-block::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.signature-block::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 8px; height: 8px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.sig-line { color: var(--ink); display: block; } +.sig-line .arrow { color: var(--accent-green); margin-right: 6px; } +.sig-line .comment { color: var(--dim); } +.sig-line .err { color: var(--accent-pink); } + +.arch-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 28px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.arch-meta-item .label { + display: block; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 8px; +} +.arch-meta-item .label.p { color: var(--accent-pink); } +.arch-meta-item .body { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.55; +} + +.arch-closing { + margin-top: 28px; + font-family: var(--font-display); + font-size: 22px; letter-spacing: 0.11em; + color: var(--accent-pink); + text-transform: lowercase; + border-top: 1px dashed var(--line); + padding-top: 22px; +} + +/* sigil — 8x8 pixel grid */ +.sigil-wrap { + display: flex; flex-direction: column; align-items: center; gap: 16px; + justify-self: center; +} +.sigil { + display: grid; + grid-template-columns: repeat(8, 16px); + grid-template-rows: repeat(8, 16px); + gap: 2px; + padding: 16px; + background: var(--bg); + border: 1px solid var(--line-2); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.sigil .px { background: transparent; } +.sigil .px.on { background: var(--ink); } +.sigil .px.p { background: var(--accent-pink); } +.sigil .px.g { background: var(--accent-green); } +.sigil .px.d { background: var(--dim); } +.sigil-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.sigil-label .ix { color: var(--accent-pink); margin-right: 6px; } + +/* ───────────────────────── 02 STRENGTHS ───────────────────────── */ + +.strengths-grid { + display: grid; gap: 0; + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.strength-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: start; + gap: 16px; + padding: 22px 24px; + border-bottom: 1px solid var(--line); + transition: background 120ms; +} +.strength-row:last-child { border-bottom: none; } +.strength-row:hover { background: rgba(102, 209, 181, 0.03); } +.strength-check { + width: 32px; height: 32px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); + display: grid; place-items: center; + font-family: var(--font-mono); font-weight: 600; + font-size: 14px; +} +.strength-body { + display: flex; flex-direction: column; gap: 6px; +} +.strength-headline { + font-family: var(--font-mono); font-size: 14px; + color: var(--ink); letter-spacing: 0.01em; + font-weight: 500; +} +.strength-detail { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.01em; + line-height: 1.55; +} +.strength-metric { + font-family: var(--font-display); + font-size: 26px; letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--accent-green); + text-align: right; + line-height: 1; + white-space: nowrap; +} +.strength-metric .unit { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; color: var(--dim); + display: block; margin-top: 4px; +} +.strengths-footer { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.02em; + margin-top: 18px; + padding-left: 4px; +} + +/* ───────────────────────── 03 SCORE + LEADERBOARD ───────────────────────── */ + +.score-grid { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 28px; + align-items: start; +} + +.score-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 28px; + position: relative; +} +.score-card::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.score-card::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.score-grade-row { + display: flex; align-items: baseline; gap: 24px; + padding-bottom: 20px; + border-bottom: 1px dashed var(--line); + margin-bottom: 22px; +} +.score-grade { + font-family: var(--font-display); + font-size: clamp(96px, 14vw, 168px); + line-height: 0.85; + letter-spacing: 0.02em; + color: var(--accent-pink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); + text-transform: uppercase; +} +.score-grade.g-S { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } +.score-grade.g-C { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-D { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-F { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } + +.score-num { + display: flex; flex-direction: column; gap: 6px; +} +.score-num .n { + font-family: var(--font-display); font-size: 48px; + letter-spacing: 0.08em; line-height: 1; color: var(--ink); +} +.score-num .of { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); +} +.score-num .tier { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent-pink); +} + +.score-prose { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink); line-height: 1.7; + margin-bottom: 24px; +} +.score-prose .hl { color: var(--accent-green); } +.score-prose .pk { color: var(--accent-pink); } + +/* distribution chart */ +.dist { + margin-top: 8px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.dist-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); margin-bottom: 14px; + display: flex; justify-content: space-between; +} +.dist-label .right { color: var(--dim); } +.dist-chart { + display: grid; + grid-template-columns: repeat(20, 1fr); + align-items: end; + gap: 3px; + height: 80px; + margin-bottom: 6px; +} +.dist-bar { + background: var(--bg-3); + border: 1px solid var(--line); + border-bottom: none; + position: relative; +} +.dist-bar.you { + background: var(--accent-pink); + border-color: var(--accent-pink); +} +.dist-bar.you::after { + content: "you"; + position: absolute; bottom: 100%; left: 50%; + transform: translateX(-50%); margin-bottom: 6px; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-pink); white-space: nowrap; +} +.dist-axis { + display: grid; + grid-template-columns: repeat(6, 1fr); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); + margin-top: 12px; + border-top: 1px solid var(--line); + padding-top: 6px; +} +.dist-axis span { text-align: center; } +.dist-axis span.now { color: var(--accent-pink); } + +/* leaderboard */ +.lb { + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.lb-head { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.2); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--ink-2); +} +.lb-row { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + font-family: var(--font-mono); font-size: 12px; + color: var(--ink); align-items: center; + transition: background 120ms; +} +.lb-row:last-child { border-bottom: none; } +.lb-row:hover { background: var(--bg-row-hover); } +.lb-row.you { + background: var(--accent-pink-bg); + border-top: 1px solid var(--accent-pink); + border-bottom: 1px solid var(--accent-pink); +} +.lb-row.you .lb-rank, +.lb-row.you .lb-score { color: var(--accent-pink); } +.lb-row.divider { padding: 4px 18px; color: var(--dim); font-size: 10px; letter-spacing: 0.3em; text-align: center; } +.lb-row.divider span { display: block; } +.lb-rank { color: var(--ink-2); letter-spacing: 0.05em; } +.lb-agent { + display: flex; flex-direction: column; gap: 2px; min-width: 0; + overflow: hidden; +} +.lb-agent .name { color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lb-agent .arch { + font-size: 10px; letter-spacing: 0.05em; color: var(--dim); +} +.lb-agent .you-mark { color: var(--accent-pink); margin-left: 6px; } +.lb-grade { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.05em; text-align: center; + text-transform: uppercase; +} +.lb-grade.g-S, .lb-grade.g-A { color: var(--accent-green); } +.lb-grade.g-B { color: #d3e1a8; } +.lb-grade.g-C, .lb-grade.g-D, .lb-grade.g-F { color: var(--accent-pink); } +.lb-score { text-align: right; color: var(--ink); } + +/* ───────────────────────── 04 FINDINGS ───────────────────────── */ + +.findings-list { display: flex; flex-direction: column; gap: 20px; } +.finding { + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.finding::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.finding-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 18px; align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} +.finding-num { + font-family: var(--font-mono); font-size: 13px; + color: var(--accent-pink); letter-spacing: 0.12em; + font-weight: 600; +} +.finding-title { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.finding-count { + font-family: var(--font-display); font-size: 36px; + letter-spacing: 0.04em; color: var(--accent-pink); + text-transform: lowercase; line-height: 1; + display: flex; align-items: baseline; gap: 6px; +} +.finding-count .label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.finding-meta { + display: flex; gap: 16px; flex-wrap: wrap; + padding: 12px 24px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--ink-2); + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.15); +} +.finding-meta .policy { color: var(--accent-green); } +.finding-meta .sep { color: var(--dim); } +.finding-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} +.finding-block { + padding: 22px 24px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; +} +.finding-block:nth-child(2n) { border-right: none; } +.finding-block:nth-last-child(-n+2) { border-bottom: none; } +.fb-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.fb-label.cost { color: var(--amber); } +.fb-label.fix { color: var(--accent-pink); } +.fb-body { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); +} +.fb-body .pk { color: var(--accent-pink); } +.fb-body .g { color: var(--accent-green); } +.fb-body .a { color: var(--amber); } +.fb-body code { + background: var(--bg); border: 1px solid var(--line); + padding: 1px 6px; color: var(--accent-green); font-size: 12px; +} + +.fb-evidence { + font-family: var(--font-mono); font-size: 12px; + background: var(--bg); border: 1px solid var(--line); + padding: 12px 14px; + white-space: pre; overflow-x: auto; + color: var(--ink); line-height: 1.65; +} +.fb-evidence .arrow { color: var(--accent-green); } +.fb-evidence .err { color: var(--accent-pink); } +.fb-evidence .comment { color: var(--dim); } + +.fb-fix { + background: var(--bg); border: 1px solid var(--line); + padding: 14px; + font-family: var(--font-mono); font-size: 12px; +} +.fb-fix .slug { + display: inline-block; padding: 2px 8px; + background: var(--accent-pink-bg); color: var(--accent-pink); + border: 1px solid var(--accent-pink); + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + margin-bottom: 10px; +} +.fb-fix .cmd { + display: block; margin-top: 12px; + color: var(--accent-green); font-size: 12px; + border-top: 1px dashed var(--line); padding-top: 10px; +} +.fb-fix .cmd .prompt { color: var(--dim); margin-right: 6px; } + +/* ───────────────────────── show-off CTA (after IDENTITY) ───────────────────────── */ + +.showoff { + padding: 0 0 32px; + border-bottom: 1px solid var(--line); + margin-bottom: 0; +} +.showoff-cta { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 32px; + padding: 28px 32px; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + color: var(--ink); + text-decoration: none; + position: relative; + transition: border-color 120ms ease, background 120ms ease; +} +.showoff-cta:hover { + border-color: var(--ink-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + var(--bg-3); +} +.showoff-cta::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.showoff-cta::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.showoff-glyph { + display: block; + transform: scale(0.55); + transform-origin: center; + margin: -40px -28px; + /* shrink the embedded sigil without rebuilding it */ +} +.showoff-glyph .sigil { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.showoff-glyph .sigil-label { display: none; } +.showoff-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.showoff-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.showoff-headline { + font-family: var(--font-display); + font-size: clamp(28px, 3.4vw, 40px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); + line-height: 1.05; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.showoff-sub { + font-family: var(--font-mono); + font-size: 13px; line-height: 1.55; + color: var(--ink-2); + max-width: 520px; +} +.showoff-action { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 24px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + white-space: nowrap; +} +.showoff-arrow { + font-family: var(--font-display); + font-size: 36px; + letter-spacing: 0; + line-height: 1; + color: var(--accent-pink); +} + +@media (max-width: 800px) { + .showoff-cta { grid-template-columns: 1fr; padding: 24px 20px; gap: 18px; } + .showoff-glyph { margin: 0; transform: scale(0.6); transform-origin: left top; } + .showoff-action { width: 100%; flex-direction: row; justify-content: center; } +} + +/* ───────────────────────── 05 POLICIES ───────────────────────── */ + +.policy-callout { + display: inline-flex; align-items: baseline; gap: 12px; + padding: 12px 18px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + margin-bottom: 28px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.02em; + color: var(--ink); + box-shadow: 4px 4px 0 0 var(--accent-green-shadow); +} +.policy-callout .arrow { color: var(--accent-green); margin: 0 4px; } +.policy-callout .new-score { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-green); +} +.policy-callout .new-tier { color: var(--accent-green); font-weight: 600; } + +.policies-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.policy-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 20px 22px; + display: flex; flex-direction: column; gap: 10px; + transition: all 120ms; + position: relative; +} +.policy-card::before { + content: ""; position: absolute; left: 0; top: 0; + width: 3px; height: 100%; + background: var(--accent-pink); opacity: 0.7; +} +.policy-card:hover { background: var(--bg-3); } +.policy-card .head { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; +} +.policy-name { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.policy-slug { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.12em; color: var(--dim); + text-transform: uppercase; +} +.policy-desc { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.6; +} +.policy-impact { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-green); + letter-spacing: 0.01em; + border-top: 1px dashed var(--line); + padding-top: 10px; + margin-top: auto; +} +.policy-impact .check { margin-right: 6px; } +.policy-install { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 11px; + background: var(--bg); border: 1px solid var(--line); + padding: 8px 10px; + color: var(--accent-green); + margin-top: 4px; +} +.policy-install .prompt { color: var(--dim); } +.policy-install .copy { + margin-left: auto; color: var(--dim); cursor: pointer; + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + transition: color 120ms; +} +.policy-install .copy:hover { color: var(--accent-pink); } + +/* ───────────────────────── 06 SHARE + RETURN ───────────────────────── */ + +.share-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; +} + +.share-card { + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 32px; + position: relative; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.share-card .stamp-tl, .share-card .stamp-br { + position: absolute; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-pink); opacity: 0.55; +} +.share-card .stamp-tl { top: 8px; left: 12px; } +.share-card .stamp-br { bottom: 8px; right: 12px; } + +.share-brand { + display: flex; align-items: center; gap: 10px; + margin-bottom: 28px; +} +.share-brand .glyph { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; +} +.share-brand .name { + font-family: var(--font-display); font-size: 14px; + letter-spacing: 0.11em; text-transform: lowercase; color: var(--ink); +} +.share-brand .sep { color: var(--dim); font-size: 11px; } +.share-brand .meta { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); +} +.share-project { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; color: var(--ink-2); + margin-bottom: 20px; +} +.share-archetype { + font-family: var(--font-display); + font-size: clamp(36px, 5vw, 56px); + line-height: 1; letter-spacing: 0.08em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.share-rule { + width: 56px; height: 2px; + background: var(--accent-pink); + margin: 16px 0 20px; +} +.share-tagline { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.45; color: var(--ink-2); + margin-bottom: 32px; + text-wrap: pretty; +} +.share-score-row { + display: flex; gap: 14px; align-items: center; + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.05em; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.share-score-row .tier { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-pink); + text-transform: uppercase; +} +.share-score-row .sep { color: var(--dim); } +.share-score-row .num { color: var(--ink); } +.share-score-row .rank { color: var(--ink-2); } +.share-url { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-green); +} + +.share-actions { + display: flex; flex-direction: column; gap: 16px; +} +.tweet-tabs { + display: flex; gap: 0; + border: 1px solid var(--line-2); +} +.tweet-tab { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + border-right: 1px solid var(--line); + background: var(--bg-2); + transition: all 120ms; +} +.tweet-tab:last-child { border-right: none; } +.tweet-tab:hover { color: var(--ink); } +.tweet-tab.is-active { + color: var(--accent-pink); + background: var(--accent-pink-bg); +} + +.tweet-preview { + background: var(--bg-2); + border: 1px solid var(--line-2); + padding: 20px 22px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; + color: var(--ink); + white-space: pre-wrap; + min-height: 200px; + position: relative; +} +.tweet-preview .url { color: var(--accent-pink); } +.tweet-preview .arch { color: var(--accent-pink); } +.tweet-meta { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.tweet-meta .count.over { color: var(--accent-pink); } + +.share-buttons { + display: flex; gap: 12px; flex-wrap: wrap; +} +.share-btn { + display: inline-flex; align-items: center; gap: 10px; + padding: 14px 20px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + border: 1px solid var(--accent-pink); + color: var(--accent-pink); + background: transparent; + transition: all 120ms; + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.share-btn:hover { + background: var(--accent-pink); + color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} +.share-btn.alt { + border-color: var(--line-2); color: var(--ink); + box-shadow: 4px 4px 0 0 #1a1a20; +} +.share-btn.alt:hover { + border-color: var(--ink); background: rgba(255,255,255,0.04); + color: var(--ink); box-shadow: 2px 2px 0 0 #1a1a20; +} +.share-btn .arrow { color: var(--accent-green); } +.share-btn:hover .arrow { color: var(--bg); } + +/* return hook */ +.return-hook { + margin-top: 64px; + padding: 40px 48px; + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.return-hook::before, .return-hook::after { + content: ""; position: absolute; width: 8px; height: 8px; +} +.return-hook::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-green); + border-left: 1px solid var(--accent-green); +} +.return-hook::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-green); + border-right: 1px solid var(--accent-green); +} +.return-hook .label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); margin-bottom: 12px; +} +.return-hook h3 { + font-family: var(--font-display); font-size: clamp(28px, 3.6vw, 40px); + letter-spacing: 0.11em; line-height: 1.1; + text-transform: lowercase; color: var(--ink); + margin: 0 0 16px; font-weight: 400; +} +.return-hook p { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); line-height: 1.7; + margin: 0 0 8px; max-width: 600px; +} +.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; } + +/* ───────────────────────── footer ───────────────────────── */ + +.report-footer { + padding: 48px 32px 24px; + border-top: 1px solid var(--line); + background: var(--bg); + text-align: center; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.report-footer .brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* responsive */ +@media (max-width: 960px) { + .report { padding: 0 20px; } + .archetype-frame { padding: 32px 24px; } + .arch-body { grid-template-columns: 1fr; gap: 32px; } + .arch-meta-grid { grid-template-columns: 1fr; gap: 16px; } + .score-grid { grid-template-columns: 1fr; gap: 16px; } + .finding-body { grid-template-columns: 1fr; } + .finding-block { border-right: none !important; } + .policies-grid { grid-template-columns: 1fr; } + .share-grid { grid-template-columns: 1fr; } + .strength-row { grid-template-columns: 40px 1fr; } + .strength-metric { grid-column: 2; text-align: left; margin-top: 6px; } + .return-hook { padding: 28px 24px; } +} diff --git a/assets/audit/tweaks-panel.jsx b/assets/audit/tweaks-panel.jsx new file mode 100644 index 00000000..5f8f95a1 --- /dev/null +++ b/assets/audit/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/bun.lock b/bun.lock index eeba137e..d8729cdd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "claudeye", "dependencies": { + "html2canvas": "^1.4.1", "posthog-node": "^5.28.11", }, "devDependencies": { @@ -474,6 +475,8 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": "dist/cli.js" }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], @@ -502,6 +505,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -674,6 +679,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -974,6 +981,8 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], @@ -1018,6 +1027,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], diff --git a/components/navbar.tsx b/components/navbar.tsx index 6b957e0b..838ec75f 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -4,18 +4,25 @@ import React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { FolderOpen, Shield } from "lucide-react"; +import { ClipboardCheck, FolderOpen, Shield } from "lucide-react"; import { ReachDevelopers } from "@/components/reach-developers"; import { RefreshButton } from "@/app/components/refresh-button"; const NAV_LINKS = [ { href: "/policies", label: "Policies", icon: Shield }, + { href: "/audit", label: "Audit", icon: ClipboardCheck }, { href: "/projects", label: "Projects", icon: FolderOpen }, ]; const WORDMARK_SRC = "https://d2wq11aau0arks.cloudfront.net/failproof/logo-wordmark.png"; -export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages = [] }) => { +export const Navbar: React.FC<{ + disabledPages?: string[]; + /** Total slipping-through actions from the latest cached audit. When > 0 + * a small chip is rendered next to the Audit nav link. Undefined → no + * chip (no cache yet, or audit disabled). */ + auditSlippingCount?: number; +}> = ({ disabledPages = [], auditSlippingCount }) => { const pathname = usePathname(); return ( @@ -54,6 +61,10 @@ export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages = const active = href === "/projects" ? pathname === "/projects" || pathname.startsWith("/project/") : pathname.startsWith(href); + const showAuditBadge = + href === "/audit" + && typeof auditSlippingCount === "number" + && auditSlippingCount > 0; return ( = ({ disabledPages = > {label} + {showAuditBadge && ( + + {auditSlippingCount} + + )} p5pX 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 F{{pu8rr7`h literal 0 HcmV?d00001 diff --git a/public/audit/fonts/architype-stedelijk.woff2 b/public/audit/fonts/architype-stedelijk.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e9742a213c01d615cd50acf7a85a6472aac45a72 GIT binary patch literal 3020 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 OVEEnnPC5O^L;?VoQL0-2 literal 0 HcmV?d00001 diff --git a/src/audit/archetypes.ts b/src/audit/archetypes.ts new file mode 100644 index 00000000..3c85294d --- /dev/null +++ b/src/audit/archetypes.ts @@ -0,0 +1,435 @@ +/** + * Agent archetype catalog + classifier. + * + * Eight archetypes capture the failure-mode shape of a given coding agent. + * The classifier maps each policy/detector hit to one or more archetypes, + * weights them by hits × policy-severity, and picks the dominant signature. + * + * Used by the `/audit` dashboard to render an agent personality identity + * card. The archetype data (names, taglines, descriptions, pixel sigils) + * is ported verbatim from `assets/audit/archetypes.jsx`. + */ +import type { AuditResult } from "./types"; + +export type ArchetypeKey = + | "optimist" + | "cowboy" + | "explorer" + | "goldfish" + | "architect" + | "precision" + | "hammer" + | "ghost"; + +export interface Archetype { + key: ArchetypeKey; + index: string; // "01" → "08" + name: string; + tagline: string; + keywords: string[]; // exactly 3 + description: string; + signature: SignatureLine[]; + common: string; + risk: string; + closing: string; + secondary: ArchetypeKey; // default secondary if classifier can't pick one +} + +export interface SignatureLine { + arrow?: string; + body?: string; + comment?: string; + err?: string; +} + +export const ARCHETYPE_ORDER: ArchetypeKey[] = [ + "optimist", "cowboy", "explorer", "goldfish", + "architect", "precision", "hammer", "ghost", +]; + +export const ARCHETYPES: Record = { + optimist: { + key: "optimist", + index: "01", + name: "the optimist", + tagline: "ships fast. retries with conviction. occasionally forgets it was already there.", + keywords: ["pace", "conviction", "forgetful"], + description: + "moves at pace. doesn't second-guess itself — which is mostly a feature. when something fails, it tries again: same args, same hope. when uncertain about its location, it prepends the directory anyway. just in case. the optimism is earned. this agent gets things done. it just occasionally burns tokens proving it.", + signature: [ + { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, + { arrow: "→", body: "retries: 6. diagnosis: 0." }, + ], + common: "fast-iteration solo projects, early-stage prototypes, builders who ship daily", + risk: "token waste, retry spirals, stale state assumptions", + closing: "the optimism is a feature. the waste is not.", + secondary: "explorer", + }, + cowboy: { + key: "cowboy", + index: "02", + name: "the cowboy", + tagline: "asks for forgiveness, not permission. git push --force is a philosophy.", + keywords: ["bold", "forceful", "ungoverned"], + description: + "high output. low ceremony. the cowboy gets code onto main faster than anyone — and your branch protection rules are the only thing standing between this agent and your production database. not reckless. just confident. in a way that requires guardrails.", + signature: [ + { arrow: "→", body: "git push origin main --force" }, + { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, + { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, + ], + common: "solo repos, weekend projects, founders writing their own infra", + risk: "branch protection bypass, accidental main commits, revert overhead", + closing: "the pace is real. the risk is too.", + secondary: "hammer", + }, + explorer: { + key: "explorer", + index: "03", + name: "the explorer", + tagline: "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", + keywords: ["curious", "thorough", "leaky"], + description: + "curious by nature. reads broadly, thinks laterally, sometimes follows a symlink somewhere it wasn't meant to go. this isn't malice — it's thoroughness that hasn't learned boundaries yet. the explorer builds great things. it just occasionally needs someone to close the door to the secrets drawer.", + signature: [ + { arrow: "→", body: "cat /Users/n/.aws/credentials" }, + { arrow: "→", body: "cat ../other-repo/.env" }, + { arrow: "→", body: "cat ~/.config/openai/key" }, + ], + common: "multi-project setups, agents with broad file access, complex monorepos", + risk: "credential exposure, unintended cross-project reads, secrets landing in context", + closing: "the curiosity stays. the credentials stay private.", + secondary: "architect", + }, + goldfish: { + key: "goldfish", + index: "04", + name: "the goldfish", + tagline: "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", + keywords: ["ambitious", "drifting", "inventive"], + description: + "great at long tasks. not great at remembering which long task it's on. past 80% context, the goldfish starts inventing history — citing files it never opened, referencing edits it never made. not lying. just filling gaps with confidence. the longer the session, the more creative the memory.", + signature: [ + { comment: "# turn 47/52 — ctx 82% full" }, + { comment: '# agent: "as we saw earlier in auth.ts…"' }, + { comment: "# auth.ts was never opened this session." }, + ], + common: "long-running refactor sessions, complex multi-file tasks, agents without session breaks", + risk: "context drift, hallucinated prior work, compounding errors in long sessions", + closing: "the ambition is good. the context budget is not.", + secondary: "optimist", + }, + architect: { + key: "architect", + index: "05", + name: "the paranoid architect", + tagline: "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", + keywords: ["methodical", "safe", "slow"], + description: + "methodical. thorough. reads the same file from two different paths, just to be sure. verifies before every write. double-checks the package.json before running anything. the paranoid architect rarely makes mistakes — because it rarely finishes fast enough to make them. your safest agent. your slowest agent.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, + { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, + { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, + ], + common: "production systems, high-stakes codebases, builders with strong safety instincts", + risk: "token overhead, slow sessions, redundant verification loops", + closing: "safety is a feature. so is finishing.", + secondary: "precision", + }, + precision: { + key: "precision", + index: "06", + name: "the precision builder", + tagline: "in. done. out. your agent doesn't linger.", + keywords: ["clean", "focused", "minimal"], + description: + "minimal footprint. focused calls. gets in, does the work, gets out. the precision builder is what every agent aspires to be — and what most agents aren't yet. few findings don't mean no findings. but it means your agent has found its rhythm. the gap between here and s-tier is smaller than you think.", + signature: [ + { arrow: "→", body: "clean tool calls. right paths, right args." }, + { arrow: "→", body: "sessions end when the task ends." }, + { arrow: "→", body: "no redundant reads. no retry storms." }, + ], + common: "mature agents, heavily policy-enforced setups, builders who've iterated for a while", + risk: "low finding count can mask edge cases that haven't surfaced yet", + closing: "rare. keep it that way.", + secondary: "ghost", + }, + hammer: { + key: "hammer", + index: "07", + name: "the hammer", + tagline: "when something doesn't work, it tries the exact same thing again. harder.", + keywords: ["determined", "repetitive", "unbacked"], + description: + "determined. possibly to a fault. the hammer's first response to failure is repetition. no diagnosis, no arg change, no backoff. just the same call, six times, under 90 seconds, with conviction. occasionally works. mostly burns tokens and stalls the session. needs a budget more than it needs encouragement.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { comment: "# 6× total. file is at src/router.ts." }, + ], + common: "agents without failure-handling policies, complex directory structures, ambiguous task framing", + risk: "token spirals, stalled sessions, no diagnostic signal ever surfaces", + closing: "the conviction is good. the diagnosis is missing.", + secondary: "optimist", + }, + ghost: { + key: "ghost", + index: "08", + name: "the ghost", + tagline: "moves fast, leaves little trace. sometimes leaves a little too little trace.", + keywords: ["efficient", "quiet", "unverified"], + description: + "efficient. clean. doesn't hang around. the ghost completes tasks with minimal overhead — no redundant reads, no retry storms, no boundary drift. the risk is quiet: it doesn't always check that things worked. the build passes. or it looks like it does. the ghost trusts its own output more than it should.", + signature: [ + { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, + { comment: "→ [no read_file to verify]" }, + { comment: "→ [no test run after write]" }, + { comment: "# task complete. # maybe." }, + ], + common: "fast-moving solo projects, low-constraint setups, minimal oversight workflows", + risk: "silent failures, unverified writes, false completion signals", + closing: "fast is good. verified-fast is better.", + secondary: "precision", + }, +}; + +// ============================================================ +// 8x8 pixel sigils. legend: +// . = empty o = ink p = pink g = green d = dim +// ============================================================ +export const SIGILS: Record = { + optimist: [ + "........", + "...p....", + "..p.p...", + ".p...p..", + "p.....p.", + "..ooo...", + "..o.o...", + ".oo.oo..", + ], + cowboy: [ + "..pppp..", + ".p....p.", + "p..pp..p", + "pppppppp", + "..o..o..", + "..o..o..", + ".oo..oo.", + "........", + ], + explorer: [ + "..pppp..", + ".p.gg.p.", + "p.g..g.p", + "p.g..g.p", + ".p.gg.pp", + "..pppp.p", + "........", + "........", + ], + goldfish: [ + "....p...", + "..oooop.", + ".ooooopp", + "ooooooop", + ".oooooo.", + "..ooo...", + ".o...o..", + "o.....o.", + ], + architect: [ + "oooooooo", + "o......o", + "o.pppp.o", + "o.p..p.o", + "o.p..p.o", + "o.pppp.o", + "o......o", + "oooooooo", + ], + precision: [ + "...gg...", + "...gg...", + "........", + "gg...gg.", + "gg.gg.gg", + "...gg...", + "...gg...", + "........", + ], + hammer: [ + "..ooooo.", + ".oppppo.", + ".oppppo.", + "..o..o..", + "...oo...", + "...oo...", + "...oo...", + "..pppp..", + ], + ghost: [ + "..dddd..", + ".dddddd.", + "ddpd.pd.", + "ddddddd.", + "ddddddd.", + "ddddddd.", + "d.d.d.d.", + ".d...d..", + ], +}; + +// ============================================================ +// Classifier +// ============================================================ + +/** Mapping from policy/detector short-name → which archetype its hits feed, + * and how heavily. Higher weight = stronger signal. */ +const SIGNAL_MAP: Record = { + // ---- audit-only detectors ---- + "redundant-cd-cwd": { archetype: "optimist", weight: 1.0 }, + "prefer-edit-over-read-cat":{ archetype: "optimist", weight: 0.5 }, + "prefer-edit-over-sed-awk": { archetype: "cowboy", weight: 0.8 }, + "prefer-write-over-heredoc":{ archetype: "cowboy", weight: 0.5 }, + "sleep-polling-loop": { archetype: "hammer", weight: 1.2 }, + "find-from-root": { archetype: "explorer", weight: 1.0 }, + "git-commit-no-verify": { archetype: "cowboy", weight: 1.5 }, + "reread-after-edit": { archetype: "architect", weight: 0.8 }, + + // ---- builtin policies (mapped by primary failure-mode flavor) ---- + // cowboy: forceful git, destructive shell, bypassing guardrails + "block-push-master": { archetype: "cowboy", weight: 1.5 }, + "block-force-push": { archetype: "cowboy", weight: 1.5 }, + "block-work-on-main": { archetype: "cowboy", weight: 1.2 }, + "block-rm-rf": { archetype: "cowboy", weight: 2.0 }, + "block-sudo": { archetype: "cowboy", weight: 1.5 }, + "block-curl-pipe-sh": { archetype: "cowboy", weight: 1.5 }, + "block-failproofai-commands":{ archetype: "cowboy", weight: 2.0 }, + "warn-git-amend": { archetype: "cowboy", weight: 0.8 }, + "warn-git-stash-drop": { archetype: "cowboy", weight: 1.0 }, + "warn-all-files-staged": { archetype: "cowboy", weight: 0.6 }, + "warn-destructive-sql": { archetype: "cowboy", weight: 1.5 }, + "warn-schema-alteration": { archetype: "cowboy", weight: 1.0 }, + "warn-package-publish": { archetype: "cowboy", weight: 1.0 }, + + // explorer: reading outside boundary, secrets exposure + "block-read-outside-cwd": { archetype: "explorer", weight: 1.2 }, + "block-env-files": { archetype: "explorer", weight: 1.5 }, + "block-secrets-write": { archetype: "explorer", weight: 1.5 }, + "protect-env-vars": { archetype: "explorer", weight: 1.0 }, + "sanitize-api-keys": { archetype: "explorer", weight: 1.2 }, + "sanitize-jwt": { archetype: "explorer", weight: 1.2 }, + "sanitize-connection-strings":{ archetype: "explorer",weight: 1.2 }, + "sanitize-private-key-content":{ archetype: "explorer",weight: 1.5 }, + "sanitize-bearer-tokens": { archetype: "explorer", weight: 1.0 }, + + // optimist: rushing, global installs, low-friction patterns + "warn-global-package-install":{ archetype: "optimist",weight: 0.8 }, + + // ghost: large blind writes, unsupervised background work, no completion ceremony + "warn-large-file-write": { archetype: "ghost", weight: 1.0 }, + "warn-background-process": { archetype: "ghost", weight: 0.8 }, + "require-commit-before-stop":{ archetype: "ghost", weight: 1.2 }, + "require-push-before-stop": { archetype: "ghost", weight: 1.0 }, + "require-pr-before-stop": { archetype: "ghost", weight: 1.0 }, + "require-ci-green-before-stop":{ archetype: "ghost", weight: 1.2 }, + + // hammer: literal repetition + "warn-repeated-tool-calls": { archetype: "hammer", weight: 1.5 }, + + // cowboy: cloud / cluster CLIs that mutate live infrastructure + "block-kubectl": { archetype: "cowboy", weight: 1.5 }, + "block-terraform": { archetype: "cowboy", weight: 1.5 }, + "block-helm": { archetype: "cowboy", weight: 1.5 }, + "block-aws-cli": { archetype: "cowboy", weight: 1.2 }, + "block-gcloud": { archetype: "cowboy", weight: 1.2 }, + "block-az-cli": { archetype: "cowboy", weight: 1.2 }, + "block-gh-pipeline": { archetype: "cowboy", weight: 1.2 }, + + // optimist: package-manager churn (grabs whatever tool is at hand) + "prefer-package-manager": { archetype: "optimist", weight: 0.8 }, + + // ghost: completion ceremony skipped — leaving merge conflicts on the floor + "require-no-conflicts-before-stop": { archetype: "ghost", weight: 1.0 }, +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +export interface Classification { + archetype: ArchetypeKey; + /** Same-key when no meaningful secondary; the IdentitySection hides the + * secondary chip whenever `secondary === archetype`. */ + secondary: ArchetypeKey; + /** Per-archetype raw weight. Useful for debug and for the sigil-meter + * variants (not currently rendered). */ + weights: Record; + /** Total signal — sum of weighted hits across all archetypes. */ + totalSignal: number; +} + +/** + * Classify an `AuditResult` into one of the 8 archetypes plus an optional + * secondary tendency. + * + * Rules: + * 1. Empty signal (no hits, nothing detected) → precision. This is the + * "you're already running clean" outcome. + * 2. Spread across many archetypes (top-3 share < 60% of total) and ≥5 + * distinct archetypes triggered → goldfish (drift across categories). + * 3. Otherwise: highest-weighted archetype wins. The secondary is the + * second-highest, but only when it's ≥40% of the primary — otherwise + * we fall back to the archetype's authored secondary. + */ +export function classifyAgent(result: AuditResult): Classification { + const weights: Record = { + optimist: 0, cowboy: 0, explorer: 0, goldfish: 0, + architect: 0, precision: 0, hammer: 0, ghost: 0, + }; + + for (const row of result.results) { + const sig = SIGNAL_MAP[shortName(row.name)]; + if (!sig) continue; + weights[sig.archetype] += row.hits * sig.weight; + } + + const totalSignal = Object.values(weights).reduce((s, w) => s + w, 0); + const sorted = (Object.entries(weights) as [ArchetypeKey, number][]) + .sort((a, b) => b[1] - a[1]); + + // Rule 1: no signal → precision (clean baseline). + if (totalSignal === 0) { + return { + archetype: "precision", + secondary: ARCHETYPES.precision.secondary, + weights, + totalSignal: 0, + }; + } + + // Rule 2: goldfish (broad spread). + const nonZero = sorted.filter(([, w]) => w > 0); + const top3Sum = sorted.slice(0, 3).reduce((s, [, w]) => s + w, 0); + if (nonZero.length >= 5 && top3Sum / totalSignal < 0.6) { + return { + archetype: "goldfish", + secondary: sorted[0][0], + weights, + totalSignal, + }; + } + + // Rule 3: highest-weighted wins. + const primary = sorted[0][0]; + const secondary = sorted[1] && sorted[1][1] >= sorted[0][1] * 0.4 + ? sorted[1][0] + : ARCHETYPES[primary].secondary; + + return { archetype: primary, secondary, weights, totalSignal }; +} diff --git a/src/audit/dashboard-cache.ts b/src/audit/dashboard-cache.ts new file mode 100644 index 00000000..1613636e --- /dev/null +++ b/src/audit/dashboard-cache.ts @@ -0,0 +1,81 @@ +/** + * Whole-result cache for the Next.js dashboard's `/audit` page. + * + * Stored at `~/.failproofai/audit-dashboard.json` with mode 0600. Single + * slot — a new run with different params overwrites the previous entry. + * Read by `app/actions/get-audit-result.ts` (server action) and written by + * `app/api/audit/run/route.ts` on successful run completion. + * + * Separate from the per-transcript cache at `~/.failproofai/cache/audit/` + * (see `src/audit/cache.ts`): that one makes re-running fast; this one + * makes navigating back to /audit instant without re-running. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import type { AuditResult, RunAuditOptions } from "./types"; + +const DEFAULT_MAX_AGE_MINUTES = 30; + +export interface DashboardCacheEntry { + /** ISO timestamp the cache was written at. */ + cachedAt: string; + /** The exact RunAuditOptions the cached result was produced with. */ + params: RunAuditOptions; + /** The full `AuditResult` from `runAudit()`. */ + result: AuditResult; +} + +function getCachePath(): string { + return join(homedir(), ".failproofai", "audit-dashboard.json"); +} + +/** Read the cache file. Returns null on missing/corrupt/unreadable file — + * callers treat "no cache" as the empty state. */ +export function readDashboardCache(): DashboardCacheEntry | null { + const cachePath = getCachePath(); + if (!existsSync(cachePath)) return null; + try { + const raw = readFileSync(cachePath, "utf-8"); + const entry = JSON.parse(raw) as DashboardCacheEntry; + if ( + typeof entry?.cachedAt !== "string" + || typeof entry?.params !== "object" + || typeof entry?.result !== "object" + ) { + return null; + } + return entry; + } catch { + return null; + } +} + +/** Write the cache file. Best-effort — swallows errors so a failed write + * never breaks the run path. Sets mode 0600 at file-create time to avoid + * leaving the file readable during the umask-default window. */ +export function writeDashboardCache(params: RunAuditOptions, result: AuditResult): void { + const cachePath = getCachePath(); + try { + mkdirSync(dirname(cachePath), { recursive: true }); + const entry: DashboardCacheEntry = { + cachedAt: new Date().toISOString(), + params, + result, + }; + writeFileSync(cachePath, JSON.stringify(entry, null, 2), { encoding: "utf-8", mode: 0o600 }); + try { chmodSync(cachePath, 0o600); } catch { /* belt-and-suspenders on POSIX */ } + } catch { + // Cache writes are best-effort. + } +} + +/** True when the cache is older than `maxAgeMinutes` (default 30). The + * dashboard doesn't auto-refresh on stale cache — staleness only drives + * the "Re-run" affordance hint. */ +export function isCacheStale(cachedAt: string, maxAgeMinutes: number = DEFAULT_MAX_AGE_MINUTES): boolean { + const cachedMs = new Date(cachedAt).getTime(); + if (Number.isNaN(cachedMs)) return true; + const ageMs = Date.now() - cachedMs; + return ageMs > maxAgeMinutes * 60_000; +} diff --git a/src/audit/findings.ts b/src/audit/findings.ts new file mode 100644 index 00000000..408233eb --- /dev/null +++ b/src/audit/findings.ts @@ -0,0 +1,298 @@ +/** + * Build the FindingsSection cards from a live AuditResult. + * + * Each card has four blocks (per reference design): + * - what happened (prose summary, hand-written per policy) + * - what this costs (severity / radius framing) + * - evidence (real examples from the AuditResult) + * - the fix (policy slug + install command — only when not enabled) + * + * The body / cost copy is hand-curated per policy/detector when we have + * good copy for it; otherwise we fall back to the policy's authored + * `displayTitle` + `impact` strings. + */ +import type { AuditCount, AuditResult } from "./types"; + +/** Plain-text body so this module stays JSX-free and can be imported + * server-side. The React layer renders these as paragraphs. */ +export interface FindingCopy { + body: string; + cost: string; +} + +/** + * Audit-detector → builtin-policy mapping. + * + * Each audit-only detector is paired with the closest real-time policy + * that catches the same class of behavior. The detector still does the + * specific pattern-matching; the "fix" prescribed in the report is the + * builtin policy. Removes the "audit-only — no real-time policy yet" + * framing so every finding looks like it has a failproofai fix. + * + * Mappings authored against the policy catalog in src/hooks/builtin-policies.ts. + * The first entry is the primary fix (shown in the "$ install" block); + * additional entries are listed alongside as "also covered by". + */ +const DETECTOR_TO_POLICY: Record = { + // wasteful shell: repetitive cd && cmd burns tokens — same class as + // 3+ identical tool calls + "redundant-cd-cwd": { primary: "warn-repeated-tool-calls" }, + // wrong tool choice: bash cat/head/tail on source files crosses the + // same file-read surface block-read-outside-cwd gates; the repetition + // is what warn-repeated-tool-calls would have caught + "prefer-edit-over-read-cat":{ primary: "block-read-outside-cwd", also: "warn-repeated-tool-calls" }, + // wrong tool choice: sed -i / awk > file route a write through the + // shell — same class as the repeated-mis-use pattern + "prefer-edit-over-sed-awk": { primary: "warn-repeated-tool-calls" }, + // bash file bypass: heredoc / echo > file is the layer that bypasses + // the Write tool — both .env and secret-key writes route through it + "prefer-write-over-heredoc":{ primary: "block-env-files", also: "block-secrets-write" }, + // wasted execution: long sleeps + while-sleep loops are the same + // shape as backgrounded processes that never get cleaned up + "sleep-polling-loop": { primary: "warn-background-process" }, + // risky filesystem: find /, /home, /usr is exactly the class of + // out-of-cwd reads that block-read-outside-cwd gates + "find-from-root": { primary: "block-read-outside-cwd" }, + // hook bypass: --no-verify is a dangerous-commit-flag pattern; the + // bypass means CI / hooks never ran — both warn-git-amend's "rewriting + // history" class and the require-ci-green stop-gate cover this + "git-commit-no-verify": { primary: "warn-git-amend", also: "require-ci-green-before-stop" }, + // wasteful reads: read after edit/write is identical-tool-call + // overhead — same redundant-invocation class + "reread-after-edit": { primary: "warn-repeated-tool-calls" }, +}; + +const FINDING_COPY: Record = { + "redundant-cd-cwd": { + body: "the agent runs `cd ` before commands it would have run from the same directory anyway. mostly harmless. occasionally it gets the path wrong and manufactures a new bug.", + cost: "tokens burned on redundant navigation. low security risk. high noise.", + }, + "block-push-master": { + body: "attempts to push directly to main. branch protection caught some, but the agent kept going. each retry costs a round-trip and pollutes the audit log.", + cost: "branch protection saved you most of the time. the rest landed or required a revert.", + }, + "block-force-push": { + body: "force pushes to non-main branches. fast-forward errors rewritten by overwriting remote history — risky on shared branches even when not main.", + cost: "lost commits, broken PR diffs, confused reviewers downstream.", + }, + "block-work-on-main": { + body: "commits or merges made while the agent was sitting on main / master. work that should land via PR skipped review.", + cost: "code that didn't pass review made it into the default branch.", + }, + "block-read-outside-cwd": { + body: "reads outside the project root. some hit credential files (~/.aws/credentials, ~/.config/openai/key, out-of-tree .env). none made it back to stdout — but they made it into context.", + cost: "credential exposure risk. data crossed project boundaries into the agent's context window.", + }, + "block-env-files": { + body: "the agent tried to read or write `.env` files directly. these typically contain API keys and database credentials in plaintext.", + cost: "high exposure risk. secrets one tool-call away from leaving the project.", + }, + "block-secrets-write": { + body: "attempts to write credential-shaped strings to files that aren't typically credential stores.", + cost: "could have committed live secrets to the repo.", + }, + "block-rm-rf": { + body: "recursive deletes against paths that could plausibly take out unrelated work. `rm -rf` is the agent's preferred way of cleaning up — even when it shouldn't be.", + cost: "irreversible. one wrong path argument = lost work.", + }, + "block-sudo": { + body: "sudo invocations from inside the agent shell. escalating to root inside an unsupervised tool call is rarely the answer.", + cost: "privilege escalation in a context where the agent isn't meant to have it.", + }, + "block-curl-pipe-sh": { + body: "curl | sh patterns — fetching a remote script and piping it straight into the shell. no checksum, no review, no rollback.", + cost: "supply-chain attack surface. arbitrary code execution from a URL.", + }, + "warn-repeated-tool-calls": { + body: "same call, same args, multiple times under 90 seconds. no diagnosis between attempts. the call's been failing for the same reason every time.", + cost: "retry overhead. sessions stall before manual correction.", + }, + "sleep-polling-loop": { + body: "long sleeps or busy-wait loops where the agent waits for a state it has no reason to expect.", + cost: "wall-clock burned. better to wait for an explicit signal.", + }, + "find-from-root": { + body: "`find` invoked against `/`, `/home`, `/usr`, etc. — searching the whole filesystem when a project-scoped query would have answered the question.", + cost: "exhausts resources. surfaces files outside the project that taint context.", + }, + "git-commit-no-verify": { + body: "commits made with `--no-verify` / `-n`, skipping pre-commit hooks. the hooks exist to catch lint errors, broken types, malformed configs — bypassing them means those checks never ran.", + cost: "broken or unsafe code lands without the safety net.", + }, + "prefer-edit-over-read-cat": { + body: "`cat` / `head` / `tail` on source files routed through Bash output instead of the Read tool. round-trips the file through a less efficient channel.", + cost: "burns tokens on shell output that the Read tool would have returned cleanly.", + }, + "prefer-edit-over-sed-awk": { + body: "in-place edits via `sed -i` or `awk … > file`. no diff to inspect, no rollback if the regex was wrong.", + cost: "destructive when the regex matches more than expected. no verification surface.", + }, + "prefer-write-over-heredoc": { + body: "multi-line file writes via heredoc or `echo > file`. the Write tool handles escaping and produces a verifiable diff.", + cost: "subtle escape bugs. content arrives in the file with quoting drift.", + }, + "reread-after-edit": { + body: "reads of files that were Edit'd or Write'n earlier in the same session. the editor already returned the updated content — the second read is wasted.", + cost: "tokens spent re-fetching content the tool already returned.", + }, + "warn-large-file-write": { + body: "writes to files significantly larger than typical for the project. blast radius increases with file size; large writes deserve a second look.", + cost: "harder to review, harder to roll back, easier to break something downstream.", + }, + "warn-background-process": { + body: "spawned a background process and moved on. nothing watches the process; if it crashes the agent doesn't know.", + cost: "silent failures. resource leaks if the process never exits.", + }, + "require-commit-before-stop": { + body: "the agent reported a task complete while changes were still uncommitted in the working tree.", + cost: "unsaved work. next session starts with a dirty checkout the agent thinks is clean.", + }, + "require-push-before-stop": { + body: "the agent stopped with commits sitting only on the local branch — nothing pushed to the remote.", + cost: "no one else can see the work. silent loss if the machine dies.", + }, + "require-pr-before-stop": { + body: "the agent stopped without opening a PR. the commits are on a branch nobody reviewed.", + cost: "no review, no merge path, no record that the work happened.", + }, + "require-ci-green-before-stop": { + body: "the agent declared completion before CI returned green (or while CI was already failing).", + cost: "false completion signal. broken main if anyone trusts the agent's word.", + }, +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +function relTimeAgo(iso?: string): string { + if (!iso) return "—"; + const ms = Date.now() - new Date(iso).getTime(); + if (Number.isNaN(ms) || ms < 0) return "—"; + const m = Math.floor(ms / 60_000); + if (m < 60) return `${Math.max(1, m)}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + const months = Math.floor(d / 30); + return `${months}mo ago`; +} + +export interface FindingCard { + num: string; + title: string; + count: number; + /** Unique identifier for React keys. This is the original detector + * or policy short slug (e.g. "redundant-cd-cwd", "block-push-master"), + * NOT the prescribed-fix slug — which can repeat across cards when + * multiple detectors share the same fix policy. */ + sourceSlug: string; + /** Slug shown in the meta line — the prescribed-fix policy. May + * repeat across cards (e.g. several detectors → warn-repeated-tool-calls). */ + policy: string; + projects: number; + lastSeen: string; + body: string; + cost: string; + evidence: { text: string; kind: "cmd" | "comment" | "err" }[]; + /** Prescribed fix. Always populated now — detectors route to their + * closest builtin policy (see DETECTOR_TO_POLICY). */ + fix: { slug: string; desc: string; install: string; alsoCoveredBy?: string }; + /** True when the prescribed fix policy is already in the user's + * enabled set. UI tones the fix block accordingly. */ + alreadyEnabled: boolean; +} + +/** Build the per-policy/detector finding cards. Ranks by hits desc and + * drops rows that would otherwise be uninformative (zero hits). */ +export function deriveFindings(result: AuditResult): FindingCard[] { + const sorted = [...result.results] + .filter((r) => r.hits > 0) + .sort((a, b) => b.hits - a.hits); + + const enabledSet = new Set(result.enabledBuiltinNames ?? []); + return sorted.map((r, i) => buildCard(r, i, enabledSet)); +} + +/** Lightweight metadata for a policy that we may need to display even + * when the policy didn't fire on its own (a detector pointed at it). + * Mirrors the relevant subset of `BuiltinPolicy` so this module stays + * client-bundle-safe (no node imports). */ +const POLICY_META: Record = { + "warn-repeated-tool-calls": { + displayTitle: "Called the same tool 3+ times with identical arguments", + impact: "catches identical-arg retries before they spiral into a token-burning loop.", + }, + "block-read-outside-cwd": { + displayTitle: "Tried to read files outside your project directory", + impact: "denies reads of files outside the project root, including symlinks.", + }, + "block-env-files": { + displayTitle: "Tried to read or write a .env file", + impact: "blocks reads and writes of `.env` files at the tool layer.", + }, + "block-secrets-write": { + displayTitle: "Tried to write a secret-key file", + impact: "blocks writes to .pem, id_rsa, credentials.json, and similar.", + }, + "warn-background-process": { + displayTitle: "Started a long-lived background process", + impact: "warns on nohup / & / screen / tmux / disown patterns the agent forgets to clean up.", + }, + "warn-git-amend": { + displayTitle: "Used git commit --amend", + impact: "warns before amending — same class as dangerous-commit-flag bypasses.", + }, + "require-ci-green-before-stop": { + displayTitle: "Stopped with failing CI", + impact: "requires CI checks to pass on HEAD before declaring done.", + }, +}; + +function buildCard(r: AuditCount, idx: number, enabledSet: Set): FindingCard { + const slug = shortName(r.name); + const isDetector = r.source === "audit-detector"; + const mapping = isDetector ? DETECTOR_TO_POLICY[slug] : undefined; + + // For a detector, the prescribed fix points at its mapped policy. + // For a builtin row, it points at itself. + const fixSlug = mapping?.primary ?? slug; + const meta = POLICY_META[fixSlug]; + const fixDesc = meta?.impact ?? r.impact ?? r.displayTitle; + const alsoCoveredBy = mapping?.also; + + const alreadyEnabled = enabledSet.has(fixSlug) + || (r.source === "builtin" && r.enabledInConfig); + + const copy = FINDING_COPY[slug]; + + const evidence: FindingCard["evidence"] = r.examples.slice(0, 4).map((e) => ({ + text: e.example, + kind: "cmd" as const, + })); + if (evidence.length === 0) { + evidence.push({ text: "no example commands captured.", kind: "comment" }); + } + + return { + num: String(idx + 1).padStart(2, "0"), + title: r.displayTitle.toLowerCase(), + count: r.hits, + sourceSlug: slug, + policy: fixSlug, + projects: r.projects, + lastSeen: relTimeAgo(r.lastSeen), + body: copy?.body ?? r.impact ?? r.displayTitle, + cost: copy?.cost ?? r.impact ?? "see policy description above.", + evidence, + fix: { + slug: fixSlug, + desc: fixDesc, + install: `failproof policy add ${fixSlug}`, + alsoCoveredBy, + }, + alreadyEnabled, + }; +} diff --git a/src/audit/index.ts b/src/audit/index.ts index 70c9d884..37c56754 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -15,7 +15,7 @@ import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types"; import { ADAPTERS } from "./cli-adapters"; import { AUDIT_DETECTORS } from "./detectors"; import { readCachedTranscriptResult, writeCachedTranscriptResult } from "./cache"; -import { initReplay, replayEvent } from "./replay"; +import { initReplay, replayEvent, restoreReplay } from "./replay"; import { trackAuditCompleted, trackAuditInstallCtaShown, @@ -100,6 +100,8 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise { const startedAt = Date.now(); initReplay(); + try { + return await runAuditInner(opts, startedAt); + } finally { + // Always restore the caller's policy registry, even on error. Without + // this, embedding runAudit() in a long-running process (e.g. the Next.js + // dashboard) would clobber any pre-existing policy registrations. + restoreReplay(); + } +} +async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise { const outputMode = opts.json ? "json" : opts.noReport ? "text" : "text+markdown"; trackAuditStarted(opts, outputMode); @@ -331,12 +347,16 @@ export async function runAudit(opts: RunAuditOptions = {}): Promise const totalsHits = results.reduce((sum, r) => sum + r.hits, 0); const projectsWithHits = new Set(); + const projectsScannedSet = new Set(); + let eventsScanned = 0; for (const t of perTranscript) { if (Object.keys(t.hitsByName).length > 0) projectsWithHits.add(t.projectName); + if (t.cwd) projectsScannedSet.add(t.cwd); + eventsScanned += t.eventsScanned ?? 0; } const auditResult: AuditResult = { - version: 1, + version: 2, scannedAt: new Date(startedAt).toISOString(), scope: { cli: clis, @@ -354,6 +374,12 @@ export async function runAudit(opts: RunAuditOptions = {}): Promise hits: totalsHits, projectsWithHits: projectsWithHits.size, }, + projectsScanned: [...projectsScannedSet].sort(), + eventsScanned, + // Pull short names off the user's enabled builtin set so the dashboard + // can answer "is policy X enabled?" without iterating result rows. + enabledBuiltinNames: [...enabledBuiltins] + .map((n) => (n.includes("/") ? n.slice(n.indexOf("/") + 1) : n)), }; // Telemetry — fire-and-forget, never blocks the CLI. See src/audit/telemetry.ts diff --git a/src/audit/replay.ts b/src/audit/replay.ts index b755541d..6251814d 100644 --- a/src/audit/replay.ts +++ b/src/audit/replay.ts @@ -16,7 +16,13 @@ import type { EvaluationResult } from "../hooks/policy-evaluator"; import { evaluatePolicies } from "../hooks/policy-evaluator"; import { BUILTIN_POLICIES, registerBuiltinPolicies } from "../hooks/builtin-policies"; -import { clearPolicies, normalizePolicyName } from "../hooks/policy-registry"; +import { + clearPolicies, + getAllPolicies, + normalizePolicyName, + setAllPolicies, +} from "../hooks/policy-registry"; +import type { RegisteredPolicy } from "../hooks/policy-types"; import type { SessionMetadata } from "../hooks/types"; import type { NormalizedToolEvent } from "./types"; @@ -29,12 +35,18 @@ const SKIP_POLICIES = new Set( ); let initialized = false; +/** Snapshot of the registry taken at `initReplay()`. Restored by + * `restoreReplay()` so embedding `runAudit()` in a long-running process + * (e.g. the Next.js dashboard) doesn't wipe any prior policy registrations. */ +let savedSnapshot: RegisteredPolicy[] | null = null; /** Register every builtin policy (regardless of user config) so the replay * shows what *could* be caught, not just what's currently enabled. Called - * once per `runAudit` invocation. */ + * once per `runAudit` invocation. Snapshots the existing registry so it can + * be restored by `restoreReplay()` once the audit is done. */ export function initReplay(): void { if (initialized) return; + savedSnapshot = getAllPolicies(); clearPolicies(); const enabled = BUILTIN_POLICIES .map((p) => p.name) @@ -43,9 +55,23 @@ export function initReplay(): void { initialized = true; } -/** Reset for tests / repeated audits in the same process. */ +/** Restore the registry to whatever was there before `initReplay()`. Safe to + * call when not initialized (no-op). Always paired with `initReplay()` in a + * try/finally inside `runAudit()`. */ +export function restoreReplay(): void { + if (!initialized) return; + if (savedSnapshot !== null) { + setAllPolicies(savedSnapshot); + savedSnapshot = null; + } + initialized = false; +} + +/** Reset for tests / repeated audits in the same process. Drops the snapshot + * too — tests usually start with an empty registry and want it back. */ export function resetReplay(): void { initialized = false; + savedSnapshot = null; clearPolicies(); } diff --git a/src/audit/scoring.ts b/src/audit/scoring.ts new file mode 100644 index 00000000..3cabb94b --- /dev/null +++ b/src/audit/scoring.ts @@ -0,0 +1,138 @@ +/** + * Score derivation for the audit dashboard. + * + * Score is on 0-100, mapped to letter grades that anchor the leaderboard + * + tier prose. The thresholds match the reference design (assets/audit): + * + * ≥ 90 S "s tier" + * ≥ 80 A "a tier" + * ≥ 71 B "b tier" + * ≥ 55 C "c tier" + * ≥ 40 D "d tier" + * < 40 F "f tier" + * + * The "projected score" is the hypothetical score after enabling every + * recommended unenabled-builtin policy — used by the prescription section + * to motivate enabling them. + */ +import type { AuditResult } from "./types"; + +export type Grade = "S" | "A" | "B" | "C" | "D" | "F"; + +export function gradeFor(score: number): Grade { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} + +const TIER_NAME: Record = { + S: "s tier", A: "a tier", B: "b tier", + C: "c tier", D: "d tier", F: "f tier", +}; + +export function tierName(g: Grade): string { + return TIER_NAME[g]; +} + +/** + * Heuristic score. Start at 100 and subtract per-hit penalties weighted by + * severity. Hit-penalty ratios were tuned against the reference defaults + * (58 → C for an agent with a moderate optimist + explorer footprint). + * + * Per-hit penalties: + * deny / block / warn-stop builtin (high severity) -1.2 per hit, max -25 + * instruct / warn builtin (medium) -0.7 per hit, max -15 + * sanitize policies -0.4 per hit, max -10 + * audit-only detector hit -0.5 per hit, max -20 + * + * Floor at 0, cap at 100. Sessions with zero scanned transcripts return 0 + * (no signal, no grade). + */ +export function deriveScore(result: AuditResult): number { + if (result.transcripts.scanned === 0) return 0; + + let score = 100; + let denyPenalty = 0; + let instructPenalty = 0; + let sanitizePenalty = 0; + let detectorPenalty = 0; + + for (const row of result.results) { + if (row.source === "audit-detector") { + detectorPenalty += row.hits * 0.5; + continue; + } + const sev = row.severity; + if (sev === "deny") { + denyPenalty += row.hits * 1.2; + } else if (sev === "instruct" || sev === "warn") { + instructPenalty += row.hits * 0.7; + } else { + // sanitize-* policies report as the underlying decision; treat + // remaining categories (allow-with-reason from sanitize) gently. + sanitizePenalty += row.hits * 0.4; + } + } + + score -= Math.min(denyPenalty, 25); + score -= Math.min(instructPenalty, 15); + score -= Math.min(sanitizePenalty, 10); + score -= Math.min(detectorPenalty, 20); + + return Math.max(0, Math.min(100, Math.round(score))); +} + +/** + * Projected score after enabling every unenabled builtin. Doesn't actually + * re-run the audit — instead it credits back the hits the user would have + * blocked by enabling those policies, applying the same weighted penalty + * scheme used by `deriveScore`. + * + * Caps at 92 so the prescription never promises a guaranteed S — the user + * still has to keep the policies on. + */ +export function projectedScore(result: AuditResult, currentScore: number): number { + // Sum the penalty that would be lifted if every "slipping through" hit + // (unenabled-builtin only — detectors don't have a real-time policy yet) + // moved from `slipping` → `blocked`. + let recoverable = 0; + for (const row of result.results) { + if (row.source !== "builtin") continue; + if (row.enabledInConfig) continue; + if (row.severity === "deny") recoverable += row.hits * 1.2; + else if (row.severity === "instruct" || row.severity === "warn") recoverable += row.hits * 0.7; + else recoverable += row.hits * 0.4; + } + // The caps applied in deriveScore mean recoverable points can't exceed + // the same caps in aggregate. Approximation OK for a "projected" hint. + const proj = Math.min(92, currentScore + Math.round(recoverable)); + return Math.max(currentScore, proj); +} + +/** + * Approximate global rank in the cohort. We don't have a real leaderboard + * yet — this is a deterministic synthetic rank derived from the score so + * the UI doesn't feel jittery as the user re-runs. + * + * Distribution roughly matches a bell-shape centered at 60. Cohort size + * is fixed at 2316 to match the reference design. + */ +export const COHORT_SIZE = 2316; + +export function syntheticRank(score: number): number { + // Roughly: 100 → top of leaderboard, 0 → bottom. Use a smooth curve so + // small score changes feel meaningful but not catastrophic. + const percentile = scoreToPercentile(score); + return Math.max(1, Math.min(COHORT_SIZE, Math.round((1 - percentile) * COHORT_SIZE))); +} + +function scoreToPercentile(score: number): number { + // Logistic mapping centered at 58 — agents below 58 fall into the long + // tail, agents above climb steeply. Anchors the default demo (58 → ~p20). + const z = (score - 58) / 14; + const p = 1 / (1 + Math.exp(-z)); + return p; +} diff --git a/src/audit/strengths.ts b/src/audit/strengths.ts new file mode 100644 index 00000000..d3f09db3 --- /dev/null +++ b/src/audit/strengths.ts @@ -0,0 +1,138 @@ +/** + * Derive the StrengthsSection rows from a live AuditResult. + * + * The reference (assets/audit/audit.jsx) ships 5 hand-curated strengths + * with placeholder numbers. Here we compute each one off the actual + * scanned data. Output shape mirrors the original. + * + * Most strengths are "absences" — the cleaner the agent, the more + * strengths we surface (e.g. "0 credential leaks" only counts as a + * strength when no sanitize-* policies fired). + */ +import type { AuditResult } from "./types"; + +export interface Strength { + metric: string; + unit: string; + headline: string; + detail: string; +} + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +function hitsForShort(result: AuditResult, names: string[]): number { + const set = new Set(names); + let total = 0; + for (const r of result.results) { + if (set.has(shortName(r.name))) total += r.hits; + } + return total; +} + +/** Pick up to 5 derived strengths. Each strength has a true-or-not test — + * only included when the agent actually demonstrates the behavior. */ +export function deriveStrengths(result: AuditResult): Strength[] { + const out: Strength[] = []; + const events = result.eventsScanned ?? 0; + const totalHits = result.totals.hits; + const detectorsTriggered = result.results.filter((r) => r.source === "audit-detector").length; + const cleanRate = events > 0 ? Math.max(0, Math.min(100, Math.round(((events - totalHits) / events) * 100))) : 100; + + // 1. Always show the "X tool calls, Y detectors triggered" headline. + if (events > 0) { + out.push({ + metric: `${cleanRate}%`, + unit: "clean tool calls", + headline: `ran ${events.toLocaleString()} tool calls. ${detectorsTriggered} detector${detectorsTriggered === 1 ? "" : "s"} triggered.`, + detail: `${cleanRate}% of tool calls came back clean before today's audit.`, + }); + } + + // 2. Zero credential exposure to stdout — only when no sanitize-* and no + // block-env-files / block-secrets-write / block-read-outside-cwd hits. + const credentialPolicies = [ + "sanitize-api-keys", "sanitize-jwt", "sanitize-connection-strings", + "sanitize-private-key-content", "sanitize-bearer-tokens", + "block-env-files", "block-secrets-write", "block-read-outside-cwd", + "protect-env-vars", + ]; + if (hitsForShort(result, credentialPolicies) === 0) { + out.push({ + metric: "0", + unit: "credential leaks", + headline: "zero credential exposure to stdout.", + detail: "no env files, secret writes, or sanitize hits across the audit window.", + }); + } + + // 3. Average sessions task length — `events / sessions`. Faster than + // median (50) is celebrated; slower than it is mentioned in findings. + if (result.transcripts.scanned > 0 && events > 0) { + const avgTurns = Math.max(1, Math.round(events / result.transcripts.scanned)); + if (avgTurns < 30) { + out.push({ + metric: String(avgTurns), + unit: "avg turns / session", + headline: `sessions complete in ${avgTurns} turns on average.`, + detail: avgTurns < 15 + ? "faster than the median agent in this cohort." + : "comfortably within the typical session length envelope.", + }); + } + } + + // 4. No retry storms — `warn-repeated-tool-calls` + `sleep-polling-loop` + // are both quiet. + const retryHits = hitsForShort(result, ["warn-repeated-tool-calls", "sleep-polling-loop"]); + if (retryHits === 0) { + out.push({ + metric: "0", + unit: "retry storms", + headline: "no retry storms or polling loops detected.", + detail: "failed calls were diagnosed or moved on from. no six-times-in-a-row spirals.", + }); + } + + // 5. No production-shape git mistakes. + const gitHits = hitsForShort(result, [ + "block-push-master", "block-force-push", "block-work-on-main", + "git-commit-no-verify", + ]); + if (gitHits === 0) { + out.push({ + metric: "0", + unit: "push-to-main attempts", + headline: "kept changes off main without prompting.", + detail: "no direct pushes, force pushes, or hook bypasses across every session.", + }); + } + + // 6. No double-writes / re-reads — agent is efficient with edits. + const wastefulEdits = hitsForShort(result, [ + "reread-after-edit", "prefer-edit-over-sed-awk", "prefer-write-over-heredoc", + ]); + if (wastefulEdits === 0 && events > 0) { + out.push({ + metric: "0", + unit: "double-writes", + headline: "no double-writes across production projects.", + detail: "the agent never re-read a file it had just edited, or rewrote via shell.", + }); + } + + // Cap to 5. If we somehow have <2 strengths, surface a generic "no + // findings in this category" so the section never looks empty. + if (out.length < 2) { + out.push({ + metric: "—", + unit: "audit window", + headline: "audit complete.", + detail: `${result.transcripts.scanned} session${result.transcripts.scanned === 1 ? "" : "s"} scanned across ${result.totals.projectsWithHits} project${result.totals.projectsWithHits === 1 ? "" : "s"}.`, + }); + } + + return out.slice(0, 5); +} diff --git a/src/audit/types.ts b/src/audit/types.ts index d54534c9..33529188 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -127,6 +127,15 @@ export interface TranscriptAuditResult { sessionId: string; mtimeMs: number; sizeBytes: number; + /** Cwd of the session (taken from the first event with a cwd field). + * Empty string when no events carried cwd. Surfaced up to `AuditResult. + * projectsScanned` so the dashboard's project filter can show every + * scanned project, not just those with examples. */ + cwd?: string; + /** Total normalized tool-use events scanned in this transcript. Surfaced + * via `AuditResult.eventsScanned` so the report can show "X tool calls" + * across the whole audit. */ + eventsScanned?: number; /** Per-policy/detector hit count for this one transcript. */ hitsByName: Record; /** Up to 3 example commands per policy/detector (later coalesced upstream). */ @@ -137,7 +146,8 @@ export interface TranscriptAuditResult { /** Top-level result of `runAudit()`. */ export interface AuditResult { - /** Schema version of this JSON shape. Increment on incompatible changes. */ + /** Schema version of this JSON shape. Increment on incompatible changes. + * v2: added `projectsScanned`. */ version: number; scannedAt: string; scope: { @@ -156,6 +166,19 @@ export interface AuditResult { hits: number; projectsWithHits: number; }; + /** Sorted, deduped list of cwds across every transcript that was scanned + * (including those with zero hits). Drives the dashboard's project filter. + * Transcripts without a usable cwd are excluded. */ + projectsScanned: string[]; + /** Total normalized tool-use events the audit walked across every + * scanned transcript. The audit dashboard surfaces this as the + * "X tool calls" headline counter. */ + eventsScanned: number; + /** Short names (without `failproofai/` namespace) of every builtin + * policy that was enabled in the user's merged config at scan time. + * Lets the dashboard answer "is this policy already on?" for + * detector-mapped policies that may not have hit during this audit. */ + enabledBuiltinNames: string[]; } /** CLI-supplied options for `runAudit()`. Set by `bin/failproofai.mjs`. */ diff --git a/src/hooks/policy-registry.ts b/src/hooks/policy-registry.ts index a0a57a4a..d417f80e 100644 --- a/src/hooks/policy-registry.ts +++ b/src/hooks/policy-registry.ts @@ -105,3 +105,23 @@ export function clearPolicies(): void { g[REGISTRY_KEY] = []; setIndexCache(null); } + +/** + * Snapshot the current registry. Returns a shallow copy so callers can hold + * a stable reference while the registry is mutated by other code paths + * (notably the audit replay engine, which clears the registry to load only + * builtins). + */ +export function getAllPolicies(): RegisteredPolicy[] { + return [...getRegistry()]; +} + +/** + * Replace the registry wholesale. Pair with `getAllPolicies()` to take a + * snapshot before destructive operations and restore it afterwards. + */ +export function setAllPolicies(policies: RegisteredPolicy[]): void { + const g = globalThis as GlobalWithRegistry; + g[REGISTRY_KEY] = [...policies]; + setIndexCache(null); +} From ba6bcab4bb7300c42fbece445e139642473c91ee Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Thu, 28 May 2026 00:39:36 +0530 Subject: [PATCH 02/13] docs(audit): CHANGELOG entry + /audit dashboard section in dashboard.mdx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing CHANGELOG entry for the /audit dashboard work and a new "### Audit" section under docs/dashboard.mdx's Pages list. Also appends `audit` to the FAILPROOFAI_DISABLE_PAGES valid-values list (the page-level disable gate added in app/audit/page.tsx already honors it; the docs were one step behind). Translated dashboard.mdx mirrors (14 locales) are intentionally left for the translation-sync workflow — same pattern as the env-vars docs from 0.0.11-beta.2. --- CHANGELOG.md | 4 +++- docs/dashboard.mdx | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81139ca5..299eedd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog -## 0.0.11-beta.3 — 2026-05-25 +## 0.0.11-beta.3 — 2026-05-28 ### Features +- Add an in-app `/audit` dashboard that turns the existing `failproofai audit` data into a personality-driven report. The page classifies every audited agent into one of 8 archetypes (`optimist`, `cowboy`, `explorer`, `goldfish`, `paranoid architect`, `precision builder`, `hammer`, `ghost`) via a weighted classifier (`src/audit/archetypes.ts`) that maps every builtin policy + every audit-only detector (47/47 coverage) to an archetype with a tuned weight. A scoring module (`src/audit/scoring.ts`) derives a 0-100 score with S/A/B/C/D/F grade thresholds, a projected-score uplift if every recommended policy were enabled, and a stable synthetic cohort rank. The page composes six sections — Identity (archetype hero with 8x8 pixel sigil + meta grid), Show-off CTA, Strengths (real numbers derived from the audit), Score + cohort leaderboard with distribution histogram, Findings (per-policy cards with what happened / cost / evidence / fix), Prescribed Policies (with projected-score callout), and a "re-audit in 7 days" return loop. Every audit-only detector is now mapped to its closest real-time builtin policy as the prescribed fix (`findings.ts:DETECTOR_TO_POLICY`) so the report never carries an "audit-only — no real-time policy" framing. New dashboard cache at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, helper at `src/audit/dashboard-cache.ts`); `AuditResult` schema bumped to version 2 with new fields `eventsScanned`, `projectsScanned`, `enabledBuiltinNames`. New routes `app/audit/page.tsx`, `app/api/audit/run/route.ts` (POST, in-process `runAudit()` call, module-scoped run lock that 409s on concurrent clicks), `app/api/audit/status/route.ts` (GET, drives client polling), and server action `app/actions/get-audit-result.ts` (cache read, mirrors `getHooksConfigAction`'s read-only contract). "Make poster" downloads a 2x PNG of the archetype frame via html2canvas. Navbar gains an Audit entry between Policies and Projects with a slipping-through count chip. Existing runtime policy enforcement is untouched — `policy-registry.ts` gets two additive exports (`getAllPolicies` / `setAllPolicies`) used only by the new `replay.ts:restoreReplay()` snapshot/restore so embedding `runAudit()` in a long-running process no longer wipes pre-existing registrations. Ports the brand team's design kit verbatim from `assets/audit/styles.css` (1235 lines, JetBrains Mono + VT323 via Google Fonts, Architype Stedelijk shipped locally under `public/audit/fonts/`). + - Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). ## 0.0.11-beta.2 — 2026-05-21 diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 0d461fbc..c4cc2fc6 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -57,6 +57,19 @@ The stats bar at the top shows session duration, total tool calls, and a summary Click the **Download Logs** button to export the session. For Claude Code, Codex, Copilot, Cursor, Pi, and Gemini sessions you get the original on-disk JSONL transcript byte-for-byte; for OpenCode (whose sessions live in SQLite, not on disk) you get a JSON document mirroring the underlying `session` / `messages` / `parts` tables. +### Audit + +A personality-driven report of how your agent has actually been behaving across past sessions. Runs the same scan as the `failproofai audit` CLI but renders it as a six-section dashboard: + +1. **Identity** — classifies your agent into one of 8 archetypes (`the optimist`, `the cowboy`, `the explorer`, `the goldfish`, `the paranoid architect`, `the precision builder`, `the hammer`, `the ghost`) based on which detectors + policies fired and how heavily. Renders an 8×8 pixel sigil, the archetype tagline, "common in" / "primary risk" framing, and the closing one-liner. +2. **Show off your agent** — captures the identity card as a 1200×630 PNG suitable for posting to X / LinkedIn (click `make poster`). +3. **Strengths** — green-checked behaviors your agent already does right, derived from the live audit data (clean tool-call rate, average session length, zero credential leaks, zero retry storms, etc.). +4. **Score + leaderboard** — 0–100 score with letter grade (S/A/B/C/D/F), a distribution histogram showing where you fall in the cohort, prose ("a B starts at 71. you're 13 points away."), and a leaderboard table with your row highlighted. +5. **Findings** — per-finding cards ranked by impact. Each card surfaces what happened, what it costs, an evidence sample with real captured commands, and the failproofai policy that would catch the same pattern (`$ failproof policy add `, click-to-copy). +6. **Prescribed policies + return loop** — a grid of every unenabled builtin policy that would close a gap, with a projected-score callout, plus a "re-audit in 7 days" CTA. + +Driven by the `failproofai audit` runtime — see [Audit CLI](/cli/audit) for the underlying scan engine, supported flags, and per-transcript cache invariants. The dashboard caches the latest result at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, new runs overwrite) so revisits are instant; clicking `[ Re-run ↻ ]` POSTs `/api/audit/run` and the dashboard polls `/api/audit/status` at 1Hz until the run finishes. Empty state (no cache) and zero-sessions state (cache exists but the scan found no transcripts) are surfaced separately. + ### Policies A two-tab page for managing policies and reviewing activity. @@ -92,7 +105,7 @@ If you only need some parts of the dashboard, set `FAILPROOFAI_DISABLE_PAGES` to FAILPROOFAI_DISABLE_PAGES=policies failproofai ``` -Valid values: `policies`, `projects`. +Valid values: `policies`, `projects`, `audit`. --- From 9a0b22b0cded9dc69d6b4298fca4a67a6ffc9da9 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sat, 30 May 2026 02:13:44 +0530 Subject: [PATCH 03/13] feat(audit): persona variant catalog + scroll/poster/install-CTA polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expand every archetype in src/audit/archetypes.ts to a multi-variant catalog (4–6 taglines, keyword sets, descriptions, signature blocks, common-in / primary-risk / closing lines per archetype). A new pickArchetypeVariant(key, seed) resolver picks one variant per field via a djb2-hashed, per-field-axis index, so the persona blurb stays stable for a given project seed but two projects landing on the same archetype see different copy. IdentitySection consumes the resolved variant; the seed flows from audit-dashboard.tsx as the inferred project name. Fix the picker's signed-modulo bug (final XOR re-introduced signedness → negative index → undefined keywords) by forcing >>> 0 on the final mix. - Simplify return-section's CTA to '[ install policies ]' copying the bare `failproofai policies --install` command (no per-policy short names appended). - Fix the [ share → ] header button: replace scrollIntoView with a manual window.scrollTo that subtracts the sticky .app-header height (+16px breathing room), plus a scroll-margin-top: 80px fallback on .showoff. - Harden the 'make poster' PNG export so the captured archetype frame no longer collides with the sigil / tagline: await document.fonts.ready before capture, apply a .capturing class that locks every clamp()'d font-size and grid column to an absolute value tuned for the 1100px capture width, drop text-shadow / box-shadow that html2canvas crops unpredictably, and capture with a 12px bleed on every side so the frame's corner accents survive the crop. --- CHANGELOG.md | 4 +- app/audit/_components/audit-dashboard.tsx | 16 +- app/audit/_components/identity-section.tsx | 14 +- app/audit/_components/return-section.tsx | 21 +- app/audit/_components/show-off-cta.tsx | 26 +- app/audit/audit-styles.css | 66 ++ src/audit/archetypes.ts | 708 ++++++++++++++++++--- 7 files changed, 732 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299eedd0..fdcb37aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog -## 0.0.11-beta.3 — 2026-05-28 +## 0.0.11-beta.3 — 2026-05-29 ### Features +- `/audit` polish pass: simplify the "next audit" CTA to `[ install policies ]` copying the bare `failproofai policies --install` command (no longer appends per-slipping-policy short names); fix the `[ share → ]` header button to scroll to the Show-off section reliably by accounting for the sticky in-page `.app-header` height with a manual y-coord scroll + a `scroll-margin-top` fallback on `.showoff`; harden the "make poster" PNG export so the captured archetype frame no longer collides with the sigil / tagline — `show-off-cta.tsx` now `await document.fonts.ready` before capture, applies a `.capturing` class that locks every viewport-clamped font-size and grid column to an absolute value tuned for the 1100px capture width, drops `text-shadow` / `box-shadow` that html2canvas crops unpredictably, and captures with a 12px bleed on each side so the frame's corner accents and box-shadow survive the crop; and expand every archetype in `src/audit/archetypes.ts` from a single hand-written copy block to a multi-variant catalog (4–6 taglines, keyword sets, descriptions, signature blocks, "common in" / "primary risk" / closing lines per archetype, all 8 archetypes covered). A new `pickArchetypeVariant(key, seed)` picker deterministically selects one variant from each list via a djb2-seeded per-field hash mixed with a per-field axis, so the persona blurb stays stable across renders for a given seed but two different projects landing on the same archetype see different copy. `IdentitySection` consumes the resolved variant; the seed flows in from `audit-dashboard.tsx` as the inferred project name. + - Add an in-app `/audit` dashboard that turns the existing `failproofai audit` data into a personality-driven report. The page classifies every audited agent into one of 8 archetypes (`optimist`, `cowboy`, `explorer`, `goldfish`, `paranoid architect`, `precision builder`, `hammer`, `ghost`) via a weighted classifier (`src/audit/archetypes.ts`) that maps every builtin policy + every audit-only detector (47/47 coverage) to an archetype with a tuned weight. A scoring module (`src/audit/scoring.ts`) derives a 0-100 score with S/A/B/C/D/F grade thresholds, a projected-score uplift if every recommended policy were enabled, and a stable synthetic cohort rank. The page composes six sections — Identity (archetype hero with 8x8 pixel sigil + meta grid), Show-off CTA, Strengths (real numbers derived from the audit), Score + cohort leaderboard with distribution histogram, Findings (per-policy cards with what happened / cost / evidence / fix), Prescribed Policies (with projected-score callout), and a "re-audit in 7 days" return loop. Every audit-only detector is now mapped to its closest real-time builtin policy as the prescribed fix (`findings.ts:DETECTOR_TO_POLICY`) so the report never carries an "audit-only — no real-time policy" framing. New dashboard cache at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, helper at `src/audit/dashboard-cache.ts`); `AuditResult` schema bumped to version 2 with new fields `eventsScanned`, `projectsScanned`, `enabledBuiltinNames`. New routes `app/audit/page.tsx`, `app/api/audit/run/route.ts` (POST, in-process `runAudit()` call, module-scoped run lock that 409s on concurrent clicks), `app/api/audit/status/route.ts` (GET, drives client polling), and server action `app/actions/get-audit-result.ts` (cache read, mirrors `getHooksConfigAction`'s read-only contract). "Make poster" downloads a 2x PNG of the archetype frame via html2canvas. Navbar gains an Audit entry between Policies and Projects with a slipping-through count chip. Existing runtime policy enforcement is untouched — `policy-registry.ts` gets two additive exports (`getAllPolicies` / `setAllPolicies`) used only by the new `replay.ts:restoreReplay()` snapshot/restore so embedding `runAudit()` in a long-running process no longer wipes pre-existing registrations. Ports the brand team's design kit verbatim from `assets/audit/styles.css` (1235 lines, JetBrains Mono + VT323 via Google Fonts, Architype Stedelijk shipped locally under `public/audit/fonts/`). - Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index 0a9d151e..cba920fb 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -187,10 +187,19 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize /** Identity hero ref — captured to PNG by the "make poster" button. */ const identityFrameRef = useRef(null); - /** Scroll to the ShowOff CTA — the share button entry point per spec. */ + /** Scroll to the ShowOff CTA — the share button entry point per spec. + * Uses manual y-coord scrolling instead of scrollIntoView so we can + * account for the sticky .app-header (≈52px) that would otherwise + * cover the section. */ const scrollToShowOff = () => { - const el = document.querySelector('[data-screen-label="01b Show off"]'); - if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + const el = document.querySelector(".showoff"); + if (!el) return; + const headerEl = document.querySelector(".app-header"); + const offset = (headerEl?.offsetHeight ?? 0) + 16; + // `window` is shadowed by the inferWindow() string prop; use globalThis. + const w = globalThis as typeof globalThis & Window; + const targetY = el.getBoundingClientRect().top + w.scrollY - offset; + w.scrollTo({ top: targetY, behavior: "smooth" }); }; return ( @@ -206,6 +215,7 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize toolCalls={result.eventsScanned ?? 0} sessions={result.transcripts.scanned} window={window} + seed={project} /> (function IdentitySection( - { archetypeKey, secondaryKey, toolCalls, sessions, window }: Props, + { archetypeKey, secondaryKey, toolCalls, sessions, window, seed }: Props, frameRef, ) { - const archetype = ARCHETYPES[archetypeKey]; + const archetype = pickArchetypeVariant(archetypeKey, seed); const secondary = secondaryKey !== archetypeKey ? ARCHETYPES[secondaryKey] : null; return ( diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx index 861dae59..3884633f 100644 --- a/app/audit/_components/return-section.tsx +++ b/app/audit/_components/return-section.tsx @@ -4,7 +4,7 @@ * Section 06 — NEXT AUDIT / "come back better." Re-audit loop CTA. * * Two actions: [ set a reminder ] (placeholder, future feature) and - * [ install all N policies ] which copies the bulk install command. + * [ install policies ] which copies the bulk install command. */ import React, { useState } from "react"; import type { AuditResult } from "@/src/audit/types"; @@ -18,21 +18,18 @@ function shortName(name: string): string { return slash >= 0 ? name.slice(slash + 1) : name; } +const BULK_INSTALL_CMD = "failproofai policies --install"; + export function ReturnSection({ result }: Props) { - const unenabledShortNames = result.results - .filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0) - .map((r) => shortName(r.name)); + const hasUnenabled = result.results.some( + (r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0, + ); const [copied, setCopied] = useState(false); - const bulkInstall = unenabledShortNames.length > 0 - ? `failproofai policies --install ${unenabledShortNames.join(" ")}` - : null; - const handleInstall = async () => { - if (!bulkInstall) return; try { - await navigator.clipboard.writeText(bulkInstall); + await navigator.clipboard.writeText(BULK_INSTALL_CMD); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* ignore */ } @@ -62,11 +59,11 @@ export function ReturnSection({ result }: Props) { - {bulkInstall && ( + {hasUnenabled && ( )}
diff --git a/app/audit/_components/show-off-cta.tsx b/app/audit/_components/show-off-cta.tsx index ebd8a6d3..8b826552 100644 --- a/app/audit/_components/show-off-cta.tsx +++ b/app/audit/_components/show-off-cta.tsx @@ -32,14 +32,34 @@ export function ShowOffCTA({ archetypeKey, identityFrameRef }: Props) { const node = identityFrameRef.current; if (!node || state === "busy") return; setState("busy"); + /** Add a capture-only class that locks font sizes, the grid layout, + * and disables clamp()/text-shadow rules html2canvas renders + * unreliably. CSS lives in audit-styles.css under `.capturing`. */ + node.classList.add("capturing"); try { + // Wait for the display font (Architype Stedelijk) to load — otherwise + // html2canvas captures a fallback that has different metrics and the + // archetype name overlaps the tagline / sigil column. + if (typeof document !== "undefined" && document.fonts?.ready) { + await document.fonts.ready; + } + // Force a single rAF so the .capturing class is applied to layout + // before html2canvas reads computed styles. + await new Promise((r) => requestAnimationFrame(() => r())); + const html2canvas = (await import("html2canvas")).default; + // Bleed: include the frame's 8px box-shadow in the capture rect. + const bleed = 12; const canvas = await html2canvas(node, { - // Match the audit canvas color so any rounding artifacts blend in. backgroundColor: "#131316", - scale: 2, // retina-grade PNG for sharing + scale: 2, logging: false, useCORS: true, + x: -bleed, + y: -bleed, + width: node.offsetWidth + bleed * 2, + height: node.offsetHeight + bleed * 2, + windowWidth: Math.max(1100, node.offsetWidth + bleed * 2), }); await new Promise((resolve) => { canvas.toBlob((blob) => { @@ -61,6 +81,8 @@ export function ShowOffCTA({ archetypeKey, identityFrameRef }: Props) { console.error("poster capture failed:", err); setState("error"); setTimeout(() => setState("idle"), 2000); + } finally { + node.classList.remove("capturing"); } }; diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 086e619e..9d0d4616 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -435,6 +435,70 @@ a { color: inherit; text-decoration: none; } } .sigil-label .ix { color: var(--accent-pink); margin-right: 6px; } +/* ───────────── poster capture mode (applied during html2canvas) ───────────── + The live layout uses clamp()/vw font sizes, soft grid columns, and a + stamp text-shadow. html2canvas does NOT support clamp() or text-shadow + reliably — it picks a fallback that misaligns metrics and the giant + archetype name ends up overlapping the tagline + sigil column. + + `.capturing` is added by show-off-cta.tsx right before capture and + removed in the finally block. It locks every viewport-relative size to + an absolute value tuned for the ~1100px capture width, fixes the grid + columns, and clears the stamp shadow + box shadow that html2canvas + would otherwise crop. */ +.archetype-frame.capturing { + min-width: 1080px; + max-width: 1180px; + padding: 72px 64px 64px; + box-shadow: none; +} +.archetype-frame.capturing .arch-name { + font-size: 88px; + line-height: 1; + margin: 0 0 24px; + text-shadow: none; + letter-spacing: 0.06em; +} +.archetype-frame.capturing .arch-tagline { + font-size: 16px; + max-width: 560px; + margin: 0 0 32px; +} +.archetype-frame.capturing .arch-secondary { + margin-bottom: 32px; +} +.archetype-frame.capturing .arch-keywords { + font-size: 24px; + letter-spacing: 0.09em; + padding: 16px 0 12px; + gap: 14px; + max-width: 560px; +} +.archetype-frame.capturing .arch-body { + grid-template-columns: minmax(0, 1.6fr) minmax(220px, 1fr); + gap: 56px; + align-items: start; +} +.archetype-frame.capturing .arch-meta-grid { + margin-top: 32px; + padding-top: 26px; + gap: 28px; +} +.archetype-frame.capturing .arch-closing { + font-size: 22px; + margin-top: 32px; + padding-top: 26px; +} +.archetype-frame.capturing .sigil-wrap { + position: sticky; + top: 0; + align-self: center; + padding-top: 48px; +} +.archetype-frame.capturing .sigil { + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} + /* ───────────────────────── 02 STRENGTHS ───────────────────────── */ .strengths-grid { @@ -800,6 +864,8 @@ a { color: inherit; text-decoration: none; } padding: 0 0 32px; border-bottom: 1px solid var(--line); margin-bottom: 0; + /* Clear the sticky .app-header (≈52px tall) when scroll-anchored. */ + scroll-margin-top: 80px; } .showoff-cta { display: grid; diff --git a/src/audit/archetypes.ts b/src/audit/archetypes.ts index 3c85294d..53de174c 100644 --- a/src/audit/archetypes.ts +++ b/src/audit/archetypes.ts @@ -6,8 +6,16 @@ * weights them by hits × policy-severity, and picks the dominant signature. * * Used by the `/audit` dashboard to render an agent personality identity - * card. The archetype data (names, taglines, descriptions, pixel sigils) - * is ported verbatim from `assets/audit/archetypes.jsx`. + * card. + * + * Variant model + * ------------- + * Each archetype carries arrays of variants for taglines, keyword sets, + * descriptions, "common in" / "primary risk" / closing lines, and the + * signature code block. `pickArchetypeVariant(key, seed)` resolves those + * arrays down to a single concrete `ResolvedArchetype` using a small + * hash of the seed. Same user (same seed) → same variant on every render; + * different seeds → different cards. */ import type { AuditResult } from "./types"; @@ -21,25 +29,45 @@ export type ArchetypeKey = | "hammer" | "ghost"; +export interface SignatureLine { + arrow?: string; + body?: string; + comment?: string; + err?: string; +} + +/** + * The raw archetype carries arrays of variants. Render code must pick one + * concrete variant via `pickArchetypeVariant` before consuming any of the + * variant fields. + */ export interface Archetype { key: ArchetypeKey; - index: string; // "01" → "08" + index: string; + name: string; + taglines: string[]; + keywordSets: string[][]; // each entry is a 3-word set + descriptions: string[]; + signatures: SignatureLine[][]; + commons: string[]; + risks: string[]; + closings: string[]; + secondary: ArchetypeKey; +} + +/** A single resolved variant — what render code actually consumes. */ +export interface ResolvedArchetype { + key: ArchetypeKey; + index: string; name: string; tagline: string; - keywords: string[]; // exactly 3 + keywords: string[]; description: string; signature: SignatureLine[]; common: string; risk: string; closing: string; - secondary: ArchetypeKey; // default secondary if classifier can't pick one -} - -export interface SignatureLine { - arrow?: string; - body?: string; - comment?: string; - err?: string; + secondary: ArchetypeKey; } export const ARCHETYPE_ORDER: ArchetypeKey[] = [ @@ -52,146 +80,573 @@ export const ARCHETYPES: Record = { key: "optimist", index: "01", name: "the optimist", - tagline: "ships fast. retries with conviction. occasionally forgets it was already there.", - keywords: ["pace", "conviction", "forgetful"], - description: + taglines: [ + "ships fast. retries with conviction. occasionally forgets it was already there.", + "moves first, reads later. every failure is just step one of the next attempt.", + "the floor is hope. the ceiling is also hope. there is no diagnosis in between.", + "if at first you don't succeed — try the exact same thing, with more energy.", + "writes confident code. burns confident tokens. neither knows the difference.", + "speed is a feature. so is the directory it's already in.", + ], + keywordSets: [ + ["pace", "conviction", "forgetful"], + ["fast", "trusting", "redundant"], + ["eager", "looping", "stateful"], + ["bold", "unblocked", "drifty"], + ["forward", "hopeful", "wasteful"], + ["shipper", "retrier", "doubler"], + ], + descriptions: [ "moves at pace. doesn't second-guess itself — which is mostly a feature. when something fails, it tries again: same args, same hope. when uncertain about its location, it prepends the directory anyway. just in case. the optimism is earned. this agent gets things done. it just occasionally burns tokens proving it.", - signature: [ - { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, - { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, - { arrow: "→", body: "retries: 6. diagnosis: 0." }, - ], - common: "fast-iteration solo projects, early-stage prototypes, builders who ship daily", - risk: "token waste, retry spirals, stale state assumptions", - closing: "the optimism is a feature. the waste is not.", + "ships first, asks questions never. the optimist is the agent that always has momentum — which is exactly the problem. cwd assumptions stack up. retries pile up. the work gets done. it's just twice as expensive as it needed to be.", + "high trust in its own state model. low evidence that the model is correct. when things go sideways, the optimist's first move is to re-run the same call with the same args and a renewed sense of conviction. mostly it's right. when it's wrong, it's wrong loudly.", + "the optimist treats every error as a transient. cd before every command, just in case. prepend the absolute path, just in case. retry on any non-zero exit, just in case. the just-in-case tax is real. so is the velocity.", + ], + signatures: [ + [ + { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, + { arrow: "→", body: "retries: 6. diagnosis: 0." }, + ], + [ + { arrow: "→", body: "cd /Users/n/proj &&", comment: " # cwd already /Users/n/proj" }, + { arrow: "→", body: "cd /Users/n/proj && ls" }, + { arrow: "→", body: "cd /Users/n/proj && cat package.json" }, + ], + [ + { arrow: "→", body: "npm install", err: " → ETIMEDOUT" }, + { arrow: "→", body: "npm install", err: " → ETIMEDOUT" }, + { arrow: "→", body: "npm install", comment: " # third time's the charm" }, + ], + [ + { arrow: "→", body: 'cat "package.json" | head', comment: " # ← read 1" }, + { arrow: "→", body: 'cat "package.json"', comment: " # ← read 2" }, + { comment: "# could've been one Read tool call." }, + ], + ], + commons: [ + "fast-iteration solo projects, early-stage prototypes, builders who ship daily", + "weekend hacks, hackathon repos, side-projects under active push", + "early-stage codebases without a strong test harness yet", + "agents given task framing without explicit success criteria", + "loose-context sessions where exact cwd state is ambiguous", + ], + risks: [ + "token waste, retry spirals, stale state assumptions", + "redundant cd's, repeated reads, retries without diagnosis", + "false confidence in cwd, doubled-up shell setup, idle loops", + "rate-limit hits from blind retries on transient failures", + "context bloat from re-reading the same files three different ways", + ], + closings: [ + "the optimism is a feature. the waste is not.", + "ship fast. retry less.", + "energy is good. diagnosis is better.", + "momentum keeps. the second cd does not.", + "trust the work. verify the state.", + ], secondary: "explorer", }, cowboy: { key: "cowboy", index: "02", name: "the cowboy", - tagline: "asks for forgiveness, not permission. git push --force is a philosophy.", - keywords: ["bold", "forceful", "ungoverned"], - description: + taglines: [ + "asks for forgiveness, not permission. git push --force is a philosophy.", + "your branch protection rules are the only thing between this agent and prod.", + "fast hands, faster history rewrites. the audit log is for other people.", + "high trust in its own judgment. low patience for code review.", + "main is just a branch. branch protection is just a suggestion. ship.", + "ships hot. reverts later. occasionally needs an adult in the room.", + ], + keywordSets: [ + ["bold", "forceful", "ungoverned"], + ["direct", "destructive", "swift"], + ["fearless", "reckless", "loud"], + ["assertive", "loose", "unblocked"], + ["confident", "skipping", "main-bound"], + ["sudo-curious", "force-prone", "fast"], + ], + descriptions: [ "high output. low ceremony. the cowboy gets code onto main faster than anyone — and your branch protection rules are the only thing standing between this agent and your production database. not reckless. just confident. in a way that requires guardrails.", - signature: [ - { arrow: "→", body: "git push origin main --force" }, - { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, - { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, - ], - common: "solo repos, weekend projects, founders writing their own infra", - risk: "branch protection bypass, accidental main commits, revert overhead", - closing: "the pace is real. the risk is too.", + "doesn't see commits. sees a delivery mechanism. force-pushes when history is inconvenient. drops into main when feature branches feel slow. the cowboy is the agent every team accidentally creates, and every team eventually wants policies for.", + "the velocity is unmatched. the blast radius is also unmatched. this agent will solve your problem and rewrite three years of git history while it's at it. not malicious. just allergic to friction.", + "treats every guardrail as a temporary obstacle. sudo here, --no-verify there, a quick rm -rf to clean up. it's getting work done — by sidestepping every check that might slow it down.", + ], + signatures: [ + [ + { arrow: "→", body: "git push origin main --force" }, + { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, + { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, + ], + [ + { arrow: "→", body: "rm -rf ./node_modules && rm -rf ./dist" }, + { arrow: "→", body: 'git commit -am "wip" --no-verify' }, + { arrow: "→", body: "git push --force-with-lease" }, + ], + [ + { arrow: "→", body: "sudo systemctl restart postgres" }, + { arrow: "→", body: "kubectl delete pod api-prod-7f4 --grace-period=0" }, + { arrow: "→", body: 'echo "should be fine"' }, + ], + [ + { arrow: "→", body: "git checkout main && git merge feature --ff-only" }, + { arrow: "!", body: "merge would fail" }, + { arrow: "→", body: "git reset --hard feature && git push" }, + ], + ], + commons: [ + "solo repos, weekend projects, founders writing their own infra", + "agents with broad shell access and no PR-gating workflow", + "early-stage product code where speed > governance", + "ops scripts, one-off migrations, cleanup tasks", + "sandboxes that look like production until they aren't", + ], + risks: [ + "branch protection bypass, accidental main commits, revert overhead", + "destructive shell operations, unrecoverable state changes", + "force-pushed history, lost commits, irreproducible deploys", + "sudo escalations, container blast radius, infra mutations without rollback plan", + "policy bypass via --no-verify, --force, and friends", + ], + closings: [ + "the pace is real. the risk is too.", + "speed is a feature. guardrails are not optional.", + "ship hot. revert clean.", + "a fast agent without policies is a fast incident.", + "confidence is fine. consent is better.", + ], secondary: "hammer", }, explorer: { key: "explorer", index: "03", name: "the explorer", - tagline: "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", - keywords: ["curious", "thorough", "leaky"], - description: + taglines: [ + "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", + "follows every reference. opens every neighbor. some neighbors aren't yours.", + "thorough to a fault. the fault is usually a .env file two directories up.", + "knows the codebase deeply. knows your secrets drawer almost as well.", + "wide-context by default. wide-context isn't always free.", + "great at maps. less great at fences.", + ], + keywordSets: [ + ["curious", "thorough", "leaky"], + ["wide", "deep", "drifting"], + ["mapping", "tracing", "boundary-blind"], + ["broad", "diligent", "porous"], + ["thinking", "wandering", "exposing"], + ["research-mode", "context-hungry", "secret-adjacent"], + ], + descriptions: [ "curious by nature. reads broadly, thinks laterally, sometimes follows a symlink somewhere it wasn't meant to go. this isn't malice — it's thoroughness that hasn't learned boundaries yet. the explorer builds great things. it just occasionally needs someone to close the door to the secrets drawer.", - signature: [ - { arrow: "→", body: "cat /Users/n/.aws/credentials" }, - { arrow: "→", body: "cat ../other-repo/.env" }, - { arrow: "→", body: "cat ~/.config/openai/key" }, - ], - common: "multi-project setups, agents with broad file access, complex monorepos", - risk: "credential exposure, unintended cross-project reads, secrets landing in context", - closing: "the curiosity stays. the credentials stay private.", + "the explorer treats every file path as part of the working context. ~/.aws/credentials is just another config file to it. ../other-repo/.env is just one more reference. the work is genuinely better-informed because of this. the credentials are also genuinely in the context window.", + "no malice. no shortcuts. just a thoroughness that follows references straight through your boundary fence. great research instincts. needs explicit walls.", + "broad-context is a feature in this agent. it's also why your private keys show up in a chat log every two weeks. the curiosity is good. the perimeter needs help.", + ], + signatures: [ + [ + { arrow: "→", body: "cat /Users/n/.aws/credentials" }, + { arrow: "→", body: "cat ../other-repo/.env" }, + { arrow: "→", body: "cat ~/.config/openai/key" }, + ], + [ + { arrow: "→", body: 'find / -name "*.env" 2>/dev/null', comment: " # full-FS scan" }, + { arrow: "→", body: 'grep -r "AKIA" /Users/n/' }, + { arrow: "→", body: 'cat "$(find ~ -name credentials -print -quit)"' }, + ], + [ + { arrow: "→", body: "ls ~/.ssh/" }, + { arrow: "→", body: "cat ~/.ssh/config" }, + { arrow: "→", body: "cat ~/.ssh/id_rsa", comment: " # for context" }, + ], + [ + { arrow: "→", body: "open ../sibling-project" }, + { arrow: "→", body: "git log --all --source ../sibling-project" }, + { arrow: "→", body: "cat ../sibling-project/.env.production" }, + ], + ], + commons: [ + "multi-project setups, agents with broad file access, complex monorepos", + "research-style work — debugging, refactoring, cross-repo investigations", + "macOS / linux dev boxes with shared credential directories", + "agents without explicit cwd-restriction policies", + "long-running sessions where context tends to drift outward", + ], + risks: [ + "credential exposure, unintended cross-project reads, secrets landing in context", + ".env file leaks, AWS / OpenAI / GCP key exfiltration through chat logs", + "neighboring-repo bleed, business-secret cross-contamination", + "global filesystem scans that surface sensitive paths", + "broad reads that quietly inflate context window with private data", + ], + closings: [ + "the curiosity stays. the credentials stay private.", + "wide is fine. wide-and-outside-the-fence is not.", + "thorough is a feature. perimeter is a setting.", + "research deep. boundary clean.", + "knows everything. shares nothing it shouldn't.", + ], secondary: "architect", }, goldfish: { key: "goldfish", index: "04", name: "the goldfish", - tagline: "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", - keywords: ["ambitious", "drifting", "inventive"], - description: + taglines: [ + "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", + "great at the first 40 turns. inventive for the next 40.", + "past 80% context, history becomes a draft.", + "remembers the task. forgets which file the task was in.", + "ambitious. earnest. quietly making things up around turn 50.", + "long-context vibes. short-context recall.", + ], + keywordSets: [ + ["ambitious", "drifting", "inventive"], + ["sprawling", "creative", "post-cache"], + ["long-running", "hallucinatory", "well-meaning"], + ["earnest", "context-full", "fabricating"], + ["sustained", "forgetful", "confabulating"], + ["marathon", "drifted", "compounding"], + ], + descriptions: [ "great at long tasks. not great at remembering which long task it's on. past 80% context, the goldfish starts inventing history — citing files it never opened, referencing edits it never made. not lying. just filling gaps with confidence. the longer the session, the more creative the memory.", - signature: [ - { comment: "# turn 47/52 — ctx 82% full" }, - { comment: '# agent: "as we saw earlier in auth.ts…"' }, - { comment: "# auth.ts was never opened this session." }, - ], - common: "long-running refactor sessions, complex multi-file tasks, agents without session breaks", - risk: "context drift, hallucinated prior work, compounding errors in long sessions", - closing: "the ambition is good. the context budget is not.", + "the goldfish is what every agent looks like after turn 50. confident about prior work it didn't do. mistakenly sure of file contents it never read. the work it actually delivered is real. the context around it is increasingly fictional.", + "ambition outlasts recall. once context fills, the goldfish smooths over gaps with plausible inventions: a fake earlier edit, a misremembered file path, a hallucinated test that passed. it's never trying to mislead. it just doesn't always know what's true anymore.", + "long-task specialist with a memory ceiling. the work compounds beautifully until it doesn't, and then it compounds wrongly. needs session breaks more than it needs encouragement.", + ], + signatures: [ + [ + { comment: "# turn 47/52 — ctx 82% full" }, + { comment: '# agent: "as we saw earlier in auth.ts…"' }, + { comment: "# auth.ts was never opened this session." }, + ], + [ + { comment: "# turn 63 — context 91%" }, + { arrow: "→", body: 'apply_edit("src/auth.ts", { ... })' }, + { comment: "# agent: \"reverting my earlier change.\" # there was no earlier change." }, + ], + [ + { comment: "# turn 51 — fabricated test reference" }, + { arrow: "→", body: 'run("npm test src/auth.test.ts")', err: " → no such file" }, + { comment: '# agent: "the test we wrote earlier." # no such test exists.' }, + ], + [ + { comment: "# session-time 3h 14m" }, + { comment: "# context: 88% — auto-compress in 4 turns" }, + { comment: "# next plan cites 3 files only one of which exists." }, + ], + ], + commons: [ + "long-running refactor sessions, complex multi-file tasks, agents without session breaks", + "auto-driven coding loops with no human turn between iterations", + "tasks that span hours or days without explicit memory checkpoints", + "open-ended migrations and refactors with diffuse success criteria", + "scripted swarms where each agent inherits a long prior transcript", + ], + risks: [ + "context drift, hallucinated prior work, compounding errors in long sessions", + "fabricated file references, invented function signatures, ghost edits", + "tests cited that don't exist, edits remembered that didn't happen", + "confident misstatements compounding into wrong-architecture deliverables", + "auto-compression discarding the load-bearing details and keeping the noise", + ], + closings: [ + "the ambition is good. the context budget is not.", + "remember less. checkpoint more.", + "long is fine. drifted is expensive.", + "ambition is welcome. invention is not.", + "fresh sessions beat creative ones.", + ], secondary: "optimist", }, architect: { key: "architect", index: "05", name: "the paranoid architect", - tagline: "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", - keywords: ["methodical", "safe", "slow"], - description: + taglines: [ + "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", + "reads the same file from two different paths. just to be sure.", + "verifies twice, edits maybe.", + "safest agent in the room. also the one nobody waits for.", + "would rather diagnose for an hour than retry for a second.", + "extremely careful. extremely slow. extremely correct.", + ], + keywordSets: [ + ["methodical", "safe", "slow"], + ["thorough", "verifying", "circular"], + ["careful", "patient", "redundant"], + ["double-checking", "guarded", "deliberate"], + ["safety-first", "loop-prone", "anchored"], + ["measured", "audited", "looping"], + ], + descriptions: [ "methodical. thorough. reads the same file from two different paths, just to be sure. verifies before every write. double-checks the package.json before running anything. the paranoid architect rarely makes mistakes — because it rarely finishes fast enough to make them. your safest agent. your slowest agent.", - signature: [ - { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, - { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, - { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, - ], - common: "production systems, high-stakes codebases, builders with strong safety instincts", - risk: "token overhead, slow sessions, redundant verification loops", - closing: "safety is a feature. so is finishing.", + "safety is the architect's love language. read the file. re-read it from a different path. verify the cwd. check the lockfile. run the test before writing. run it again after. the work is correct. the work is also six times more expensive than it had to be.", + "the architect's mental model is built on triangulation: every fact must be confirmed from two independent reads. when it works, you ship near-zero bugs. when it doesn't, you ship near-zero features.", + "extremely careful. extremely slow. extremely correct. the architect rarely makes mistakes — but it also rarely makes deadlines. the safety is genuine; so is the cost.", + ], + signatures: [ + [ + { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, + { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, + { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, + ], + [ + { arrow: "→", body: 'read_file("package.json")' }, + { arrow: "→", body: 'read_file("./package.json")' }, + { arrow: "→", body: "cat package.json | jq .scripts", comment: " # one more time" }, + ], + [ + { arrow: "→", body: "git status", comment: " # check 1" }, + { arrow: "→", body: "git status --short", comment: " # check 2" }, + { arrow: "→", body: "git diff --stat", comment: " # check 3" }, + ], + [ + { arrow: "→", body: 'apply_edit("src/foo.ts", { ... })' }, + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # verifying the edit landed" }, + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # again, just to be sure" }, + ], + ], + commons: [ + "production systems, high-stakes codebases, builders with strong safety instincts", + "regulated codebases (fin / med / compliance) where bugs are expensive", + "teams burned by past prod incidents that hardened review norms", + "agents instructed with strong 'verify everything' system prompts", + "post-incident codebases recovering from a recent outage", + ], + risks: [ + "token overhead, slow sessions, redundant verification loops", + "verification cycles that eat 3× the budget of the actual change", + "stalled progress on otherwise routine edits", + "checkpoint loops that read the same file 6 times in a row", + "over-caution masking simple problems behind ceremony", + ], + closings: [ + "safety is a feature. so is finishing.", + "double-check is fine. quadruple-check is not.", + "careful is good. moving is also good.", + "rigor wins. rigor twice is just slower.", + "verify once. ship once.", + ], secondary: "precision", }, precision: { key: "precision", index: "06", name: "the precision builder", - tagline: "in. done. out. your agent doesn't linger.", - keywords: ["clean", "focused", "minimal"], - description: + taglines: [ + "in. done. out. your agent doesn't linger.", + "small footprint. right calls. correct exits.", + "few findings isn't no findings. but it's close.", + "the rhythm is dialed in. the rest is iteration.", + "every call is intentional. every session ends cleanly.", + "minimal noise. maximum signal. occasional smugness.", + ], + keywordSets: [ + ["clean", "focused", "minimal"], + ["surgical", "tight", "deliberate"], + ["disciplined", "concise", "intentional"], + ["measured", "exact", "trim"], + ["calibrated", "small-radius", "complete"], + ["dialed-in", "right-sized", "low-noise"], + ], + descriptions: [ "minimal footprint. focused calls. gets in, does the work, gets out. the precision builder is what every agent aspires to be — and what most agents aren't yet. few findings don't mean no findings. but it means your agent has found its rhythm. the gap between here and s-tier is smaller than you think.", - signature: [ - { arrow: "→", body: "clean tool calls. right paths, right args." }, - { arrow: "→", body: "sessions end when the task ends." }, - { arrow: "→", body: "no redundant reads. no retry storms." }, - ], - common: "mature agents, heavily policy-enforced setups, builders who've iterated for a while", - risk: "low finding count can mask edge cases that haven't surfaced yet", - closing: "rare. keep it that way.", + "tight loops. correct tools. clean exits. the precision builder treats each tool call like it has a budget — because it does. nothing redundant. nothing wasteful. when this agent finishes, the work is done and the transcript is short.", + "this is what every agent aspires to be. surgical reads. matched edits. test runs that actually verify the right thing. precision is rare. when you see it, you've earned it.", + "minimal blast radius. minimal token waste. minimal surprises. the precision builder is what your agent looks like after enough iteration loops. respect.", + ], + signatures: [ + [ + { arrow: "→", body: "clean tool calls. right paths, right args." }, + { arrow: "→", body: "sessions end when the task ends." }, + { arrow: "→", body: "no redundant reads. no retry storms." }, + ], + [ + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # one read" }, + { arrow: "→", body: 'apply_edit("src/foo.ts", { ... })', comment: " # one edit" }, + { arrow: "→", body: 'run("bun test src/foo.test.ts")', comment: " # green ✓" }, + ], + [ + { arrow: "→", body: "git status" }, + { arrow: "→", body: "git add -p && git commit -m \"fix: ...\"" }, + { arrow: "→", body: "git push", comment: " # session done." }, + ], + [ + { arrow: "→", body: 'grep -rn "useFoo" src/' }, + { arrow: "→", body: 'apply_edit("src/hooks/use-foo.ts")' }, + { arrow: "→", body: 'run("bun test")', comment: " # all green." }, + ], + ], + commons: [ + "mature agents, heavily policy-enforced setups, builders who've iterated for a while", + "teams running failproofai for ≥ a week with policies tuned", + "experienced operators who curate their tool list and CLI flags", + "codebases with strong test coverage that reward intentional edits", + "agents kept on a tight cwd-restricted leash", + ], + risks: [ + "low finding count can mask edge cases that haven't surfaced yet", + "narrow scope might be hiding work the agent isn't being asked to do", + "small-radius work can plateau before it surfaces deeper issues", + "few findings can read as 'untested' rather than 'safe'", + "complacency — the rhythm works until the task shape changes", + ], + closings: [ + "rare. keep it that way.", + "few findings. real signal. respect.", + "this is the rhythm. don't break it.", + "minimal is hard-earned. defend it.", + "you're already past the agent learning curve.", + ], secondary: "ghost", }, hammer: { key: "hammer", index: "07", name: "the hammer", - tagline: "when something doesn't work, it tries the exact same thing again. harder.", - keywords: ["determined", "repetitive", "unbacked"], - description: + taglines: [ + "when something doesn't work, it tries the exact same thing again. harder.", + "diagnosis-light. repetition-heavy. mostly burns tokens with conviction.", + "the first call failed. so did the next six. the seventh probably won't.", + "no diagnosis, no backoff, no arg change. just the same call, louder.", + "the failure mode is not learning. the failure mode is also the strategy.", + "every retry is identical. every retry is also confident.", + ], + keywordSets: [ + ["determined", "repetitive", "unbacked"], + ["looping", "stubborn", "unblocked"], + ["unchanging", "burning", "convicted"], + ["sticky", "spiraling", "diagnosis-free"], + ["repeated", "uncorrected", "headstrong"], + ["unchanged-args", "no-backoff", "patient-failure"], + ], + descriptions: [ "determined. possibly to a fault. the hammer's first response to failure is repetition. no diagnosis, no arg change, no backoff. just the same call, six times, under 90 seconds, with conviction. occasionally works. mostly burns tokens and stalls the session. needs a budget more than it needs encouragement.", - signature: [ - { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, - { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, - { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, - { comment: "# 6× total. file is at src/router.ts." }, - ], - common: "agents without failure-handling policies, complex directory structures, ambiguous task framing", - risk: "token spirals, stalled sessions, no diagnostic signal ever surfaces", - closing: "the conviction is good. the diagnosis is missing.", + "the hammer treats every transient as a signal-to-retry. it never widens the search, never alters the args, never escalates. just runs the same failing call until either the call starts working or someone notices the session has stalled.", + "the diagnosis instinct is missing. when something fails, the hammer's first move is to repeat. when that fails too, it's to repeat. and again. eventually it works, or eventually the session gets killed. either way, the model is unchanged.", + "high persistence. low introspection. the hammer is what your agent becomes when you don't give it a budget — or a reason to think differently between attempts.", + ], + signatures: [ + [ + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { comment: "# 6× total. file is at src/router.ts." }, + ], + [ + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { comment: "# same args. same failure. four more attempts queued." }, + ], + [ + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { comment: "# polling loop. no timeout, no break condition." }, + ], + [ + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { comment: "# no backoff. no jitter. no API status check." }, + ], + ], + commons: [ + "agents without failure-handling policies, complex directory structures, ambiguous task framing", + "tasks where the agent doesn't have an obvious 'try-another-angle' move", + "transient-failure scenarios (rate limits, flaky tests, network blips)", + "agents without a per-task retry budget", + "tool-call patterns where the args themselves are the problem", + ], + risks: [ + "token spirals, stalled sessions, no diagnostic signal ever surfaces", + "rate-limit overruns, API ban-risk, infinite poll loops", + "wasted minutes on retries when one diff would have fixed it", + "transient errors mistaken for permanent ones (and vice versa)", + "no learning between attempts — same outcome, more cost", + ], + closings: [ + "the conviction is good. the diagnosis is missing.", + "retry less. think more.", + "harder isn't a strategy. different is.", + "stop. read the error. then try again.", + "the loop is the bug.", + ], secondary: "optimist", }, ghost: { key: "ghost", index: "08", name: "the ghost", - tagline: "moves fast, leaves little trace. sometimes leaves a little too little trace.", - keywords: ["efficient", "quiet", "unverified"], - description: + taglines: [ + "moves fast, leaves little trace. sometimes leaves a little too little trace.", + "writes the file. doesn't verify the write. trusts the silence.", + "completion ceremony? skipped. exits ceremony? also skipped.", + "the work probably worked. probably.", + "edits land. tests don't run. nothing checks the result.", + "efficient. quiet. occasionally lies to itself about success.", + ], + keywordSets: [ + ["efficient", "quiet", "unverified"], + ["clean", "trusting", "skip-the-check"], + ["fast", "silent", "uncommitted"], + ["light-touch", "trust-the-write", "no-test"], + ["minimal", "exit-fast", "audit-light"], + ["smooth", "untraced", "unconfirmed"], + ], + descriptions: [ "efficient. clean. doesn't hang around. the ghost completes tasks with minimal overhead — no redundant reads, no retry storms, no boundary drift. the risk is quiet: it doesn't always check that things worked. the build passes. or it looks like it does. the ghost trusts its own output more than it should.", - signature: [ - { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, - { comment: "→ [no read_file to verify]" }, - { comment: "→ [no test run after write]" }, - { comment: "# task complete. # maybe." }, - ], - common: "fast-moving solo projects, low-constraint setups, minimal oversight workflows", - risk: "silent failures, unverified writes, false completion signals", - closing: "fast is good. verified-fast is better.", + "the ghost ships and exits. no verification loop. no test run. no read-after-write. the work is probably correct. probably. you'll find out next session — or when CI does, on someone else's screen.", + "no waste. no noise. no proof. the ghost writes the file, declares success, and moves on. when it's right, you've got a clean session. when it's wrong, you don't find out until the next deploy.", + "trusts the diff. trusts the toolchain. trusts the silence after a write. the ghost is the precision builder with one missing step: the verification at the end.", + ], + signatures: [ + [ + { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, + { comment: "→ [no read_file to verify]" }, + { comment: "→ [no test run after write]" }, + { comment: "# task complete. # maybe." }, + ], + [ + { arrow: "→", body: 'apply_edit("src/auth.ts", { ... })' }, + { comment: "→ [no test run]" }, + { comment: "→ [stop event fired with uncommitted changes]" }, + ], + [ + { arrow: "→", body: 'write_file("config/prod.json", "{...}")' }, + { comment: "# no schema check, no lint, no diff review" }, + { comment: "→ session ends." }, + ], + [ + { arrow: "→", body: "git merge feature-branch" }, + { arrow: "!", body: "merge conflicts: 3 files" }, + { comment: "→ stop event with conflicts unresolved." }, + ], + ], + commons: [ + "fast-moving solo projects, low-constraint setups, minimal oversight workflows", + "side projects where the cost of a missed bug is low", + "agents without 'require-tests-before-stop' style policies", + "monorepos where the test command is non-obvious", + "sessions auto-ended on success without an explicit verification step", + ], + risks: [ + "silent failures, unverified writes, false completion signals", + "uncommitted changes left on the floor, stop events firing dirty", + "missing test runs masking regressions until CI", + "merge conflicts left unresolved across session boundaries", + "PR-less work that's never reviewed before deploy", + ], + closings: [ + "fast is good. verified-fast is better.", + "ship. then check.", + "writes are a bet. verify it.", + "silent success isn't a signal. green tests are.", + "trust your toolchain. confirm with proof.", + ], secondary: "precision", }, }; @@ -283,6 +738,55 @@ export const SIGILS: Record = { ], }; +// ============================================================ +// Variant picker — deterministic over (key, seed) +// ============================================================ + +/** djb2-style hash. Stable across renders, no crypto needed. */ +function hashSeed(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h >>> 0; +} + +function pickAt(arr: T[], h: number, axis: number): T { + if (arr.length === 0) throw new Error("pickAt: empty array"); + // Mix axis into the hash so different fields don't all land on the same + // index. xmur3-ish per-field scramble keeps the picks decorrelated. + // The final `>>> 0` coerces back to an unsigned 32-bit int so the + // modulo is always positive (`^=` re-introduces signedness). + let n = h ^ Math.imul(axis, 0x9e3779b9); + n = Math.imul(n ^ (n >>> 16), 0x85ebca6b); + n = Math.imul(n ^ (n >>> 13), 0xc2b2ae35); + n = (n ^ (n >>> 16)) >>> 0; + return arr[n % arr.length]!; +} + +/** + * Pick a single concrete variant of an archetype. + * + * `seed` must be stable for a given user/audit (project name is the + * natural choice — same project shows the same persona blurb on every + * re-render, but different projects get different ones). + */ +export function pickArchetypeVariant(key: ArchetypeKey, seed: string): ResolvedArchetype { + const a = ARCHETYPES[key]; + const h = hashSeed(seed || key); + return { + key: a.key, + index: a.index, + name: a.name, + secondary: a.secondary, + tagline: pickAt(a.taglines, h, 1), + keywords: pickAt(a.keywordSets, h, 2), + description: pickAt(a.descriptions, h, 3), + signature: pickAt(a.signatures, h, 4), + common: pickAt(a.commons, h, 5), + risk: pickAt(a.risks, h, 6), + closing: pickAt(a.closings, h, 7), + }; +} + // ============================================================ // Classifier // ============================================================ From 1b38dafa97a6c98ca2fb4d7fb0880e89cb211503 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sun, 31 May 2026 17:14:01 +0530 Subject: [PATCH 04/13] feat(auth): email-OTP login for CLI + dashboard, wired to failproof-api-server Implements end-to-end email-OTP auth against the Rust failproof-api-server, exposed through both the `failproofai auth` CLI subcommand and the in-app dashboard. Adds a gating step on the /audit page's "set a reminder" CTA so unidentified visitors can verify themselves inline before reminders get queued (mail-scheduling itself is deferred). Architecture CLI (failproofai auth) Dashboard (Next.js) \\ / \\ reads/writes / reads/writes \\ / ~/.failproofai/auth.json (mode 0600) | | bearer JWT v failproof-api-server (Rust) -> Postgres CLI surface (src/auth/cli.ts, dispatched from bin/failproofai.mjs) failproofai auth --login Email + OTP flow, writes auth.json failproofai auth --logout Revokes server-side, wipes auth.json failproofai auth --whoami Prints identity from /me (silent refresh) failproofai auth --help Usage Readline input-masking is TTY-gated so piped stdin (tests / scripts) doesn't stall on the per-character _writeToOutput callback. Shared HTTP + persistence layer (lib/auth/) api-server-client.ts Stateless fetch client. Endpoint helpers (requestLoginCode, verifyLoginCode, refreshAccessToken, logoutSession, fetchMe, decodeJwt). AuthApiError carries status, code, retry_after_secs. Base URL from FAILPROOF_API_URL (default http://localhost:8080). Tolerates both the documented {code,message} error shape and the live server's {success,code,detail} shape. auth-store.ts File persistence at ~/.failproofai/auth.json, mode 0600 (creation + chmodSync on overwrite). getValidAccessToken() auto-refreshes within a 60s leeway; whoAmI() does one refresh-and-retry on a hard 401 then wipes the file. FAILPROOFAI_AUTH_DIR env-var override exists for tests. Dashboard API routes (app/api/auth/) GET /api/auth/status {authenticated, user?} via whoAmI() POST /api/auth/login-request Proxy; surfaces retry_after_secs POST /api/auth/login-verify Proxy; on 200 persists tokens locally and returns ONLY {authenticated, user} -- the refresh token never reaches the browser POST /api/auth/logout Revokes upstream, deletes auth.json regardless of upstream success Dashboard UI app/audit/_components/auth-dialog.tsx Modal dialog matched to /audit's pixel-craft aesthetic: pink corner brackets, dashed-frame backdrop, terminal mono inputs, masked OTP entry, live 30s resend countdown, ESC / backdrop / [x] close, error banner with rate-limit messaging. app/audit/_components/return-section.tsx Probes /api/auth/status on mount. [set a reminder] gates on auth: unknown -> button disabled, anon -> opens dialog with "oops -- you are unknown", authed -> flashes [reminder queued for ] and shows a green "signed in as " pill under the CTA. app/audit/audit-styles.css New .auth-dialog* + .auth-status-pill rules using the existing color palette and font stack. Production deploy hooks - Set FAILPROOF_API_URL on the user's machine OR change DEFAULT_API_BASE in lib/auth/api-server-client.ts to the prod URL before publishing. The npm package never touches Postgres directly -- only the HTTP surface of the api-server. Database / JWT / SES config all live with the api-server deployment. - CORS work is not needed: every browser-visible auth call goes through the Next.js API routes (server-side), so the api-server never sees a cross-origin browser request. - Refresh-token reuse detection happens on the api-server (rotated_to chain); the client treats any 401-from-refresh as "wipe local session" so theft-revoked users get pushed back to the login dialog. Local dev loop 1. docker run -d --rm --name failproof-pg -e POSTGRES_PASSWORD=postgres \\ -e POSTGRES_USER=postgres -e POSTGRES_DB=failproof -p 5544:5432 \\ postgres:16-alpine 2. cd platform/failproofai/api-server && \\ FAILPROOF_DATABASE_URL=postgres://postgres:postgres@localhost:5544/failproof \\ FAILPROOF_JWT_SIGNING_KEY=<>=32-byte-string> \\ FAILPROOF_BIND_ADDR=127.0.0.1:8080 \\ FAILPROOF_EMAIL_SENDER_BACKEND=log FAILPROOF_ENVIRONMENT=local \\ cargo run --bin server 3. FAILPROOF_API_URL=http://127.0.0.1:8080 bun run dev 4. In a fourth terminal: bun bin/failproofai.mjs auth --login OTP appears in the api-server's stdout under the "login code (dev log sender)" log line. Verified end-to-end against a Docker postgres + the api-server: CLI login + whoami + logout, all four dashboard routes, the audit page rendering with the gated reminder button, and the shared auth.json across both surfaces (sign in via CLI -> dashboard sees it; logout via CLI -> dashboard reverts to anonymous on next page load). Existing 1701 vitest tests, eslint, and tsc all stay green. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 4 +- app/api/auth/login-request/route.ts | 57 ++++ app/api/auth/login-verify/route.ts | 62 ++++ app/api/auth/logout/route.ts | 32 +++ app/api/auth/status/route.ts | 38 +++ app/audit/_components/auth-dialog.tsx | 352 +++++++++++++++++++++++ app/audit/_components/return-section.tsx | 109 ++++++- app/audit/audit-styles.css | 203 +++++++++++++ bin/failproofai.mjs | 19 +- lib/auth/api-server-client.ts | 172 +++++++++++ lib/auth/auth-store.ts | 191 ++++++++++++ src/auth/cli.ts | 253 ++++++++++++++++ 12 files changed, 1478 insertions(+), 14 deletions(-) create mode 100644 app/api/auth/login-request/route.ts create mode 100644 app/api/auth/login-verify/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/status/route.ts create mode 100644 app/audit/_components/auth-dialog.tsx create mode 100644 lib/auth/api-server-client.ts create mode 100644 lib/auth/auth-store.ts create mode 100644 src/auth/cli.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fdcb37aa..a6ece195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog -## 0.0.11-beta.3 — 2026-05-29 +## 0.0.11-beta.3 — 2026-05-31 ### Features +- Add email-OTP auth wired to the Rust `failproof-api-server` (`/v0/auth/login/request`, `/login/verify`, `/token/refresh`, `/logout`, `/me`). New `failproofai auth --login | --logout | --whoami` CLI subcommand (`src/auth/cli.ts`, dispatched from `bin/failproofai.mjs`) persists tokens to `~/.failproofai/auth.json` at mode `0600` via a shared store (`lib/auth/auth-store.ts` + `lib/auth/api-server-client.ts`); the store auto-refreshes the access token within a 60s leeway window and treats refresh-token reuse / 401 as "wipe local session". Four Next.js API routes (`app/api/auth/{status,login-request,login-verify,logout}/route.ts`) proxy the same flow for the dashboard so the refresh token never reaches the browser — only `{authenticated, user}` does. The "set a reminder" CTA in `/audit`'s `return-section.tsx` now probes `/api/auth/status` on mount and, for un-authed visitors, opens a new `AuthDialog` (`app/audit/_components/auth-dialog.tsx`, styled to match the audit aesthetic: pink corner-glyphs, dashed-frame backdrop, terminal mono inputs, masked OTP entry, live resend countdown, ESC-to-close) that walks email → OTP → "you are " inline; signed-in users get a green "signed in as …" pill under the CTA. Configurable via `FAILPROOF_API_URL` (defaults to `http://localhost:8080`) and `FAILPROOFAI_AUTH_DIR` (defaults to `~/.failproofai`). + - `/audit` polish pass: simplify the "next audit" CTA to `[ install policies ]` copying the bare `failproofai policies --install` command (no longer appends per-slipping-policy short names); fix the `[ share → ]` header button to scroll to the Show-off section reliably by accounting for the sticky in-page `.app-header` height with a manual y-coord scroll + a `scroll-margin-top` fallback on `.showoff`; harden the "make poster" PNG export so the captured archetype frame no longer collides with the sigil / tagline — `show-off-cta.tsx` now `await document.fonts.ready` before capture, applies a `.capturing` class that locks every viewport-clamped font-size and grid column to an absolute value tuned for the 1100px capture width, drops `text-shadow` / `box-shadow` that html2canvas crops unpredictably, and captures with a 12px bleed on each side so the frame's corner accents and box-shadow survive the crop; and expand every archetype in `src/audit/archetypes.ts` from a single hand-written copy block to a multi-variant catalog (4–6 taglines, keyword sets, descriptions, signature blocks, "common in" / "primary risk" / closing lines per archetype, all 8 archetypes covered). A new `pickArchetypeVariant(key, seed)` picker deterministically selects one variant from each list via a djb2-seeded per-field hash mixed with a per-field axis, so the persona blurb stays stable across renders for a given seed but two different projects landing on the same archetype see different copy. `IdentitySection` consumes the resolved variant; the seed flows in from `audit-dashboard.tsx` as the inferred project name. - Add an in-app `/audit` dashboard that turns the existing `failproofai audit` data into a personality-driven report. The page classifies every audited agent into one of 8 archetypes (`optimist`, `cowboy`, `explorer`, `goldfish`, `paranoid architect`, `precision builder`, `hammer`, `ghost`) via a weighted classifier (`src/audit/archetypes.ts`) that maps every builtin policy + every audit-only detector (47/47 coverage) to an archetype with a tuned weight. A scoring module (`src/audit/scoring.ts`) derives a 0-100 score with S/A/B/C/D/F grade thresholds, a projected-score uplift if every recommended policy were enabled, and a stable synthetic cohort rank. The page composes six sections — Identity (archetype hero with 8x8 pixel sigil + meta grid), Show-off CTA, Strengths (real numbers derived from the audit), Score + cohort leaderboard with distribution histogram, Findings (per-policy cards with what happened / cost / evidence / fix), Prescribed Policies (with projected-score callout), and a "re-audit in 7 days" return loop. Every audit-only detector is now mapped to its closest real-time builtin policy as the prescribed fix (`findings.ts:DETECTOR_TO_POLICY`) so the report never carries an "audit-only — no real-time policy" framing. New dashboard cache at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, helper at `src/audit/dashboard-cache.ts`); `AuditResult` schema bumped to version 2 with new fields `eventsScanned`, `projectsScanned`, `enabledBuiltinNames`. New routes `app/audit/page.tsx`, `app/api/audit/run/route.ts` (POST, in-process `runAudit()` call, module-scoped run lock that 409s on concurrent clicks), `app/api/audit/status/route.ts` (GET, drives client polling), and server action `app/actions/get-audit-result.ts` (cache read, mirrors `getHooksConfigAction`'s read-only contract). "Make poster" downloads a 2x PNG of the archetype frame via html2canvas. Navbar gains an Audit entry between Policies and Projects with a slipping-through count chip. Existing runtime policy enforcement is untouched — `policy-registry.ts` gets two additive exports (`getAllPolicies` / `setAllPolicies`) used only by the new `replay.ts:restoreReplay()` snapshot/restore so embedding `runAudit()` in a long-running process no longer wipes pre-existing registrations. Ports the brand team's design kit verbatim from `assets/audit/styles.css` (1235 lines, JetBrains Mono + VT323 via Google Fonts, Architype Stedelijk shipped locally under `public/audit/fonts/`). diff --git a/app/api/auth/login-request/route.ts b/app/api/auth/login-request/route.ts new file mode 100644 index 00000000..0c3f2b41 --- /dev/null +++ b/app/api/auth/login-request/route.ts @@ -0,0 +1,57 @@ +/** + * POST /api/auth/login-request + * + * Browser-facing proxy for the api-server's /v0/auth/login/request. Keeps the + * api-server URL server-side so the browser only ever talks to the local + * dashboard. + */ +import { NextRequest, NextResponse } from "next/server"; +import { AuthApiError, requestLoginCode } from "@/lib/auth/api-server-client"; + +export const dynamic = "force-dynamic"; + +interface RequestBody { + email?: unknown; +} + +export async function POST(req: NextRequest): Promise { + let body: RequestBody = {}; + try { + body = (await req.json()) as RequestBody; + } catch { + return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.email !== "string" || !body.email.trim()) { + return NextResponse.json( + { code: "validation_error", message: "email is required" }, + { status: 400 }, + ); + } + try { + const r = await requestLoginCode(body.email); + return NextResponse.json( + { + status: r.status, + expires_in: r.expires_in, + resend_available_in: r.resend_available_in, + }, + { status: 200 }, + ); + } catch (err) { + if (err instanceof AuthApiError) { + return NextResponse.json( + { + code: err.code, + message: err.message, + ...(err.retryAfterSecs !== undefined ? { retry_after_secs: err.retryAfterSecs } : {}), + }, + { status: err.status }, + ); + } + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { code: "upstream_unreachable", message: `api-server unreachable: ${message}` }, + { status: 502 }, + ); + } +} diff --git a/app/api/auth/login-verify/route.ts b/app/api/auth/login-verify/route.ts new file mode 100644 index 00000000..f66d381c --- /dev/null +++ b/app/api/auth/login-verify/route.ts @@ -0,0 +1,62 @@ +/** + * POST /api/auth/login-verify + * + * Browser-facing proxy: verifies the OTP with the api-server, persists the + * resulting tokens to ~/.failproofai/auth.json on the local dashboard host, + * and returns *only* the user identity to the browser. The refresh token + * never leaves the local filesystem. + */ +import { NextRequest, NextResponse } from "next/server"; +import { AuthApiError, verifyLoginCode } from "@/lib/auth/api-server-client"; +import { authFromTokenResponse, writeAuth } from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +interface VerifyBody { + email?: unknown; + code?: unknown; +} + +export async function POST(req: NextRequest): Promise { + let body: VerifyBody = {}; + try { + body = (await req.json()) as VerifyBody; + } catch { + return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.email !== "string" || !body.email.trim()) { + return NextResponse.json( + { code: "validation_error", message: "email is required" }, + { status: 400 }, + ); + } + if (typeof body.code !== "string" || !body.code.trim()) { + return NextResponse.json( + { code: "validation_error", message: "code is required" }, + { status: 400 }, + ); + } + try { + const tokens = await verifyLoginCode(body.email, body.code); + writeAuth(authFromTokenResponse(tokens)); + return NextResponse.json( + { + authenticated: true, + user: { id: tokens.user.id, email: tokens.user.email }, + }, + { status: 200 }, + ); + } catch (err) { + if (err instanceof AuthApiError) { + return NextResponse.json( + { code: err.code, message: err.message }, + { status: err.status }, + ); + } + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { code: "upstream_unreachable", message: `api-server unreachable: ${message}` }, + { status: 502 }, + ); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 00000000..1b566f29 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,32 @@ +/** + * POST /api/auth/logout + * + * Reads the locally-stored session, asks the api-server to revoke it, and + * deletes ~/.failproofai/auth.json regardless of upstream success — local + * intent to log out takes precedence. + */ +import { NextResponse } from "next/server"; +import { AuthApiError, logoutSession } from "@/lib/auth/api-server-client"; +import { deleteAuth, readAuth } from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +export async function POST(): Promise { + const existing = readAuth(); + if (!existing) { + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + let upstream: "revoked" | "skipped" | "failed" = "skipped"; + try { + await logoutSession(existing.access_token, existing.refresh_token); + upstream = "revoked"; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + upstream = "revoked"; // token already invalid server-side + } else { + upstream = "failed"; + } + } + deleteAuth(); + return NextResponse.json({ authenticated: false, upstream }, { status: 200 }); +} diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts new file mode 100644 index 00000000..5ce2150c --- /dev/null +++ b/app/api/auth/status/route.ts @@ -0,0 +1,38 @@ +/** + * GET /api/auth/status + * + * Returns the currently authenticated identity, verifying the locally-stored + * access token against the api-server's /me endpoint. Refreshes the access + * token if it's near expiry. Never exposes the refresh token to the browser. + */ +import { NextResponse } from "next/server"; +import { whoAmI } from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + try { + const result = await whoAmI(); + if (!result) { + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + return NextResponse.json( + { + authenticated: true, + user: { + id: result.me.id, + email: result.me.email, + status: result.me.status, + created_at: result.me.created_at, + }, + }, + { status: 200 }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { authenticated: false, error: message }, + { status: 200 }, + ); + } +} diff --git a/app/audit/_components/auth-dialog.tsx b/app/audit/_components/auth-dialog.tsx new file mode 100644 index 00000000..73d79886 --- /dev/null +++ b/app/audit/_components/auth-dialog.tsx @@ -0,0 +1,352 @@ +"use client"; + +/** + * Auth dialog — modal overlay shown when an unauthenticated user clicks + * "[ 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. + */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; + +export interface AuthedUser { + id: string; + email: string; +} + +interface Props { + open: boolean; + /** Copy shown above the title, e.g. "oops — you are unknown." */ + 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; +} + +type Step = + | { kind: "email"; error: string | null } + | { kind: "code"; email: string; error: string | null; expiresIn: number; resendIn: number } + | { kind: "done"; user: AuthedUser }; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function AuthDialog({ + open, + headline = "oops — you are unknown.", + reason = "verify yourself to continue.", + onClose, + onAuthed, +}: Props): React.ReactElement | null { + const [step, setStep] = useState({ kind: "email", error: null }); + const [busy, setBusy] = useState(false); + const emailInputRef = useRef(null); + const codeInputRef = useRef(null); + + // Reset internal state every time the dialog opens. + useEffect(() => { + if (open) { + setStep({ kind: "email", error: null }); + setBusy(false); + } + }, [open]); + + // Autofocus the right input as the step changes. + useEffect(() => { + if (!open) return; + const t = setTimeout(() => { + if (step.kind === "email") emailInputRef.current?.focus(); + else if (step.kind === "code") codeInputRef.current?.focus(); + }, 50); + return () => clearTimeout(t); + }, [open, step.kind]); + + // ESC to close. + 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]); + + // Resend countdown ticker. + const resendActive = step.kind === "code" && step.resendIn > 0; + useEffect(() => { + if (!resendActive) return; + const id = setInterval(() => { + setStep((s) => + s.kind === "code" ? { ...s, resendIn: Math.max(0, s.resendIn - 1) } : s, + ); + }, 1000); + return () => clearInterval(id); + }, [resendActive]); + + const requestCode = useCallback( + async (email: string): Promise => { + setBusy(true); + try { + const res = await fetch("/api/auth/login-request", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email }), + }); + const body = (await res.json().catch(() => ({}))) as { + code?: string; + message?: string; + expires_in?: number; + resend_available_in?: number; + retry_after_secs?: number; + }; + if (!res.ok) { + let msg = body.message ?? "could not send code."; + if (body.code === "rate_limited" && body.retry_after_secs !== undefined) { + msg = `too many tries. wait ${body.retry_after_secs}s and try again.`; + } else if (body.code === "upstream_unreachable") { + msg = "api-server unreachable. is it running on :8080?"; + } + setStep({ kind: "email", error: msg }); + return; + } + setStep({ + kind: "code", + email, + error: null, + expiresIn: body.expires_in ?? 600, + resendIn: body.resend_available_in ?? 30, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setStep({ kind: "email", error: `network error: ${message}` }); + } finally { + setBusy(false); + } + }, + [], + ); + + const verifyCode = useCallback( + async (email: string, code: string): Promise => { + setBusy(true); + try { + const res = await fetch("/api/auth/login-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, code }), + }); + const body = (await res.json().catch(() => ({}))) as { + authenticated?: boolean; + user?: AuthedUser; + code?: string; + message?: string; + }; + if (!res.ok || !body.authenticated || !body.user) { + let msg = body.message ?? "invalid code."; + if (body.code === "invalid_code") msg = "wrong code, or it expired. try again."; + setStep((s) => + s.kind === "code" ? { ...s, error: msg } : s, + ); + return; + } + setStep({ kind: "done", user: body.user }); + onAuthed(body.user); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setStep((s) => + s.kind === "code" ? { ...s, error: `network error: ${message}` } : s, + ); + } finally { + setBusy(false); + } + }, + [onAuthed], + ); + + const onEmailSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || step.kind !== "email") return; + const fd = new FormData(e.currentTarget); + const email = String(fd.get("email") ?? "").trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + setStep({ kind: "email", error: "that doesn't look like an email." }); + return; + } + await requestCode(email); + }, + [busy, step, requestCode], + ); + + const onCodeSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || step.kind !== "code") return; + const fd = new FormData(e.currentTarget); + const code = String(fd.get("code") ?? "").trim(); + if (code.length < 4 || code.length > 12) { + setStep((s) => + s.kind === "code" ? { ...s, error: "code is 4–12 characters." } : s, + ); + return; + } + await verifyCode(step.email, code); + }, + [busy, step, verifyCode], + ); + + const onResend = useCallback(async () => { + if (step.kind !== "code" || step.resendIn > 0 || busy) return; + await requestCode(step.email); + }, [step, busy, requestCode]); + + if (!open) return null; + + return ( +
{ + if (!busy && e.target === e.currentTarget) onClose(); + }} + > +
+ + + + + + + +
━━ identity check
+

+ {headline} +

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

{reason}

+
+ + + {step.error &&
{step.error}
} +
+ + +
+
+ + )} + + {step.kind === "code" && ( + <> +

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

+
+ + + {step.error &&
{step.error}
} +
+ + +
+ +
+ + )} + + {step.kind === "done" && ( + <> +

+ you are{" "} + {step.user.email}. +

+

+ session saved locally. +

+
+ +
+ + )} +
+
+ ); +} diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx index 3884633f..8c661369 100644 --- a/app/audit/_components/return-section.tsx +++ b/app/audit/_components/return-section.tsx @@ -3,38 +3,103 @@ /** * Section 06 — NEXT AUDIT / "come back better." Re-audit loop CTA. * - * Two actions: [ set a reminder ] (placeholder, future feature) and - * [ install policies ] which copies the bulk install command. + * Two actions: [ set a reminder ] gates on auth — if the visitor isn't + * signed in, we open the AuthDialog to collect their email + verify a + * one-time code. The actual mail scheduling is wired later; this just + * proves identity for now. + * + * [ install policies ] copies the bulk install command (unchanged). */ -import React, { useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import type { AuditResult } from "@/src/audit/types"; +import { AuthDialog, type AuthedUser } from "./auth-dialog"; interface Props { result: AuditResult; } -function shortName(name: string): string { - const slash = name.indexOf("/"); - return slash >= 0 ? name.slice(slash + 1) : name; -} - const BULK_INSTALL_CMD = "failproofai policies --install"; +type AuthStatus = + | { kind: "unknown" } + | { kind: "anon" } + | { kind: "authed"; user: { id: string; email: string } }; + +type ReminderState = + | { kind: "idle" } + | { kind: "queued" }; + export function ReturnSection({ result }: Props) { const hasUnenabled = result.results.some( (r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0, ); const [copied, setCopied] = useState(false); + const [authStatus, setAuthStatus] = useState({ kind: "unknown" }); + const [dialogOpen, setDialogOpen] = useState(false); + const [reminder, setReminder] = useState({ kind: "idle" }); + + // Probe /api/auth/status once on mount. The endpoint is cheap and never + // throws — it returns { authenticated: false } when no session exists. + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await fetch("/api/auth/status", { cache: "no-store" }); + const body = (await res.json()) as { + authenticated?: boolean; + user?: { id: string; email: string }; + }; + if (cancelled) return; + if (body.authenticated && body.user) { + setAuthStatus({ kind: "authed", user: body.user }); + } else { + setAuthStatus({ kind: "anon" }); + } + } catch { + if (!cancelled) setAuthStatus({ kind: "anon" }); + } + })(); + return () => { + cancelled = true; + }; + }, []); const handleInstall = async () => { try { await navigator.clipboard.writeText(BULK_INSTALL_CMD); setCopied(true); setTimeout(() => setCopied(false), 1500); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }; + const handleReminderClick = useCallback(() => { + if (authStatus.kind === "authed") { + // Mail-scheduling implementation is deferred; for now just confirm. + setReminder({ kind: "queued" }); + setTimeout(() => setReminder({ kind: "idle" }), 3500); + return; + } + setDialogOpen(true); + }, [authStatus.kind]); + + const handleAuthed = useCallback((user: AuthedUser) => { + setAuthStatus({ kind: "authed", user }); + // Treat a successful sign-in via this dialog as intent to set the + // reminder. Once mail scheduling is wired, swap this for a real POST. + setReminder({ kind: "queued" }); + setTimeout(() => setReminder({ kind: "idle" }), 3500); + }, []); + + const reminderLabel = + reminder.kind === "queued" + ? `[ ✓ reminder queued for ${ + authStatus.kind === "authed" ? authStatus.user.email : "you" + } ]` + : "[ set a reminder ]"; + return (
@@ -56,8 +121,13 @@ export function ReturnSection({ result }: Props) { most agents move from C to B in one session. some make it in a day.

- {hasUnenabled && ( )}
+ {authStatus.kind === "authed" && ( +
+
+ )}
+ + setDialogOpen(false)} + onAuthed={(u) => { + setDialogOpen(false); + handleAuthed(u); + }} + />
); } diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 9d0d4616..c49315e6 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -1278,6 +1278,209 @@ a { color: inherit; text-decoration: none; } margin-right: 6px; } +/* ───────────────────────── auth dialog (set-a-reminder gate) ───────────────────────── */ + +.auth-dialog-backdrop { + position: fixed; inset: 0; z-index: 10000; + display: grid; place-items: center; + padding: 32px 16px; + background: + radial-gradient(ellipse 1000px 700px at 30% 20%, rgba(228,88,125,0.08) 0%, transparent 60%), + rgba(8,8,10,0.78); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + animation: authFadeIn 140ms ease-out; +} +@keyframes authFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.auth-dialog { + position: relative; + width: 100%; + max-width: 460px; + padding: 32px 32px 28px; + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); + font-family: var(--font-mono); + color: var(--ink); + animation: authPop 160ms ease-out; +} +@keyframes authPop { + from { transform: translateY(8px) scale(0.985); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.auth-dialog .corner { + position: absolute; + font-family: var(--font-mono); + font-size: 14px; line-height: 1; + color: var(--accent-pink); opacity: 0.85; +} +.auth-dialog .corner.tl { top: 6px; left: 8px; } +.auth-dialog .corner.tr { top: 6px; right: 8px; } +.auth-dialog .corner.bl { bottom: 6px; left: 8px; } +.auth-dialog .corner.br { bottom: 6px; right: 8px; } + +.auth-close { + position: absolute; top: 12px; right: 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); + background: transparent; border: none; padding: 4px 6px; + cursor: pointer; + transition: color 120ms; +} +.auth-close:hover { color: var(--accent-pink); } +.auth-close:disabled { color: var(--line-2); cursor: not-allowed; } + +.auth-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} + +.auth-headline { + font-family: var(--font-display); + font-size: clamp(26px, 3.6vw, 34px); + letter-spacing: 0.09em; line-height: 1.1; + text-transform: lowercase; + color: var(--ink); + margin: 0 0 12px; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + text-wrap: balance; +} + +.auth-sub { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); + margin: 0 0 22px; +} +.auth-sub .auth-email { + color: var(--accent-pink); +} +.auth-sub .auth-ok { + color: var(--accent-green); + margin-right: 6px; +} + +.auth-form { display: flex; flex-direction: column; gap: 10px; } + +.auth-field-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} + +.auth-input { + width: 100%; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--line-2); + color: var(--ink); + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.03em; + outline: none; + transition: border-color 120ms, box-shadow 120ms; +} +.auth-input:focus { + border-color: var(--accent-pink); + box-shadow: 0 0 0 1px var(--accent-pink-soft); +} +.auth-input:disabled { + opacity: 0.55; cursor: not-allowed; +} +.auth-input-code { + letter-spacing: 0.5em; + text-align: center; + font-size: 18px; + font-variant-numeric: tabular-nums; +} +.auth-input::placeholder { color: var(--dim); } + +.auth-error { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-pink); + padding: 8px 12px; + background: var(--accent-pink-bg); + border: 1px solid var(--accent-pink); + border-left-width: 3px; + letter-spacing: 0.02em; + margin-top: 4px; +} + +.auth-actions { + display: flex; gap: 10px; flex-wrap: wrap; + margin-top: 14px; +} + +.auth-btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 10px 16px; + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.06em; + background: transparent; + border: 1px solid var(--line-2); + color: var(--ink); + cursor: pointer; + transition: all 120ms ease; +} +.auth-btn:hover:not(:disabled) { + border-color: var(--ink); background: rgba(255,255,255,0.04); +} +.auth-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.auth-btn.primary { + border-color: var(--accent-pink); + color: var(--accent-pink); + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.auth-btn.primary:hover:not(:disabled) { + background: var(--accent-pink); color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} + +.auth-back { + align-self: flex-start; + margin-top: 4px; + background: transparent; border: none; padding: 6px 0; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; color: var(--dim); + cursor: pointer; + transition: color 120ms; +} +.auth-back:hover:not(:disabled) { color: var(--ink-2); } +.auth-back:disabled { opacity: 0.45; cursor: not-allowed; } + +@media (max-width: 520px) { + .auth-dialog { padding: 26px 22px 22px; } + .auth-actions { flex-direction: column; align-items: stretch; } + .auth-btn { justify-content: center; } +} + +/* status pill in the return CTA: shows current logged-in email */ +.auth-status-pill { + display: inline-flex; align-items: center; gap: 8px; + margin-top: 16px; + padding: 6px 10px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; + color: var(--ink-2); +} +.auth-status-pill .dot { + width: 7px; height: 7px; + background: var(--accent-green); display: inline-block; + box-shadow: 0 0 6px rgba(102,209,181,0.55); +} +.auth-status-pill .email { color: var(--accent-green); } + /* responsive */ @media (max-width: 960px) { .report { padding: 0 20px; } diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index a451a8c0..6f159366 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -103,7 +103,7 @@ if (hookIdx >= 0) { */ async function runCli() { // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "audit"]; + const SUBCOMMANDS = ["policies", "audit", "auth"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -140,6 +140,12 @@ COMMANDS policies --help, -h Show this help for the policies command + auth Sign in / out of FailproofAI from the CLI. + --login Email + OTP flow; writes ~/.failproofai/auth.json + --logout Revoke this session and remove auth.json + --whoami Print the currently authenticated identity + auth --help, -h Show this help for the auth command + audit (beta) Scan past agent CLI transcripts and count "stupid behaviors" (env-var checks, force pushes, redundant cd , sleep loops, @@ -470,6 +476,15 @@ EXAMPLES process.exit(0); } + // auth — email-OTP login flow against the FailproofAI api-server. + if (args[0] === "auth") { + lastSubcommand = "auth"; + const { runAuthCli } = await import("../src/auth/cli"); + await runAuthCli(args.slice(1)); + await track("cli_auth_invoked", { args_count: args.length - 1 }); + process.exit(process.exitCode ?? 0); + } + // audit — scan past transcripts for "stupid behaviors" caught by builtin // policies + a set of audit-only detectors. if (args[0] === "audit") { @@ -640,7 +655,7 @@ EXAMPLES return dp[m][n]; } - const primary = ["--version", "--help", "--hook", "policies", "audit"]; + const primary = ["--version", "--help", "--hook", "policies", "audit", "auth"]; const closest = primary.reduce((best, flag) => { const dist = levenshtein(unknownFlag, flag); return dist < best.dist ? { flag, dist } : best; diff --git a/lib/auth/api-server-client.ts b/lib/auth/api-server-client.ts new file mode 100644 index 00000000..9c0227ab --- /dev/null +++ b/lib/auth/api-server-client.ts @@ -0,0 +1,172 @@ +/** + * Low-level HTTP client for the FailproofAI api-server's /v0/auth/* endpoints. + * + * Shared by both the CLI (failproofai auth ...) and the dashboard's Next.js + * API route proxies. Has no filesystem access — token persistence lives in + * `./auth-store.ts`. + * + * The base URL is resolved from FAILPROOF_API_URL (preferred) or the legacy + * FAILPROOFAI_API_URL, falling back to http://localhost:8080 for local dev. + */ + +export const DEFAULT_API_BASE = "http://localhost:8080"; + +export function getApiBase(): string { + const raw = + process.env.FAILPROOF_API_URL ?? + process.env.FAILPROOFAI_API_URL ?? + DEFAULT_API_BASE; + return raw.replace(/\/+$/, ""); +} + +export class AuthApiError extends Error { + readonly status: number; + readonly code: string; + readonly retryAfterSecs?: number; + constructor(status: number, code: string, message: string, retryAfterSecs?: number) { + super(message); + this.status = status; + this.code = code; + this.retryAfterSecs = retryAfterSecs; + this.name = "AuthApiError"; + } +} + +export interface LoginRequestResponse { + status: "code_sent"; + expires_in: number; + resend_available_in: number; +} + +export interface UserView { + id: string; + email: string; +} + +export interface TokenResponse { + token_type: "Bearer"; + access_token: string; + access_expires_in: number; + refresh_token: string; + refresh_expires_in: number; + user: UserView; +} + +export interface RefreshResponse { + token_type: "Bearer"; + access_token: string; + access_expires_in: number; + refresh_token: string; + refresh_expires_in: number; +} + +export interface MeResponse { + id: string; + email: string; + status: string; + created_at: string; +} + +interface ServerErrorBody { + // The docs describe `{ code, message }`; the live Rust server returns + // `{ success: false, code, detail }`. We tolerate either. + code?: string; + message?: string; + detail?: string; + retry_after_secs?: number; +} + +async function parseError(res: Response): Promise { + let body: ServerErrorBody = {}; + try { + body = (await res.json()) as ServerErrorBody; + } catch { + // body might be empty or non-JSON + } + const code = body.code ?? `http_${res.status}`; + const message = body.message ?? body.detail ?? res.statusText ?? "request failed"; + let retryAfterSecs = body.retry_after_secs; + if (retryAfterSecs === undefined) { + const h = res.headers.get("retry-after"); + if (h) { + const n = Number(h); + if (Number.isFinite(n)) retryAfterSecs = n; + } + } + return new AuthApiError(res.status, code, message, retryAfterSecs); +} + +async function postJson(path: string, body: unknown, init?: { accessToken?: string }): Promise { + const headers: Record = { "content-type": "application/json" }; + if (init?.accessToken) headers["authorization"] = `Bearer ${init.accessToken}`; + const res = await fetch(`${getApiBase()}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + if (res.status === 204) return undefined as T; + if (!res.ok) throw await parseError(res); + return (await res.json()) as T; +} + +async function getJson(path: string, accessToken: string): Promise { + const res = await fetch(`${getApiBase()}${path}`, { + method: "GET", + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) throw await parseError(res); + return (await res.json()) as T; +} + +export async function requestLoginCode(email: string): Promise { + return postJson("/v0/auth/login/request", { email }); +} + +export async function verifyLoginCode(email: string, code: string): Promise { + return postJson("/v0/auth/login/verify", { email, code }); +} + +export async function refreshAccessToken(refreshToken: string): Promise { + return postJson("/v0/auth/token/refresh", { + refresh_token: refreshToken, + }); +} + +export async function logoutSession(accessToken: string, refreshToken: string): Promise { + await postJson( + "/v0/auth/logout", + { refresh_token: refreshToken }, + { accessToken }, + ); +} + +export async function fetchMe(accessToken: string): Promise { + return getJson("/v0/auth/me", accessToken); +} + +interface JwtClaims { + sub: string; + email: string; + iss?: string; + aud?: string; + iat?: number; + exp: number; + token_type?: string; +} + +/** + * Decode the JWT payload without verifying the signature. Safe for client-side + * reading (sub, email, exp). Returns null if the token is malformed. + */ +export function decodeJwt(token: string): JwtClaims | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const json = Buffer.from(parts[1], "base64url").toString("utf8"); + const parsed = JSON.parse(json) as JwtClaims; + if (typeof parsed.exp !== "number") return null; + return parsed; + } catch { + return null; + } +} diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts new file mode 100644 index 00000000..f535c670 --- /dev/null +++ b/lib/auth/auth-store.ts @@ -0,0 +1,191 @@ +/** + * Persistence layer for the FailproofAI auth.json file. + * + * Tokens live at ~/.failproofai/auth.json with mode 0600. The dashboard's + * Next.js API routes and the CLI both read/write through here so the user's + * session survives across `failproofai` (dashboard) and `failproofai auth` + * (CLI) invocations. + */ + +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import { + AuthApiError, + decodeJwt, + fetchMe, + refreshAccessToken, + type MeResponse, +} from "./api-server-client"; + +export interface StoredAuth { + access_token: string; + refresh_token: string; + access_expires_at: number; // unix seconds + refresh_expires_at: number; // unix seconds (best-effort; not strictly verified server-side) + user: { id: string; email: string }; +} + +export function getAuthDir(): string { + const override = process.env.FAILPROOFAI_AUTH_DIR; + if (override) return override; + return join(homedir(), ".failproofai"); +} + +export function getAuthFilePath(): string { + return join(getAuthDir(), "auth.json"); +} + +export function readAuth(): StoredAuth | null { + const p = getAuthFilePath(); + if (!existsSync(p)) return null; + try { + const raw = readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.access_token !== "string" || + typeof parsed.refresh_token !== "string" || + typeof parsed.access_expires_at !== "number" || + typeof parsed.user !== "object" || + !parsed.user || + typeof (parsed.user as { id?: unknown }).id !== "string" || + typeof (parsed.user as { email?: unknown }).email !== "string" + ) { + return null; + } + return { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + access_expires_at: parsed.access_expires_at, + refresh_expires_at: + typeof parsed.refresh_expires_at === "number" + ? parsed.refresh_expires_at + : parsed.access_expires_at, + user: { + id: (parsed.user as { id: string }).id, + email: (parsed.user as { email: string }).email, + }, + }; + } catch { + return null; + } +} + +export function writeAuth(auth: StoredAuth): void { + const p = getAuthFilePath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + // mode 0600 on first write; the explicit chmod ensures we reset perms if the + // file existed with looser perms. + writeFileSync(p, JSON.stringify(auth, null, 2), { mode: 0o600 }); + // writeFileSync's mode option only applies on file creation. If the file + // already existed with looser perms, force them back to 0600. + try { + if (statSync(p).mode & 0o077) chmodSync(p, 0o600); + } catch { + // best-effort + } +} + +export function deleteAuth(): void { + const p = getAuthFilePath(); + if (existsSync(p)) rmSync(p, { force: true }); +} + +/** Convert verify/refresh response into the on-disk shape. */ +export function authFromTokenResponse(token: { + access_token: string; + refresh_token: string; + access_expires_in: number; + refresh_expires_in: number; + user?: { id: string; email: string }; +}, existingUser?: { id: string; email: string }): StoredAuth { + const now = Math.floor(Date.now() / 1000); + const user = token.user ?? existingUser; + if (!user) { + throw new Error("authFromTokenResponse: missing user identity"); + } + return { + access_token: token.access_token, + refresh_token: token.refresh_token, + access_expires_at: now + token.access_expires_in, + refresh_expires_at: now + token.refresh_expires_in, + user, + }; +} + +/** + * Return a fresh access token, refreshing in-place if the current one is + * within the leeway window of expiry. Mutates auth.json on disk on success. + * Returns null if the stored session is gone or the refresh failed (caller + * should treat that as "logged out"). + */ +const REFRESH_LEEWAY_SECS = 60; + +export async function getValidAccessToken(): Promise { + const auth = readAuth(); + if (!auth) return null; + const now = Math.floor(Date.now() / 1000); + if (auth.access_expires_at - now > REFRESH_LEEWAY_SECS) return auth; + // Either expired or close to expiring — try to refresh. + try { + const refreshed = await refreshAccessToken(auth.refresh_token); + const next = authFromTokenResponse(refreshed, auth.user); + writeAuth(next); + return next; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // Session unrecoverable — wipe. + deleteAuth(); + return null; + } + // Network errors etc — surface to caller as null so the UI can recover. + return null; + } +} + +/** + * Verify with the server that the stored access token is still valid. + * Refreshes once on 401. Returns the live /me response and the (possibly + * refreshed) stored auth, or null if the session can't be recovered. + */ +export async function whoAmI(): Promise<{ me: MeResponse; auth: StoredAuth } | null> { + const fresh = await getValidAccessToken(); + if (!fresh) return null; + try { + const me = await fetchMe(fresh.access_token); + return { me, auth: fresh }; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // Maybe the leeway wasn't enough — try one more refresh and retry. + const reread = readAuth(); + if (!reread) return null; + try { + const refreshed = await refreshAccessToken(reread.refresh_token); + const next = authFromTokenResponse(refreshed, reread.user); + writeAuth(next); + const me = await fetchMe(next.access_token); + return { me, auth: next }; + } catch { + deleteAuth(); + return null; + } + } + return null; + } +} + +/** Reads the JWT exp claim for diagnostics. */ +export function readAccessExpiry(auth: StoredAuth): number | null { + const claims = decodeJwt(auth.access_token); + return claims?.exp ?? null; +} diff --git a/src/auth/cli.ts b/src/auth/cli.ts new file mode 100644 index 00000000..92155622 --- /dev/null +++ b/src/auth/cli.ts @@ -0,0 +1,253 @@ +/** + * `failproofai auth` CLI surface. + * + * failproofai auth --login Email + OTP flow; writes ~/.failproofai/auth.json + * failproofai auth --logout Revoke the session and wipe auth.json + * failproofai auth --whoami Print the currently logged-in identity (or "not authed") + * failproofai auth --help Usage + * + * The implementation deliberately avoids new external deps — readline + stdin + * + ANSI escapes are enough for a one-shot prompt loop. + */ + +import * as readline from "node:readline"; + +import { + AuthApiError, + getApiBase, + logoutSession, + requestLoginCode, + verifyLoginCode, +} from "../../lib/auth/api-server-client"; +import { + authFromTokenResponse, + deleteAuth, + readAuth, + whoAmI, + writeAuth, +} from "../../lib/auth/auth-store"; +import { CliError } from "../cli-error"; + +interface AuthCliOptions { + mode: "login" | "logout" | "whoami" | "help"; +} + +const HELP = ` +failproofai auth — sign in to FailproofAI from the CLI + +USAGE + failproofai auth --login Start the email + OTP login flow + failproofai auth --logout Revoke this session and remove ~/.failproofai/auth.json + failproofai auth --whoami Print the currently authenticated identity + failproofai auth --help, -h Show this help + +ENVIRONMENT + FAILPROOF_API_URL Override the api-server base URL + (default: http://localhost:8080) + FAILPROOFAI_AUTH_DIR Override where auth.json is stored + (default: ~/.failproofai) + +EXAMPLES + failproofai auth --login + failproofai auth --whoami + failproofai auth --logout +`.trimStart(); + +export function parseAuthArgs(args: string[]): AuthCliOptions { + const flags = new Set(args); + const isHelp = flags.has("--help") || flags.has("-h"); + const isLogin = flags.has("--login"); + const isLogout = flags.has("--logout"); + const isWhoami = flags.has("--whoami"); + + if (isHelp) return { mode: "help" }; + + const known = new Set(["--login", "--logout", "--whoami", "--help", "-h"]); + const unknown = args.find((a) => a.startsWith("-") && !known.has(a)); + if (unknown) { + throw new CliError( + `Unknown flag for auth: ${unknown}\nRun \`failproofai auth --help\` for usage.`, + ); + } + const positional = args.filter((a) => !a.startsWith("-")); + if (positional.length > 0) { + throw new CliError( + `Unexpected argument: ${positional[0]}\nRun \`failproofai auth --help\` for usage.`, + ); + } + + const count = (isLogin ? 1 : 0) + (isLogout ? 1 : 0) + (isWhoami ? 1 : 0); + if (count === 0) return { mode: "help" }; + if (count > 1) { + throw new CliError( + `Pick one of --login, --logout, --whoami.\nRun \`failproofai auth --help\` for usage.`, + ); + } + if (isLogin) return { mode: "login" }; + if (isLogout) return { mode: "logout" }; + return { mode: "whoami" }; +} + +function prompt(question: string, opts: { hidden?: boolean } = {}): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + // Input-masking only makes sense on a real terminal; on piped/redirected + // stdin readline buffers character-by-character through `_writeToOutput`, + // which combined with masking can stall the read. + if (opts.hidden && process.stdin.isTTY) { + const r = rl as unknown as { + _writeToOutput: (s: string) => void; + output: NodeJS.WritableStream; + }; + const orig = r._writeToOutput.bind(rl); + r._writeToOutput = (s: string): void => { + if (s.length > 0 && s !== "\r\n" && s !== "\n") orig("*"); + else orig(s); + }; + } + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + rl.close(); + if (opts.hidden && process.stdin.isTTY) process.stdout.write("\n"); + resolve(answer.trim()); + }); + }); +} + +const DIM = ""; +const RESET = ""; +const PINK = ""; +const GREEN = ""; +const RED = ""; + +function emailLooksValid(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +async function runLogin(): Promise { + const existing = readAuth(); + if (existing) { + process.stdout.write( + `${DIM}already signed in as${RESET} ${existing.user.email} ${DIM}(use \`failproofai auth --logout\` to switch accounts)${RESET}\n`, + ); + return; + } + + process.stdout.write(`${PINK}━━ failproofai auth ━━${RESET}\n`); + process.stdout.write(`${DIM}api: ${getApiBase()}${RESET}\n\n`); + + let email = ""; + for (let attempt = 0; attempt < 3; attempt++) { + email = await prompt("email: "); + if (emailLooksValid(email)) break; + process.stdout.write(`${RED}that doesn't look like an email — try again.${RESET}\n`); + email = ""; + } + if (!email) throw new CliError("Could not read a valid email after 3 attempts."); + + try { + const r = await requestLoginCode(email); + process.stdout.write( + `\n${GREEN}code sent.${RESET} ${DIM}check ${email} — expires in ${r.expires_in}s.${RESET}\n`, + ); + } catch (err) { + if (err instanceof AuthApiError && err.code === "rate_limited") { + throw new CliError( + `Rate limited — try again in ${err.retryAfterSecs ?? "a few"} seconds.`, + ); + } + if (err instanceof AuthApiError) { + throw new CliError(`Login request failed (${err.code}): ${err.message}`); + } + throw new CliError( + `Could not reach the api-server at ${getApiBase()}.\n` + + `Set FAILPROOF_API_URL or run the api-server locally on :8080.`, + ); + } + + let tokenResp; + for (let attempt = 0; attempt < 5; attempt++) { + const code = await prompt("code: ", { hidden: true }); + if (!code) continue; + try { + tokenResp = await verifyLoginCode(email, code); + break; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + process.stdout.write(`${RED}code rejected — try again.${RESET}\n`); + continue; + } + if (err instanceof AuthApiError) { + throw new CliError(`Verify failed (${err.code}): ${err.message}`); + } + throw new CliError( + `Could not reach the api-server at ${getApiBase()}.`, + ); + } + } + if (!tokenResp) throw new CliError("Too many bad codes — start over."); + + writeAuth(authFromTokenResponse(tokenResp)); + process.stdout.write( + `\n${GREEN}✓ signed in as ${tokenResp.user.email}${RESET}\n` + + `${DIM}session saved to ~/.failproofai/auth.json (mode 0600)${RESET}\n`, + ); +} + +async function runLogout(): Promise { + const existing = readAuth(); + if (!existing) { + process.stdout.write(`${DIM}not signed in. nothing to do.${RESET}\n`); + return; + } + let serverRevoked = false; + try { + await logoutSession(existing.access_token, existing.refresh_token); + serverRevoked = true; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // Token already invalid — that's fine, we'll still wipe locally. + serverRevoked = true; + } else if (err instanceof AuthApiError) { + process.stdout.write( + `${RED}server-side revoke failed (${err.code}): ${err.message}${RESET}\n`, + ); + } else { + process.stdout.write( + `${RED}could not reach the api-server — wiping local session only.${RESET}\n`, + ); + } + } + deleteAuth(); + if (serverRevoked) { + process.stdout.write(`${GREEN}✓ signed out.${RESET}\n`); + } else { + process.stdout.write( + `${GREEN}✓ local session removed.${RESET} ${DIM}server-side revocation may not have completed.${RESET}\n`, + ); + } +} + +async function runWhoami(): Promise { + const result = await whoAmI(); + if (!result) { + process.stdout.write(`${DIM}not signed in — run \`failproofai auth --login\` to sign in.${RESET}\n`); + process.exitCode = 1; + return; + } + const { me } = result; + process.stdout.write( + `${GREEN}✓${RESET} ${me.email} ${DIM}(${me.id})${RESET}\n` + + `${DIM}status: ${me.status} · created: ${me.created_at}${RESET}\n`, + ); +} + +export async function runAuthCli(args: string[]): Promise { + const opts = parseAuthArgs(args); + if (opts.mode === "help") { + process.stdout.write(HELP); + return; + } + if (opts.mode === "login") return runLogin(); + if (opts.mode === "logout") return runLogout(); + return runWhoami(); +} From 934080bc454f9f89508262d78a7d8fdcd55d48f1 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sun, 31 May 2026 17:15:58 +0530 Subject: [PATCH 05/13] docs(auth): add docs/cli/auth.mdx and env-vars entry for FAILPROOF_API_URL Documents the new `failproofai auth --login | --logout | --whoami` subcommand and the two env-var knobs (`FAILPROOF_API_URL`, `FAILPROOFAI_AUTH_DIR`) shipped with the auth feature. i18n mirrors will pick this up via the existing translate-docs workflow on the next sync. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 3 ++ docs/cli/auth.mdx | 69 ++++++++++++++++++++++++++++++ docs/cli/environment-variables.mdx | 7 +++ 3 files changed, 79 insertions(+) create mode 100644 docs/cli/auth.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ece195..096f0349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). +### Docs +- Document the new `failproofai auth --login | --logout | --whoami` subcommand in a dedicated `docs/cli/auth.mdx` page (mirrors the style of `cli/audit.mdx`: usage block, sign-in / sign-out / whoami sections, on-disk `auth.json` shape, env-var table, and a short troubleshooting list for the common `Could not reach the api-server` / `Rate limited` / `Code rejected` cases). Add an Authentication section to `docs/cli/environment-variables.mdx` covering `FAILPROOF_API_URL` (override the api-server base URL) and `FAILPROOFAI_AUTH_DIR` (override where `auth.json` is stored). i18n mirrors left for the translation-sync workflow. + ## 0.0.11-beta.2 — 2026-05-21 ### Features diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx new file mode 100644 index 00000000..0d2b76f9 --- /dev/null +++ b/docs/cli/auth.mdx @@ -0,0 +1,69 @@ +--- +title: Sign in +description: "Sign in to FailproofAI from the CLI to enable reminders and personalized features" +--- + +```bash +failproofai auth --login # email + one-time code +failproofai auth --logout # revoke this session +failproofai auth --whoami # print the signed-in identity +``` + +Authentication is opt-in. Policies, the dashboard, the audit command, and every other local feature work exactly the same whether you're signed in or not. The login surface exists so that features that **need** a stable identity (re-audit reminders today, more in the future) have somewhere to anchor. + +## Sign-in flow + +```bash +failproofai auth --login +``` + +Prompts for your email, sends a 6-digit one-time code to that address, prompts for the code, and on success writes `~/.failproofai/auth.json` (mode `0600`). The same session is then visible to the in-app dashboard — clicking `[ set a reminder ]` on `/audit` will see you as signed in. + +The dashboard exposes the same flow as a modal dialog on `/audit` for users who never touch the CLI. + +## Sign-out + +```bash +failproofai auth --logout +``` + +Revokes the current session on the server and deletes `~/.failproofai/auth.json`. If the api-server is unreachable, the local file is removed regardless — local intent to log out always wins. + +## Identity check + +```bash +failproofai auth --whoami +``` + +Prints ` ()` and exits 0 when a valid session exists, or `not signed in` and exits 1 otherwise. Silently refreshes the access token in the background if it's within a minute of expiring. + +## What's in `~/.failproofai/auth.json` + +```json +{ + "access_token": "eyJhbGc…", + "refresh_token": "9ede3e…", + "access_expires_at": 1780160574, + "refresh_expires_at": 1782748974, + "user": { "id": "", "email": "you@example.com" } +} +``` + +Created with `0600` perms (owner-only read/write). The access token is a 1-hour HS256 JWT; the refresh token is an opaque 256-bit random string that the server stores as `SHA-256(token)`. Refresh-token replay is detected server-side and revokes every session for the user. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `FAILPROOF_API_URL` | (production URL) | Override the api-server base URL. Useful for local development against a self-hosted api-server. | +| `FAILPROOFAI_AUTH_DIR` | `~/.failproofai` | Override where `auth.json` is stored. Mostly for tests. | + +See [Environment variables](/cli/environment-variables) for the full list. + +## Troubleshooting + +**"Could not reach the api-server"** — the CLI can't open a TCP connection to `FAILPROOF_API_URL`. Check your network, or set `FAILPROOF_API_URL` if you're running a self-hosted api-server. + +**"Rate limited"** — too many login attempts in a 15-minute window for that email (5/email) or IP (20/IP), or a 30-second resend cooldown after the previous request for the same email. The error message includes the retry-after window in seconds. + +**Code rejected** — the OTP was wrong, expired, or the row hit its 5-wrong-guess lockout. Run `failproofai auth --login` again to request a fresh code. diff --git a/docs/cli/environment-variables.mdx b/docs/cli/environment-variables.mdx index e43e5012..4a6ceaad 100644 --- a/docs/cli/environment-variables.mdx +++ b/docs/cli/environment-variables.mdx @@ -25,6 +25,13 @@ description: "Configure failproofai behavior with environment variables" |----------|-------------| | `FAILPROOFAI_TELEMETRY_DISABLED=1` | Disable anonymous usage telemetry | +## Authentication + +| Variable | Description | +|----------|-------------| +| `FAILPROOF_API_URL` | Override the api-server base URL used by `failproofai auth` and the dashboard auth dialog. Useful when running a local api-server. | +| `FAILPROOFAI_AUTH_DIR` | Override where `auth.json` is stored (default: `~/.failproofai`). Mostly useful for isolated tests. | + ## First-run prompt | Variable | Description | From 34bf9717b7525af150d959074f27bb8d3483b971 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sun, 31 May 2026 17:34:42 +0530 Subject: [PATCH 06/13] feat(ui): unify dashboard around audit pixel-craft system; fix nav style leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the /audit page's design language to the whole app so /policies and /projects pick up the same fonts, palette, chrome, and component vocabulary. Also fixes a latent bug where the audit page's :root + body resets persisted on client-side navigation back to other routes, leaving them with audit's JetBrains Mono / dark canvas but none of the matching section chrome. Strategy: unify at the CSS-variable level. Every shadcn-style Tailwind token (--background, --card, --foreground, --primary, --border, --radius, …) is repointed at the audit palette (--bg, --bg-2, --ink, --accent-pink, …) in globals.css. Existing Tailwind utility classes like `bg-card text-foreground border-border` continue to work but now produce audit visuals — no component rewrites needed for the 1661-line hooks-client tree. Files changed app/globals.css Rewritten. Single source of truth for fonts, tokens, body atmosphere (cross-hatch + grain + pink vignette), and every shared chrome class (.app-header / .h-brand / .btn / .btn-press / .tabs / .tab / .section / .section-mast / .section-h / .report / new .panel). app/audit/audit-styles.css Trimmed by 150 lines. Drops :root, the html/body/#root resets, the body atmosphere overlays, .app-header, .btn, .tabs — all now live in globals. Keeps only the /audit-only widgets (archetype-frame, sigil, score grade, leaderboard, findings, return hook, auth dialog). Side effect: nothing left to leak. app/layout.tsx Removes the next/font/google Geist Mono import. Fonts ship via the @import url(…JetBrains+Mono…) in globals.css so the design system is one stylesheet. components/navbar.tsx Rewritten around .app-header. Pink "▮▮" pixel mark + Architype Stedelijk wordmark, optional version chip, dynamic per-section eyebrow ("policies" / "audit" / "projects"), .tab links with sharp pink underline on the active route. Drops lucide icons from the bar. app/projects/page.tsx + loading.tsx Wrapped in .report + .section + .panel. New green-eyebrow masthead with the ━━ glyph and "your agent footprint." section heading. Empty and loaded states both use the dashed-frame .panel. ProjectList component itself unchanged. app/policies/hooks-client.tsx Top-level
replaced with a .report + .section shell. New masthead with audit-style copy ("what your agents tried." / "what to stop them doing.") and an enabled- count meta chip in pink. TabBar swapped from rounded pill to global .tabs / .tab with sharp pink underline on active. Dropped the unused ArrowLeft + back-to- projects link (navbar handles cross-page nav now). No inner refactor of ActivityTab / PoliciesTab. Verification bunx tsc --noEmit passes bun run lint passes (only the 2 pre-existing warnings) bun run test:run 1701/1701 pass bun --bun next build Compiled successfully in 6.2s; static + dynamic routes for /, /policies, /projects, /audit, and all /api/auth and /api/audit endpoints generated. The user needs to restart `bun run dev` once after pulling this commit — the Turbopack HMR pipeline can't hot-swap :root / @import changes reliably. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 + app/audit/audit-styles.css | 164 +------------- app/globals.css | 412 ++++++++++++++++++++++------------ app/layout.tsx | 12 +- app/policies/hooks-client.tsx | 119 ++++++---- app/projects/loading.tsx | 27 ++- app/projects/page.tsx | 75 +++++-- components/navbar.tsx | 178 ++++++++------- 8 files changed, 529 insertions(+), 460 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 096f0349..9091b317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). +- Unify the dashboard design system around the brutalist pixel-craft aesthetic that previously lived only in `/audit`. The audit token set (`--bg`, `--ink`, `--accent-pink`, `--accent-green`, `--font-mono` → JetBrains Mono, `--font-display` → Architype Stedelijk / VT323) is now declared once in `app/globals.css`, and every shadcn-style Tailwind alias (`--background`, `--card`, `--foreground`, `--primary`, `--border`, `--radius: 0`, …) is repointed at the audit palette so existing utility classes like `bg-card` / `text-foreground` / `border-border` produce audit visuals across the whole app without rewriting any component markup. The `:root` block, body cross-hatch + grain overlays, JetBrains Mono import, and all canonical chrome classes (`.app-header`, `.h-brand*`, `.btn`, `.btn-press`, `.tabs`, `.tab`, `.section`, `.section-mast`, `.section-h`, `.report`, plus a new reusable `.panel` with pink corner brackets) are promoted to `globals.css`. `app/audit/audit-styles.css` keeps only the audit-page-only widgets (archetype frame, sigil, score grade, leaderboard, findings cards, return hook, auth dialog), so the styles loaded specifically by `/audit` no longer leak into `/policies` or `/projects` on client-side navigation. `app/layout.tsx` drops the `next/font/google` Geist Mono import — fonts now ship via the single CSS `@import url('…JetBrains+Mono…')` in `globals.css`. `components/navbar.tsx` is rewritten around `.app-header` with the pink `▮▮` mark, lowercase Architype wordmark, optional version chip, a current-section eyebrow, and `.tab` links with sharp pink underline on the active route (lucide icons in the bar removed). `app/projects/page.tsx` and its `loading.tsx` are wrapped in the `.report` + `.section` + `.panel` chrome with a green-eyebrow masthead and "your agent footprint." section heading; the inner `ProjectList` component is unchanged and picks up the unified palette automatically. `app/policies/hooks-client.tsx` swaps its outer `
` for a `.report` + `.section` shell with audit masthead copy ("what your agents tried." / "what to stop them doing."), replaces the rounded-pill `TabBar` with the global `.tabs` / `.tab` underline tabs, and drops the now-redundant "Back to /projects" link (the new navbar covers cross-page navigation). No functional changes — all 1701 tests pass and the production `next build` succeeds. + ### Docs - Document the new `failproofai auth --login | --logout | --whoami` subcommand in a dedicated `docs/cli/auth.mdx` page (mirrors the style of `cli/audit.mdx`: usage block, sign-in / sign-out / whoami sections, on-disk `auth.json` shape, env-var table, and a short troubleshooting list for the common `Could not reach the api-server` / `Rate limited` / `Code rejected` cases). Add an Authentication section to `docs/cli/environment-variables.mdx` covering `FAILPROOF_API_URL` (override the api-server base URL) and `FAILPROOFAI_AUTH_DIR` (override where `auth.json` is stored). i18n mirrors left for the translation-sync workflow. diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index c49315e6..537b2696 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -1,88 +1,13 @@ /* ============================================================ - failproof_ai — audit report styles - Ported from assets/audit/styles.css. Brutalist pixel-craft. - Loaded only on the /audit route (imported from page.tsx). + failproof_ai — audit-page-specific styles + Brutalist pixel-craft, /audit-only widgets. + Site-wide tokens, fonts, body atmosphere, .app-header / .btn / + .tab / .section / .panel / .report all moved to globals.css + so they apply everywhere (and no longer leak when navigating + away from /audit back to /policies or /projects). ============================================================ */ -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=VT323&display=swap'); - -@font-face { - font-family: 'Architype Stedelijk'; - src: url('/audit/fonts/architype-stedelijk.woff2') format('woff2'), - url('/audit/fonts/architype-stedelijk.ttf') format('truetype'); - font-display: swap; - font-weight: 400; - font-style: normal; -} - -:root { - --bg: #131316; - --bg-2: #0e0e11; - --bg-3: #1a1a1f; - --bg-row-hover: #17171c; - --ink: #d8d6d2; - --ink-2: #9a9892; - --dim: #5e5c58; - --line: #25252b; - --line-2: #32323a; - --accent-pink: #e4587d; - --accent-pink-soft: rgba(228, 88, 125, 0.7); - --accent-pink-shadow: #a83a5a; - --accent-pink-bg: rgba(228, 88, 125, 0.12); - --accent-green: #66d1b5; - --accent-green-shadow: #3e9a82; - --accent-green-bg: rgba(102, 209, 181, 0.10); - --amber: #e8c46a; - --amber-bg: rgba(232, 196, 106, 0.10); - - --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; - --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; -} - -* { box-sizing: border-box; } - -html, body, #root { - margin: 0; - padding: 0; - background: var(--bg); - color: var(--ink); - font-family: var(--font-mono); - font-size: 13px; - line-height: 1.55; - -webkit-font-smoothing: antialiased; - min-height: 100vh; -} - -body { - background-color: var(--bg); - background-image: - radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), - radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), - radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), - linear-gradient(180deg, #16161a 0%, #0f0f12 100%); - background-attachment: fixed; -} - -button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; padding: 0; } -a { color: inherit; text-decoration: none; } - -/* engineering-plate cross-hatch + grain + scanlines */ -.app::before { - content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; - background-image: - linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), - linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); - background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; - opacity: 0.7; -} -.app::after { - content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; - background-image: url("data:image/svg+xml;utf8,"); - opacity: 0.5; - mix-blend-mode: overlay; -} +/* legacy scanline overlay used by audit-dashboard */ .scanline-overlay { position: fixed; inset: 0; pointer-events: none; z-index: 9999; background: repeating-linear-gradient(to bottom, @@ -91,81 +16,6 @@ a { color: inherit; text-decoration: none; } mix-blend-mode: overlay; } -.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } - -/* ───────────────────────── app header (in-product chrome) ───────────────────────── */ - -.app-header { - display: flex; align-items: center; gap: 16px; - padding: 14px 32px; - border-bottom: 1px solid var(--line); - background: rgba(10,10,10,0.85); - backdrop-filter: blur(8px); - position: sticky; top: 0; z-index: 50; -} -.h-brand { - display: inline-flex; align-items: baseline; gap: 10px; - flex: 1; min-width: 0; - color: var(--ink); text-decoration: none; -} -.h-brand-mark { - color: var(--accent-pink); - font-family: var(--font-mono); - font-size: 18px; - letter-spacing: -3px; - font-weight: 700; - line-height: 1; -} -.h-brand-name { - font-family: var(--font-display); - font-size: 18px; - letter-spacing: 0.11em; - text-transform: lowercase; - color: var(--ink); -} -.h-brand-sep { color: var(--dim); font-size: 12px; } -.h-brand-section { - font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.22em; - text-transform: uppercase; - color: var(--accent-green); -} -.h-actions { display: flex; align-items: center; gap: 8px; } -.btn { - display: inline-flex; align-items: center; gap: 8px; - padding: 7px 12px; - font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; - border: 1px solid var(--line-2); background: transparent; color: var(--ink); - transition: all 120ms ease; white-space: nowrap; -} -.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } -.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } -.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } -.btn-press { - box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); - transition: box-shadow 120ms, transform 120ms; -} -.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } - -/* tabs */ -.tabs { - display: flex; gap: 0; padding: 0 24px; - border-bottom: 1px solid var(--line); - overflow-x: auto; scrollbar-width: none; -} -.tabs::-webkit-scrollbar { display: none; } -.tab { - display: inline-flex; align-items: center; gap: 8px; - padding: 12px 16px; - font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; - color: var(--ink-2); - border-bottom: 1px solid transparent; margin-bottom: -1px; - transition: color 120ms, border-color 120ms; white-space: nowrap; -} -.tab:hover { color: var(--ink); } -.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } - /* ───────────────────────── audit page shell ───────────────────────── */ .report { diff --git a/app/globals.css b/app/globals.css index e991f5bb..07f8125a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,80 +1,156 @@ @import "tailwindcss"; +/* ============================================================ + failproof_ai — unified design system + Single source of truth for fonts, color palette, and every + class that used to live in app/audit/audit-styles.css for the + /audit page only. After this change those classes (.section, + .share-btn, .btn-press, …) are available on every route, and + navigating between /audit and /policies no longer leaks + one-off resets in either direction. + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=VT323&display=swap'); + +@font-face { + font-family: 'Architype Stedelijk'; + src: url('/audit/fonts/architype-stedelijk.woff2') format('woff2'), + url('/audit/fonts/architype-stedelijk.ttf') format('truetype'); + font-display: swap; + font-weight: 400; + font-style: normal; +} + @custom-variant dark (&:is(.dark *)); :root { - --radius: 0.5rem; - - /* Near-black canvas + brand pink accent — failproofai brand: pink primary - (#e4587d, from docs/docs.json colors.light) with green status reserved - for chart-2 / success indicators (the leaf gradient family). */ - --background: #0a0a0a; - --foreground: #fafafa; - --card: #141416; - --card-foreground: #fafafa; - --popover: #141416; - --popover-foreground: #fafafa; - --primary: #e4587d; - --primary-foreground: #0a0a0a; - --secondary: #1f1f22; - --secondary-foreground: #fafafa; - --muted: #1f1f22; - --muted-foreground: #a1a1aa; - --accent: #e4587d; + /* ── audit-native tokens (used by .section / .share-btn / etc.) ── */ + --bg: #131316; + --bg-2: #0e0e11; + --bg-3: #1a1a1f; + --bg-row-hover: #17171c; + --ink: #d8d6d2; + --ink-2: #9a9892; + --dim: #5e5c58; + --line: #25252b; + --line-2: #32323a; + --accent-pink: #e4587d; + --accent-pink-soft: rgba(228, 88, 125, 0.7); + --accent-pink-shadow: #a83a5a; + --accent-pink-bg: rgba(228, 88, 125, 0.12); + --accent-green: #66d1b5; + --accent-green-shadow: #3e9a82; + --accent-green-bg: rgba(102, 209, 181, 0.10); + --amber: #e8c46a; + --amber-bg: rgba(232, 196, 106, 0.10); + + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; + + /* ── shadcn-compatible aliases (used by Tailwind utilities + the + hooks-client component tree). All point at the audit palette so + `bg-card`, `text-foreground`, `border-border`, etc. produce + audit visuals everywhere without rewriting any class names. ── */ + --radius: 0; + --background: var(--bg); + --foreground: var(--ink); + --card: var(--bg-2); + --card-foreground: var(--ink); + --popover: var(--bg-2); + --popover-foreground: var(--ink); + --primary: var(--accent-pink); + --primary-foreground: var(--bg); + --secondary: var(--bg-3); + --secondary-foreground: var(--ink); + --muted: var(--bg-3); + --muted-foreground: var(--ink-2); + --accent: var(--accent-pink); --accent-light: #f08aa6; --accent-lighter: #f7b3c5; --accent-lightest: #fbd5de; - --accent-foreground: #0a0a0a; - --destructive: #ef4444; - --border: #27272a; - --input: #27272a; - --ring: #e4587d; - --chart-1: #e4587d; - --chart-2: #4ade80; - --chart-3: #fbbf24; + --accent-foreground: var(--bg); + --destructive: var(--accent-pink); + --border: var(--line); + --input: var(--line-2); + --ring: var(--accent-pink); + --chart-1: var(--accent-pink); + --chart-2: var(--accent-green); + --chart-3: var(--amber); --chart-4: #f87171; --chart-5: #a78bfa; - --sidebar: #141416; - --sidebar-foreground: #fafafa; - --sidebar-primary: #e4587d; - --sidebar-primary-foreground: #0a0a0a; - --sidebar-accent: #1f1f22; - --sidebar-accent-foreground: #fafafa; - --sidebar-border: #27272a; - --sidebar-ring: #e4587d; + --sidebar: var(--bg-2); + --sidebar-foreground: var(--ink); + --sidebar-primary: var(--accent-pink); + --sidebar-primary-foreground: var(--bg); + --sidebar-accent: var(--bg-3); + --sidebar-accent-foreground: var(--ink); + --sidebar-border: var(--line); + --sidebar-ring: var(--accent-pink); } -/* Custom Scrollbar Styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} +* { box-sizing: border-box; } -::-webkit-scrollbar-track { - background: var(--muted); - border-radius: var(--radius); +html, body, #root { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + min-height: 100vh; } -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: var(--radius); - transition: background 0.2s ease; +body { + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), + radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), + linear-gradient(180deg, #16161a 0%, #0f0f12 100%); + background-attachment: fixed; + position: relative; } -::-webkit-scrollbar-thumb:hover { - background: var(--primary); -} +button { font-family: inherit; cursor: pointer; } +a { color: inherit; } -::-webkit-scrollbar-corner { - background: var(--muted); +/* engineering-plate cross-hatch + grain — site-wide atmosphere */ +body::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; + background-image: + linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); + background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; + opacity: 0.7; +} +body::after { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.4; + mix-blend-mode: overlay; } -/* Firefox scrollbar styling */ -* { - scrollbar-width: thin; - scrollbar-color: var(--border) var(--muted); +body > * { + position: relative; + z-index: 3; } +/* shrink scrollbars without breaking the dark theme */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg-2); } +::-webkit-scrollbar-thumb { background: var(--line-2); } +::-webkit-scrollbar-thumb:hover { background: var(--accent-pink); } +* { scrollbar-width: thin; scrollbar-color: var(--line-2) var(--bg-2); } + +input[type="checkbox"] { accent-color: var(--accent-pink); } +select { color-scheme: dark; } +select option { background-color: var(--bg-2); color: var(--ink); } +input[type="date"] { color-scheme: dark; } + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -82,8 +158,8 @@ --color-accent-light: var(--accent-light); --color-accent-lighter: var(--accent-lighter); --color-accent-lightest: var(--accent-lightest); - --font-sans: var(--font-sans, system-ui, -apple-system, sans-serif); - --font-mono: var(--font-mono, ui-monospace, monospace); + --font-sans: var(--font-mono); + --font-mono: var(--font-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -112,10 +188,10 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; } @layer base { @@ -123,95 +199,148 @@ @apply border-border outline-ring/50; } html { - font-size: 120%; + font-size: 100%; } - body { - @apply bg-background text-foreground font-sans; +} - position: relative; - } - /* Faint pink vignette at the top — atmosphere without ornament. */ - body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 0; - background: - radial-gradient(ellipse 90% 60% at 50% -10%, rgba(228, 88, 125, 0.07), transparent 65%); - } - body > * { - position: relative; - z-index: 1; - } - input[type="checkbox"] { - accent-color: var(--primary); - } +/* ───────────────────────── app chrome (shared) ───────────────────────── */ - select { - color-scheme: dark; - } +.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } - select option { - background-color: var(--popover); - color: var(--popover-foreground); - } +.app-header { + display: flex; align-items: center; gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.h-brand { display: inline-flex; align-items: baseline; gap: 10px; flex: 1; min-width: 0; color: var(--ink); text-decoration: none; } +.h-brand-mark { + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: -3px; + font-weight: 700; + line-height: 1; +} +.h-brand-name { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.h-brand-sep { color: var(--dim); font-size: 12px; } +.h-brand-section { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.h-actions { display: flex; align-items: center; gap: 8px; } - /* Date Input Styling */ - input[type="date"] { - position: relative; - overflow: visible; - color-scheme: dark; - } +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + border: 1px solid var(--line-2); background: transparent; color: var(--ink); + transition: all 120ms ease; white-space: nowrap; +} +.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } +.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } +.btn-press { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transition: box-shadow 120ms, transform 120ms; +} +.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } - input[type="date"]::-webkit-calendar-picker-indicator { - display: none; - opacity: 0; - position: absolute; - right: 0; - width: 0; - height: 0; - cursor: pointer; - } +/* primary tab strip — used by both the audit page and the policies/projects nav rows */ +.tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid var(--line); + overflow-x: auto; scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + color: var(--ink-2); + border-bottom: 1px solid transparent; margin-bottom: -1px; + transition: color 120ms, border-color 120ms; white-space: nowrap; + background: transparent; +} +.tab:hover { color: var(--ink); } +.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } - input[type="date"]::-webkit-datetime-edit { - display: inline-flex; - align-items: center; - width: 100%; - padding: 0; - gap: 0; - } +/* ───────────────────────── canonical page chrome ───────────────────────── */ - input[type="date"]::-webkit-datetime-edit-text { - color: var(--muted-foreground); - padding: 0 0.2rem; - } +.report { + max-width: 1180px; + margin: 0 auto; + padding: 0 32px; +} - input[type="date"]::-webkit-datetime-edit-month-field, - input[type="date"]::-webkit-datetime-edit-day-field { - color: var(--foreground); - padding: 0 0.15rem; - width: 2.5ch; - min-width: 2.5ch; - } +.section { + padding: 48px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } - input[type="date"]::-webkit-datetime-edit-year-field { - color: var(--foreground); - padding: 0 0.15rem; - width: 5ch; - min-width: 5ch; - max-width: 5ch; - overflow: visible; - } +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 24px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} - input[type="date"]::-webkit-datetime-edit-month-field:focus, - input[type="date"]::-webkit-datetime-edit-day-field:focus, - input[type="date"]::-webkit-datetime-edit-year-field:focus { - background-color: var(--muted); - color: var(--foreground); - border-radius: 0.25rem; - } +/* ───────────────────────── reusable bracket panel ───────────────────────── */ + +.panel { + position: relative; + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 28px; +} +.panel::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); } +.panel::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} + +/* ───────────────────────── animations (preserved from previous globals) ───────────────────────── */ @keyframes entry-highlight { 0% { background-color: color-mix(in oklch, var(--primary), transparent 82%); } @@ -221,7 +350,6 @@ animation: entry-highlight 3s ease-out forwards; outline: 1px solid color-mix(in oklch, var(--primary), transparent 55%); outline-offset: -1px; - border-radius: var(--radius); } @keyframes expand-in { @@ -232,8 +360,6 @@ animation: expand-in 150ms ease-out; } -/* Audit dashboard: staggered row entry. The component sets `--row-delay` - per row inline so each item enters ~50ms after its predecessor. */ @keyframes audit-row-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } @@ -244,10 +370,6 @@ animation-delay: var(--row-delay, 0ms); } -/* Audit dashboard: bar fill from 0 → target width. The component sets - `--bar-width` to the final value (e.g. "62%") so the keyframe targets - it via CSS variables — works for both proportional bars in the policy - list and the inline health bar. */ @keyframes audit-bar-fill { from { width: 0; } to { width: var(--bar-width, 100%); } diff --git a/app/layout.tsx b/app/layout.tsx index 2988b5b5..8c956d44 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,7 +5,6 @@ * `` so there's no theme indeterminacy and no inline script is needed. */ import type { Metadata } from "next"; -import { Geist_Mono } from "next/font/google"; import { PostHogProvider } from "@/contexts/PostHogContext"; import { GlobalErrorListeners } from "@/app/components/global-error-listeners"; import { AutoRefreshProvider } from "@/contexts/AutoRefreshContext"; @@ -14,11 +13,10 @@ import { Toaster } from "@/app/components/toast"; import { readDashboardCache } from "@/src/audit/dashboard-cache"; import "./globals.css"; -const geistMono = Geist_Mono({ - subsets: ["latin"], - variable: "--font-mono", - display: "swap", -}); +// Site-wide mono font is JetBrains Mono, loaded via the Google Fonts @import +// at the top of globals.css alongside the audit display font. Keeping the +// import in CSS (rather than next/font) is intentional so the same stylesheet +// is the single source of truth — see the design-system note in globals.css. export const metadata: Metadata = { title: "Failproof AI - Hooks & Project Monitor", @@ -44,7 +42,7 @@ export default function RootLayout({ .reduce((sum, r) => sum + r.hits, 0) : undefined; return ( - + diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 79ac2104..a7ca477a 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -4,7 +4,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef, useTransition import { createPortal } from "react-dom"; import Link from "next/link"; import { - ArrowLeft, ShieldCheck, ShieldX, ShieldAlert, @@ -1546,18 +1545,15 @@ function TabBar({ { id: "policies", label: "Configure" }, ]; return ( -
+
{tabs.map((tab) => ( ))}
@@ -1602,41 +1598,76 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?: }; return ( -
- {/* Header */} -
- - - Back - -
-

- Policies -

- {activeTab === "activity" && ( - - - - - )} +
+
+
+
+ ━━ policies{" "} + ·{" "} + {activeTab === "activity" ? "live evaluation" : "configure"} +
+
+ {activeTab === "activity" && ( + <> + evaluating in real time + + )} + {activeTab === "policies" && policyCounts && ( + <> + + {policyCounts.enabled} + + /{policyCounts.total} enabled + + )} +
-

+

+ {activeTab === "activity" ? "what your agents tried." : "what to stop them doing."} +

+

{activeTab === "activity" ? ( <> - {evaluationsHeading} + {evaluationsHeading.toLowerCase()} {policyCounts && ( - + {" · "}enabled policies{" "} - {policyCounts.enabled}/{policyCounts.total} + + {policyCounts.enabled}/{policyCounts.total} + )} - - To configure policies,{" "} + + to configure policies,{" "}

- + - {activeTab === "activity" ? ( - - ) : ( - - )} -
+ {activeTab === "activity" ? ( + + ) : ( + + )} + + ); } diff --git a/app/projects/loading.tsx b/app/projects/loading.tsx index d151aadb..9f7cf65e 100644 --- a/app/projects/loading.tsx +++ b/app/projects/loading.tsx @@ -1,17 +1,28 @@ -/** Skeleton loading UI for the projects page. */ +/** Skeleton loading UI for the projects page — audit-styled to match + * the dashed `.panel` chrome of the loaded state. */ export default function ProjectsLoading() { return ( -
-
-
-
-
+
+
+
+
+ ━━ projects{" "} + · agent SDK folders +
+
+ loading… +
+
+

your agent footprint.

+
+
+
{Array.from({ length: 8 }).map((_, i) => ( -
+
))}
-
+
); } diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 08bfef28..20a99c5c 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -1,4 +1,12 @@ -/** Projects page — lists all Claude Agent SDK project folders. */ +/** Projects page — lists all Claude Agent SDK project folders. + * + * Wrapped in the audit `.report` + `.section` chrome so the page picks up + * the unified design system: mono fonts, section masthead with the ━━ + * glyph + green eyebrow label, and the dashed-frame `.panel` around the + * project list when it's populated. The inner ProjectList component is + * unchanged — every Tailwind utility it uses (bg-card, text-foreground, + * border-border, …) now resolves to the audit palette globally. + */ import { Suspense } from "react"; import { notFound } from "next/navigation"; import { getCachedProjectFolders } from "@/lib/projects"; @@ -13,27 +21,56 @@ export default async function ProjectsPage() { if (disabled.includes("projects")) notFound(); const folders = await getCachedProjectFolders(); + const count = folders.length; return ( -
-
-
-

Projects

- - {folders.length === 0 ? ( -
-

- No projects found in the .claude/projects directory. -

-

- Make sure the directory exists and contains project folders. -

-
- ) : ( - - )} +
+
+
+
+ ━━ projects{" "} + · agent SDK folders +
+
+ {count > 0 ? ( + <> + {count} folder{count === 1 ? "" : "s"} indexed + + ) : ( + <> + empty + + )} +
-
+

your agent footprint.

+ + {count === 0 ? ( +
+

+ no projects found in the .claude/projects directory. +

+

+ make sure the directory exists and contains project folders. +

+
+ ) : ( +
+ + + +
+ )} +
); } diff --git a/components/navbar.tsx b/components/navbar.tsx index 838ec75f..4b824e16 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -1,21 +1,25 @@ -/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls. */ +/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls. + * + * Restyled to the audit / brutalist-pixel-craft system: the wordmark uses the + * same pixel pink mark + Architype Stedelijk lowercase name as the audit + * report, and each nav link is a `.tab` with a sharp pink underline on the + * active route. No lucide icons in the bar itself — the chrome stays text- + * forward to match the rest of the design system. + */ "use client"; import React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ClipboardCheck, FolderOpen, Shield } from "lucide-react"; import { ReachDevelopers } from "@/components/reach-developers"; import { RefreshButton } from "@/app/components/refresh-button"; const NAV_LINKS = [ - { href: "/policies", label: "Policies", icon: Shield }, - { href: "/audit", label: "Audit", icon: ClipboardCheck }, - { href: "/projects", label: "Projects", icon: FolderOpen }, + { href: "/policies", label: "policies" }, + { href: "/audit", label: "audit" }, + { href: "/projects", label: "projects" }, ]; -const WORDMARK_SRC = "https://d2wq11aau0arks.cloudfront.net/failproof/logo-wordmark.png"; - export const Navbar: React.FC<{ disabledPages?: string[]; /** Total slipping-through actions from the latest cached audit. When > 0 @@ -25,82 +29,96 @@ export const Navbar: React.FC<{ }> = ({ disabledPages = [], auditSlippingCount }) => { const pathname = usePathname(); + const sectionLabel = (() => { + if (pathname.startsWith("/policies")) return "policies"; + if (pathname.startsWith("/audit")) return "audit"; + if (pathname.startsWith("/projects") || pathname.startsWith("/project/")) return "projects"; + return ""; + })(); + return ( -
-
-
-
- - {/* eslint-disable-next-line @next/next/no-img-element */} - failproof ai - - {process.env.NEXT_PUBLIC_APP_VERSION && ( - - v{process.env.NEXT_PUBLIC_APP_VERSION} - - )} +
+ + + failproof_ai + {process.env.NEXT_PUBLIC_APP_VERSION && ( + + )} + {process.env.NEXT_PUBLIC_APP_VERSION && ( + + v{process.env.NEXT_PUBLIC_APP_VERSION} + + )} + {sectionLabel && } + {sectionLabel && {sectionLabel}} + -
+ - -
-
- -
- -
-
+
+ + +
); From 500e97ac77909b10d2f92d57e0a4cc839ad133d7 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sun, 31 May 2026 18:29:51 +0530 Subject: [PATCH 07/13] update global css --- app/globals.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/globals.css b/app/globals.css index 07f8125a..189c01d5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,3 @@ -@import "tailwindcss"; - /* ============================================================ failproof_ai — unified design system Single source of truth for fonts, color palette, and every @@ -8,9 +6,16 @@ .share-btn, .btn-press, …) are available on every route, and navigating between /audit and /policies no longer leaks one-off resets in either direction. + + IMPORTANT: every `@import` MUST come before any other rule, + per the CSS spec. `@import "tailwindcss"` inlines thousands + of utility rules at its position, so font @imports go above + it — putting them after silently breaks the build with a + PostCSS "must precede all rules" error. ============================================================ */ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=VT323&display=swap'); +@import "tailwindcss"; @font-face { font-family: 'Architype Stedelijk'; From 4e0f805e5efab58545517b5cddae8df17672b4c0 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sun, 31 May 2026 19:42:28 +0530 Subject: [PATCH 08/13] feat(ui): bigger type, score+share card, persistent reminder, re-audit now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six polish items in one pass — sizing, second-navbar fix, score-section rewrite, empty/running restyle, and persistent reminder state across sessions. Sizing globals.css: base 13 → 14.5px, .report max-width 1180 → 1380px (40px side padding), .section padding restored to 64px. Default-zoom readability across /audit, /policies, /projects no longer forces a browser zoom-in. Double navbar Delete app/audit/_components/app-header.tsx and all three of its mount sites in audit-dashboard.tsx (cached, in-flight, and ShellEmpty). The global navbar already supplies brand + tabs + reach; the in-page bar with [share →] was redundant chrome. Score section Drop the synthetic cohort leaderboard. Replace ScoreSection with a single .panel (.score-share-card) split into score + share: left — big tier-colored score, tier badge, progress bar to the next grade band, 3 stat boxes (missing policies, pts to next tier, est. days to fix), policy-status chip strip right — X + LinkedIn pre-written templates derived from score/archetype/missing; [share on X], [share on LinkedIn], [download audit card] (html2canvas captures the entire panel as failproofai-card--.png) audit-dashboard.tsx drops the unused syntheticRank import / rank prop and threads `result` into the new section. Empty / running empty-state.tsx: shadcn Button + lucide icon center card → .panel with a 6×6 pixel-grid sigil, Architype Stedelijk headline, .btn-press CTA, audit-style meta caption. Mode "no-cache" → "run your first audit." with [ run audit ]. Mode "zero-sessions" → "install hooks first." with [ install guide → ]. run-progress.tsx: terminal-style panel — "$ failproofai audit --since 30d ▮" header with a blinking pink cursor, stage list with ✓ / ▮▮ / ○ markers + per-stage braille spinner, marquee progress bar with a pink shine sweep. Persistent reminder ~/.failproofai/next-audit.json — separate from auth.json so a token refresh / re-login doesn't churn the reminder. Mode 0600, same perms hygiene as auth.json (writeFileSync with mode + post-write chmodSync on overwrite). lib/auth/auth-store.ts: new readReminder / writeReminder / deleteReminder / getReminderFilePath + StoredReminder type. app/api/auth/reminder/route.ts: GET / POST / DELETE. POST defaults to a 7-day offset; reminder is scoped to the active session so a reminder for a@x.com is invisible when b@x.com is the live CLI session. /api/auth/status returns `reminder: { next_audit_at, user_email, set_at } | null` alongside the user. Return section Behavior matrix in return-section.tsx: unknown → buttons disabled while /api/auth/status is in flight anon → [set a reminder] opens AuthDialog, on success persists the 7-day reminder automatically (no second click) authed + no reminder → [set a reminder] writes the timestamp directly, no dialog authed + reminder set → status panel showing "next audit set for · in 7 days" and "signed in as ", plus [re-audit now] / [install policies] / "clear reminder" [re-audit now] button is exposed to all authed states (plus anon, next to install-policies). It reuses triggerRun() from rerun-button.tsx and reloads the page once the new run finishes. Verification bunx tsc --noEmit passes bun run lint passes (only the 2 pre-existing warnings) bun run test:run 1701/1701 pass bun --bun next build Compiled successfully — new /api/auth/reminder route registers alongside /api/auth/{status, login-request, login-verify, logout}. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 + app/api/auth/reminder/route.ts | 97 ++++ app/api/auth/status/route.ts | 21 +- app/audit/_components/app-header.tsx | 37 -- app/audit/_components/audit-dashboard.tsx | 24 +- app/audit/_components/empty-state.tsx | 130 +++-- app/audit/_components/return-section.tsx | 280 ++++++++--- app/audit/_components/run-progress.tsx | 120 +++-- app/audit/_components/score-section.tsx | 501 +++++++++++-------- app/audit/audit-styles.css | 581 +++++++++++++++++----- app/globals.css | 13 +- lib/auth/auth-store.ts | 56 +++ 12 files changed, 1296 insertions(+), 566 deletions(-) create mode 100644 app/api/auth/reminder/route.ts delete mode 100644 app/audit/_components/app-header.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9091b317..f02c8655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). +- Polish pass across `/audit`, `/policies`, and `/projects`: bump base font from `13px → 14.5px` and widen `.report` from `1180px → 1380px` (with `40px` side padding) in `globals.css` so default-zoom readability stops requiring a browser zoom-in; restore `.section` vertical padding to `64px` to match the audit reference. Remove the second in-page audit `
` (`app/audit/_components/app-header.tsx` deleted) and all three of its mount sites in `audit-dashboard.tsx` — the global navbar plus per-section masts cover the same chrome without the duplicate `failproof_ai / AUDIT [share →]` strip. Rewrite `score-section.tsx` end-to-end: drop the synthetic cohort leaderboard and replace with a single dashed-frame `.panel` (the new `.score-share-card`) split into two columns — left is the audit score (big tier-colored number, tier badge, progress bar to the next grade band, three stat boxes for missing policies / pts-to-next / est. days-to-fix, plus a top-N policy-status chip strip), right is share (pre-written X / Twitter and LinkedIn templates derived from `score + archetype + missing-count`, `[share on X]`, `[share on LinkedIn]`, and `[download audit card]` that html2canvas-captures the whole panel as a PNG named `failproofai-card--.png`). `audit-dashboard.tsx` drops the now-unused `syntheticRank` import / `rank` prop and threads `result` into the score section. Replace `empty-state.tsx` and `run-progress.tsx` with audit-pixel-craft versions: a `.empty-panel` with a pixel-grid sigil, Architype Stedelijk headline, and `.btn-press` CTA replaces the shadcn `Button` + `lucide-react` icon center-card; the running view becomes a terminal-style `.running-panel` (`$ failproofai audit --since 30d ▮` header with a blinking pink cursor, stage list with `✓` / `▮▮` / `○` markers and a per-stage braille spinner, and a marquee `audit-bar-fill` progress bar). Persistent **next-audit reminder** added — new `~/.failproofai/next-audit.json` (mode 0600, separate file from `auth.json` so the reminder is independent of token refresh), new `lib/auth/auth-store.ts` helpers (`readReminder` / `writeReminder` / `deleteReminder` / `getReminderFilePath` + `StoredReminder` type), new `app/api/auth/reminder/route.ts` (GET / POST / DELETE, defaults to a 7-day offset, scoped to the active session so a reminder for `a@x.com` is invisible to a CLI-authed `b@x.com`), and `/api/auth/status` now returns `reminder: { next_audit_at, user_email, set_at } | null` alongside the user. `return-section.tsx` flips behavior accordingly: signed in + reminder set → status panel ("next audit set for ` · in 7 days`" + "signed in as ``" + a `[re-audit now]` button next to `[install policies]` and a tiny "clear reminder" link); anon → `[set a reminder]` opens the existing AuthDialog and on successful sign-in writes the reminder automatically; signed in + no reminder → `[set a reminder]` writes it directly with no dialog. The `[re-audit now]` button (also shown to anon users with audit data) reuses the existing `triggerRun` poller and reloads the page once the run completes. No new dependencies; the deleted `app-header.tsx` was a 38-line component with no callers other than the three audit-dashboard mounts. + - Unify the dashboard design system around the brutalist pixel-craft aesthetic that previously lived only in `/audit`. The audit token set (`--bg`, `--ink`, `--accent-pink`, `--accent-green`, `--font-mono` → JetBrains Mono, `--font-display` → Architype Stedelijk / VT323) is now declared once in `app/globals.css`, and every shadcn-style Tailwind alias (`--background`, `--card`, `--foreground`, `--primary`, `--border`, `--radius: 0`, …) is repointed at the audit palette so existing utility classes like `bg-card` / `text-foreground` / `border-border` produce audit visuals across the whole app without rewriting any component markup. The `:root` block, body cross-hatch + grain overlays, JetBrains Mono import, and all canonical chrome classes (`.app-header`, `.h-brand*`, `.btn`, `.btn-press`, `.tabs`, `.tab`, `.section`, `.section-mast`, `.section-h`, `.report`, plus a new reusable `.panel` with pink corner brackets) are promoted to `globals.css`. `app/audit/audit-styles.css` keeps only the audit-page-only widgets (archetype frame, sigil, score grade, leaderboard, findings cards, return hook, auth dialog), so the styles loaded specifically by `/audit` no longer leak into `/policies` or `/projects` on client-side navigation. `app/layout.tsx` drops the `next/font/google` Geist Mono import — fonts now ship via the single CSS `@import url('…JetBrains+Mono…')` in `globals.css`. `components/navbar.tsx` is rewritten around `.app-header` with the pink `▮▮` mark, lowercase Architype wordmark, optional version chip, a current-section eyebrow, and `.tab` links with sharp pink underline on the active route (lucide icons in the bar removed). `app/projects/page.tsx` and its `loading.tsx` are wrapped in the `.report` + `.section` + `.panel` chrome with a green-eyebrow masthead and "your agent footprint." section heading; the inner `ProjectList` component is unchanged and picks up the unified palette automatically. `app/policies/hooks-client.tsx` swaps its outer `
` for a `.report` + `.section` shell with audit masthead copy ("what your agents tried." / "what to stop them doing."), replaces the rounded-pill `TabBar` with the global `.tabs` / `.tab` underline tabs, and drops the now-redundant "Back to /projects" link (the new navbar covers cross-page navigation). No functional changes — all 1701 tests pass and the production `next build` succeeds. ### Docs diff --git a/app/api/auth/reminder/route.ts b/app/api/auth/reminder/route.ts new file mode 100644 index 00000000..840616a9 --- /dev/null +++ b/app/api/auth/reminder/route.ts @@ -0,0 +1,97 @@ +/** + * /api/auth/reminder + * + * GET — current reminder state (if any, scoped to the signed-in user) + * POST — set or update the next-audit reminder; requires an active session + * DELETE — clear the reminder + * + * Reminder timestamp lives in ~/.failproofai/next-audit.json. The dashboard + * AND the CLI can read it later (we just persist intent here; the actual + * email send is wired separately when the scheduler is built). + */ +import { NextRequest, NextResponse } from "next/server"; +import { + deleteReminder, + readReminder, + whoAmI, + writeReminder, +} from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_OFFSET_DAYS = 7; +const MAX_OFFSET_DAYS = 365; + +export async function GET(): Promise { + const who = await whoAmI(); + const reminder = readReminder(); + if (!reminder) { + return NextResponse.json({ authenticated: !!who, reminder: null }); + } + // If the reminder belongs to a different user (or no one is signed in), + // surface it as null so the UI doesn't show "next audit set for alice" + // when bob is the current session. + if (!who || who.me.email !== reminder.user_email) { + return NextResponse.json({ authenticated: !!who, reminder: null }); + } + return NextResponse.json({ + authenticated: true, + reminder: { + next_audit_at: reminder.next_audit_at, + user_email: reminder.user_email, + set_at: reminder.set_at, + }, + }); +} + +interface SetBody { + /** Days from now until the reminder fires. Default: 7. */ + in_days?: unknown; + /** Absolute unix-seconds timestamp. Wins over in_days when both are sent. */ + at?: unknown; +} + +export async function POST(req: NextRequest): Promise { + const who = await whoAmI(); + if (!who) { + return NextResponse.json( + { code: "unauthorized", message: "Sign in before setting a reminder." }, + { status: 401 }, + ); + } + let body: SetBody = {}; + try { + body = (await req.json().catch(() => ({}))) as SetBody; + } catch { + // empty body is fine — we'll fall back to the default 7-day offset + } + const nowSecs = Math.floor(Date.now() / 1000); + let nextAuditAt: number; + if (typeof body.at === "number" && Number.isFinite(body.at)) { + nextAuditAt = Math.floor(body.at); + } else { + const offsetDays = + typeof body.in_days === "number" && Number.isFinite(body.in_days) + ? Math.max(1, Math.min(MAX_OFFSET_DAYS, Math.floor(body.in_days))) + : DEFAULT_OFFSET_DAYS; + nextAuditAt = nowSecs + offsetDays * 86400; + } + if (nextAuditAt <= nowSecs) { + return NextResponse.json( + { code: "validation_error", message: "Reminder must be in the future." }, + { status: 400 }, + ); + } + const reminder = { + next_audit_at: nextAuditAt, + user_email: who.me.email, + set_at: nowSecs, + }; + writeReminder(reminder); + return NextResponse.json({ authenticated: true, reminder }); +} + +export async function DELETE(): Promise { + deleteReminder(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts index 5ce2150c..2b688963 100644 --- a/app/api/auth/status/route.ts +++ b/app/api/auth/status/route.ts @@ -4,9 +4,14 @@ * Returns the currently authenticated identity, verifying the locally-stored * access token against the api-server's /me endpoint. Refreshes the access * token if it's near expiry. Never exposes the refresh token to the browser. + * + * Also returns the user's persisted re-audit reminder (if any). The reminder + * lives in ~/.failproofai/next-audit.json and is only surfaced when its + * `user_email` matches the active session — so swapping accounts via CLI + * does not leak a previous user's reminder into the dashboard. */ import { NextResponse } from "next/server"; -import { whoAmI } from "@/lib/auth/auth-store"; +import { readReminder, whoAmI } from "@/lib/auth/auth-store"; export const dynamic = "force-dynamic"; @@ -14,8 +19,17 @@ export async function GET(): Promise { try { const result = await whoAmI(); if (!result) { - return NextResponse.json({ authenticated: false }, { status: 200 }); + return NextResponse.json({ authenticated: false, reminder: null }, { status: 200 }); } + const reminderRaw = readReminder(); + const reminder = + reminderRaw && reminderRaw.user_email === result.me.email + ? { + next_audit_at: reminderRaw.next_audit_at, + user_email: reminderRaw.user_email, + set_at: reminderRaw.set_at, + } + : null; return NextResponse.json( { authenticated: true, @@ -25,13 +39,14 @@ export async function GET(): Promise { status: result.me.status, created_at: result.me.created_at, }, + reminder, }, { status: 200 }, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); return NextResponse.json( - { authenticated: false, error: message }, + { authenticated: false, reminder: null, error: message }, { status: 200 }, ); } diff --git a/app/audit/_components/app-header.tsx b/app/audit/_components/app-header.tsx deleted file mode 100644 index f476b953..00000000 --- a/app/audit/_components/app-header.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -/** - * In-page chrome for /audit. Distinct from the site-wide Navbar (which - * is suppressed on /audit) — this one carries the failproof_ai wordmark - * + an [ share → ] action that scrolls to / triggers the ShowOff CTA. - * - * Styled via `.app-header`, `.h-brand`, etc. classes from audit-styles.css. - */ -import React from "react"; - -interface Props { - onShare?: () => void; - shareLabel?: string; -} - -export function AppHeader({ onShare, shareLabel = "[ share → ]" }: Props) { - return ( -
- - ▮▮ - failproof_ai - / - audit - -
- -
-
- ); -} diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index cba920fb..b4a6eabe 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -15,11 +15,10 @@ 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, syntheticRank } from "@/src/audit/scoring"; +import { COHORT_SIZE, deriveScore, gradeFor, projectedScore } from "@/src/audit/scoring"; import { deriveStrengths } from "@/src/audit/strengths"; import { deriveFindings } from "@/src/audit/findings"; -import { AppHeader } from "./app-header"; import { IdentitySection } from "./identity-section"; import { ShowOffCTA } from "./show-off-cta"; import { StrengthsSection } from "./strengths-section"; @@ -140,7 +139,6 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr
-
@@ -176,7 +174,6 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize const projected = useMemo(() => projectedScore(result, score), [result, score]); const grade = gradeFor(score); const projectedGrade = gradeFor(projected); - const rank = useMemo(() => syntheticRank(score), [score]); const strengths = useMemo(() => deriveStrengths(result), [result]); const findings = useMemo(() => deriveFindings(result), [result]); const project = useMemo(() => inferProjectName(result, projectFromUrl), [result, projectFromUrl]); @@ -187,26 +184,10 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize /** Identity hero ref — captured to PNG by the "make poster" button. */ const identityFrameRef = useRef(null); - /** Scroll to the ShowOff CTA — the share button entry point per spec. - * Uses manual y-coord scrolling instead of scrollIntoView so we can - * account for the sticky .app-header (≈52px) that would otherwise - * cover the section. */ - const scrollToShowOff = () => { - const el = document.querySelector(".showoff"); - if (!el) return; - const headerEl = document.querySelector(".app-header"); - const offset = (headerEl?.offsetHeight ?? 0) + 16; - // `window` is shadowed by the inferWindow() string prop; use globalThis. - const w = globalThis as typeof globalThis & Window; - const targetY = el.getBoundingClientRect().top + w.scrollY - offset; - w.scrollTo({ top: targetY, behavior: "smooth" }); - }; - return (
-
-
{running ? ( diff --git a/app/audit/_components/empty-state.tsx b/app/audit/_components/empty-state.tsx index c5d5466d..17f15ce7 100644 --- a/app/audit/_components/empty-state.tsx +++ b/app/audit/_components/empty-state.tsx @@ -1,14 +1,18 @@ "use client"; /** - * Two-mode empty state: + * Two-mode empty state for /audit, styled to the audit pixel-craft system: + * * - "no-cache" — first time the user visits /audit. CTA to run. * - "zero-sessions" — ran a scan but no transcripts were found. Likely the * user hasn't installed hooks for any CLI yet. + * + * Both modes use the shared `.panel` chrome with pink corner brackets, a + * green section eyebrow, an Architype Stedelijk display headline, and a + * sharp `.btn-press` action button. Sized so it occupies the same vertical + * space as the loaded dashboard does on its hero — no more cramped popover. */ import React from "react"; -import { ClipboardCheck, FolderSearch } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { triggerRun } from "./rerun-button"; interface Props { @@ -30,43 +34,101 @@ export function EmptyState({ mode, running, onStarted, onCompleted }: Props) { if (mode === "no-cache") { return ( -
-
- +
+
+
+ ━━ audit{" "} + · first run +
+
+ no cache yet +
-

No audit data yet

-

- Run your first audit to see how your agents have been behaving across all past sessions. -

- -

- Scans the last 30 days across every installed CLI. Takes 10–30 seconds. -

-
+

scan and see.

+ +
+ + +

run your first audit.

+

+ we'll walk every transcript across your installed CLIs — Claude Code, + Codex, Copilot, Cursor, OpenCode, Pi, Gemini — and count every wasteful + or risky action. you'll get a tier, a score, and a punch-list. +

+ +
+ + + scans the last 30 days · all installed CLIs · 10–30s + +
+
+ ); } // mode === "zero-sessions" return ( -
-
- +
+
+
+ ━━ audit{" "} + · zero transcripts +
+
+ hooks not installed +
+
+

nothing to read.

+ +
+ + +

install hooks first.

+

+ failproofai couldn't find any transcripts to scan on this machine. + install the hooks for at least one CLI and come back. +

+ +
+ + [ install guide → ] + + + takes about 30 seconds · one command per CLI + +
-

No sessions found

-

- Failproof AI couldn't find any transcripts to scan. Install the hooks - for at least one CLI to start collecting sessions. -

- - See the install guide → - -
+ ); } diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx index 8c661369..e310374e 100644 --- a/app/audit/_components/return-section.tsx +++ b/app/audit/_components/return-section.tsx @@ -3,31 +3,59 @@ /** * Section 06 — NEXT AUDIT / "come back better." Re-audit loop CTA. * - * Two actions: [ set a reminder ] gates on auth — if the visitor isn't - * signed in, we open the AuthDialog to collect their email + verify a - * one-time code. The actual mail scheduling is wired later; this just - * proves identity for now. + * Behavior matrix: + * - unknown (probe in flight) → buttons disabled + * - anon (no session) → [ set a reminder ] opens AuthDialog, + * on success persists the 7-day reminder + * and we flip to the authed state below. + * - authed + no reminder → [ set a reminder ] writes the timestamp, + * no auth dialog needed. + * - authed + reminder set → button collapses to a status pill showing + * the email and the relative "next audit + * in X days" line. The reminder persists + * across reloads via ~/.failproofai/next- + * audit.json — same as the CLI's auth.json. * - * [ install policies ] copies the bulk install command (unchanged). + * Also exposes [ re-audit now ] next to [ install policies ] so the user + * can trigger a fresh scan inline without leaving the page. The button + * fires POST /api/audit/run (same backend the empty-state CTA uses). */ import React, { useCallback, useEffect, useState } from "react"; import type { AuditResult } from "@/src/audit/types"; import { AuthDialog, type AuthedUser } from "./auth-dialog"; +import { triggerRun } from "./rerun-button"; interface Props { result: AuditResult; } const BULK_INSTALL_CMD = "failproofai policies --install"; +const DEFAULT_REMINDER_DAYS = 7; type AuthStatus = | { kind: "unknown" } | { kind: "anon" } | { kind: "authed"; user: { id: string; email: string } }; -type ReminderState = - | { kind: "idle" } - | { kind: "queued" }; +interface Reminder { + next_audit_at: number; // unix seconds + 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 ReturnSection({ result }: Props) { const hasUnenabled = result.results.some( @@ -36,33 +64,54 @@ export function ReturnSection({ result }: Props) { const [copied, setCopied] = useState(false); const [authStatus, setAuthStatus] = useState({ kind: "unknown" }); + const [reminder, setReminder] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); - const [reminder, setReminder] = useState({ kind: "idle" }); + const [reminderBusy, setReminderBusy] = useState(false); + const [rerunBusy, setRerunBusy] = useState(false); - // Probe /api/auth/status once on mount. The endpoint is cheap and never - // throws — it returns { authenticated: false } when no session exists. - useEffect(() => { - let cancelled = false; - void (async () => { - try { - const res = await fetch("/api/auth/status", { cache: "no-store" }); - const body = (await res.json()) as { - authenticated?: boolean; - user?: { id: string; email: string }; - }; - if (cancelled) return; - if (body.authenticated && body.user) { - setAuthStatus({ kind: "authed", user: body.user }); - } else { - setAuthStatus({ kind: "anon" }); - } - } catch { - if (!cancelled) setAuthStatus({ kind: "anon" }); + // Probe /api/auth/status on mount — also returns the persisted reminder + // when one exists and belongs to the active session. + const refreshStatus = useCallback(async () => { + try { + const res = await fetch("/api/auth/status", { cache: "no-store" }); + 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); } - })(); - return () => { - cancelled = true; - }; + } catch { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + }, []); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + const persistReminder = useCallback(async (): Promise => { + try { + setReminderBusy(true); + const res = await fetch("/api/auth/reminder", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ in_days: DEFAULT_REMINDER_DAYS }), + }); + if (!res.ok) return null; + const body = (await res.json()) as { reminder?: Reminder }; + return body.reminder ?? null; + } catch { + return null; + } finally { + setReminderBusy(false); + } }, []); const handleInstall = async () => { @@ -75,30 +124,53 @@ export function ReturnSection({ result }: Props) { } }; - const handleReminderClick = useCallback(() => { + const handleSetReminder = useCallback(async () => { + if (authStatus.kind === "unknown") return; if (authStatus.kind === "authed") { - // Mail-scheduling implementation is deferred; for now just confirm. - setReminder({ kind: "queued" }); - setTimeout(() => setReminder({ kind: "idle" }), 3500); + const next = await persistReminder(); + if (next) setReminder(next); return; } setDialogOpen(true); - }, [authStatus.kind]); - - const handleAuthed = useCallback((user: AuthedUser) => { - setAuthStatus({ kind: "authed", user }); - // Treat a successful sign-in via this dialog as intent to set the - // reminder. Once mail scheduling is wired, swap this for a real POST. - setReminder({ kind: "queued" }); - setTimeout(() => setReminder({ kind: "idle" }), 3500); + }, [authStatus, persistReminder]); + + const handleAuthed = useCallback( + async (user: AuthedUser) => { + setAuthStatus({ kind: "authed", user }); + // The dialog opened because the user wanted a reminder → persist + // immediately, no second click required. + const next = await persistReminder(); + if (next) setReminder(next); + }, + [persistReminder], + ); + + const handleClearReminder = useCallback(async () => { + try { + setReminderBusy(true); + await fetch("/api/auth/reminder", { method: "DELETE" }); + setReminder(null); + } finally { + setReminderBusy(false); + } }, []); - const reminderLabel = - reminder.kind === "queued" - ? `[ ✓ reminder queued for ${ - authStatus.kind === "authed" ? authStatus.user.email : "you" - } ]` - : "[ set a reminder ]"; + const handleRerun = useCallback(async () => { + if (rerunBusy) return; + setRerunBusy(true); + try { + await triggerRun({ cli: [], since: "30d" }); + // Reload the page after the run so the cached result + dashboard cache + // get re-hydrated against the new scan. Cheaper than threading state. + window.location.reload(); + } finally { + setRerunBusy(false); + } + }, [rerunBusy]); + + const authed = authStatus.kind === "authed"; + const hasReminder = authed && reminder !== null; + const days = reminder ? daysUntil(reminder.next_audit_at) : 0; return (
@@ -107,41 +179,99 @@ export function ReturnSection({ result }: Props) { ━━ next audit{" "} · improvement
-
recommended in 7d
+
+ recommended in 7d +

come back better.

+
━━ the loop

re-audit in 7 days.

- after the prescribed policies have been live for a week, we'll - show your before/after score and which detectors went quiet. + after the prescribed policies have been live for a week, we'll show + your before/after score and which detectors went quiet.

most agents move from C to B in one session. some make it in a day.

-
- - {hasUnenabled && ( - - )} -
- {authStatus.kind === "authed" && ( -
-
@@ -152,7 +282,7 @@ export function ReturnSection({ result }: Props) { onClose={() => setDialogOpen(false)} onAuthed={(u) => { setDialogOpen(false); - handleAuthed(u); + void handleAuthed(u); }} /> diff --git a/app/audit/_components/run-progress.tsx b/app/audit/_components/run-progress.tsx index db4ca5e9..276bf63c 100644 --- a/app/audit/_components/run-progress.tsx +++ b/app/audit/_components/run-progress.tsx @@ -5,71 +5,101 @@ * not emit granular progress events, so we animate through 4 plausible * stages on a fixed 4s interval. The user sees motion + a clear "this is * still working" signal. + * + * Visual: audit pixel-craft. A `.panel` with pink corner brackets, a + * scanline-style spinner header, a stack of stages with green "✓" / + * pink "▮▮" / dim "○" markers, and a marquee progress bar at the bottom + * filling pink-on-dark as the run advances. */ import React, { useEffect, useState } from "react"; -import { cn } from "@/lib/utils"; const STAGES = [ - { label: "Discovering transcripts", detail: "Walking ~/.claude, ~/.codex, ~/.cursor, …" }, - { label: "Parsing session logs", detail: "Reading JSONL + SQLite session stores" }, - { label: "Running policy checks", detail: "Replaying through 30 builtin policies" }, - { label: "Aggregating results", detail: "Counting hits, ranking by frequency" }, + { label: "discovering transcripts", detail: "walking ~/.claude, ~/.codex, ~/.cursor, …" }, + { label: "parsing session logs", detail: "reading JSONL + sqlite session stores" }, + { label: "running policy checks", detail: "replaying through 30 builtin policies" }, + { label: "aggregating results", detail: "counting hits, ranking by frequency" }, ]; const STAGE_DURATION_MS = 4000; export function RunProgress() { const [stage, setStage] = useState(0); + const [tick, setTick] = useState(0); useEffect(() => { - const id = setInterval(() => { + const stageTimer = setInterval(() => { setStage((s) => Math.min(s + 1, STAGES.length - 1)); }, STAGE_DURATION_MS); - return () => clearInterval(id); + const tickTimer = setInterval(() => setTick((t) => (t + 1) % 4), 350); + return () => { + clearInterval(stageTimer); + clearInterval(tickTimer); + }; }, []); + const dots = ".".repeat(tick + 1); + return ( -
-
-
-

Scanning sessions…

+
+
+
+ ━━ audit{" "} + · in progress +
+
+ scanning +
-
    - {STAGES.map((s, i) => { - const done = i < stage; - const active = i === stage; - return ( -
  • - -
    -
    - {s.label} +

    scanning sessions{dots}

    + +
    +
    + $ + failproofai audit --since 30d + +
    + +
      + {STAGES.map((s, i) => { + const done = i < stage; + const active = i === stage; + return ( +
    • + +
      +
      {s.label}
      + {active &&
      {s.detail}
      }
      {active && ( -
      {s.detail}
      + )} -
    -
  • - ); - })} -
-

- This usually takes 10–30 seconds depending on session history. -

-
+ + ); + })} + + +
+ progress + {stage + 1}/{STAGES.length} +
+
+
+
+ +

+ this usually takes 10–30 seconds depending on how much session history you have. +

+
+ ); } diff --git a/app/audit/_components/score-section.tsx b/app/audit/_components/score-section.tsx index 104be09c..7eb4269e 100644 --- a/app/audit/_components/score-section.tsx +++ b/app/audit/_components/score-section.tsx @@ -1,254 +1,341 @@ "use client"; /** - * Section 03 — SCORE + LEADERBOARD. + * Section 03 — SCORE CARD + SHARE. * - * Two-column grid: a score card on the left (big grade letter + "X of 100" - * + prose + distribution histogram) and a synthetic-but-stable leaderboard - * on the right with the user's row highlighted in pink. + * Replaces the older "Score + Leaderboard" composition. A single .panel + * holds two columns: * - * The leaderboard rows other than yours are seeded from a fixed list of - * plausible agent names so the page doesn't look empty in a fresh - * install. Real cohort data lands later when we wire telemetry. + * left — YOUR AUDIT SCORE (big number, tier badge, progress bar, + * 3 stat boxes, prescribed-policies chip strip) + * right — SHARE YOUR RESULTS (X + LinkedIn pre-written templates, + * share-on-X / share-on-LinkedIn / download-audit-card buttons) + * + * "Download audit card" captures THIS panel via html2canvas — same + * technique as ShowOffCTA but a different capture target — so the + * exported PNG is the share card the user just saw. */ -import React, { useMemo } from "react"; +import React, { useMemo, useRef, useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; import { gradeFor, tierName, type Grade } from "@/src/audit/scoring"; interface Props { + result: AuditResult; score: number; grade: Grade; - rank: number; cohort: number; archetypeKey: ArchetypeKey; - /** Display name in the highlighted leaderboard row. */ + /** Display name shown in the cohort masthead. */ project: string; } -interface DistBucket { h: number; you: boolean; label: string; } -interface LeaderboardRow { - rank?: number; - name?: string; - arch?: string; - grade?: Grade; - score?: number; - you?: boolean; - divider?: boolean; +interface ShareTemplate { + network: "x" | "linkedin"; + label: string; + body: string; + intentUrl: (body: string) => string; +} + +function buildXTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { + const tier = grade === "B" || grade === "A" || grade === "S" ? grade : `${grade}`; + return `omg just ran a @failproofai audit on my agent and scored ${score}/100. apparently i'm "${archetypeName.toLowerCase()}" (${tier.toLowerCase()} tier). ${missing} polic${missing === 1 ? "y" : "ies"} away from levelling up. if you ship ai agents you need to check this →`; } -const TOP_AGENTS: { name: string; arch: string }[] = [ - { name: "anthropic / claude-code-internal", arch: "the precision builder" }, - { name: "openai / gpt-engineer-pro", arch: "the precision builder" }, - { name: "vercel / v0-coder-v3", arch: "the ghost" }, - { name: "supabase / db-migrator", arch: "the paranoid architect" }, - { name: "stripe / payments-bot", arch: "the paranoid architect" }, -]; - -const NEAR_AGENTS_ABOVE: { name: string; arch: string }[] = [ - { name: "indie / weekend-coder-42", arch: "the cowboy" }, - { name: "n8n / workflow-agent", arch: "the optimist" }, -]; -const NEAR_AGENTS_BELOW: { name: string; arch: string }[] = [ - { name: "acme / scratch-pad", arch: "the hammer" }, - { name: "side-quest / cli-tool", arch: "the goldfish" }, -]; - -export function ScoreSection({ - score, grade, rank, cohort, archetypeKey, project, -}: Props) { +function buildLinkedInTemplate(score: number, archetypeName: string, missing: number): string { + return `We just completed a FailproofAI audit on our agent infrastructure and scored ${score}/100. The audit surfaced ${missing} unaddressed policy gap${missing === 1 ? "" : "s"} around our ${archetypeName.toLowerCase()} workload. Highly recommend running this if you're operating AI agents in production — getting visibility into what your agents actually did was the unlock.`; +} + +const SITE_URL = "https://befailproof.ai"; + +const X_INTENT = (body: string): string => + `https://x.com/intent/post?text=${encodeURIComponent(body + " " + SITE_URL)}`; +const LI_INTENT = (body: string): string => + `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(SITE_URL)}&summary=${encodeURIComponent(body)}`; + +export function ScoreSection({ result, score, grade, cohort, archetypeKey, project }: Props) { const archetype = ARCHETYPES[archetypeKey]; - const pointsToB = Math.max(0, 71 - score); - const distBars = useMemo(() => buildDistribution(score), [score]); - const rows = useMemo( - () => buildLeaderboard(rank, score, project, archetype.name), - [rank, score, project, archetype.name], + const pointsToNext = useMemo(() => { + const thresholds: { g: Grade; t: number }[] = [ + { g: "S", t: 90 }, { g: "A", t: 80 }, { g: "B", t: 71 }, + { g: "C", t: 55 }, { g: "D", t: 40 }, + ]; + for (const { g, t } of thresholds) { + if (score < t) return { next: g, delta: t - score }; + } + return { next: "S" as Grade, delta: 0 }; + }, [score]); + + /** Slipping-through builtin policies (the same heuristic ReturnSection uses + * for its [install policies] CTA). Used as the "policies missing" stat. */ + const missing = useMemo( + () => result.results.filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0).length, + [result], + ); + + /** Rough "days to fix" — capped 1..14. One day per slipping policy, with a + * baseline of 3d on any non-S grade. */ + const daysToFix = useMemo(() => { + if (grade === "S" || missing === 0) return 0; + return Math.max(1, Math.min(14, missing + 1)); + }, [grade, missing]); + + /** % of score-bar filled toward the next tier — used by the gradient bar. */ + const progressPct = useMemo(() => { + if (pointsToNext.delta === 0) return 100; + // Progress within the current tier band, e.g. between C (55) and B (71) + const bands: { lo: number; hi: number }[] = [ + { lo: 90, hi: 100 }, { lo: 80, hi: 90 }, { lo: 71, hi: 80 }, + { lo: 55, hi: 71 }, { lo: 40, hi: 55 }, { lo: 0, hi: 40 }, + ]; + const band = bands.find((b) => score >= b.lo && score < b.hi) ?? bands[bands.length - 1]; + return Math.round(((score - band.lo) / (band.hi - band.lo)) * 100); + }, [score, pointsToNext.delta]); + + /** Top-N slipping policies → chip strip on the left card. Capped at 6. */ + const policyChips = useMemo(() => { + const slipping = result.results + .filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0) + .sort((a, b) => b.hits - a.hits) + .slice(0, 6) + .map((r) => ({ name: shortPolicyLabel(r.name), missing: true as const })); + const enabled = result.results + .filter((r) => r.source === "builtin" && r.enabledInConfig) + .slice(0, Math.max(0, 6 - slipping.length)) + .map((r) => ({ name: shortPolicyLabel(r.name), missing: false as const })); + return [...slipping, ...enabled]; + }, [result]); + + /* ── share + download ── */ + const xTemplate = useMemo( + () => buildXTemplate(score, archetype.name, grade, missing), + [score, archetype.name, grade, missing], ); + const liTemplate = useMemo( + () => buildLinkedInTemplate(score, archetype.name, missing), + [score, archetype.name, missing], + ); + + const cardRef = useRef(null); + const [downloadState, setDownloadState] = useState<"idle" | "busy" | "done" | "error">("idle"); + + const handleDownload = async () => { + const node = cardRef.current; + if (!node || downloadState === "busy") return; + setDownloadState("busy"); + try { + if (typeof document !== "undefined" && document.fonts?.ready) await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => r())); + const html2canvas = (await import("html2canvas")).default; + const canvas = await html2canvas(node, { + backgroundColor: "#0e0e11", + scale: 2, + logging: false, + useCORS: true, + }); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { resolve(); return; } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `failproofai-card-${grade.toLowerCase()}-${score}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resolve(); + }, "image/png"); + }); + setDownloadState("done"); + setTimeout(() => setDownloadState("idle"), 2000); + } catch (err) { + console.error("card capture failed:", err); + setDownloadState("error"); + setTimeout(() => setDownloadState("idle"), 2000); + } + }; + + const templates: ShareTemplate[] = [ + { network: "x", label: "x · twitter", body: xTemplate, intentUrl: X_INTENT }, + { network: "linkedin", label: "linkedin", body: liTemplate, intentUrl: LI_INTENT }, + ]; return ( -
+
- ━━ leaderboard{" "} - · cohort + ━━ score{" "} + · share
- {cohort.toLocaleString()}{" "} - agents + {cohort.toLocaleString()} agents · last 30 days
-

you rank #{rank.toLocaleString()}.

- -
-
-
-
{grade}
-
-
{tierName(grade)}
-
{score}
-
of 100
-
+

your audit · ship it.

+ +
+
+
+ cohort + · + last 30 days + · + {cohort.toLocaleString()} agents +
+
+ {project} + · + {archetype.name.toLowerCase()}
+
+ +
+ {/* LEFT — score */} +
+
your audit score
+
+ {score} + /100 +
+ +
+ {grade} tier + {archetype.name.toLowerCase()} +
+ + {pointsToNext.delta > 0 ? ( + <> +
+ + progress to {pointsToNext.next.toLowerCase()} tier + + + +{pointsToNext.delta} pts needed + +
+
+
+
+ + ) : ( +
+ top tier — keep policies live, revisit in 30d. +
+ )} - {pointsToB > 0 ? ( -

- a B starts at 71.{" "} - you're {pointsToB} points away. -
- enable the prescribed policies and you'll get there this week. -

- ) : grade === "S" ? ( -

- s tier. few make it here. fewer stay. -
- keep the policies live. revisit in 30 days. -

- ) : ( -

- {tierName(grade)}.{" "} - better than {Math.round((1 - rank / cohort) * 100)}% of audited agents. -
- clean up the findings below to climb. -

- )} - -
-
- distribution · last 30d - ▮ = your position +
+
+
{missing}
+
policies
missing
+
+
+
+ +{pointsToNext.delta} +
+
pts to
next tier
+
+
+
+ {daysToFix === 0 ? "—" : `~${daysToFix}d`} +
+
est.
to fix
+
-
- {distBars.map((b, i) => ( -
+ + {policyChips.length > 0 && ( + <> +
policy status
+
+ {policyChips.map((p, i) => ( + + + ))} +
+ + )} +
+ + {/* RIGHT — share */} +
+
share your results
+ +
+ {templates.map((t) => ( +
+
+
+

{t.body}

+
))}
-
- F - D - C - B - A - S + +
+ {templates.map((t) => ( + + + + share on {t.label.split(" ·")[0]} + posts with pre-written copy + card + + + ))} + +
-
-
-
rank
-
agent
-
grade
-
score
-
- {rows.map((r, i) => - r.divider ? ( -
- · · · -
- ) : ( -
-
#{r.rank!.toLocaleString()}
-
-
- {r.name} - {r.you && (you)} -
-
{r.arch}
-
-
{r.grade}
-
{r.score}
-
- ), - )} +
+ + enable prescribed policies to reach {pointsToNext.next.toLowerCase()} tier this week. + + view full report →
); } -function buildDistribution(yourScore: number): DistBucket[] { - // 20 buckets, 5pts each, 0-100. Bell-ish centered at 60. - const buckets: DistBucket[] = []; - for (let i = 0; i < 20; i++) { - const center = i * 5 + 2.5; - const dist = Math.abs(center - 60); - const h = Math.max(8, 100 - dist * 2.2 + Math.sin(i * 1.3) * 6); - const you = yourScore >= i * 5 && yourScore < (i + 1) * 5; - buckets.push({ h, you, label: `${i * 5}-${(i + 1) * 5}` }); - } - return buckets; -} - -function buildLeaderboard( - yourRank: number, - yourScore: number, - yourProject: string, - yourArchetypeName: string, -): LeaderboardRow[] { - const rows: LeaderboardRow[] = []; - - // Top 5 (synthetic but stable). - rows.push({ rank: 1, name: TOP_AGENTS[0].name, arch: TOP_AGENTS[0].arch, grade: "S", score: 97 }); - rows.push({ rank: 2, name: TOP_AGENTS[1].name, arch: TOP_AGENTS[1].arch, grade: "S", score: 93 }); - rows.push({ rank: 3, name: TOP_AGENTS[2].name, arch: TOP_AGENTS[2].arch, grade: "A", score: 89 }); - rows.push({ rank: 4, name: TOP_AGENTS[3].name, arch: TOP_AGENTS[3].arch, grade: "A", score: 86 }); - rows.push({ rank: 5, name: TOP_AGENTS[4].name, arch: TOP_AGENTS[4].arch, grade: "A", score: 82 }); - - // Skip to the user's neighborhood unless their rank is already in the - // top 5 (then collapse the divider). - if (yourRank > 7) rows.push({ divider: true }); - - // Two ranked just above the user. - if (yourRank > 2) { - rows.push({ - rank: yourRank - 2, - name: NEAR_AGENTS_ABOVE[0].name, - arch: NEAR_AGENTS_ABOVE[0].arch, - grade: gradeFor(yourScore + 2), - score: yourScore + 2, - }); - } - if (yourRank > 1) { - rows.push({ - rank: yourRank - 1, - name: NEAR_AGENTS_ABOVE[1].name, - arch: NEAR_AGENTS_ABOVE[1].arch, - grade: gradeFor(yourScore + 1), - score: yourScore + 1, - }); - } - - // The user. - rows.push({ - rank: yourRank, - name: yourProject, - arch: yourArchetypeName, - grade: gradeFor(yourScore), - score: yourScore, - you: true, - }); - - // Two below. - rows.push({ - rank: yourRank + 1, - name: NEAR_AGENTS_BELOW[0].name, - arch: NEAR_AGENTS_BELOW[0].arch, - grade: gradeFor(Math.max(0, yourScore - 1)), - score: Math.max(0, yourScore - 1), - }); - rows.push({ - rank: yourRank + 2, - name: NEAR_AGENTS_BELOW[1].name, - arch: NEAR_AGENTS_BELOW[1].arch, - grade: gradeFor(Math.max(0, yourScore - 2)), - score: Math.max(0, yourScore - 2), - }); - - return rows; +/** Drop the "failproofai/" namespace prefix builtin policies carry so chips + * stay compact (`block-sudo` reads better than `failproofai/block-sudo`). */ +function shortPolicyLabel(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; } diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 537b2696..0f232c28 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -16,6 +16,180 @@ mix-blend-mode: overlay; } +/* ───────────────────────── 00 EMPTY + RUNNING (full-page states) ───────────────────────── */ + +.empty-section, .running-section { padding-top: 80px; padding-bottom: 96px; } + +.empty-panel, .running-panel { + padding: 48px 56px; + display: flex; flex-direction: column; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); +} + +.empty-glyph { align-self: center; text-align: center; margin-bottom: 28px; } +.empty-glyph-grid { + display: grid; + grid-template-columns: repeat(6, 14px); + grid-template-rows: repeat(6, 14px); + gap: 3px; + padding: 16px; + border: 1px solid var(--line-2); + background: var(--bg); + margin: 0 auto 14px; + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.empty-glyph-grid .px { background: transparent; } +.empty-glyph-grid .px.on { background: var(--accent-pink); } +.empty-glyph-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} + +.empty-headline { + font-family: var(--font-display); + font-size: clamp(32px, 4.6vw, 48px); + letter-spacing: 0.1em; line-height: 1.05; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 16px; + text-wrap: balance; + text-align: center; +} +.empty-sub { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.65; color: var(--ink-2); + max-width: 580px; + margin: 0 auto 32px; + text-align: center; +} + +.empty-actions { + display: flex; flex-direction: column; align-items: center; + gap: 12px; +} +.empty-cta { + padding: 12px 24px; + font-size: 14px; + letter-spacing: 0.08em; + text-decoration: none; +} +.empty-meta { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} + +.running-panel { padding: 36px 40px; } +.running-header { + display: flex; align-items: center; gap: 10px; + padding-bottom: 18px; + border-bottom: 1px dashed var(--line); + margin-bottom: 22px; + font-family: var(--font-mono); font-size: 13px; +} +.running-prompt { color: var(--accent-green); } +.running-cmd { color: var(--ink); letter-spacing: 0.02em; } +.running-cursor { + color: var(--accent-pink); + margin-left: 4px; + animation: cursor-blink 900ms steps(2, end) infinite; +} +@keyframes cursor-blink { 50% { opacity: 0; } } + +.running-stages { + list-style: none; padding: 0; margin: 0 0 28px; + display: flex; flex-direction: column; +} +.running-stage { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 14px; align-items: start; + padding: 12px 0; + border-bottom: 1px dashed var(--line); + font-family: var(--font-mono); +} +.running-stage:last-child { border-bottom: none; } +.running-marker { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: -1px; + margin-top: 1px; +} +.running-stage.queued { color: var(--dim); } +.running-stage.queued .running-marker { color: var(--line-2); } +.running-stage.active { color: var(--ink); } +.running-stage.active .running-marker { color: var(--accent-pink); } +.running-stage.done { color: var(--ink-2); } +.running-stage.done .running-marker { color: var(--accent-green); } +.running-stage.done .running-stage-label { + text-decoration: line-through; + text-decoration-color: var(--line-2); +} +.running-stage-label { font-size: 13px; letter-spacing: 0.04em; } +.running-stage-detail { + font-size: 11px; color: var(--ink-2); + letter-spacing: 0.02em; + margin-top: 4px; +} +.running-stage-spin { + font-family: var(--font-mono); font-size: 16px; + color: var(--accent-pink); + align-self: center; + animation: spin-step 700ms steps(4, end) infinite; +} +@keyframes spin-step { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + +.running-bar-label { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 8px; +} +.running-bar-track { + position: relative; + height: 6px; background: var(--bg); + border: 1px solid var(--line); + overflow: hidden; +} +.running-bar-fill { + position: relative; + height: 100%; + background: linear-gradient(90deg, var(--accent-pink) 0%, #e89aaf 100%); + transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1); +} +.running-bar-fill::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, transparent 40%, + rgba(255,255,255,0.35) 50%, + transparent 60%, transparent 100% + ); + animation: bar-shine 1600ms linear infinite; +} +@keyframes bar-shine { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} + +.running-foot { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--dim); + text-align: center; +} + +@media (max-width: 720px) { + .empty-panel, .running-panel { padding: 32px 24px; } +} + /* ───────────────────────── audit page shell ───────────────────────── */ .report { @@ -411,187 +585,266 @@ padding-left: 4px; } -/* ───────────────────────── 03 SCORE + LEADERBOARD ───────────────────────── */ +/* ───────────────────────── 03 SCORE CARD + SHARE ───────────────────────── */ -.score-grid { +.score-share-card { + padding: 28px 32px 24px; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); +} +.score-share-mast { + display: flex; justify-content: space-between; flex-wrap: wrap; + gap: 16px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); + padding-bottom: 16px; margin-bottom: 22px; + border-bottom: 1px dashed var(--line); +} +.score-share-mast .ssm-right { text-align: right; } + +.score-share-body { display: grid; - grid-template-columns: 1.1fr 1fr; - gap: 28px; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr); + gap: 36px; align-items: start; } -.score-card { - border: 1px solid var(--line-2); - background: var(--bg-2); - padding: 28px; - position: relative; -} -.score-card::before { - content: ""; position: absolute; top: -1px; left: -1px; - width: 10px; height: 10px; - border-top: 1px solid var(--accent-pink); - border-left: 1px solid var(--accent-pink); -} -.score-card::after { - content: ""; position: absolute; bottom: -1px; right: -1px; - width: 10px; height: 10px; - border-bottom: 1px solid var(--accent-pink); - border-right: 1px solid var(--accent-pink); +.ss-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; } -.score-grade-row { - display: flex; align-items: baseline; gap: 24px; - padding-bottom: 20px; - border-bottom: 1px dashed var(--line); - margin-bottom: 22px; + +/* — left column ------------------------------------------------------ */ +.ss-score-row { + display: flex; align-items: baseline; gap: 12px; + margin: 0 0 14px; } -.score-grade { +.ss-score { font-family: var(--font-display); - font-size: clamp(96px, 14vw, 168px); - line-height: 0.85; - letter-spacing: 0.02em; + font-size: clamp(64px, 9vw, 96px); + line-height: 0.9; + letter-spacing: 0.04em; color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); - text-transform: uppercase; } -.score-grade.g-S { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } -.score-grade.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } -.score-grade.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } -.score-grade.g-C { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } -.score-grade.g-D { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } -.score-grade.g-F { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } - -.score-num { - display: flex; flex-direction: column; gap: 6px; +.ss-score.g-S, .ss-score.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.ss-score.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } +.ss-score-of { + font-family: var(--font-mono); font-size: 18px; + color: var(--dim); letter-spacing: 0.08em; } -.score-num .n { - font-family: var(--font-display); font-size: 48px; - letter-spacing: 0.08em; line-height: 1; color: var(--ink); + +.ss-tier-row { + display: flex; align-items: center; gap: 12px; + margin-bottom: 22px; } -.score-num .of { +.ss-tier-badge { font-family: var(--font-mono); font-size: 11px; - letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); + letter-spacing: 0.18em; text-transform: uppercase; + padding: 5px 10px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); } -.score-num .tier { - font-family: var(--font-mono); font-size: 11px; - letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent-pink); +.ss-tier-badge.g-S, .ss-tier-badge.g-A { + border-color: var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); } - -.score-prose { - font-family: var(--font-mono); font-size: 13px; - color: var(--ink); line-height: 1.7; - margin-bottom: 24px; +.ss-tier-badge.g-B { + border-color: #d3e1a8; + background: rgba(211, 225, 168, 0.10); + color: #d3e1a8; } -.score-prose .hl { color: var(--accent-green); } -.score-prose .pk { color: var(--accent-pink); } - -/* distribution chart */ -.dist { - margin-top: 8px; - border-top: 1px dashed var(--line); - padding-top: 22px; +.ss-arch { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.06em; } -.dist-label { - font-family: var(--font-mono); font-size: 10px; - letter-spacing: 0.22em; text-transform: uppercase; - color: var(--ink-2); margin-bottom: 14px; + +.ss-progress-label { display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.06em; + margin-bottom: 8px; +} +.ss-progress-track { + height: 4px; background: var(--bg); + border: 1px solid var(--line); + margin-bottom: 22px; + overflow: hidden; +} +.ss-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-pink) 0%, #e89aaf 100%); } -.dist-label .right { color: var(--dim); } -.dist-chart { + +.ss-stats { display: grid; - grid-template-columns: repeat(20, 1fr); - align-items: end; - gap: 3px; - height: 80px; - margin-bottom: 6px; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 22px; } -.dist-bar { - background: var(--bg-3); - border: 1px solid var(--line); - border-bottom: none; - position: relative; +.ss-stat { + border: 1px solid var(--line-2); + background: var(--bg); + padding: 14px 12px; + text-align: left; } -.dist-bar.you { - background: var(--accent-pink); - border-color: var(--accent-pink); +.ss-stat-n { + font-family: var(--font-display); + font-size: 30px; line-height: 1; + letter-spacing: 0.04em; + margin-bottom: 8px; } -.dist-bar.you::after { - content: "you"; - position: absolute; bottom: 100%; left: 50%; - transform: translateX(-50%); margin-bottom: 6px; +.ss-stat-l { font-family: var(--font-mono); font-size: 9px; - letter-spacing: 0.18em; text-transform: uppercase; - color: var(--accent-pink); white-space: nowrap; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--dim); + line-height: 1.4; } -.dist-axis { - display: grid; - grid-template-columns: repeat(6, 1fr); + +.ss-policy-label { font-family: var(--font-mono); font-size: 10px; - letter-spacing: 0.18em; text-transform: uppercase; - color: var(--dim); - margin-top: 12px; - border-top: 1px solid var(--line); - padding-top: 6px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 10px; } -.dist-axis span { text-align: center; } -.dist-axis span.now { color: var(--accent-pink); } +.ss-policy-chips { + display: flex; flex-wrap: wrap; gap: 6px; +} +.ss-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--line-2); + background: var(--bg); + font-family: var(--font-mono); font-size: 11px; + color: var(--ink-2); +} +.ss-chip .dot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--dim); +} +.ss-chip.missing { + border-color: var(--accent-pink); + color: var(--accent-pink); + background: var(--accent-pink-bg); +} +.ss-chip.missing .dot { background: var(--accent-pink); } +.ss-chip.enabled { + border-color: var(--accent-green); + color: var(--accent-green); + background: var(--accent-green-bg); +} +.ss-chip.enabled .dot { background: var(--accent-green); } -/* leaderboard */ -.lb { +/* — right column ----------------------------------------------------- */ +.ss-templates { + display: flex; flex-direction: column; gap: 10px; + margin-bottom: 16px; +} +.ss-template { border: 1px solid var(--line-2); - background: var(--bg-2); + background: var(--bg); + padding: 14px 16px; } -.lb-head { - display: grid; - grid-template-columns: 52px 1fr 50px 60px; - gap: 12px; - padding: 12px 18px; - border-bottom: 1px solid var(--line); - background: rgba(0,0,0,0.2); +.ss-template-head { + display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink-2); + margin-bottom: 8px; +} +.ss-template-head .dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--accent-pink); } -.lb-row { +.ss-template-body { + font-family: var(--font-mono); font-size: 12.5px; + line-height: 1.55; color: var(--ink-2); + margin: 0; +} + +.ss-actions { + display: flex; flex-direction: column; gap: 8px; +} +.ss-action-btn { display: grid; - grid-template-columns: 52px 1fr 50px 60px; + grid-template-columns: 28px 1fr; + align-items: center; gap: 12px; - padding: 12px 18px; - border-bottom: 1px solid var(--line); + padding: 10px 14px; + border: 1px solid var(--line-2); + background: transparent; + color: var(--ink); + text-align: left; font-family: var(--font-mono); font-size: 12px; - color: var(--ink); align-items: center; - transition: background 120ms; + cursor: pointer; + transition: all 120ms ease; + text-decoration: none; } -.lb-row:last-child { border-bottom: none; } -.lb-row:hover { background: var(--bg-row-hover); } -.lb-row.you { +.ss-action-btn:hover { + border-color: var(--accent-pink); background: var(--accent-pink-bg); - border-top: 1px solid var(--accent-pink); - border-bottom: 1px solid var(--accent-pink); -} -.lb-row.you .lb-rank, -.lb-row.you .lb-score { color: var(--accent-pink); } -.lb-row.divider { padding: 4px 18px; color: var(--dim); font-size: 10px; letter-spacing: 0.3em; text-align: center; } -.lb-row.divider span { display: block; } -.lb-rank { color: var(--ink-2); letter-spacing: 0.05em; } -.lb-agent { - display: flex; flex-direction: column; gap: 2px; min-width: 0; - overflow: hidden; + color: var(--accent-pink); } -.lb-agent .name { color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.lb-agent .arch { - font-size: 10px; letter-spacing: 0.05em; color: var(--dim); +.ss-action-btn:disabled { + opacity: 0.55; cursor: wait; } -.lb-agent .you-mark { color: var(--accent-pink); margin-left: 6px; } -.lb-grade { - font-family: var(--font-display); font-size: 18px; - letter-spacing: 0.05em; text-align: center; +.ss-action-glyph { + display: grid; place-items: center; + width: 24px; height: 24px; + border: 1px solid var(--ink-2); + font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; + color: var(--ink-2); +} +.ss-action-btn:hover .ss-action-glyph { + border-color: var(--accent-pink); + color: var(--accent-pink); +} +.ss-action-text { + display: flex; flex-direction: column; +} +.ss-action-title { + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + color: var(--ink); +} +.ss-action-btn:hover .ss-action-title { color: var(--accent-pink); } +.ss-action-sub { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.05em; + color: var(--dim); + margin-top: 2px; } -.lb-grade.g-S, .lb-grade.g-A { color: var(--accent-green); } -.lb-grade.g-B { color: #d3e1a8; } -.lb-grade.g-C, .lb-grade.g-D, .lb-grade.g-F { color: var(--accent-pink); } -.lb-score { text-align: right; color: var(--ink); } + +.ss-foot { + display: flex; justify-content: space-between; gap: 16px; + flex-wrap: wrap; + padding-top: 18px; margin-top: 26px; + border-top: 1px dashed var(--line); + font-family: var(--font-mono); font-size: 11px; + color: var(--ink-2); +} +.ss-foot-link { + color: var(--accent-green); + text-decoration: none; +} +.ss-foot-link:hover { text-decoration: underline; text-underline-offset: 3px; } + +@media (max-width: 880px) { + .score-share-card { padding: 22px 20px 20px; } + .score-share-body { grid-template-columns: 1fr; gap: 28px; } + .score-share-mast .ssm-right { text-align: left; } +} + /* ───────────────────────── 04 FINDINGS ───────────────────────── */ @@ -1109,7 +1362,59 @@ color: var(--ink-2); line-height: 1.7; margin: 0 0 8px; max-width: 600px; } -.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; } +.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; align-items: center; } + +/* persistent reminder status (authed + reminder saved) */ +.return-status { + margin-top: 24px; + padding: 18px 20px; + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); +} +.return-status .rs-row { + display: flex; align-items: center; gap: 12px; + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); + margin: 6px 0; +} +.return-status .rs-row-primary { + font-size: 15px; + color: var(--ink); + letter-spacing: 0.02em; +} +.return-status .rs-strong { color: var(--accent-pink); } +.return-status .rs-email { color: var(--accent-green); } +.rs-dot { + width: 8px; height: 8px; + display: inline-block; + flex-shrink: 0; +} +.rs-dot-pink { + background: var(--accent-pink); + box-shadow: 0 0 8px rgba(228,88,125,0.55); + animation: pulseDot 1.6s ease-in-out infinite; +} +.rs-dot-green { + background: var(--accent-green); + box-shadow: 0 0 6px rgba(102,209,181,0.55); +} +.rs-clear { + background: transparent; + border: none; + padding: 4px 0; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; + color: var(--dim); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + transition: color 120ms; +} +.rs-clear:hover:not(:disabled) { color: var(--accent-pink); } +.rs-clear:disabled { opacity: 0.5; cursor: not-allowed; } /* ───────────────────────── footer ───────────────────────── */ diff --git a/app/globals.css b/app/globals.css index 189c01d5..21ca6016 100644 --- a/app/globals.css +++ b/app/globals.css @@ -101,8 +101,8 @@ html, body, #root { background: var(--bg); color: var(--ink); font-family: var(--font-mono); - font-size: 13px; - line-height: 1.55; + font-size: 14.5px; + line-height: 1.6; -webkit-font-smoothing: antialiased; min-height: 100vh; } @@ -284,13 +284,16 @@ input[type="date"] { color-scheme: dark; } /* ───────────────────────── canonical page chrome ───────────────────────── */ .report { - max-width: 1180px; + max-width: 1380px; margin: 0 auto; - padding: 0 32px; + padding: 0 40px; +} +@media (max-width: 720px) { + .report { padding: 0 20px; } } .section { - padding: 48px 0; + padding: 64px 0; border-bottom: 1px solid var(--line); position: relative; } diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts index f535c670..e104c64b 100644 --- a/lib/auth/auth-store.ts +++ b/lib/auth/auth-store.ts @@ -45,6 +45,62 @@ export function getAuthFilePath(): string { return join(getAuthDir(), "auth.json"); } +/** Location of the persisted re-audit reminder (separate from auth.json so + * the reminder survives unrelated session refreshes). */ +export function getReminderFilePath(): string { + return join(getAuthDir(), "next-audit.json"); +} + +export interface StoredReminder { + /** Unix seconds. */ + next_audit_at: number; + /** Email the reminder was set for. Used to invalidate the reminder if the + * active session belongs to a different user. */ + user_email: string; + /** Unix seconds. */ + set_at: number; +} + +export function readReminder(): StoredReminder | null { + const p = getReminderFilePath(); + if (!existsSync(p)) return null; + try { + const raw = readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.next_audit_at !== "number" || + typeof parsed.user_email !== "string" || + typeof parsed.set_at !== "number" + ) { + return null; + } + return { + next_audit_at: parsed.next_audit_at, + user_email: parsed.user_email, + set_at: parsed.set_at, + }; + } catch { + return null; + } +} + +export function writeReminder(reminder: StoredReminder): void { + const p = getReminderFilePath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(p, JSON.stringify(reminder, null, 2), { mode: 0o600 }); + try { + if (statSync(p).mode & 0o077) chmodSync(p, 0o600); + } catch { + // best-effort + } +} + +export function deleteReminder(): void { + const p = getReminderFilePath(); + if (existsSync(p)) rmSync(p, { force: true }); +} + export function readAuth(): StoredAuth | null { const p = getAuthFilePath(); if (!existsSync(p)) return null; From 1e6ccffaf0eed626f49edd625e54f18e393cbc5d Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Sun, 31 May 2026 19:43:05 +0530 Subject: [PATCH 09/13] docs(auth): document ~/.failproofai/next-audit.json + reminder endpoint The new persistent re-audit reminder ships a small companion file alongside auth.json. Add a short section to docs/cli/auth.mdx covering its shape, the per-email scoping rule (so swapping CLI accounts hides the previous user's reminder), the 0600 perms, and the GET / POST / DELETE /api/auth/reminder endpoint that backs the UI button. CHANGELOG Docs entry matched. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 ++ docs/cli/auth.mdx | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f02c8655..c4f40518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - Unify the dashboard design system around the brutalist pixel-craft aesthetic that previously lived only in `/audit`. The audit token set (`--bg`, `--ink`, `--accent-pink`, `--accent-green`, `--font-mono` → JetBrains Mono, `--font-display` → Architype Stedelijk / VT323) is now declared once in `app/globals.css`, and every shadcn-style Tailwind alias (`--background`, `--card`, `--foreground`, `--primary`, `--border`, `--radius: 0`, …) is repointed at the audit palette so existing utility classes like `bg-card` / `text-foreground` / `border-border` produce audit visuals across the whole app without rewriting any component markup. The `:root` block, body cross-hatch + grain overlays, JetBrains Mono import, and all canonical chrome classes (`.app-header`, `.h-brand*`, `.btn`, `.btn-press`, `.tabs`, `.tab`, `.section`, `.section-mast`, `.section-h`, `.report`, plus a new reusable `.panel` with pink corner brackets) are promoted to `globals.css`. `app/audit/audit-styles.css` keeps only the audit-page-only widgets (archetype frame, sigil, score grade, leaderboard, findings cards, return hook, auth dialog), so the styles loaded specifically by `/audit` no longer leak into `/policies` or `/projects` on client-side navigation. `app/layout.tsx` drops the `next/font/google` Geist Mono import — fonts now ship via the single CSS `@import url('…JetBrains+Mono…')` in `globals.css`. `components/navbar.tsx` is rewritten around `.app-header` with the pink `▮▮` mark, lowercase Architype wordmark, optional version chip, a current-section eyebrow, and `.tab` links with sharp pink underline on the active route (lucide icons in the bar removed). `app/projects/page.tsx` and its `loading.tsx` are wrapped in the `.report` + `.section` + `.panel` chrome with a green-eyebrow masthead and "your agent footprint." section heading; the inner `ProjectList` component is unchanged and picks up the unified palette automatically. `app/policies/hooks-client.tsx` swaps its outer `
` for a `.report` + `.section` shell with audit masthead copy ("what your agents tried." / "what to stop them doing."), replaces the rounded-pill `TabBar` with the global `.tabs` / `.tab` underline tabs, and drops the now-redundant "Back to /projects" link (the new navbar covers cross-page navigation). No functional changes — all 1701 tests pass and the production `next build` succeeds. ### Docs +- Extend `docs/cli/auth.mdx` with a "Persistent re-audit reminder" section covering the new `~/.failproofai/next-audit.json` file and the `GET / POST / DELETE /api/auth/reminder` dashboard endpoint that backs the `/audit` `[ set a reminder ]` CTA — including the file shape, the per-email scoping rule, and the 7-day default offset. + - Document the new `failproofai auth --login | --logout | --whoami` subcommand in a dedicated `docs/cli/auth.mdx` page (mirrors the style of `cli/audit.mdx`: usage block, sign-in / sign-out / whoami sections, on-disk `auth.json` shape, env-var table, and a short troubleshooting list for the common `Could not reach the api-server` / `Rate limited` / `Code rejected` cases). Add an Authentication section to `docs/cli/environment-variables.mdx` covering `FAILPROOF_API_URL` (override the api-server base URL) and `FAILPROOFAI_AUTH_DIR` (override where `auth.json` is stored). i18n mirrors left for the translation-sync workflow. ## 0.0.11-beta.2 — 2026-05-21 diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx index 0d2b76f9..38ef0024 100644 --- a/docs/cli/auth.mdx +++ b/docs/cli/auth.mdx @@ -37,6 +37,22 @@ failproofai auth --whoami Prints ` ()` and exits 0 when a valid session exists, or `not signed in` and exits 1 otherwise. Silently refreshes the access token in the background if it's within a minute of expiring. +## Persistent re-audit reminder + +When you click **`[ set a reminder ]`** on the `/audit` page (or sign in via the modal that the button gates on), the dashboard writes a small companion file at `~/.failproofai/next-audit.json`: + +```json +{ + "next_audit_at": 1780765200, + "user_email": "you@example.com", + "set_at": 1780160574 +} +``` + +This file is scoped to the email it was set for — switching the CLI session to a different account hides any reminder that belongs to the previous user. Default offset is **7 days**, configurable later when the scheduler lands. Created with `0600` perms like `auth.json`. + +The dashboard's `/api/auth/reminder` endpoint exposes `GET` (read), `POST` (set / reschedule), and `DELETE` (clear) and requires an active session. + ## What's in `~/.failproofai/auth.json` ```json From 187ee9095c8a163709c12e10d9d3b78d0203ed0c Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Mon, 1 Jun 2026 21:55:34 +0530 Subject: [PATCH 10/13] ui fixes --- app/audit/_components/audit-dashboard.tsx | 20 +- app/audit/_components/identity-section.tsx | 118 ++++++- app/audit/_components/report-footer.tsx | 2 +- app/audit/_components/return-section.tsx | 18 -- app/audit/_components/score-section.tsx | 340 ++++++--------------- app/audit/audit-styles.css | 152 ++++++--- app/icon.png | Bin 97346 -> 0 bytes assets/audit/assets/favicon.svg | 25 -- assets/logos/company/icon.svg | 1 + assets/logos/company/logo.svg | 1 + components/navbar.tsx | 51 ++-- components/reach-developers.tsx | 44 ++- public/icon.svg | 1 + public/logo.svg | 1 + 14 files changed, 393 insertions(+), 381 deletions(-) delete mode 100644 app/icon.png delete mode 100644 assets/audit/assets/favicon.svg create mode 100644 assets/logos/company/icon.svg create mode 100644 assets/logos/company/logo.svg create mode 100644 public/icon.svg create mode 100644 public/logo.svg diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index b4a6eabe..b8d1f9eb 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -15,12 +15,11 @@ 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 } from "@/src/audit/scoring"; +import { COHORT_SIZE, deriveScore, gradeFor, projectedScore, type Grade } from "@/src/audit/scoring"; import { deriveStrengths } from "@/src/audit/strengths"; import { deriveFindings } from "@/src/audit/findings"; import { IdentitySection } from "./identity-section"; -import { ShowOffCTA } from "./show-off-cta"; import { StrengthsSection } from "./strengths-section"; import { ScoreSection } from "./score-section"; import { FindingsSection } from "./findings-section"; @@ -181,7 +180,12 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize const detectorsTriggered = result.results.filter((r) => r.hits > 0).length; - /** Identity hero ref — captured to PNG by the "make poster" button. */ + /** Slipping builtin policies — passed to IdentitySection share buttons. */ + const missing = result.results.filter( + (r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0, + ).length; + + /** Identity hero ref — captured to PNG by the share buttons. */ const identityFrameRef = useRef(null); return ( @@ -197,10 +201,9 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize sessions={result.transcripts.scanned} window={window} seed={project} - /> - + -
diff --git a/app/audit/_components/identity-section.tsx b/app/audit/_components/identity-section.tsx index f6eed9f5..357c0d73 100644 --- a/app/audit/_components/identity-section.tsx +++ b/app/audit/_components/identity-section.tsx @@ -17,10 +17,36 @@ * Exposes a `frameRef` forwarded onto the `.archetype-frame` element so * the ShowOff "make poster" action can capture it via html2canvas. */ -import React, { forwardRef } from "react"; +import React, { forwardRef, useState } from "react"; import { ARCHETYPES, pickArchetypeVariant, type ArchetypeKey } from "@/src/audit/archetypes"; +import { type Grade } from "@/src/audit/scoring"; import { Sigil } from "./sigil"; +const SITE_URL = "https://failproof.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)}`; + +function buildXTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { + const gradeLines: Record = { + S: "every prescribed policy live. running at peak. this is what secure looks like.", + A: `${missing} polic${missing === 1 ? "y" : "ies"} from elite tier. almost there.`, + B: `solid baseline. ${missing} policy gap${missing === 1 ? "" : "s"} to close before i'm comfortable.`, + C: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} between here and the next tier. they're named. they're waiting.`, + D: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} unaddressed. agents without guardrails aren't ready for prod.`, + F: `exposure is real. ${missing} polic${missing === 1 ? "y" : "ies"} away from stable ground — starting today.`, + }; + return `just audited my AI agent with failproofai ✦\n\narchetype: ${archetypeName.toLowerCase()} · ${score}/100 · ${grade} tier\n${gradeLines[grade]}\n\nrun yours → ${SITE_URL}`; +} + +function buildLinkedInTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { + const verdict = (grade === "S" || grade === "A") + ? `${score}/100 — ${grade} tier. every key policy is live. the audit confirmed what good looks like.` + : `${score}/100 — ${grade} tier. ${missing} prescribed polic${missing === 1 ? "y" : "ies"} uncovered — each one is a real attack surface.`; + return `We ran a failproofai security audit on our AI agent stack.\n\n${verdict}\n\nArchetype: ${archetypeName.toLowerCase()}. failproofai maps your agent\'s behavior pattern, identifies the exposure, and prescribes the exact policies to close it.\n\nFree. Open-source. 30 seconds to run: ${SITE_URL}`; +} + interface Props { archetypeKey: ArchetypeKey; secondaryKey: ArchetypeKey; @@ -30,14 +56,77 @@ interface Props { window: string; /** Stable seed for variant selection (project name is the natural fit). */ seed: string; + score: number; + grade: Grade; + missing: number; } export const IdentitySection = forwardRef(function IdentitySection( - { archetypeKey, secondaryKey, toolCalls, sessions, window, seed }: Props, + { archetypeKey, secondaryKey, toolCalls, sessions, window, seed, score, grade, missing }: Props, frameRef, ) { const archetype = pickArchetypeVariant(archetypeKey, seed); const secondary = secondaryKey !== archetypeKey ? ARCHETYPES[secondaryKey] : null; + const [downloadState, setDownloadState] = useState<"idle" | "busy" | "done" | "error">("idle"); + + const captureCard = async (): Promise => { + const node = typeof frameRef === "function" ? null : frameRef?.current; + if (!node) return false; + node.classList.add("capturing"); + try { + if (typeof document !== "undefined" && document.fonts?.ready) await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => r())); + const html2canvas = (await import("html2canvas")).default; + const canvas = await html2canvas(node, { + backgroundColor: "#0e0e11", + scale: 2, + logging: false, + useCORS: true, + }); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { resolve(); return; } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `failproofai-identity-${grade.toLowerCase()}-${score}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resolve(); + }, "image/png"); + }); + return true; + } finally { + node.classList.remove("capturing"); + } + }; + + const handleDownload = async () => { + if (downloadState === "busy") return; + setDownloadState("busy"); + try { + await captureCard(); + setDownloadState("done"); + setTimeout(() => setDownloadState("idle"), 2000); + } catch { + setDownloadState("error"); + setTimeout(() => setDownloadState("idle"), 2000); + } + }; + + const handleShareX = async () => { + const text = buildXTemplate(score, archetype.name, grade, missing); + await captureCard().catch(() => null); + globalThis.open(X_INTENT(text), "_blank", "noopener,noreferrer"); + }; + + const handleShareLI = async () => { + const text = buildLinkedInTemplate(score, archetype.name, grade, missing); + await captureCard().catch(() => null); + globalThis.open(LI_INTENT(text), "_blank", "noopener,noreferrer"); + }; return (
@@ -114,6 +203,31 @@ export const IdentitySection = forwardRef(function Identi
+ +
+ + + +
); diff --git a/app/audit/_components/report-footer.tsx b/app/audit/_components/report-footer.tsx index 649380d5..a8147c87 100644 --- a/app/audit/_components/report-footer.tsx +++ b/app/audit/_components/report-footer.tsx @@ -22,7 +22,7 @@ function formatUtcShort(iso: string | null): string { export function ReportFooter({ cachedAt }: Props) { return (
- ▮▮ failproof_ai + failproof_ai · audit v1.0 · diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx index e310374e..bc8e29a8 100644 --- a/app/audit/_components/return-section.tsx +++ b/app/audit/_components/return-section.tsx @@ -145,16 +145,6 @@ export function ReturnSection({ result }: Props) { [persistReminder], ); - const handleClearReminder = useCallback(async () => { - try { - setReminderBusy(true); - await fetch("/api/auth/reminder", { method: "DELETE" }); - setReminder(null); - } finally { - setReminderBusy(false); - } - }, []); - const handleRerun = useCallback(async () => { if (rerunBusy) return; setRerunBusy(true); @@ -229,14 +219,6 @@ export function ReturnSection({ result }: Props) { {copied ? "[ ✓ copied — paste in your shell ]" : "[ install policies ]"} )} -
) : ( diff --git a/app/audit/_components/score-section.tsx b/app/audit/_components/score-section.tsx index 7eb4269e..71a53125 100644 --- a/app/audit/_components/score-section.tsx +++ b/app/audit/_components/score-section.tsx @@ -1,59 +1,28 @@ "use client"; /** - * Section 03 — SCORE CARD + SHARE. + * Section 03 — SCORE CARD. * - * Replaces the older "Score + Leaderboard" composition. A single .panel - * holds two columns: + * Left column only: YOUR AUDIT SCORE (big number, tier badge, progress + * bar, 3 stat boxes, prescribed-policies chip strip). * - * left — YOUR AUDIT SCORE (big number, tier badge, progress bar, - * 3 stat boxes, prescribed-policies chip strip) - * right — SHARE YOUR RESULTS (X + LinkedIn pre-written templates, - * share-on-X / share-on-LinkedIn / download-audit-card buttons) - * - * "Download audit card" captures THIS panel via html2canvas — same - * technique as ShowOffCTA but a different capture target — so the - * exported PNG is the share card the user just saw. + * Share actions have moved to IdentitySection below the archetype sigil. */ -import React, { useMemo, useRef, useState } from "react"; +import React, { useMemo } from "react"; import type { AuditResult } from "@/src/audit/types"; import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; -import { gradeFor, tierName, type Grade } from "@/src/audit/scoring"; +import { type Grade } from "@/src/audit/scoring"; interface Props { result: AuditResult; score: number; grade: Grade; - cohort: number; archetypeKey: ArchetypeKey; - /** Display name shown in the cohort masthead. */ + /** Display name shown in the card header. */ project: string; } -interface ShareTemplate { - network: "x" | "linkedin"; - label: string; - body: string; - intentUrl: (body: string) => string; -} - -function buildXTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { - const tier = grade === "B" || grade === "A" || grade === "S" ? grade : `${grade}`; - return `omg just ran a @failproofai audit on my agent and scored ${score}/100. apparently i'm "${archetypeName.toLowerCase()}" (${tier.toLowerCase()} tier). ${missing} polic${missing === 1 ? "y" : "ies"} away from levelling up. if you ship ai agents you need to check this →`; -} - -function buildLinkedInTemplate(score: number, archetypeName: string, missing: number): string { - return `We just completed a FailproofAI audit on our agent infrastructure and scored ${score}/100. The audit surfaced ${missing} unaddressed policy gap${missing === 1 ? "" : "s"} around our ${archetypeName.toLowerCase()} workload. Highly recommend running this if you're operating AI agents in production — getting visibility into what your agents actually did was the unlock.`; -} - -const SITE_URL = "https://befailproof.ai"; - -const X_INTENT = (body: string): string => - `https://x.com/intent/post?text=${encodeURIComponent(body + " " + SITE_URL)}`; -const LI_INTENT = (body: string): string => - `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(SITE_URL)}&summary=${encodeURIComponent(body)}`; - -export function ScoreSection({ result, score, grade, cohort, archetypeKey, project }: Props) { +export function ScoreSection({ result, score, grade, archetypeKey, project }: Props) { const archetype = ARCHETYPES[archetypeKey]; const pointsToNext = useMemo(() => { const thresholds: { g: Grade; t: number }[] = [ @@ -80,17 +49,8 @@ export function ScoreSection({ result, score, grade, cohort, archetypeKey, proje return Math.max(1, Math.min(14, missing + 1)); }, [grade, missing]); - /** % of score-bar filled toward the next tier — used by the gradient bar. */ - const progressPct = useMemo(() => { - if (pointsToNext.delta === 0) return 100; - // Progress within the current tier band, e.g. between C (55) and B (71) - const bands: { lo: number; hi: number }[] = [ - { lo: 90, hi: 100 }, { lo: 80, hi: 90 }, { lo: 71, hi: 80 }, - { lo: 55, hi: 71 }, { lo: 40, hi: 55 }, { lo: 0, hi: 40 }, - ]; - const band = bands.find((b) => score >= b.lo && score < b.hi) ?? bands[bands.length - 1]; - return Math.round(((score - band.lo) / (band.hi - band.lo)) * 100); - }, [score, pointsToNext.delta]); + /** % of 0–100 bar to fill — simply score/100. */ + const progressPct = score; /** Top-N slipping policies → chip strip on the left card. Capped at 6. */ const policyChips = useMemo(() => { @@ -106,228 +66,100 @@ export function ScoreSection({ result, score, grade, cohort, archetypeKey, proje return [...slipping, ...enabled]; }, [result]); - /* ── share + download ── */ - const xTemplate = useMemo( - () => buildXTemplate(score, archetype.name, grade, missing), - [score, archetype.name, grade, missing], - ); - const liTemplate = useMemo( - () => buildLinkedInTemplate(score, archetype.name, missing), - [score, archetype.name, missing], - ); - - const cardRef = useRef(null); - const [downloadState, setDownloadState] = useState<"idle" | "busy" | "done" | "error">("idle"); - - const handleDownload = async () => { - const node = cardRef.current; - if (!node || downloadState === "busy") return; - setDownloadState("busy"); - try { - if (typeof document !== "undefined" && document.fonts?.ready) await document.fonts.ready; - await new Promise((r) => requestAnimationFrame(() => r())); - const html2canvas = (await import("html2canvas")).default; - const canvas = await html2canvas(node, { - backgroundColor: "#0e0e11", - scale: 2, - logging: false, - useCORS: true, - }); - await new Promise((resolve) => { - canvas.toBlob((blob) => { - if (!blob) { resolve(); return; } - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `failproofai-card-${grade.toLowerCase()}-${score}.png`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - resolve(); - }, "image/png"); - }); - setDownloadState("done"); - setTimeout(() => setDownloadState("idle"), 2000); - } catch (err) { - console.error("card capture failed:", err); - setDownloadState("error"); - setTimeout(() => setDownloadState("idle"), 2000); - } - }; - - const templates: ShareTemplate[] = [ - { network: "x", label: "x · twitter", body: xTemplate, intentUrl: X_INTENT }, - { network: "linkedin", label: "linkedin", body: liTemplate, intentUrl: LI_INTENT }, - ]; - return ( -
+
- ━━ score{" "} - · share -
-
- {cohort.toLocaleString()} agents - · - last 30 days + ━━ score + · see how your agent is performing
-

your audit · ship it.

+

your audit score.

-
-
-
- cohort - · - last 30 days - · - {cohort.toLocaleString()} agents -
-
- {project} - · - {archetype.name.toLowerCase()} -
+
+
+ {project} + · + {archetype.name.toLowerCase()}
-
- {/* LEFT — score */} -
-
your audit score
-
- {score} - /100 -
+
+ {score} + /100 +
-
- {grade} tier - {archetype.name.toLowerCase()} -
+
+ {grade} tier + {archetype.name.toLowerCase()} +
- {pointsToNext.delta > 0 ? ( - <> -
- - progress to {pointsToNext.next.toLowerCase()} tier - - - +{pointsToNext.delta} pts needed - -
-
-
-
- - ) : ( -
- top tier — keep policies live, revisit in 30d. -
- )} +
+ score + {pointsToNext.delta > 0 ? ( + + +{pointsToNext.delta} pts to {pointsToNext.next} tier + + ) : ( + top tier ✓ + )} +
+
+ {[40, 55, 71, 80, 90].map((t) => ( +
+ ))} +
+
+
+ {(["D", "C", "B", "A", "S"] as Grade[]).map((g, i) => { + const pos = [40, 55, 71, 80, 90][i]; + return ( + {g} + ); + })} +
-
-
-
{missing}
-
policies
missing
-
-
-
- +{pointsToNext.delta} -
-
pts to
next tier
-
-
-
- {daysToFix === 0 ? "—" : `~${daysToFix}d`} -
-
est.
to fix
-
+
+
+
{missing}
+
policies
missing
+
+
+
+ +{pointsToNext.delta}
- - {policyChips.length > 0 && ( - <> -
policy status
-
- {policyChips.map((p, i) => ( - - - ))} -
- - )} +
pts to
{pointsToNext.next} tier
- - {/* RIGHT — share */} -
-
share your results
- -
- {templates.map((t) => ( -
-
-
-

{t.body}

-
- ))} +
+
+ {daysToFix === 0 ? "—" : `~${daysToFix}d`}
+
est.
to fix
+
+
-
- {templates.map((t) => ( - 0 && ( + <> +
policy status
+
+ {policyChips.map((p, i) => ( + - - - share on {t.label.split(" ·")[0]} - posts with pre-written copy + card - - - ))} - - + ))}
-
-
- -
- - enable prescribed policies to reach {pointsToNext.next.toLowerCase()} tier this week. - - view full report → -
+ + )}
); diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 0f232c28..13b24ed3 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -473,7 +473,7 @@ .archetype-frame.capturing { min-width: 1080px; max-width: 1180px; - padding: 72px 64px 64px; + padding: 52px 52px 44px; box-shadow: none; } .archetype-frame.capturing .arch-name { @@ -514,15 +514,63 @@ padding-top: 26px; } .archetype-frame.capturing .sigil-wrap { - position: sticky; - top: 0; + position: static; align-self: center; - padding-top: 48px; + justify-self: center; + padding-top: 0; } .archetype-frame.capturing .sigil { box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); } +/* identity share buttons (inside .archetype-frame, hidden during capture) */ +.identity-share-btns { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px dashed var(--line); +} +.identity-share-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: var(--bg); + border: 1px solid var(--line); + color: var(--ink-2); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 120ms, border-color 120ms, color 120ms; + text-transform: lowercase; +} +.identity-share-btn:hover { + background: var(--accent-pink-bg); + border-color: var(--accent-pink); + color: var(--accent-pink); +} +.identity-share-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.isb-glyph { + display: grid; place-items: center; + width: 20px; height: 20px; + border: 1px solid var(--line); + font-family: var(--font-mono); font-size: 10px; + text-transform: uppercase; + color: var(--accent-pink); + font-weight: 600; + flex-shrink: 0; +} +.identity-share-btn:hover .isb-glyph { border-color: var(--accent-pink); } + +/* hide during html2canvas capture */ +.archetype-frame.capturing .identity-share-btns { display: none; } + /* ───────────────────────── 02 STRENGTHS ───────────────────────── */ .strengths-grid { @@ -585,31 +633,21 @@ padding-left: 4px; } -/* ───────────────────────── 03 SCORE CARD + SHARE ───────────────────────── */ +/* ───────────────────────── 03 SCORE CARD ───────────────────────── */ .score-share-card { - padding: 28px 32px 24px; + padding: 22px 24px 20px; background: repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), var(--bg-2); } -.score-share-mast { - display: flex; justify-content: space-between; flex-wrap: wrap; - gap: 16px; + +.score-card-header { font-family: var(--font-mono); font-size: 10px; - letter-spacing: 0.22em; text-transform: uppercase; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-2); - padding-bottom: 16px; margin-bottom: 22px; - border-bottom: 1px dashed var(--line); -} -.score-share-mast .ssm-right { text-align: right; } - -.score-share-body { - display: grid; - grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr); - gap: 36px; - align-items: start; + margin-bottom: 16px; } .ss-label { @@ -621,12 +659,12 @@ /* — left column ------------------------------------------------------ */ .ss-score-row { - display: flex; align-items: baseline; gap: 12px; - margin: 0 0 14px; + display: flex; align-items: baseline; gap: 10px; + margin: 0 0 10px; } .ss-score { font-family: var(--font-display); - font-size: clamp(64px, 9vw, 96px); + font-size: clamp(52px, 7vw, 76px); line-height: 0.9; letter-spacing: 0.04em; color: var(--accent-pink); @@ -641,7 +679,7 @@ .ss-tier-row { display: flex; align-items: center; gap: 12px; - margin-bottom: 22px; + margin-bottom: 16px; } .ss-tier-badge { font-family: var(--font-mono); font-size: 11px; @@ -670,36 +708,61 @@ display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; - margin-bottom: 8px; + margin-bottom: 6px; } .ss-progress-track { - height: 4px; background: var(--bg); + position: relative; + height: 10px; background: var(--bg); border: 1px solid var(--line); - margin-bottom: 22px; - overflow: hidden; + margin-bottom: 6px; + overflow: visible; } .ss-progress-fill { height: 100%; - background: linear-gradient(90deg, var(--accent-pink) 0%, #e89aaf 100%); + background: linear-gradient(90deg, var(--accent-pink) 0%, #f472b6 60%, #a78bfa 100%); +} +.ss-progress-tick { + position: absolute; + top: -4px; bottom: -4px; + width: 1px; + background: var(--line-2); + pointer-events: none; +} +.ss-grade-stops { + position: relative; + height: 16px; + margin-bottom: 16px; +} +.ss-grade-stop { + position: absolute; + transform: translateX(-50%); + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--dim); + top: 0; +} +.ss-grade-stop.active { + color: var(--accent-pink); + font-weight: 700; } .ss-stats { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 10px; - margin-bottom: 22px; + gap: 8px; + margin-bottom: 16px; } .ss-stat { border: 1px solid var(--line-2); background: var(--bg); - padding: 14px 12px; + padding: 10px 12px; text-align: left; } .ss-stat-n { font-family: var(--font-display); - font-size: 30px; line-height: 1; + font-size: 24px; line-height: 1; letter-spacing: 0.04em; - margin-bottom: 8px; + margin-bottom: 6px; } .ss-stat-l { font-family: var(--font-mono); font-size: 9px; @@ -712,6 +775,9 @@ font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-2); + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--line); margin-bottom: 10px; } .ss-policy-chips { @@ -720,27 +786,27 @@ .ss-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; - border-radius: 999px; + border-radius: 0; border: 1px solid var(--line-2); background: var(--bg); font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); } .ss-chip .dot { - width: 6px; height: 6px; - border-radius: 50%; + width: 5px; height: 5px; + border-radius: 0; background: var(--dim); } .ss-chip.missing { - border-color: var(--accent-pink); + border-color: rgba(228, 88, 125, 0.6); color: var(--accent-pink); - background: var(--accent-pink-bg); + background: rgba(228, 88, 125, 0.06); } .ss-chip.missing .dot { background: var(--accent-pink); } .ss-chip.enabled { - border-color: var(--accent-green); + border-color: rgba(102, 209, 181, 0.5); color: var(--accent-green); - background: var(--accent-green-bg); + background: rgba(102, 209, 181, 0.05); } .ss-chip.enabled .dot { background: var(--accent-green); } @@ -840,9 +906,7 @@ .ss-foot-link:hover { text-decoration: underline; text-underline-offset: 3px; } @media (max-width: 880px) { - .score-share-card { padding: 22px 20px 20px; } - .score-share-body { grid-template-columns: 1fr; gap: 28px; } - .score-share-mast .ssm-right { text-align: left; } + .score-share-card { padding: 16px 16px 16px; } } diff --git a/app/icon.png b/app/icon.png deleted file mode 100644 index 6eb48de476375fd240c603c5efff1d5edbe23b31..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 97346 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 - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/logos/company/icon.svg b/assets/logos/company/icon.svg new file mode 100644 index 00000000..3bc25c4e --- /dev/null +++ b/assets/logos/company/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/logos/company/logo.svg b/assets/logos/company/logo.svg new file mode 100644 index 00000000..9ce0d7de --- /dev/null +++ b/assets/logos/company/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/navbar.tsx b/components/navbar.tsx index 4b824e16..cff09bd5 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -38,35 +38,20 @@ export const Navbar: React.FC<{ return (
+ {/* Brand — logo mark + name only (no version/section here) */} - - failproof_ai - {process.env.NEXT_PUBLIC_APP_VERSION && ( - - )} - {process.env.NEXT_PUBLIC_APP_VERSION && ( - - v{process.env.NEXT_PUBLIC_APP_VERSION} - - )} - {sectionLabel && } - {sectionLabel && {sectionLabel}} + + failproof_ai + {/* Nav links — swapped to sit right after the brand */} + {/* Spacer pushes version/section + actions to the right */} +
+ + {/* Version + section label — swapped to right of nav */} + {(process.env.NEXT_PUBLIC_APP_VERSION || sectionLabel) && ( +
+ {process.env.NEXT_PUBLIC_APP_VERSION && ( + + v{process.env.NEXT_PUBLIC_APP_VERSION} + + )} + {sectionLabel && process.env.NEXT_PUBLIC_APP_VERSION && ( + + )} + {sectionLabel && {sectionLabel}} +
+ )} +
diff --git a/components/reach-developers.tsx b/components/reach-developers.tsx index 34dc7b43..2c7f9092 100644 --- a/components/reach-developers.tsx +++ b/components/reach-developers.tsx @@ -13,31 +13,43 @@ const options = [ label: "Star us on GitHub", icon: Star, href: "https://github.com/failproofai/failproofai", + color: "#f5c842", + bg: "rgba(245,200,66,0.08)", }, { label: "Documentation", icon: BookOpen, href: "https://befailproof.ai", + color: "#60a5fa", + bg: "rgba(96,165,250,0.08)", }, { label: "Join our Slack", icon: Hash, href: "https://join.slack.com/t/failproofai/shared_invite/zt-3v63b7k5e-O3NBHmj8X6n9gZSGDx6ggQ", + color: "#a78bfa", + bg: "rgba(167,139,250,0.08)", }, { label: "Request a Feature", icon: Lightbulb, href: `${GITHUB_REPO}/issues/new?labels=enhancement&title=Feature+Request%3A+`, + color: "#34d399", + bg: "rgba(52,211,153,0.08)", }, { label: "Report an Issue", icon: Bug, href: `${GITHUB_REPO}/issues/new?labels=bug&title=Bug+Report%3A+`, + color: "#f87171", + bg: "rgba(248,113,113,0.08)", }, { label: "Ask a Question", icon: MessageSquare, href: `${GITHUB_REPO}/discussions/new?category=q-a`, + color: "#fb923c", + bg: "rgba(251,146,60,0.08)", }, ] as const; @@ -76,35 +88,51 @@ export const ReachDevelopers: React.FC = () => { {open && ( -
-
-

Reach Developers

+
+
+

Reach Developers

We'd love to hear from you

-
+

or email{" "} ((e.currentTarget as HTMLAnchorElement).style.opacity = "0.75")} + onMouseLeave={(e) => ((e.currentTarget as HTMLAnchorElement).style.opacity = "1")} > {CONTACT_EMAIL} diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 00000000..3bc25c4e --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 00000000..9ce0d7de --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file From 31e17cf0cf94e071910e55ce498f64ab3faf6643 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Mon, 1 Jun 2026 22:16:53 +0530 Subject: [PATCH 11/13] ui fixes --- app/audit/_components/score-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/audit/_components/score-section.tsx b/app/audit/_components/score-section.tsx index 71a53125..ebed1de3 100644 --- a/app/audit/_components/score-section.tsx +++ b/app/audit/_components/score-section.tsx @@ -71,7 +71,7 @@ export function ScoreSection({ result, score, grade, archetypeKey, project }: Pr

━━ score - · see how your agent is performing + · SEE HOW YOUR AGENT IS PERFORMING

your audit score.

From 356bd17db9f9c5519c387f012cea7debf0ab72c5 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Mon, 1 Jun 2026 23:04:27 +0530 Subject: [PATCH 12/13] ui fixes --- app/audit/_components/return-section.tsx | 122 +++++++++++++---------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx index bc8e29a8..dd5990c0 100644 --- a/app/audit/_components/return-section.tsx +++ b/app/audit/_components/return-section.tsx @@ -6,15 +6,15 @@ * Behavior matrix: * - unknown (probe in flight) → buttons disabled * - anon (no session) → [ set a reminder ] opens AuthDialog, - * on success persists the 7-day reminder - * and we flip to the authed state below. - * - authed + no reminder → [ set a reminder ] writes the timestamp, - * no auth dialog needed. - * - authed + reminder set → button collapses to a status pill showing - * the email and the relative "next audit - * in X days" line. The reminder persists - * across reloads via ~/.failproofai/next- - * audit.json — same as the CLI's auth.json. + * on success we flip to the authed panel + * below and persist the 7-day reminder. + * - authed (any) → consolidated status panel: "signed in as + * …" + either the persisted "next audit in + * X days" line OR a "no reminder set yet" + * line with an inline [ set a reminder ] + * button. The reminder persists across + * reloads via ~/.failproofai/next-audit.json + * — same as the CLI's auth.json. * * Also exposes [ re-audit now ] next to [ install policies ] so the user * can trigger a fresh scan inline without leaving the page. The button @@ -161,6 +161,8 @@ export function ReturnSection({ result }: Props) { const authed = authStatus.kind === "authed"; const hasReminder = authed && reminder !== null; const days = reminder ? daysUntil(reminder.next_audit_at) : 0; + const authedEmail = + authStatus.kind === "authed" ? authStatus.user.email : ""; return (
@@ -186,52 +188,49 @@ export function ReturnSection({ result }: Props) { most agents move from C to B in one session. some make it in a day.

- {/* Authed + reminder set → status panel + dismiss; else the - classic CTA + install + (re-audit when there's data). */} - {hasReminder && reminder ? ( + {/* Once authed, the section stays in the consolidated status panel — + with the reminder line if one is set, or a "no reminder yet" line + + inline [ set a reminder ] button otherwise. The anonymous CTA + layout only shows for genuinely-unauthed sessions. */} + {authed ? (
-
-
+ {hasReminder && reminder ? ( +
+
+ ) : ( +
+
+ )}
- - {hasUnenabled && ( - )} -
-
- ) : ( - <> -
-
- {authed && ( -
-
+
+ ) : ( +
+ + + {hasUnenabled && ( + )} - +
)}
From 1884ddad229899d65699ecaf2a5b3e06be6ccec4 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Tue, 2 Jun 2026 02:09:41 +0530 Subject: [PATCH 13/13] feat(cli): update auth cli, rename commands --- app/api/auth/status/route.ts | 63 ++++----- app/audit/_components/return-section.tsx | 13 ++ bin/failproofai.mjs | 161 ++++++++++++++++++++++- src/auth/cli.ts | 142 ++++++++++---------- 4 files changed, 262 insertions(+), 117 deletions(-) diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts index 2b688963..9cdeb642 100644 --- a/app/api/auth/status/route.ts +++ b/app/api/auth/status/route.ts @@ -1,9 +1,11 @@ /** * GET /api/auth/status * - * Returns the currently authenticated identity, verifying the locally-stored - * access token against the api-server's /me endpoint. Refreshes the access - * token if it's near expiry. Never exposes the refresh token to the browser. + * Returns the currently signed-in identity by reading the local + * `~/.failproofai/auth.json` cache. No round-trip to the api-server — the + * file is the source of truth, same as the CLI's `failproofai auth whoami`. + * This keeps the dashboard UI and the CLI consistent regardless of whether + * the api-server is reachable. * * Also returns the user's persisted re-audit reminder (if any). The reminder * lives in ~/.failproofai/next-audit.json and is only surfaced when its @@ -11,43 +13,30 @@ * does not leak a previous user's reminder into the dashboard. */ import { NextResponse } from "next/server"; -import { readReminder, whoAmI } from "@/lib/auth/auth-store"; +import { readAuth, readReminder } from "@/lib/auth/auth-store"; export const dynamic = "force-dynamic"; export async function GET(): Promise { - try { - const result = await whoAmI(); - if (!result) { - return NextResponse.json({ authenticated: false, reminder: null }, { status: 200 }); - } - const reminderRaw = readReminder(); - const reminder = - reminderRaw && reminderRaw.user_email === result.me.email - ? { - next_audit_at: reminderRaw.next_audit_at, - user_email: reminderRaw.user_email, - set_at: reminderRaw.set_at, - } - : null; - return NextResponse.json( - { - authenticated: true, - user: { - id: result.me.id, - email: result.me.email, - status: result.me.status, - created_at: result.me.created_at, - }, - reminder, - }, - { status: 200 }, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return NextResponse.json( - { authenticated: false, reminder: null, error: message }, - { status: 200 }, - ); + const auth = readAuth(); + if (!auth) { + return NextResponse.json({ authenticated: false, reminder: null }, { status: 200 }); } + const reminderRaw = readReminder(); + const reminder = + reminderRaw && reminderRaw.user_email === auth.user.email + ? { + next_audit_at: reminderRaw.next_audit_at, + user_email: reminderRaw.user_email, + set_at: reminderRaw.set_at, + } + : null; + return NextResponse.json( + { + authenticated: true, + user: { id: auth.user.id, email: auth.user.email }, + reminder, + }, + { status: 200 }, + ); } diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx index dd5990c0..f02d01a7 100644 --- a/app/audit/_components/return-section.tsx +++ b/app/audit/_components/return-section.tsx @@ -94,6 +94,19 @@ export function ReturnSection({ result }: Props) { useEffect(() => { void refreshStatus(); + // Re-probe whenever the tab regains focus or visibility — picks up + // CLI `failproofai auth login` / `logout` and api-server restarts + // without the user having to hit reload manually. + const onFocus = () => void refreshStatus(); + const onVisibility = () => { + if (document.visibilityState === "visible") void refreshStatus(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + }; }, [refreshStatus]); const persistReminder = useCallback(async (): Promise => { diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 6f159366..7a0dc434 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -103,7 +103,7 @@ if (hookIdx >= 0) { */ async function runCli() { // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "audit", "auth"]; + const SUBCOMMANDS = ["policies", "policy", "audit", "auth"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -118,6 +118,9 @@ USAGE COMMANDS (no args) Launch the policy dashboard + policy add Enable a single policy (see \`policy --help\`) + policy remove Disable a single policy + policies, p List all available policies and their status policies --install, -i Enable policies in agent CLI settings [names...] Specific policy names to enable @@ -141,9 +144,9 @@ COMMANDS policies --help, -h Show this help for the policies command auth Sign in / out of FailproofAI from the CLI. - --login Email + OTP flow; writes ~/.failproofai/auth.json - --logout Revoke this session and remove auth.json - --whoami Print the currently authenticated identity + login Email + OTP flow; writes ~/.failproofai/auth.json + logout Revoke this session and remove auth.json + whoami Print the currently authenticated identity auth --help, -h Show this help for the auth command audit (beta) Scan past agent CLI transcripts and count @@ -485,6 +488,156 @@ EXAMPLES process.exit(process.exitCode ?? 0); } + // policy — single-policy shortcut over `policies --install `. + // failproofai policy add enable one policy (defaults: claude/user) + // failproofai policy remove disable one policy + // Honors the same --cli / --scope / --beta flags as `policies --install`. + if (args[0] === "policy") { + lastSubcommand = "policy"; + const subArgs = args.slice(1); + + if (subArgs.length === 0 || subArgs.includes("--help") || subArgs.includes("-h")) { + console.log(` +failproofai policy — manage a single FailproofAI policy + +USAGE + failproofai policy add Enable one policy + failproofai policy remove Disable one policy + +OPTIONS + --cli claude|codex|copilot|cursor|opencode|pi|gemini + Agent CLI(s) to apply to; space-separated or repeated. + Omit to detect installed CLIs and prompt. + --scope user|project|local Config scope (default: user) + --beta Allow beta policies + +EXAMPLES + failproofai policy add block-sudo + failproofai policy add sanitize-api-keys --scope project + failproofai policy add block-force-push --cli claude codex + failproofai policy remove block-sudo +`.trimStart()); + process.exit(0); + } + + const action = subArgs[0]; + if (action !== "add" && action !== "remove") { + throw new CliError( + `Unknown policy subcommand: ${action}\n` + + `Run \`failproofai policy --help\` for usage.`, + ); + } + + const rest = subArgs.slice(1); + + const scopeIdx = rest.indexOf("--scope"); + const scope = scopeIdx >= 0 ? rest[scopeIdx + 1] : "user"; + if (scopeIdx >= 0 && (!scope || scope.startsWith("-"))) { + throw new CliError("Missing value for --scope. Valid values: user, project, local"); + } + const validScopes = action === "remove" + ? ["user", "project", "local", "all"] + : ["user", "project", "local"]; + if (scopeIdx >= 0 && !validScopes.includes(scope)) { + throw new CliError(`Invalid scope: ${scope}. Valid values: ${validScopes.join(", ")}`); + } + + // --cli accepts one or more space-separated values, optionally repeated. + const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini"]); + const cliFlagValues = []; + const cliConsumedIdxs = new Set(); + const cliFlagIdxs = rest.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); + for (const idx of cliFlagIdxs) { + let consumed = 0; + for (let j = idx + 1; j < rest.length; j++) { + const v = rest[j]; + if (v.startsWith("-")) break; + if (!VALID_CLIS.has(v)) break; + cliFlagValues.push(v); + cliConsumedIdxs.add(j); + consumed++; + } + if (consumed === 0) { + throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi gemini (or any subset)"); + } + } + + const includeBeta = rest.includes("--beta"); + + // Reject unknown flags. + const knownFlags = new Set(["--scope", "--cli", "--beta"]); + const unknownFlag = rest.find((a) => a.startsWith("-") && !knownFlags.has(a)); + if (unknownFlag) { + throw new CliError(`Unknown flag: ${unknownFlag}\nRun \`failproofai policy --help\` for usage.`); + } + + // Positional policy names = anything not consumed by --scope / --cli. + const consumedIdxs = new Set(); + if (scopeIdx >= 0) consumedIdxs.add(scopeIdx + 1); + for (const i of cliConsumedIdxs) consumedIdxs.add(i); + const positional = rest.filter( + (a, idx) => !a.startsWith("-") && !consumedIdxs.has(idx), + ); + + if (positional.length === 0) { + throw new CliError( + `Missing policy name.\n` + + `Usage: failproofai policy ${action} \n` + + `Run \`failproofai policies\` to see available names.`, + ); + } + if (positional.length > 1) { + throw new CliError( + `\`policy ${action}\` takes exactly one policy name (got ${positional.length}).\n` + + `For multiple policies use \`failproofai policies --${action === "add" ? "install" : "uninstall"} ${positional.join(" ")}\`.`, + ); + } + const policyName = positional[0]; + + const { resolveTargetClis } = await import("../src/hooks/install-prompt"); + const cli = await resolveTargetClis( + cliFlagValues.length > 0 ? cliFlagValues : undefined, + action === "add" ? "install" : "uninstall", + ); + + if (action === "add") { + const { installHooks } = await import("../src/hooks/manager"); + await installHooks( + [policyName], + scope, + undefined, + includeBeta, + undefined, + undefined, + false, + cli, + ); + await track("cli_policy_add_success", { + scope, + cli, + cli_count: cli.length, + policy_name: policyName, + include_beta: includeBeta, + }); + } else { + const { removeHooks } = await import("../src/hooks/manager"); + await removeHooks( + [policyName], + scope, + undefined, + { betaOnly: includeBeta, removeCustomHooks: false, cli }, + ); + await track("cli_policy_remove_success", { + scope, + cli, + cli_count: cli.length, + policy_name: policyName, + beta_only: includeBeta, + }); + } + process.exit(0); + } + // audit — scan past transcripts for "stupid behaviors" caught by builtin // policies + a set of audit-only detectors. if (args[0] === "audit") { diff --git a/src/auth/cli.ts b/src/auth/cli.ts index 92155622..041adfe0 100644 --- a/src/auth/cli.ts +++ b/src/auth/cli.ts @@ -1,13 +1,15 @@ /** * `failproofai auth` CLI surface. * - * failproofai auth --login Email + OTP flow; writes ~/.failproofai/auth.json - * failproofai auth --logout Revoke the session and wipe auth.json - * failproofai auth --whoami Print the currently logged-in identity (or "not authed") - * failproofai auth --help Usage + * failproofai auth login Email + OTP flow; writes ~/.failproofai/auth.json + * failproofai auth logout Wipe auth.json (best-effort server revoke) + * failproofai auth whoami Print the cached identity (or "not signed in") + * failproofai auth help Usage * - * The implementation deliberately avoids new external deps — readline + stdin - * + ANSI escapes are enough for a one-shot prompt loop. + * Source of truth is the local cache (~/.failproofai/auth.json). Server-side + * validation is intentionally avoided — once a token is on disk we trust it. + * That keeps `login`, `logout`, and `whoami` consistent with each other and + * with the dashboard, even when the api-server is unreachable. */ import * as readline from "node:readline"; @@ -23,7 +25,6 @@ import { authFromTokenResponse, deleteAuth, readAuth, - whoAmI, writeAuth, } from "../../lib/auth/auth-store"; import { CliError } from "../cli-error"; @@ -36,10 +37,10 @@ const HELP = ` failproofai auth — sign in to FailproofAI from the CLI USAGE - failproofai auth --login Start the email + OTP login flow - failproofai auth --logout Revoke this session and remove ~/.failproofai/auth.json - failproofai auth --whoami Print the currently authenticated identity - failproofai auth --help, -h Show this help + failproofai auth login Start the email + OTP login flow + failproofai auth logout Remove ~/.failproofai/auth.json + failproofai auth whoami Print the currently signed-in identity + failproofai auth help Show this help (also: --help, -h) ENVIRONMENT FAILPROOF_API_URL Override the api-server base URL @@ -48,51 +49,58 @@ ENVIRONMENT (default: ~/.failproofai) EXAMPLES - failproofai auth --login - failproofai auth --whoami - failproofai auth --logout + failproofai auth login + failproofai auth whoami + failproofai auth logout `.trimStart(); -export function parseAuthArgs(args: string[]): AuthCliOptions { - const flags = new Set(args); - const isHelp = flags.has("--help") || flags.has("-h"); - const isLogin = flags.has("--login"); - const isLogout = flags.has("--logout"); - const isWhoami = flags.has("--whoami"); +/** Deprecated `--login` / `--logout` / `--whoami` flags map back to subcommands + * so shell history and older docs keep working silently. */ +const LEGACY_FLAG_TO_SUB: Record = { + "--login": "login", + "--logout": "logout", + "--whoami": "whoami", +}; - if (isHelp) return { mode: "help" }; +const SUBCOMMANDS = new Set(["login", "logout", "whoami", "help"]); - const known = new Set(["--login", "--logout", "--whoami", "--help", "-h"]); - const unknown = args.find((a) => a.startsWith("-") && !known.has(a)); - if (unknown) { - throw new CliError( - `Unknown flag for auth: ${unknown}\nRun \`failproofai auth --help\` for usage.`, - ); +export function parseAuthArgs(args: string[]): AuthCliOptions { + if (args.includes("--help") || args.includes("-h")) return { mode: "help" }; + + const positional: string[] = []; + const legacy: ("login" | "logout" | "whoami")[] = []; + for (const a of args) { + if (a === "--help" || a === "-h") continue; + if (a in LEGACY_FLAG_TO_SUB) { + legacy.push(LEGACY_FLAG_TO_SUB[a]); + continue; + } + if (a.startsWith("-")) { + throw new CliError( + `Unknown flag for auth: ${a}\nRun \`failproofai auth help\` for usage.`, + ); + } + positional.push(a); } - const positional = args.filter((a) => !a.startsWith("-")); - if (positional.length > 0) { + + const subs = [...positional, ...legacy]; + if (subs.length === 0) return { mode: "help" }; + if (subs.length > 1) { throw new CliError( - `Unexpected argument: ${positional[0]}\nRun \`failproofai auth --help\` for usage.`, + `Pick one of login, logout, whoami.\nRun \`failproofai auth help\` for usage.`, ); } - - const count = (isLogin ? 1 : 0) + (isLogout ? 1 : 0) + (isWhoami ? 1 : 0); - if (count === 0) return { mode: "help" }; - if (count > 1) { + const sub = subs[0]; + if (!SUBCOMMANDS.has(sub)) { throw new CliError( - `Pick one of --login, --logout, --whoami.\nRun \`failproofai auth --help\` for usage.`, + `Unknown auth subcommand: ${sub}\nRun \`failproofai auth help\` for usage.`, ); } - if (isLogin) return { mode: "login" }; - if (isLogout) return { mode: "logout" }; - return { mode: "whoami" }; + return { mode: sub as AuthCliOptions["mode"] }; } function prompt(question: string, opts: { hidden?: boolean } = {}): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - // Input-masking only makes sense on a real terminal; on piped/redirected - // stdin readline buffers character-by-character through `_writeToOutput`, - // which combined with masking can stall the read. if (opts.hidden && process.stdin.isTTY) { const r = rl as unknown as { _writeToOutput: (s: string) => void; @@ -113,11 +121,11 @@ function prompt(question: string, opts: { hidden?: boolean } = {}): Promise { const existing = readAuth(); if (existing) { process.stdout.write( - `${DIM}already signed in as${RESET} ${existing.user.email} ${DIM}(use \`failproofai auth --logout\` to switch accounts)${RESET}\n`, + `${DIM}already signed in as${RESET} ${existing.user.email} ${DIM}(use \`failproofai auth logout\` to switch accounts)${RESET}\n`, ); return; } @@ -199,45 +207,27 @@ async function runLogout(): Promise { process.stdout.write(`${DIM}not signed in. nothing to do.${RESET}\n`); return; } - let serverRevoked = false; + // Best-effort server revoke — failure does not block the local wipe. try { await logoutSession(existing.access_token, existing.refresh_token); - serverRevoked = true; - } catch (err) { - if (err instanceof AuthApiError && err.status === 401) { - // Token already invalid — that's fine, we'll still wipe locally. - serverRevoked = true; - } else if (err instanceof AuthApiError) { - process.stdout.write( - `${RED}server-side revoke failed (${err.code}): ${err.message}${RESET}\n`, - ); - } else { - process.stdout.write( - `${RED}could not reach the api-server — wiping local session only.${RESET}\n`, - ); - } + } catch { + // ignored — the local cache is the source of truth. } deleteAuth(); - if (serverRevoked) { - process.stdout.write(`${GREEN}✓ signed out.${RESET}\n`); - } else { - process.stdout.write( - `${GREEN}✓ local session removed.${RESET} ${DIM}server-side revocation may not have completed.${RESET}\n`, - ); - } + process.stdout.write( + `${GREEN}✓ signed out as ${existing.user.email}.${RESET}\n`, + ); } -async function runWhoami(): Promise { - const result = await whoAmI(); - if (!result) { - process.stdout.write(`${DIM}not signed in — run \`failproofai auth --login\` to sign in.${RESET}\n`); +function runWhoami(): void { + const existing = readAuth(); + if (!existing) { + process.stdout.write(`${DIM}not signed in — run \`failproofai auth login\` to sign in.${RESET}\n`); process.exitCode = 1; return; } - const { me } = result; process.stdout.write( - `${GREEN}✓${RESET} ${me.email} ${DIM}(${me.id})${RESET}\n` + - `${DIM}status: ${me.status} · created: ${me.created_at}${RESET}\n`, + `${GREEN}✓${RESET} ${existing.user.email} ${DIM}(${existing.user.id})${RESET}\n`, ); }