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 ( +
+
{cells}
+
+ ); + } + + const indexLabel = String(archetype.index).padStart(2, "0"); + return (
-
{cells}
- {!hideLabel && ( -
- №{archetype.index} - sigil +
+
); } diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 7041c893..3d55fd04 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -192,10 +192,18 @@ /* ───────────────────────── audit page shell ───────────────────────── */ +/* the audit page intentionally runs narrower than the global .report — the + archetype hero, findings cards, and score-share card are tuned for a + ~1400px-max layout where the headline line measure stays "billboard" + tight. On a 2400px+ monitor we still want to break out of the 1180px + feel that used to leave too much black on both flanks; clamp up to + 1480px and let `.archetype-frame` cap itself further so the hero doesn't + stretch past its compositional break-point. */ .report { - max-width: 1180px; + width: 100%; + max-width: clamp(720px, 92vw, 1480px); margin: 0 auto; - padding: 0 32px; + padding: 0 clamp(20px, 3vw, 48px); } .section { @@ -242,6 +250,11 @@ .archetype-frame { position: relative; + /* hero composition was tuned for ~1180px max — let it grow a little but + don't let the giant Bitcount name turn into a billboard on 4K */ + max-width: 1320px; + margin-left: auto; + margin-right: auto; border: 1px solid var(--line-2); background: repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), @@ -295,6 +308,9 @@ 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } } +@media (prefers-reduced-motion: reduce) { + .arch-target .dot-live { animation: none; } +} .arch-counter { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); @@ -432,32 +448,139 @@ padding-top: 22px; } -/* sigil — 8x8 pixel grid */ +/* ───────────────────────── sigil — brutalist instrument plate ───────────── + The audit hero's central image. Was a bare 16px-cell pixel grid that + felt visually underweight next to the 124px Bitcount archetype name + and underdesigned compared to the dashed-frame panels everywhere else. + + Treatment: an "instrument card" — register crosshairs at corners, header + strip with index + 8×8 coordinate, the grid on a dashed inner frame + with 20px cells, footer strip with the archetype slug, and a stacked + pink + black hard-offset shadow for depth. + + The `data-bare` variant (used by the ShowOff CTA via `hideLabel`) + skips the plate and renders a bare grid so the showoff card can scale + the sigil down independently and the html2canvas poster export doesn't + need to capture the new plate chrome. */ + .sigil-wrap { - display: flex; flex-direction: column; align-items: center; gap: 16px; + display: flex; flex-direction: column; align-items: center; justify-self: center; } + +.sigil-plate { + position: relative; + padding: 18px 22px; + background: + /* faint pink+green gridlines — same recipe as `.archetype-frame` so the + sigil reads as a smaller specimen of the same instrument family */ + repeating-linear-gradient(0deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 12px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.04) 0 1px, transparent 1px 12px), + var(--bg); + border: 1px solid var(--line-2); + /* stacked hard-offsets: pink at the front, black behind for depth */ + box-shadow: + 8px 8px 0 0 var(--accent-pink-shadow), + 16px 16px 0 0 rgba(0, 0, 0, 0.55); +} + +/* register crosshair marks at the four corners — CSS-only "+" rendered + from two 1px bars so they stay pixel-crisp at any zoom */ +.sigil-mark { + position: absolute; + width: 10px; height: 10px; + color: var(--accent-pink); + opacity: 0.75; + pointer-events: none; +} +.sigil-mark::before, +.sigil-mark::after { + content: ""; + position: absolute; + background: currentColor; +} +.sigil-mark::before { top: 50%; left: 0; right: 0; height: 1px; } +.sigil-mark::after { left: 50%; top: 0; bottom: 0; width: 1px; } +.sigil-mark.tl { top: 6px; left: 6px; } +.sigil-mark.tr { top: 6px; right: 6px; } +.sigil-mark.bl { bottom: 6px; left: 6px; } +.sigil-mark.br { bottom: 6px; right: 6px; } + +/* the header (top) and footer (bottom) strips of the plate */ +.sigil-strip { + display: flex; justify-content: space-between; align-items: baseline; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.sigil-strip--top { + padding-bottom: 10px; + margin-bottom: 14px; + border-bottom: 1px dashed var(--line); +} +.sigil-strip--bot { + padding-top: 10px; + margin-top: 14px; + border-top: 1px dashed var(--line); +} +.sigil-ix { color: var(--accent-pink); } +.sigil-coord { color: var(--dim); } +.sigil-strip-key { color: var(--dim); } +.sigil-strip-val { color: var(--accent-green); } + +/* the actual 8×8 grid — bumped from 16px → 20px cells so the sigil + carries enough visual weight to sit next to the archetype name */ .sigil { display: grid; - grid-template-columns: repeat(8, 16px); - grid-template-rows: repeat(8, 16px); + grid-template-columns: repeat(8, 20px); + grid-template-rows: repeat(8, 20px); gap: 2px; - padding: 16px; + padding: 12px; + background: var(--bg-2); + border: 1px dashed var(--line); + margin: 0 auto; +} +/* When the sigil is rendered bare (ShowOff path), restore the original + single-shadow look so the showoff card stays compositionally what it + was before the plate landed. */ +.sigil-wrap[data-bare="true"] .sigil { 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 { + background: transparent; + /* diagonal fade-in: cells light up along an (x+y) wave starting from + the top-left when the hero first paints. Total animation ≈ 600ms. */ + opacity: 0; + animation: sigil-cell-in 280ms cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation-delay: calc((var(--cx, 0) + var(--cy, 0)) * 22ms); +} .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 .px.p { + background: var(--accent-pink); + /* subtle inner glow on accent cells so they pop without breaking pixel-craft */ + box-shadow: 0 0 4px rgba(228, 88, 125, 0.45); +} +.sigil .px.g { + background: var(--accent-green); + box-shadow: 0 0 4px rgba(102, 209, 181, 0.35); +} +.sigil .px.d { background: var(--dim); } + +@keyframes sigil-cell-in { + from { opacity: 0; transform: scale(0.4); } + to { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .sigil .px { + animation: none; + opacity: 1; + transform: 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 @@ -519,57 +642,147 @@ justify-self: center; padding-top: 0; } -.archetype-frame.capturing .sigil { - box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +/* html2canvas renders stacked box-shadows unreliably and won't run our + sigil-cell-in animation. Collapse to a single hard offset on the plate + and force every cell to its final state for the capture. */ +.archetype-frame.capturing .sigil-plate { + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.archetype-frame.capturing .sigil { box-shadow: none; } +.archetype-frame.capturing .sigil .px { + animation: none; + opacity: 1; + transform: none; } -/* 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; +/* ──────────────── identity share buttons (chunky CTAs) ──────────────── + Three branded buttons that sit below the archetype card. Each leads + with a platform mark tile (𝕏 / in / ↓), then a small "share on" / "save" + eyebrow above the platform label, then a trailing arrow. The buttons + read as armed at rest via a 2px pink right-edge stroke; on hover the + stroke fills the full border and a hard-offset pink shadow appears. + On press they translate (2px, 2px) and the shadow collapses — matches + the .btn-press motion language used elsewhere. + + The shared `.share-btn` class is also used by the floating ShareDock so + the visual rhythm carries between surfaces. */ + +.identity-share-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-top: 22px; + padding-top: 18px; border-top: 1px dashed var(--line); } -.identity-share-btn { - display: flex; +@media (max-width: 720px) { + .identity-share-grid { grid-template-columns: 1fr; } +} + +.share-btn { + position: relative; + display: grid; + grid-template-columns: 44px 1fr auto; align-items: center; - gap: 8px; - padding: 8px 14px; + gap: 14px; + padding: 12px 18px; background: var(--bg); - border: 1px solid var(--line); - color: var(--ink-2); + border: 1px solid var(--line-2); + /* pink right-edge stroke at rest — reads as armed */ + border-right: 2px solid var(--accent-pink-soft); + color: var(--ink); font-family: var(--font-mono); - font-size: 11px; - letter-spacing: 0.04em; + text-align: left; cursor: pointer; - transition: background 120ms, border-color 120ms, color 120ms; - text-transform: lowercase; + transition: + border-color 160ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 160ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1), + transform 160ms cubic-bezier(0.22, 1, 0.36, 1); } -.identity-share-btn:hover { - background: var(--accent-pink-bg); +.share-btn:hover { border-color: var(--accent-pink); - color: var(--accent-pink); + background: var(--accent-pink-bg); + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transform: translate(-1px, -1px); +} +.share-btn:active { + box-shadow: 0 0 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); } -.identity-share-btn:disabled { - opacity: 0.5; +.share-btn:disabled { + opacity: 0.55; cursor: not-allowed; + border-right-color: var(--line); } -.isb-glyph { +.share-btn:disabled:hover { + background: var(--bg); + border-color: var(--line-2); + border-right-color: var(--line); + box-shadow: none; + transform: none; +} + +.share-btn-mark { display: grid; place-items: center; - width: 20px; height: 20px; - border: 1px solid var(--line); - font-family: var(--font-mono); font-size: 10px; + width: 44px; height: 44px; + font-family: var(--font-mono); + font-size: 22px; + font-weight: 700; + line-height: 1; + border: 1px solid var(--line-2); + /* corner-crosshair adornment — matches the sigil-plate's register marks */ + background: + linear-gradient(to right, var(--accent-pink) 0 6px, transparent 6px) top left/100% 1px no-repeat, + linear-gradient(to bottom, var(--accent-pink) 0 6px, transparent 6px) top left/1px 100% no-repeat, + linear-gradient(to left, var(--accent-pink) 0 6px, transparent 6px) bottom right/100% 1px no-repeat, + linear-gradient(to top, var(--accent-pink) 0 6px, transparent 6px) bottom right/1px 100% no-repeat, + var(--bg-2); + color: var(--ink); +} +.share-btn-mark--x { color: #ffffff; background-color: #000000; } +.share-btn-mark--li { + color: #ffffff; + background-color: #0a66c2; + /* override the pink-crosshair gradients so the LinkedIn blue tile reads cleanly */ + background-image: none; +} +.share-btn-mark--dl { color: var(--accent-pink); } + +.share-btn-body { + display: flex; flex-direction: column; + gap: 2px; min-width: 0; +} +.share-btn-eyebrow { + font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.share-btn-label { + font-size: 13px; + letter-spacing: 0.05em; + color: var(--ink); + font-weight: 500; + /* avoid spilling on long localized labels */ + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.share-btn-arrow { + font-family: var(--font-mono); + font-size: 18px; color: var(--accent-pink); - font-weight: 600; - flex-shrink: 0; + opacity: 0.7; + transition: opacity 160ms cubic-bezier(0.22, 1, 0.36, 1), transform 160ms cubic-bezier(0.22, 1, 0.36, 1); +} +.share-btn:hover .share-btn-arrow { opacity: 1; transform: translateX(2px); } + +@media (prefers-reduced-motion: reduce) { + .share-btn, .share-btn-arrow { transition: none; } + .share-btn:hover { transform: none; } } -.identity-share-btn:hover .isb-glyph { border-color: var(--accent-pink); } -/* hide during html2canvas capture */ -.archetype-frame.capturing .identity-share-btns { display: none; } +/* hide during html2canvas capture so the buttons don't render into the poster */ +.archetype-frame.capturing .identity-share-grid { display: none; } /* ───────────────────────── 02 STRENGTHS ───────────────────────── */ @@ -1076,9 +1289,12 @@ margin: -40px -28px; /* shrink the embedded sigil without rebuilding it */ } -.showoff-glyph .sigil { +.showoff-glyph .sigil-wrap[data-bare="true"] .sigil { box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); } +/* legacy: pre-plate sigil rendered a .sigil-label sibling. The new plate + renders strip rows inside the plate instead, but keep this null-rule so + any cached HTML between deploys doesn't surface a stray label. */ .showoff-glyph .sigil-label { display: none; } .showoff-copy { display: flex; @@ -1724,3 +1940,127 @@ .strength-metric { grid-column: 2; text-align: left; margin-top: 6px; } .return-hook { padding: 28px 24px; } } + +/* reduced-motion: silence the ambient terminal motion (blinking cursor, + spinner, marquee shine, status pulse) so vestibular-sensitive users get a + still UI. Functional progress (.running-bar-fill width) still updates; + only the decorative loops are stopped. */ +@media (prefers-reduced-motion: reduce) { + .running-cursor, + .running-stage-spin, + .running-bar-fill::after, + .auth-status-pill .dot { + animation: none; + } + .running-bar-fill { transition: none; } +} + +/* ──────────────── floating share dock ──────────────── + Persistent share affordance pinned to the bottom-right of the audit + page. Uses the same .share-btn styling as the inline strip in + identity-section so the visual rhythm carries. The dock can be + collapsed to a single pink FAB; preference persists in sessionStorage. */ + +.share-dock { + position: fixed; + right: clamp(16px, 2.5vw, 32px); + bottom: clamp(16px, 2.5vw, 32px); + z-index: 40; + width: 300px; + background: var(--bg-2); + border: 1px solid var(--line-2); + box-shadow: + 8px 8px 0 0 var(--accent-pink-shadow), + 16px 16px 0 0 rgba(0, 0, 0, 0.5); + animation: share-dock-in 320ms cubic-bezier(0.22, 1, 0.36, 1); +} +.share-dock::before, +.share-dock::after { content: ""; position: absolute; width: 10px; height: 10px; } +.share-dock::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.share-dock::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} + +@keyframes share-dock-in { + from { opacity: 0; transform: translate(8px, 8px); } + to { opacity: 1; transform: translate(0, 0); } +} + +.share-dock-head { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px dashed var(--line); +} +.share-dock-eyebrow { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.share-dock-eyebrow > span { color: var(--accent-pink); margin-right: 8px; letter-spacing: -2px; } +.share-dock-caret { + display: grid; place-items: center; + width: 24px; height: 24px; + background: transparent; + border: 1px solid var(--line-2); + color: var(--ink-2); + font-family: var(--font-mono); font-size: 11px; + cursor: pointer; + transition: color 140ms cubic-bezier(0.22, 1, 0.36, 1), border-color 140ms cubic-bezier(0.22, 1, 0.36, 1); +} +.share-dock-caret:hover { color: var(--accent-pink); border-color: var(--accent-pink); } + +.share-dock-stack { + display: flex; flex-direction: column; + gap: 8px; + padding: 14px 14px 10px; +} +/* Dock buttons fill the dock width — same `.share-btn` skin, just full-width. */ +.share-dock-stack .share-btn { width: 100%; } + +.share-dock-foot { + padding: 10px 16px 12px; + border-top: 1px dashed var(--line); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); + text-align: center; +} +.share-dock-foot > span { color: var(--accent-pink); margin-right: 6px; } + +/* Collapsed-state FAB — single pink-tile button that re-expands the dock. */ +.share-dock-fab { + position: fixed; + right: clamp(16px, 2.5vw, 32px); + bottom: clamp(16px, 2.5vw, 32px); + z-index: 40; + width: 56px; height: 56px; + display: grid; place-items: center; + background: var(--accent-pink); + color: var(--bg); + border: 1px solid var(--accent-pink); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); + font-family: var(--font-mono); + font-size: 22px; font-weight: 700; + cursor: pointer; + transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 160ms cubic-bezier(0.22, 1, 0.36, 1); + animation: share-dock-in 320ms cubic-bezier(0.22, 1, 0.36, 1); +} +.share-dock-fab:hover { transform: translate(-2px, -2px); box-shadow: 9px 9px 0 0 var(--accent-pink-shadow); } +.share-dock-fab:active { transform: translate(2px, 2px); box-shadow: 0 0 0 0 var(--accent-pink-shadow); } + +/* hide on mobile — inline buttons cover the same affordance there */ +@media (max-width: 760px) { + .share-dock, .share-dock-fab { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .share-dock, .share-dock-fab { animation: none; } + .share-dock-fab { transition: none; } + .share-dock-caret { transition: none; } +} diff --git a/app/globals.css b/app/globals.css index ba5b9139..df777e86 100644 --- a/app/globals.css +++ b/app/globals.css @@ -100,7 +100,7 @@ html, body, #root { background: var(--bg); color: var(--ink); font-family: var(--font-mono); - font-size: 14.5px; + font-size: 16px; line-height: 1.6; -webkit-font-smoothing: antialiased; min-height: 100vh; @@ -155,6 +155,35 @@ select { color-scheme: dark; } select option { background-color: var(--bg-2); color: var(--ink); } input[type="date"] { color-scheme: dark; } +/* selection — pink wash ties prose snippets and code-like fragments to the + accent without redesigning anything; used app-wide. */ +::selection { + background: var(--accent-pink-bg); + color: var(--accent-light); +} +::-moz-selection { + background: var(--accent-pink-bg); + color: var(--accent-light); +} + +/* focus-visible — keyboard users get a clear pink ring on every interactive + element. Pointer users see nothing different (`:focus-visible` only fires + on keyboard-driven focus). Bracketed outline + outline-offset matches the + pixel-craft chrome (no rounded glow). */ +:where(a, button, [role="button"], input, textarea, select, summary, [tabindex]):focus-visible { + outline: 1px solid var(--accent-pink); + outline-offset: 2px; +} +.btn:focus-visible, +.tab:focus-visible { + outline: 1px solid var(--accent-pink); + outline-offset: 3px; +} +.btn-press:focus-visible { + outline: 1px solid var(--accent-pink); + outline-offset: 4px; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -248,18 +277,34 @@ input[type="date"] { 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; + font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.04em; border: 1px solid var(--line-2); background: transparent; color: var(--ink); - transition: all 120ms ease; white-space: nowrap; + /* limit transitioned props so width/height/font don't animate, and use the + same cubic-bezier curve as .audit-bar-fill for a coherent motion feel */ + transition: + color 140ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 140ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 140ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), + transform 140ms cubic-bezier(0.22, 1, 0.36, 1); + white-space: nowrap; } .btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn:active { transform: translateY(1px); } +.btn[aria-disabled="true"], .btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn[aria-disabled="true"]:hover, .btn:disabled:hover { border-color: var(--line-2); background: transparent; } .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; + transition: + box-shadow 140ms cubic-bezier(0.22, 1, 0.36, 1), + transform 140ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 140ms cubic-bezier(0.22, 1, 0.36, 1), + color 140ms cubic-bezier(0.22, 1, 0.36, 1); } .btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } +.btn-press:active { box-shadow: 0 0 0 0 var(--accent-pink-shadow); transform: translate(4px, 4px); } /* primary tab strip — used by both the audit page and the policies/projects nav rows */ .tabs { @@ -269,26 +314,49 @@ input[type="date"] { color-scheme: dark; } } .tabs::-webkit-scrollbar { display: none; } .tab { + position: relative; display: inline-flex; align-items: center; gap: 8px; padding: 12px 16px; - font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + font-family: var(--font-mono); font-size: 13px; 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; + transition: + color 160ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 160ms cubic-bezier(0.22, 1, 0.36, 1); + white-space: nowrap; background: transparent; } .tab:hover { color: var(--ink); } +/* faint underline preview on hover — emerges from center on inactive tabs; + active state keeps the existing sharp full-width pink line. */ +.tab::after { + content: ""; + position: absolute; + left: 16px; right: 16px; bottom: -1px; + height: 1px; + background: var(--accent-pink); + transform: scaleX(0); + transform-origin: center; + opacity: 0.4; + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.tab:hover::after { transform: scaleX(1); } .tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } +.tab.is-active::after { display: none; } /* ───────────────────────── canonical page chrome ───────────────────────── */ +/* the canonical content shell. Scales to fill ultrawide monitors without + collapsing on mobile or letting prose lines get unreadable. The cap at + 1840px keeps the line measure for tabular pages (policies activity, + sessions list) comfortably below "scan back to start of next row" length + on a 2560px display. Side gutter is clamped 20→56px so the chrome breathes + on big screens without crowding small ones. */ .report { - max-width: 1380px; + width: 100%; + max-width: clamp(720px, 96vw, 1840px); margin: 0 auto; - padding: 0 40px; -} -@media (max-width: 720px) { - .report { padding: 0 20px; } + padding: 0 clamp(20px, 3vw, 56px); } .section { @@ -304,14 +372,14 @@ input[type="date"] { color-scheme: dark; } } .section-label { font-family: var(--font-mono); - font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + font-size: 12px; 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; + font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); } .section-meta .g { color: var(--accent-green); } @@ -340,6 +408,167 @@ input[type="date"] { color-scheme: dark; } 50% { opacity: 1; box-shadow: 0 0 14px var(--accent-green); } } +/* ───────────────────────── activity table styles ───────────────────────── + /policies activity tab. Lives next to the new .stat-bar + .filter-bar so + the table feels like the third panel of the same instrument family. */ + +.activity-table-wrap { + border: 1px solid var(--line-2); + border-top: 0; + background: var(--bg-2); +} +.activity-table { width: 100%; } +.activity-thead { background: var(--bg-3); } +.activity-thead tr { border-bottom: 1px solid var(--line-2); } +.activity-th { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 500; + color: var(--accent-green); + text-align: left; +} +.activity-th.text-right { text-align: right; } + +/* zebra rhythm — adds ambient banding without breaking the dark feel. + Hover overrides the stripe so rows feel responsive. */ +.activity-table tbody tr.activity-row-stripe { background: rgba(255, 255, 255, 0.012); } + +/* expanded-row treatment */ +.activity-detail { + border-left: 3px solid var(--accent-pink); + background: var(--bg); + padding: 16px 22px 18px; +} +.activity-detail-eyebrow { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-pink); + margin-bottom: 10px; + display: inline-flex; align-items: baseline; gap: 6px; +} + +/* ───────────────────────── instrument strip (.stat-bar) ───────────────────── + 3-cell brutalist stats row used by /policies activity. Spans the full + container so the top of the view feels composed at every width. + Each cell carries a small green eyebrow caption above a big mono numeral. + On narrow viewports the grid collapses to a single column. */ + +.stat-bar { + position: relative; + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.stat-bar::before, +.stat-bar::after { content: ""; position: absolute; width: 10px; height: 10px; } +.stat-bar::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.stat-bar::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.stat-cell { + padding: 18px 24px; + border-right: 1px dashed var(--line); + display: flex; flex-direction: column; gap: 6px; + min-width: 0; +} +.stat-cell:last-child { border-right: none; } +.stat-cell .eyebrow { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.stat-cell .eyebrow .glyph { color: var(--accent-pink); letter-spacing: -2px; margin-right: 6px; } +.stat-cell .value { + font-family: var(--font-mono); + font-size: 26px; + font-weight: 600; + line-height: 1; + color: var(--ink); + font-variant-numeric: tabular-nums; +} +.stat-cell .value.is-deny { color: var(--accent-pink); } +.stat-cell .value.is-warn { color: var(--amber); } +.stat-cell .meta { + font-family: var(--font-mono); + font-size: 11px; + color: var(--dim); + letter-spacing: 0.05em; + /* let .meta values that contain a slash (e.g. policy paths) ellipsis */ + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +@media (max-width: 800px) { + .stat-bar { grid-template-columns: 1fr; } + .stat-cell { border-right: none; border-bottom: 1px dashed var(--line); } + .stat-cell:last-child { border-bottom: none; } +} + +/* ───────────────────────── filter console (.filter-bar) ────────────────── + Elastic row of `.filter-group`s. Each group stacks a small green eyebrow + label above its control. `.filter-group--grow` absorbs leftover space so + the text inputs naturally fill the remaining horizontal room. */ + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px 16px; + padding: 14px 16px; + background: var(--bg-2); + border: 1px solid var(--line-2); + border-top: 0; +} +.filter-group { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.filter-group--grow { flex: 1 1 220px; } +.filter-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.filter-input { + height: 30px; + width: 100%; + padding: 0 10px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink); + background: var(--bg); + border: 1px solid var(--line-2); + outline: none; + transition: border-color 140ms cubic-bezier(0.22, 1, 0.36, 1); +} +.filter-input::placeholder { color: var(--dim); } +.filter-input:focus { border-color: var(--accent-pink); } +.filter-clear { + align-self: flex-end; + /* sits at the same baseline as the inputs; matches .btn but smaller */ + padding: 6px 10px; + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-pink); + background: transparent; + border: 1px dashed var(--accent-pink-soft); +} +.filter-clear:hover { background: var(--accent-pink-bg); border-color: var(--accent-pink); } + /* ───────────────────────── reusable bracket panel ───────────────────────── */ .panel { @@ -347,6 +576,15 @@ input[type="date"] { color-scheme: dark; } border: 1px solid var(--line-2); background: var(--bg-2); padding: 28px; + transition: + border-color 200ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 200ms cubic-bezier(0.22, 1, 0.36, 1); +} +.panel::before, +.panel::after { + transition: width 200ms cubic-bezier(0.22, 1, 0.36, 1), + height 200ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 200ms cubic-bezier(0.22, 1, 0.36, 1); } .panel::before { content: ""; position: absolute; top: -1px; left: -1px; @@ -360,6 +598,13 @@ input[type="date"] { color-scheme: dark; } border-bottom: 1px solid var(--accent-pink); border-right: 1px solid var(--accent-pink); } +/* opt-in interactive flavour: hovering grows the corner brackets and softens + the panel border. Apply to clickable card-like panels (e.g. project rows). */ +.panel.is-interactive { cursor: pointer; } +.panel.is-interactive:hover { border-color: var(--line); } +.panel.is-interactive:hover::before, +.panel.is-interactive:hover::after { width: 16px; height: 16px; } +.panel.is-interactive:focus-within { border-color: var(--accent-pink-soft); } /* ───────────────────────── animations (preserved from previous globals) ───────────────────────── */ @@ -407,4 +652,15 @@ input[type="date"] { color-scheme: dark; } animation: none; opacity: 1; } + .section-h-dot { + animation: none; + } + .tab::after, + .panel, + .panel::before, + .panel::after, + .btn, + .btn-press { + transition: none; + } } diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 5b7f8de5..d605f482 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -285,22 +285,41 @@ function DecisionPills({ // -- Stats Bar -- function StatsBar({ stats }: { stats: HookActivityPayload["stats"] }) { - const denyRate = stats.totalEvents > 0 ? ((stats.denyCount / stats.totalEvents) * 100).toFixed(0) : "0"; + const denyRatePct = stats.totalEvents > 0 ? (stats.denyCount / stats.totalEvents) * 100 : 0; + const denyRateLabel = denyRatePct.toFixed(denyRatePct >= 10 ? 0 : 1); + // amber once >2%, pink once >5% \u2014 keeps the bar honest at a glance + const denyTone = + stats.denyCount === 0 ? "" : denyRatePct >= 5 ? " is-deny" : denyRatePct >= 2 ? " is-warn" : ""; return ( -
-
- {stats.totalEvents} total events +
+
+ total events + {stats.totalEvents.toLocaleString()} + across all installed agents
-
- 0 ? "text-red-400" : "text-foreground"}`}> - {denyRate}% - {" "} - deny rate +
+ deny rate + {denyRateLabel}% + {stats.denyCount.toLocaleString()} of {stats.totalEvents.toLocaleString()} denied
-
- top policy:{" "} - {stats.topPolicy ?? "\u2014"} +
+ top policy + + {stats.topPolicy ?? "\u2014"} + + most-evaluated policy in scope
); @@ -315,9 +334,13 @@ function DetailPanel({ }) { return ( - -
-
+ +
+ + + event detail + +
Session ID: @@ -341,13 +364,13 @@ function DetailPanel({
{item.policyNames && item.policyNames.length > 1 && ( -
+
Policies: {item.policyNames.join(", ")}
)} {item.reason && ( -
+
Full reason: {item.reason}
@@ -497,61 +520,88 @@ function ActivityTab({ return ( <> {data?.stats && data.stats.totalEvents > 0 && ( -
+
)} -
- {/* Filter bar */} -
- - - -
+
+ {/* Filter console — elastic row that fills the container at every width */} +
+
+ decision + +
+
+ event + +
+
+ cli + +
+
+ policy setFilterPolicy(e.target.value)} - placeholder="Filter by policy\u2026" - className="h-7 rounded-md border border-border bg-background px-2.5 text-xs text-foreground placeholder:text-muted-foreground w-44 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/40 transition-shadow" + placeholder="filter by policy\u2026" + className="filter-input" />
-
+
+ session setFilterSessionId(e.target.value)} - placeholder="Filter by session…" - className="h-7 rounded-md border border-border bg-background px-2.5 text-xs text-foreground placeholder:text-muted-foreground w-44 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/40 transition-shadow" + placeholder="filter by session…" + className="filter-input" />
+ {hasActiveFilters && ( + + )}
{items.length === 0 ? ( @@ -580,21 +630,34 @@ function ActivityTab({ )}
) : ( -
- - - - - - - - - - - - - +
+
- DecisionEventCLIToolPolicyReasonDurationSessionModeTime
+ + + + + + + + + + + + + + + + + + + + + + + + + @@ -646,7 +709,7 @@ function ActivityTab({ )}
+ DecisionEventCLIToolPolicyReasonDurationSessionModeTime
{item.reason ?? "\u2014"} @@ -1604,18 +1667,27 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?: {activeTab === "activity" ? "Policies" : "what to stop them doing."} {activeTab === "activity" && } -

- {activeTab === "activity" ? ( - <> + {activeTab === "activity" ? ( +

+
{evaluationsHeading.toLowerCase()} {policyCounts && ( @@ -1625,38 +1697,50 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?: )} +
+
- 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 ( -
-
- - - Back to Projects - - -
-

- {canonicalRoot} -

-
-

- Path: {displayPath} -

- {lastModifiedFormatted && ( -

- Modified: {lastModifiedFormatted} -

- )} +
+
+
+ + ━━ + back to projects + +
+ {sessionFiles.length} session{sessionFiles.length === 1 ? "" : "s"}
- {/* Sessions Section */} -
-

Sessions

+

+ {canonicalRoot} +

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

- No .jsonl files found in this project. -

-

- 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 (
+
+
+ ━━ projects +
+
+ loading… +
+

- Projects - + your agent footprint.

{Array.from({ length: 8 }).map((_, i) => ( -
+
))}
diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 4e547ff2..ed46ea48 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -26,18 +26,53 @@ export default async function ProjectsPage() { return (
+
+
+ ━━ projects +
+
+ 0 ? "g" : "p"}>●{" "} + {count} {count === 1 ? "folder" : "folders"} +
+

- Projects - {/* */} + your agent footprint.

{count === 0 ? (
+

- 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"; + } +}