diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15c5fe63..916de56e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,19 @@
# Changelog
-## 0.0.11-beta.3 — 2026-06-08
+## 0.0.11-beta.3 — 2026-06-09
### Dependencies
- Swap the Vitest DOM environment from `happy-dom` to `jsdom` (`vitest.config.mts`, `package.json`, 15 `docs/*/testing.mdx`). happy-dom is single-maintainer and had a 2024 critical CVE; jsdom has 6 maintainers, ~7× the weekly downloads, and a perfect Snyk maintenance score. Test suite (1691 tests across 82 files) stays green on jsdom (#419).
### Features
+- Tighten the `/audit` share flow: drop the inline share buttons (the floating dock from the prior change is the single share surface now), wire the dock to try the Web Share API with an image file attached (`navigator.share({ files: [pngFile], text })`) before falling back to clipboard + intent URL, and route the dedicated "save audit-card" button through a new `downloadCard()` helper that always downloads (no clipboard try). On iOS / Android / Safari / recent Chrome desktop the X / LinkedIn buttons now produce a one-tap share-with-image via the system sheet. On browsers without `navigator.share` files-support, the existing clipboard-then-paste path is unchanged. The save button finally just saves. New `shareCardNative()` helper (returns boolean + early-exits on `AbortError`) and split `downloadCard()` / `copyOrDownloadCard()` in `lib/share-card.ts`; `shareCardToastMessage()` adds a `"native"` variant ("✅ image attached — pick where to post"). `app/audit/_components/identity-section.tsx` is now display-only — the share handlers, state, helpers, and three-button strip JSX are gone; `score` / `grade` / `missing` props removed too (the dock owns them via its own props from `audit-dashboard`). Telemetry `image_method` field gains a `"native"` value so we can measure native-share success rates. +4 tests for `downloadCard` (anchor click) and `shareCardNative` (no-API / resolves / AbortError).
+- Rework the `/audit` share-card experience so the audit PNG actually lands in the user's social post. (1) New `lib/share-card.ts` exports `copyOrDownloadCard(blob, filename)` — tries `navigator.clipboard.write([new ClipboardItem({ "image/png": blob })])` first (Chromium ≥63 / Safari ≥13.1) and falls back to a local download on permission denial or older browsers; returns `"clipboard" | "download" | "failed"` so callers can show method-specific toasts via the existing site-wide ``. (2) `app/audit/_components/identity-section.tsx` `captureCard` is split into `captureCardBlob()` (returns a `Blob | null`) and the three click handlers (`handleShareX`, `handleShareLI`, `handleDownload`) now sequence `captureCardBlob → copyOrDownloadCard → toast → window.open`. Telemetry events (`audit_card_share_clicked`, `audit_card_capture_completed`) gain `image_method` ("clipboard" / "download" / "failed") and `source` ("identity" / "dock") so we can measure how often the clipboard path succeeds and which surface drove the share. (3) Inline share buttons are redesigned around a new shared `.share-btn` class — 44px square platform mark on the left (X tile in black with white `𝕏`, LinkedIn tile in `#0a66c2` blue with white `in`, download tile with pink-crosshair corner adornment), small green "share on" / "save" eyebrow + 13px platform label in the middle, pink trailing arrow on the right; a 2px pink right-edge stroke at rest reads as "armed", hover fills the border + adds a 4px hard-offset pink shadow + lifts (-1px, -1px), press translates (2px, 2px) and collapses the shadow. (4) New `app/audit/_components/share-dock.tsx` mounts a floating bottom-right dock that shares the same `.share-btn` styling — three full-width buttons stacked vertically, pink corner brackets on the outer panel, a collapse caret in the header that shrinks the dock to a single 56px pink FAB (preference persists in `sessionStorage`), slide-in animation, hidden under 760px viewports. Mounted once from `audit-dashboard.tsx`, sharing the same `identityFrameRef` the inline buttons already capture. All new motion respects `prefers-reduced-motion: reduce`. +4 tests: `__tests__/lib/share-card.test.ts` covers clipboard-success, clipboard-rejection-fallback-to-download, `ClipboardItem`-undefined-fallback-to-download, and method-keyed toast copy.
+- Rework the top of the `/policies` activity view so it feels composed at every width (instead of the recent `.report` widening leaving its top elements clustered on the left). Three coordinated moves in `app/policies/hooks-client.tsx` + two new reusable classes in `app/globals.css`. (1) `StatsBar` is rebuilt around a new shared `.stat-bar` class — a 3-cell brutalist instrument strip (full-width grid, pink corner brackets like `.panel`, dashed vertical dividers, green eyebrow captions, 26px mono numerals, tabular-numeric formatting, locale-grouped thousands, amber tone on deny-rate ≥ 2%, pink on ≥ 5%); collapses to a single column under 800px. (2) The filter strip is rebuilt around a new shared `.filter-bar` class — each control sits in a `.filter-group` (label + control stacked) with 10px green-eyebrow labels; the two text-input groups get `flex: 1 1 220px` (`.filter-group--grow`) so they elastically absorb leftover horizontal space instead of staying pinned to `w-44`; a dashed `[ clear ]` chip appears only when at least one filter is active. (3) The header description block above the tabs is freed from its `maxWidth: 720` cap — it's now a 2-column flex row: descriptive copy on the left, a right-aligned `[ configure policies → ]` `.btn-primary` (replacing the inline text-underlined `go here`) with a small green-eyebrow caption above it. The activity table itself gets a `
` with proportional column widths (Policy 18%, Reason 22%, badges 8% each, Duration 6%, Time 7%) and `table-layout: fixed` so the columns hold intentional proportions across viewport sizes — Policy + Reason become the visual anchors (40% of the row) and badges stay compact instead of stretching. `.activity-thead` gets green-eyebrow uppercase headers with 0.18em tracking, and `.activity-detail` replaces the old `bg-muted/20` expanded-row style with a 3px pink left border + a `▾ EVENT DETAIL` eyebrow caption. The expanded-row `colSpan` bug (was `10`, table has 11 columns) is also fixed so the detail panel covers the rightmost Time column.
+- Reframe the audit hero's central pixel sigil as a brutalist "instrument plate" (`app/audit/_components/sigil.tsx`, `app/audit/audit-styles.css` `.sigil-plate` block). The old bare 8×8 grid felt visually underweight beside the 124px Bitcount archetype name. The new treatment wraps the grid in a plate with register crosshair marks at all four corners (CSS-only `+` from two 1px bars), a header strip showing the archetype index + an "8×8" coordinate label, the grid mounted on a dashed inner frame with cells bumped from 16px → 20px, and a footer strip naming the archetype. The plate gets a stacked hard-offset shadow (pink at 8px, black at 16px) for depth, accent pink/green cells get a subtle inner glow, and cells fade in along a diagonal `(x+y)` wave on mount via per-cell `--cx` / `--cy` custom properties (22ms × cell-index stagger, 280ms duration, `cubic-bezier(0.22, 1, 0.36, 1)`, total ≈ 600ms). The ShowOff CTA's bare-grid sigil and the html2canvas poster capture both keep their pre-plate look via a `data-bare` flag + `.archetype-frame.capturing` collapse to a single shadow that html2canvas renders reliably. Reduced-motion users see the final state with no diagonal wave.
+- Bump base font-size from `14.5px` → `16px` in `app/globals.css` for general readability, and round the smallest mono chrome labels up to compensate: `.btn` and `.tab` go `12px → 13px`, `.section-label` and `.section-meta` go `11px → 12px`. Tailwind text utilities downstream (`text-xs` = 0.75rem, `text-[0.7rem]`, etc.) scale automatically with the new root — the policies activity table that read ~11px now reads ~12px without touching `hooks-client.tsx`.
+- Make the dashboard chrome scale to fill ultrawide monitors. `.report` in `app/globals.css` swaps the fixed `max-width: 1380px; padding: 0 40px` for `max-width: clamp(720px, 96vw, 1840px); padding: 0 clamp(20px, 3vw, 56px)` — on a 2400px viewport the content shell now occupies ~1840px (~530px of empty side margin → ~280px) without letting tabular line measure get unreadably long on 4K. The audit-page override in `app/audit/audit-styles.css` matches, narrower: `clamp(720px, 92vw, 1480px)` so the archetype hero stays composed. `.archetype-frame` itself gets a `max-width: 1320px` cap with `margin: 0 auto` so the giant Bitcount headline + pink-shadowed border don't stretch past their compositional break-point on huge screens. Prose blocks (`.arch-desc`, `.arch-tagline` at 580px max-width) and side gutters keep readability tight at every step of the clamp.
+- Subtle polish pass across all 5 pages — same brutalist pixel-craft aesthetic, more carefully made. `app/globals.css` gets a global `::selection` pink wash, an opt-in `:focus-visible` ring system that fires keyboard-only on every interactive element (`a`, `button`, `input`, `.btn`, `.tab`, `.btn-press`), and the existing `.btn` / `.btn-press` / `.tab` / `.panel` transitions are repointed at the `cubic-bezier(0.22, 1, 0.36, 1)` curve already used by `.audit-bar-fill` (away from `transition: all 120ms ease`, which was animating layout properties unintentionally). New: a `.btn:active` press-down, a `.btn-press:active` collapse, a `.tab::after` underline that emerges from center on hover for inactive tabs, and an opt-in `.panel.is-interactive` hover that grows the pink corner brackets from 10px → 16px. `app/projects/page.tsx` gains a section-mast row with the `━━ projects` glyph + folder counter, swaps "Projects" for the lowercased "your agent footprint." headline, and the empty state now renders a 6×6 pixel-grid "no projects" sigil with a 4px hard-offset pink shadow. `app/projects/loading.tsx` staggers its 8 skeleton rows with the same `audit-row-enter` keyframe used by the audit findings table and adds an `aria-busy` "loading…" pip in the mast. `app/project/[name]/page.tsx` migrates from the old shadcn-style `container mx-auto bg-card rounded-lg` chrome to the unified `.report` + `.section` + `.panel` shell — `` becomes a `.btn` with the same `━━` glyph, the path / modified pair is a tight green-eyebrow `
` grid, and the sessions block gets its own section-label mast. `components/navbar.tsx` tightens the slipping-through badge aria (pluralised, `role="status"`, native title tooltip). All new motion respects `prefers-reduced-motion: reduce` — `globals.css` and `audit-styles.css` each got a guard that stills the new tab underline, panel corner growth, button micro-motion, the audit terminal cursor blink, spinner step, marquee shine, and identity dot pulse for vestibular-sensitive users.
+
- Drop the standalone pixel icon from the top navbar (`components/navbar.tsx`) — the brand cluster is now wordmark-only. Logo resolution is also reworked: a new `useBrandLogo` hook attempts a runtime `fetch` of the remote brand URL on mount, blob-wraps the response into an object URL on success, and falls back to the bundled `/logo.svg` (served from `public/`, mirrored at `assets/logos/company/logo.svg`) on any error/non-OK status. The local fallback is also rendered as the initial state so SSR + pre-fetch frames show the brand without a flash.
- Swap the display font across the app from `Architype Stedelijk` to `Bitcount Prop Single` (the wordmark treatment used on befailproof.ai). Replaces both font binaries — `public/audit/fonts/architype-stedelijk.{woff2,ttf}` and `assets/audit/assets/fonts/architype-stedelijk.{woff2,ttf}` — with the single self-hosted static instance `bitcount-prop-single.woff2` (wght 417 + ELSH 55 baked in, so no `font-variation-settings` plumbing is needed). Both `@font-face` blocks (`app/globals.css`, `assets/audit/styles.css`) and both `--font-display` declarations are updated; the CSS variable name and fallback stack (`"VT323", "JetBrains Mono", monospace`) stay unchanged so every consumer of `var(--font-display)` picks up the new face with no further edits. Stale Architype references in the comment headers of `components/navbar.tsx`, `app/audit/_components/empty-state.tsx`, and `app/audit/_components/show-off-cta.tsx` are renamed to match.
- Pin the `/audit` report footer to the viewport bottom on the empty / running states. `ReportFooter` (`app/audit/_components/report-footer.tsx`) gains an optional `fixed` prop that adds a `report-footer--fixed` class (`position: fixed; left/right/bottom: 0; padding: 16px 32px; z-index: 10`, defined in `app/audit/audit-styles.css`); `ShellEmpty` in `audit-dashboard.tsx` passes it so the pre-run `EmptyState` and the `RunProgress` view — both short pages where the in-flow footer was floating mid-viewport and scrolling with the page — get a sticky status-bar style footer. The post-run dashboard mount is left unchanged because its long content places the footer at the document end naturally.
@@ -23,6 +31,7 @@
- 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).
### Fixes
+- Drop the literal `━━` escape sequences that were rendering as visible text inside the three `.stat-cell` eyebrow labels on the `/policies` activity tab (`app/policies/hooks-client.tsx:297,302,307`). JSX text content doesn't interpret `\uXXXX` escapes — those only work inside JS string literals — so the railroad-track glyph was painting as eight raw characters. The eyebrow rule + green accent color already give the captions enough visual weight; the decorative glyph was net negative.
- Tier-C polish + efficiency pass from the deferred-review plan. **`app/audit/_components/audit-dashboard.tsx`** — `detectorsTriggered` + `missing` were two independent O(N) scans over `result.results` per render; merged into a single `useMemo` keyed on `result`. The scroll handler now coalesces events through `requestAnimationFrame` so reading `scrollHeight` (a layout-reflow trigger) fires at most once per frame instead of dozens per second during a fast scroll. **`app/audit/_components/policies-section.tsx`** — wrapped `buildPolicyCards(result)` in `useMemo`; previously it rebuilt a `Map + Set + sort` aggregation on every parent re-render. **`app/audit/_components/identity-section.tsx`** — wrapped `pickArchetypeVariant(archetypeKey, seed)` in `useMemo`; previously re-hashed the seed string and ran four `xmur3` mix passes per axis on every IdentitySection state change (the share buttons toggle `downloadState` which rerenders us 4× per click). **`app/audit/_components/return-section.tsx`** — added a 5s throttle to the focus + visibilitychange handlers' `refreshStatus()` calls so rapid alt-tabbing doesn't thrash `/api/auth/status` (two disk reads each); also extracted a `` helper that takes a `showSetReminder` slot so the authed and anon branches stop duplicating the `[ re-audit now ]` + `[ install policies ]` buttons (they had already drifted on `marginTop` styling). **`bin/failproofai.mjs`** — `policy remove ` no longer threads `--beta` into the manager call as `betaOnly`; the manager only used `betaOnly` for telemetry tagging (`removal_mode: "beta_policies"`), so passing `--beta` to `policy remove` was a mislabel that produced ghost "beta removal" events in PostHog without affecting which policy was actually removed. The flag is dropped from this path so `beta_only: false` is emitted unconditionally — match the actual semantics. No tests changed; 1769 still pass.
- Tier-B refactor pass from the deferred-review plan. Consolidates the duplication the prior review surfaced before it can cause another drift (the prior PR already shipped `REQUEST_TIMEOUT_MS=15s` on the client and `=10s` on the server with no comment tying them together). **`lib/fetch-with-timeout.ts`** — new shared module exporting `fetchWithTimeout(input, init, timeoutMs)` and an `isAbortError(err)` predicate. Replaces three byte-equivalent implementations: the client-side helpers in `app/audit/_components/auth-dialog.tsx` + `rerun-button.tsx` (15s default) and the inline `err.name === "AbortError" || err.name === "TimeoutError"` check inside `lib/auth/api-server-client.ts`'s server-side wrapper (which keeps its `trackEvent`-on-timeout side-effect but delegates the predicate). Also pulls `app/audit/_components/return-section.tsx` onto the same `isAbortError` predicate. **`lib/atomic-write.ts`** — new shared module exporting `writeJsonAtomically(filePath, value, { mode, dirMode })`. Replaces the near-identical temp-file-then-rename dances in `lib/auth/auth-store.ts` (for `auth.json` + `next-audit.json`) and the inline version in `src/audit/dashboard-cache.ts` (added in the prior PR). Single helper means any future on-disk JSON writer gets the same crash-safety + perm-reassertion logic without copy-pasting. **`app/audit/_components/rerun-button.tsx`** — deleted the unused `` React component (exported but never rendered by anyone — the rerun UI is integrated into `return-section.tsx` and `empty-state.tsx` which call `triggerRun` directly). The deletion also drops the stale `lucide-react` / `usePostHog` / `cn` imports and a duplicate `audit_rerun_failed` capture branch that would never fire from this file. **`lib/telemetry.ts`** — `initTelemetry()` now wraps its **entire** body in a single outer try/catch with a "never throws" guarantee documented on the function. Removed the now-redundant per-route `try { await initTelemetry(); } catch {}` wrapper from `app/api/auth/login-verify/route.ts` (and the file comment now points future readers at the helper-level contract so they don't re-add a defensive wrapper). Net delta: ~30 LOC deleted across the touched files plus 2 new shared modules.
diff --git a/__tests__/lib/share-card.test.ts b/__tests__/lib/share-card.test.ts
new file mode 100644
index 00000000..867a52c4
--- /dev/null
+++ b/__tests__/lib/share-card.test.ts
@@ -0,0 +1,144 @@
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import {
+ copyOrDownloadCard,
+ downloadCard,
+ shareCardNative,
+ shareCardToastMessage,
+} from "@/lib/share-card";
+
+const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic header
+
+function makeBlob(): Blob {
+ return new Blob([PNG_BYTES], { type: "image/png" });
+}
+
+describe("lib/share-card", () => {
+ beforeEach(() => {
+ // jsdom doesn't ship URL.createObjectURL / revokeObjectURL — mock them so
+ // the download-fallback path doesn't throw out of its try block.
+ Object.defineProperty(URL, "createObjectURL", {
+ configurable: true, writable: true, value: vi.fn().mockReturnValue("blob:mock"),
+ });
+ Object.defineProperty(URL, "revokeObjectURL", {
+ configurable: true, writable: true, value: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete (globalThis as { ClipboardItem?: unknown }).ClipboardItem;
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ });
+
+ it("returns 'clipboard' when navigator.clipboard.write succeeds", async () => {
+ const write = vi.fn().mockResolvedValue(undefined);
+ (globalThis as { ClipboardItem?: unknown }).ClipboardItem = class {
+ constructor(public items: Record) {}
+ };
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { write },
+ });
+
+ const result = await copyOrDownloadCard(makeBlob(), "x.png");
+
+ expect(result).toBe("clipboard");
+ expect(write).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to 'download' when clipboard.write rejects", async () => {
+ const write = vi.fn().mockRejectedValue(new Error("denied"));
+ (globalThis as { ClipboardItem?: unknown }).ClipboardItem = class {
+ constructor(public items: Record) {}
+ };
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { write },
+ });
+ const click = vi.fn();
+ const realCreate = document.createElement.bind(document);
+ vi.spyOn(document, "createElement").mockImplementation((tag) => {
+ const node = realCreate(tag);
+ if (tag === "a") Object.defineProperty(node, "click", { value: click });
+ return node;
+ });
+
+ const result = await copyOrDownloadCard(makeBlob(), "x.png");
+
+ expect(result).toBe("download");
+ expect(click).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to 'download' when ClipboardItem is undefined", async () => {
+ // ClipboardItem absent, navigator.clipboard absent — function should go
+ // straight to the download fallback.
+ const click = vi.fn();
+ const realCreate = document.createElement.bind(document);
+ vi.spyOn(document, "createElement").mockImplementation((tag) => {
+ const node = realCreate(tag);
+ if (tag === "a") Object.defineProperty(node, "click", { value: click });
+ return node;
+ });
+
+ const result = await copyOrDownloadCard(makeBlob(), "x.png");
+
+ expect(result).toBe("download");
+ expect(click).toHaveBeenCalledTimes(1);
+ });
+
+ it("toast copy is method-specific", () => {
+ expect(shareCardToastMessage("native")).toMatch(/attached/);
+ expect(shareCardToastMessage("clipboard")).toMatch(/copied/);
+ expect(shareCardToastMessage("download")).toMatch(/downloaded/);
+ expect(shareCardToastMessage("failed")).toMatch(/couldn/i);
+ });
+
+ it("downloadCard triggers an anchor click and returns true", () => {
+ const click = vi.fn();
+ const realCreate = document.createElement.bind(document);
+ vi.spyOn(document, "createElement").mockImplementation((tag) => {
+ const node = realCreate(tag);
+ if (tag === "a") Object.defineProperty(node, "click", { value: click });
+ return node;
+ });
+
+ const ok = downloadCard(makeBlob(), "x.png");
+
+ expect(ok).toBe(true);
+ expect(click).toHaveBeenCalledTimes(1);
+ });
+
+ it("shareCardNative returns false when navigator.share is unavailable", async () => {
+ const originalShare = navigator.share;
+ Object.defineProperty(navigator, "share", { configurable: true, value: undefined });
+ try {
+ const ok = await shareCardNative(makeBlob(), "x.png", "hello");
+ expect(ok).toBe(false);
+ } finally {
+ Object.defineProperty(navigator, "share", { configurable: true, value: originalShare });
+ }
+ });
+
+ it("shareCardNative returns true when navigator.share resolves", async () => {
+ const share = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "share", { configurable: true, value: share });
+ Object.defineProperty(navigator, "canShare", { configurable: true, value: () => true });
+
+ const ok = await shareCardNative(makeBlob(), "x.png", "hello");
+
+ expect(ok).toBe(true);
+ expect(share).toHaveBeenCalledTimes(1);
+ });
+
+ it("shareCardNative returns false on AbortError (user cancelled)", async () => {
+ const abort = Object.assign(new Error("cancelled"), { name: "AbortError" });
+ const share = vi.fn().mockRejectedValue(abort);
+ Object.defineProperty(navigator, "share", { configurable: true, value: share });
+ Object.defineProperty(navigator, "canShare", { configurable: true, value: () => true });
+
+ const ok = await shareCardNative(makeBlob(), "x.png", "hello");
+
+ expect(ok).toBe(false);
+ });
+});
diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx
index dc696fe5..88170c41 100644
--- a/app/audit/_components/audit-dashboard.tsx
+++ b/app/audit/_components/audit-dashboard.tsx
@@ -21,6 +21,7 @@ import { deriveFindings } from "@/src/audit/findings";
import { usePostHog } from "@/contexts/PostHogContext";
import { IdentitySection } from "./identity-section";
+import { ShareDock } from "./share-dock";
import { StrengthsSection } from "./strengths-section";
import { ScoreSection } from "./score-section";
import { FindingsSection } from "./findings-section";
@@ -296,9 +297,6 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize
sessions={result.transcripts.scanned}
window={scopeWindow}
seed={project}
- score={score}
- grade={grade}
- missing={missing}
/>
+
);
}
diff --git a/app/audit/_components/identity-section.tsx b/app/audit/_components/identity-section.tsx
index 390dff4d..9c2df894 100644
--- a/app/audit/_components/identity-section.tsx
+++ b/app/audit/_components/identity-section.tsx
@@ -17,41 +17,11 @@
* Exposes a `frameRef` forwarded onto the `.archetype-frame` element so
* the ShowOff "make poster" action can capture it via html2canvas.
*/
-import React, { forwardRef, useMemo, useState } from "react";
+import React, { forwardRef, useMemo } from "react";
import { ARCHETYPES, pickArchetypeVariant, type ArchetypeKey } from "@/src/audit/archetypes";
import { type Grade } from "@/src/audit/scoring";
-import { usePostHog } from "@/contexts/PostHogContext";
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 {
- // "every key policy is live" is only true when the audit returned no
- // unenabled prescribed policies. A-grade with a non-zero `missing` count
- // is the "almost there but still has gaps" state — softer copy.
- const cleanRun = grade === "S" || (grade === "A" && missing === 0);
- const verdict = cleanRun
- ? `${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;
@@ -61,117 +31,19 @@ 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, score, grade, missing }: Props,
+ { archetypeKey, secondaryKey, toolCalls, sessions, window, seed }: Props,
frameRef,
) {
// `pickArchetypeVariant` re-hashes the seed string via djb2 + 4 mix
- // passes per axis. Deterministic over (archetypeKey, seed) so memoize
- // — the share buttons toggle `downloadState` which rerenders us 4×.
+ // passes per axis. Deterministic over (archetypeKey, seed) so memoize.
const archetype = useMemo(
() => pickArchetypeVariant(archetypeKey, seed),
[archetypeKey, seed],
);
const secondary = secondaryKey !== archetypeKey ? ARCHETYPES[secondaryKey] : null;
- const { capture } = usePostHog();
- 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;
- capture("audit_card_download_clicked", {
- score,
- grade,
- missing_policies: missing,
- });
- setDownloadState("busy");
- try {
- const captured = await captureCard();
- capture("audit_card_capture_completed", {
- trigger: "download",
- status: captured ? "success" : "no_frame",
- });
- setDownloadState("done");
- setTimeout(() => setDownloadState("idle"), 2000);
- } catch {
- capture("audit_card_capture_completed", {
- trigger: "download",
- status: "error",
- });
- setDownloadState("error");
- setTimeout(() => setDownloadState("idle"), 2000);
- }
- };
-
- const handleShareX = async () => {
- const text = buildXTemplate(score, archetype.name, grade, missing);
- capture("audit_card_share_clicked", {
- channel: "x",
- score,
- grade,
- missing_policies: missing,
- });
- const captured = await captureCard().catch(() => false);
- capture("audit_card_capture_completed", {
- trigger: "share_x",
- status: captured ? "success" : "error",
- });
- globalThis.open(X_INTENT(text), "_blank", "noopener,noreferrer");
- };
-
- const handleShareLI = async () => {
- const text = buildLinkedInTemplate(score, archetype.name, grade, missing);
- capture("audit_card_share_clicked", {
- channel: "linkedin",
- score,
- grade,
- missing_policies: missing,
- });
- const captured = await captureCard().catch(() => false);
- capture("audit_card_capture_completed", {
- trigger: "share_linkedin",
- status: captured ? "success" : "error",
- });
- globalThis.open(LI_INTENT(text), "_blank", "noopener,noreferrer");
- };
return (
@@ -248,31 +120,6 @@ export const IdentitySection = forwardRef(function Identi
-
-
-
-
-
-
);
diff --git a/app/audit/_components/share-dock.tsx b/app/audit/_components/share-dock.tsx
new file mode 100644
index 00000000..be6d25f7
--- /dev/null
+++ b/app/audit/_components/share-dock.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+/**
+ * Floating share dock — always-visible bottom-right panel on the /audit
+ * dashboard. Three buttons (X, LinkedIn, download) that all share the
+ * `.share-btn` styling used by the inline buttons in `identity-section`,
+ * so the visual rhythm carries between surfaces.
+ *
+ * Capture flow:
+ * 1. Dock click → ref'd `.archetype-frame` is captured to a PNG blob
+ * via html2canvas.
+ * 2. `copyOrDownloadCard` writes the blob to the clipboard if the
+ * browser supports it, falling back to a download. The user gets a
+ * toast telling them what to do next.
+ * 3. For shares, the X / LinkedIn intent window opens with the
+ * templated text.
+ *
+ * UX:
+ * - Collapses to a single 48px pink FAB via a header caret. Preference
+ * persists across page navigations within a session.
+ * - Slide-in animation on mount (respects prefers-reduced-motion).
+ * - Hidden on viewports < 760px (mobile) — the inline buttons are
+ * already visible there and a floating dock would cover content.
+ * - Hidden until the archetype hero has actually mounted (frameRef
+ * resolves to a node). On the empty / running states we render
+ * nothing.
+ */
+import React, { useEffect, useState } from "react";
+import { type ArchetypeKey, pickArchetypeVariant } from "@/src/audit/archetypes";
+import { type Grade } from "@/src/audit/scoring";
+import { copyOrDownloadCard, downloadCard, shareCardNative, shareCardToastMessage } from "@/lib/share-card";
+import { toast } from "@/app/components/toast";
+import { usePostHog } from "@/contexts/PostHogContext";
+
+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)}`;
+
+const COLLAPSED_KEY = "failproofai:audit:share-dock-collapsed";
+
+interface Props {
+ /** Ref to the `.archetype-frame` to capture. Same node `identity-section`
+ * forwards into IdentitySection's `frameRef`. */
+ frameRef: React.RefObject;
+ archetypeKey: ArchetypeKey;
+ /** Seed for deterministic archetype-variant copy. Same as IdentitySection's
+ * `seed` prop — usually the inferred project name. */
+ seed: string;
+ score: number;
+ grade: Grade;
+ missing: number;
+}
+
+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 cleanRun = grade === "S" || (grade === "A" && missing === 0);
+ const verdict = cleanRun
+ ? `${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}`;
+}
+
+export function ShareDock({ frameRef, archetypeKey, seed, score, grade, missing }: Props) {
+ const [collapsed, setCollapsed] = useState(false);
+ const [busy, setBusy] = useState(null);
+ const { capture } = usePostHog();
+ const archetype = pickArchetypeVariant(archetypeKey, seed);
+ const archetypeDisplayName = archetype.name;
+
+ // Restore collapsed preference from sessionStorage on first mount.
+ useEffect(() => {
+ try {
+ if (typeof window !== "undefined" && window.sessionStorage.getItem(COLLAPSED_KEY) === "1") {
+ setCollapsed(true);
+ }
+ } catch { /* private mode, etc. — fall through to default */ }
+ }, []);
+
+ const toggle = (next: boolean) => {
+ setCollapsed(next);
+ try { window.sessionStorage.setItem(COLLAPSED_KEY, next ? "1" : "0"); } catch { /* ignore */ }
+ capture("audit_share_dock_toggled", { collapsed: next });
+ };
+
+ const captureCardBlob = async (): Promise => {
+ const node = frameRef.current;
+ if (!node) return null;
+ 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,
+ });
+ return await new Promise((resolve) => {
+ canvas.toBlob((blob) => resolve(blob), "image/png");
+ });
+ } finally {
+ node.classList.remove("capturing");
+ }
+ };
+
+ const filenameFor = (channel: "x" | "linkedin" | "download") =>
+ `failproofai-${channel}-${grade.toLowerCase()}-${score}.png`;
+
+ const handleShare = async (channel: "x" | "linkedin" | "download") => {
+ if (busy) return;
+ setBusy(channel);
+ capture("audit_card_share_clicked", {
+ channel: channel === "download" ? "download" : channel,
+ source: "dock",
+ score,
+ grade,
+ missing_policies: missing,
+ });
+ try {
+ const blob = await captureCardBlob().catch(() => null);
+ if (!blob) {
+ capture("audit_card_capture_completed", {
+ trigger: channel === "download" ? "download" : `share_${channel}`,
+ status: "error",
+ image_method: "failed",
+ source: "dock",
+ });
+ toast(shareCardToastMessage("failed"));
+ return;
+ }
+
+ // Dedicated "save audit-card" button: always download, never clipboard.
+ if (channel === "download") {
+ const ok = downloadCard(blob, filenameFor(channel));
+ const method = ok ? "download" : "failed";
+ capture("audit_card_capture_completed", {
+ trigger: "download",
+ status: ok ? "success" : "error",
+ image_method: method,
+ source: "dock",
+ });
+ toast(shareCardToastMessage(method));
+ return;
+ }
+
+ // X / LinkedIn share: try the native share sheet with file first
+ // (one-tap "image attached" on iOS / Android / recent Safari / recent
+ // Chrome). Fall back to clipboard + opening the intent URL when the
+ // native share sheet isn't available or the user dismissed it.
+ const shareText = channel === "x"
+ ? buildXTemplate(score, archetypeDisplayName, grade, missing)
+ : buildLinkedInTemplate(score, archetypeDisplayName, grade, missing);
+ const native = await shareCardNative(blob, filenameFor(channel), shareText);
+ if (native) {
+ capture("audit_card_capture_completed", {
+ trigger: `share_${channel}`,
+ status: "success",
+ image_method: "native",
+ source: "dock",
+ });
+ toast(shareCardToastMessage("native"));
+ return;
+ }
+
+ const fallbackMethod = await copyOrDownloadCard(blob, filenameFor(channel));
+ capture("audit_card_capture_completed", {
+ trigger: `share_${channel}`,
+ status: "success",
+ image_method: fallbackMethod,
+ source: "dock",
+ });
+ toast(shareCardToastMessage(fallbackMethod));
+ const intent = channel === "x"
+ ? X_INTENT(shareText)
+ : LI_INTENT(shareText);
+ globalThis.open(intent, "_blank", "noopener,noreferrer");
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ // Render the collapsed FAB. Single pink-tile pulse that re-expands the dock.
+ if (collapsed) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+ShareDock.displayName = "ShareDock";
diff --git a/app/audit/_components/sigil.tsx b/app/audit/_components/sigil.tsx
index 0dc23b18..32e7a75e 100644
--- a/app/audit/_components/sigil.tsx
+++ b/app/audit/_components/sigil.tsx
@@ -7,9 +7,21 @@
* . = 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.
+ * Two rendering modes:
+ *
+ * • Default (used on the audit hero, identity-section): wraps the grid in
+ * a brutalist "instrument plate" — register crosshairs at the four
+ * corners, a header strip with the archetype index + an 8×8 coordinate
+ * label, the pixel grid mounted on a dashed inner frame with 20px cells,
+ * a footer strip naming the archetype, and a stacked pink + dim hard-
+ * offset shadow for depth. Cells fade in along a diagonal wave on
+ * mount (`--cx` / `--cy` custom properties), guarded by
+ * `prefers-reduced-motion`.
+ *
+ * • hideLabel (used in the ShowOff CTA + html2canvas capture): a bare
+ * `.sigil` grid with no plate or labels, so the showoff card can scale
+ * the sigil down independently and html2canvas doesn't have to capture
+ * the new plate chrome.
*/
import React from "react";
import { ARCHETYPES, SIGILS, type ArchetypeKey } from "@/src/audit/archetypes";
@@ -33,19 +45,49 @@ export function Sigil({ archetypeKey, hideLabel }: Props) {
else if (c === "p") cls += " p";
else if (c === "g") cls += " g";
else if (c === "d") cls += " d";
- cells.push();
+ cells.push(
+ ,
+ );
}
}
+ if (hideLabel) {
+ return (
+
- to configure policies,{" "}
-
+ want to change which policies fire?
- >
- ) : (
- "switch policies on or off across your installed agent CLIs."
- )}
-
+
+
+
+ ) : (
+
+ switch policies on or off across your installed agent CLIs.
+
+ )}
diff --git a/app/project/[name]/page.tsx b/app/project/[name]/page.tsx
index 0a1aa092..918c7fbd 100644
--- a/app/project/[name]/page.tsx
+++ b/app/project/[name]/page.tsx
@@ -13,7 +13,6 @@ import { notFound } from "next/navigation";
import { existsSync } from "fs";
import { stat } from "fs/promises";
import Link from "next/link";
-import { ArrowLeft } from "lucide-react";
import { formatDate } from "@/lib/format-date";
import SessionsList from "@/app/components/sessions-list";
@@ -115,50 +114,101 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
const displayPath = claudeExists && claudeProjectPath ? claudeProjectPath : canonicalRoot;
return (
-
-
- Session files will appear here once they are created.
-
-
- ) : (
-
+
+
+ path
+
+
{displayPath}
+ {lastModifiedFormatted && (
+ <>
+
+ modified
+
+
{lastModifiedFormatted}
+ >
)}
+
+
+
+
+ ━━ sessions
+
-
+
+ {sessionFiles.length === 0 ? (
+
+
+ no .jsonl files found in this project.
+
+
+ session files will appear here once they are created.
+
+
+ ) : (
+
+
+
+ )}
+
);
}
diff --git a/app/projects/loading.tsx b/app/projects/loading.tsx
index a8804495..4fea943b 100644
--- a/app/projects/loading.tsx
+++ b/app/projects/loading.tsx
@@ -1,18 +1,35 @@
/** Skeleton loading UI for the projects page — audit-styled to match
- * the dashed `.panel` chrome of the loaded state. */
+ * the dashed `.panel` chrome of the loaded state. Staggered fade-in on the
+ * rows gives the skeleton its own rhythm rather than every bar blinking in
+ * lockstep. */
export default function ProjectsLoading() {
return (
+
- no projects found in the .claude/projects directory.
+ no projects found in the .claude/projects directory.
- make sure the directory exists and contains project folders.
+ run an agent in any folder and it will show up here.
) : (
diff --git a/components/navbar.tsx b/components/navbar.tsx
index 3d406af6..51332a85 100644
--- a/components/navbar.tsx
+++ b/components/navbar.tsx
@@ -114,6 +114,7 @@ export const Navbar: React.FC<{
{label}
{showAuditBadge && (
{auditSlippingCount}
diff --git a/lib/share-card.ts b/lib/share-card.ts
new file mode 100644
index 00000000..9f448b7b
--- /dev/null
+++ b/lib/share-card.ts
@@ -0,0 +1,120 @@
+/**
+ * Helpers for attaching the audit-card PNG to a social share.
+ *
+ * X and LinkedIn intent URLs cannot carry image attachments — only text
+ * and a target URL. To actually get an image into a post we have three
+ * options at different levels of "automatic":
+ *
+ * 1. `shareCardNative` → `navigator.share({ files })` — the system
+ * share sheet handles the image + text in one step. Works on
+ * iOS, Android, recent Safari, recent Chrome desktop. When the
+ * user picks their target (X / LinkedIn / …) the image is
+ * already attached.
+ * 2. `copyOrDownloadCard` → clipboard + open intent URL — the user
+ * pastes (⌘/Ctrl+V) in the share dialog and the image attaches.
+ * 3. `downloadCard` → straight download to disk, no other side
+ * effects. Used by the dedicated "save audit-card" button so
+ * the user gets a file every time, regardless of clipboard
+ * permissions.
+ */
+
+export type ShareCardMethod = "native" | "clipboard" | "download" | "failed";
+
+/**
+ * Try the native Web Share API with a file attachment. Returns `true` on
+ * success, `false` if the API is unavailable, files-sharing isn't
+ * supported, or the user dismissed the sheet. Caller should fall back to
+ * `copyOrDownloadCard` on `false`.
+ */
+export async function shareCardNative(
+ blob: Blob,
+ filename: string,
+ text: string,
+): Promise {
+ if (typeof navigator === "undefined" || typeof navigator.share !== "function") {
+ return false;
+ }
+ const file = new File([blob], filename, { type: "image/png" });
+ // Some browsers (older Chrome desktop) expose `navigator.share` but reject
+ // file payloads. `canShare({ files })` gates that cleanly.
+ type CanShareNav = Navigator & { canShare?: (data: ShareData) => boolean };
+ const navWithCan = navigator as CanShareNav;
+ if (typeof navWithCan.canShare === "function" && !navWithCan.canShare({ files: [file] })) {
+ return false;
+ }
+ try {
+ await navigator.share({ files: [file], text });
+ return true;
+ } catch (err) {
+ // AbortError → user dismissed the sheet. Treat as a soft failure so the
+ // caller can decide whether to open the intent URL anyway.
+ if (err instanceof Error && err.name === "AbortError") return false;
+ return false;
+ }
+}
+
+/**
+ * Copy a captured share-card PNG to the system clipboard, or fall back to a
+ * download if the browser doesn't support image clipboard writes (or the
+ * user denied permission). The caller MUST be inside an active user gesture
+ * (click handler) — Chromium gates `navigator.clipboard.write` on user
+ * activation.
+ */
+export async function copyOrDownloadCard(
+ blob: Blob,
+ filename: string,
+): Promise> {
+ if (
+ typeof ClipboardItem !== "undefined"
+ && typeof navigator !== "undefined"
+ && navigator.clipboard
+ && typeof navigator.clipboard.write === "function"
+ ) {
+ try {
+ await navigator.clipboard.write([
+ new ClipboardItem({ "image/png": blob }),
+ ]);
+ return "clipboard";
+ } catch {
+ /* permission denied, secure-context issue, browser fence, … */
+ }
+ }
+ return downloadCard(blob, filename) ? "download" : "failed";
+}
+
+/**
+ * Trigger a local download of the PNG. Returns `true` on success. No
+ * clipboard interaction — used by the explicit "save audit-card" CTA so the
+ * user reliably gets a file even when clipboard write would succeed.
+ */
+export function downloadCard(blob: Blob, filename: string): boolean {
+ try {
+ if (typeof document === "undefined" || typeof URL === "undefined") return false;
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ queueMicrotask(() => URL.revokeObjectURL(url));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Human-readable toast copy keyed by share method. */
+export function shareCardToastMessage(method: ShareCardMethod): string {
+ switch (method) {
+ case "native":
+ return "✅ image attached — pick where to post";
+ case "clipboard":
+ return "📋 image copied — paste it in the post (⌘/Ctrl+V)";
+ case "download":
+ return "⬇ image downloaded — attach it to your post";
+ case "failed":
+ return "couldn't capture image — opening text-only share";
+ }
+}