failproofai 0.0.11-beta.4 → 0.0.11-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  34. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/index.html +1 -1
  37. package/.next/standalone/.next/server/app/index.rsc +14 -14
  38. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  40. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  41. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  42. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  47. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0sb_5m8._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__13ra2jq._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1_mqemn._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1b9z5-i._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rhmvod._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/_1-1804f._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +8 -8
  66. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00jkjmt._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013du6r._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0-wdurj._.js → [root-of-the-server]__09sb8gm._.js} +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e85wxv._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gfxvb1._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__14dd6h8._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1cd25c7._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06f-yix._.js → [root-of-the-server]__1hldld5._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1hlrq6y._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ihxdo5._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mt35_w._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pcxxwg._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1vvfde2._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +15 -15
  81. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html2canvas_dist_html2canvas_esm_0hmsdod.js → node_modules_html2canvas_dist_html2canvas_esm_0jar0u6.js} +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_1d_onnt._.js +2 -2
  85. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  86. package/.next/standalone/.next/server/pages/404.html +1 -1
  87. package/.next/standalone/.next/server/pages/500.html +1 -1
  88. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  89. package/.next/standalone/.next/server/server-reference-manifest.json +10 -10
  90. package/.next/standalone/.next/static/chunks/{2zjavpea8glet.js → 0faafgt7it9wg.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{36l507r2mbf5h.js → 1ha7bu3h33vlg.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{1e0o-6kjfnwzh.js → 28ktbb6r1yts-.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{41jjdgqqpl7em.js → 2msp6lxlj6y58.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/2posnfuhnwuc9.js +41 -0
  95. package/.next/standalone/.next/static/chunks/{20b5bz43so-1k.js → 3-hps4mh626t-.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{3y3l184vijlfg.js → 35vxydl4wq7x_.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{1btx2c49fk9xt.css → 3co9znvxzaj4r.css} +1 -1
  98. package/.next/standalone/.next/static/chunks/{0nbmu4_n1j2-c.js → 3yg0zz4h9pe--.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{0fkntmojmibda.js → 445x6v-0e8vzo.js} +1 -1
  100. package/.next/standalone/app/actions/get-audit-result.ts +14 -3
  101. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +108 -20
  102. package/.next/standalone/app/audit/_components/audit-progress-strip.tsx +98 -0
  103. package/.next/standalone/app/audit/_components/rerun-button.tsx +4 -1
  104. package/.next/standalone/app/audit/_components/return-section.tsx +17 -29
  105. package/.next/standalone/app/audit/_components/top-audit-bar.tsx +153 -0
  106. package/.next/standalone/app/audit/audit-styles.css +258 -0
  107. package/.next/standalone/app/audit/page.tsx +19 -9
  108. package/.next/standalone/lib/fetch-with-timeout.ts +37 -20
  109. package/.next/standalone/package.json +1 -1
  110. package/.next/standalone/server.js +1 -1
  111. package/README.md +6 -0
  112. package/dist/cli.mjs +6 -3
  113. package/lib/fetch-with-timeout.ts +37 -20
  114. package/package.json +1 -1
  115. package/src/audit/archetypes.ts +24 -4
  116. package/src/audit/cache.ts +20 -1
  117. package/src/audit/dashboard-cache.ts +40 -0
  118. package/src/audit/features.ts +65 -19
  119. package/.next/standalone/.next/static/chunks/1bxdpd036n3l9.js +0 -41
  120. /package/.next/standalone/.next/static/{t4nu8RTRp3Z2srol-9pVb → eWoFUCYXiD-HNIeHw6fYp}/_buildManifest.js +0 -0
  121. /package/.next/standalone/.next/static/{t4nu8RTRp3Z2srol-9pVb → eWoFUCYXiD-HNIeHw6fYp}/_clientMiddlewareManifest.js +0 -0
  122. /package/.next/standalone/.next/static/{t4nu8RTRp3Z2srol-9pVb → eWoFUCYXiD-HNIeHw6fYp}/_ssgManifest.js +0 -0
@@ -30,6 +30,9 @@ import { ReturnSection } from "./return-section";
30
30
  import { ReportFooter } from "./report-footer";
31
31
  import { EmptyState } from "./empty-state";
32
32
  import { RunProgress } from "./run-progress";
33
+ import { TopAuditBar, type TopAuditBarMode } from "./top-audit-bar";
34
+ import { AuditProgressStrip, type RerunStatus } from "./audit-progress-strip";
35
+ import { RerunError, triggerRun } from "./rerun-button";
33
36
 
34
37
  // IMPORTANT: do NOT import BUILTIN_POLICIES or AUDIT_DETECTORS here.
35
38
  // Both pull in node:fs and execSync (workflow policies), which Next.js
@@ -38,7 +41,12 @@ import { RunProgress } from "./run-progress";
38
41
 
39
42
  type Initial =
40
43
  | { status: "cached"; cachedAt: string; params: RunAuditOptions; result: AuditResult }
41
- | { status: "empty" };
44
+ | { status: "empty"; expired: boolean; expiredAt: string | null };
45
+
46
+ /** Tag passed to the shared `startRerun()` handler so PostHog can tell
47
+ * whether the click came from the new top bar or the existing bottom
48
+ * return-section button. */
49
+ export type RerunSource = "top_bar" | "return_section" | "empty_state";
42
50
 
43
51
  interface Props {
44
52
  initial: Initial;
@@ -82,6 +90,7 @@ function inferProjectName(result: AuditResult, override?: string): string {
82
90
  export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Props) {
83
91
  const [cache, setCache] = useState<Initial>(initial);
84
92
  const [running, setRunning] = useState(false);
93
+ const [rerunStatus, setRerunStatus] = useState<RerunStatus>({ kind: "idle" });
85
94
  const { capture } = usePostHog();
86
95
  const pageViewStateRef = useRef<string | null>(null);
87
96
  const scrollTrackedRef = useRef(false);
@@ -90,7 +99,37 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr
90
99
 
91
100
  const refreshFromCache = useCallback(async () => {
92
101
  const payload = await getAuditResultAction();
93
- if (payload.status === "cached") setCache(payload);
102
+ setCache(payload);
103
+ }, []);
104
+
105
+ /** Shared re-audit handler used by the top bar and the bottom button.
106
+ * Drives `rerunStatus` (which renders the sticky progress strip) and
107
+ * soft-refreshes the dashboard cache on success — no full page reload. */
108
+ const startRerun = useCallback(async (source: RerunSource) => {
109
+ if (running) return;
110
+ capture("audit_rerun_clicked", { source, since: "30d" });
111
+ setRunning(true);
112
+ setRerunStatus({ kind: "running", startedAt: Date.now() });
113
+ try {
114
+ await triggerRun({ cli: [], since: "30d" });
115
+ await refreshFromCache();
116
+ setRerunStatus({ kind: "idle" });
117
+ } catch (err) {
118
+ const kind = err instanceof RerunError ? err.kind : "network";
119
+ capture("audit_rerun_failed", {
120
+ kind,
121
+ source,
122
+ since: "30d",
123
+ cli_filter: "all",
124
+ });
125
+ setRerunStatus({ kind: "failed", reason: kind, failedAt: Date.now() });
126
+ } finally {
127
+ setRunning(false);
128
+ }
129
+ }, [capture, refreshFromCache, running]);
130
+
131
+ const dismissRerunStatus = useCallback(() => {
132
+ setRerunStatus({ kind: "idle" });
94
133
  }, []);
95
134
 
96
135
  // Body class for audit-only background + grain texture. Applied once on
@@ -152,11 +191,21 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr
152
191
  };
153
192
  }, [cache.status, capture, resultsCount, running, transcriptsScanned]);
154
193
 
194
+ // Compute the top-bar mode (only matters on empty paths — MainReport
195
+ // always renders the bar in "cached" mode below).
196
+ const emptyBarMode: TopAuditBarMode =
197
+ cache.status === "empty" && cache.expired ? "expired" : "empty";
198
+ const emptyBarCachedAt =
199
+ cache.status === "empty" && cache.expired ? cache.expiredAt : null;
200
+
155
201
  /* ---- empty / first-run ----------------------------------------- */
156
202
  if (cache.status === "empty" && !running) {
157
203
  return (
158
204
  <ShellEmpty
159
205
  running={false}
206
+ topBar={{ mode: emptyBarMode, cachedAt: emptyBarCachedAt, isRunning: running, onRerun: () => startRerun("empty_state") }}
207
+ rerunStatus={rerunStatus}
208
+ onDismissRerun={dismissRerunStatus}
160
209
  onStarted={() => setRunning(true)}
161
210
  onCompleted={async () => { setRunning(false); await refreshFromCache(); }}
162
211
  />
@@ -166,6 +215,9 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr
166
215
  return (
167
216
  <ShellEmpty
168
217
  running
218
+ topBar={{ mode: emptyBarMode, cachedAt: emptyBarCachedAt, isRunning: running, onRerun: () => startRerun("empty_state") }}
219
+ rerunStatus={rerunStatus}
220
+ onDismissRerun={dismissRerunStatus}
169
221
  onStarted={() => {}}
170
222
  onCompleted={async () => { setRunning(false); await refreshFromCache(); }}
171
223
  />
@@ -184,28 +236,20 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr
184
236
  <ShellEmpty
185
237
  running={running}
186
238
  mode="zero-sessions"
239
+ topBar={{ mode: "cached", cachedAt, isRunning: running, onRerun: () => startRerun("empty_state") }}
240
+ rerunStatus={rerunStatus}
241
+ onDismissRerun={dismissRerunStatus}
187
242
  onStarted={() => setRunning(true)}
188
243
  onCompleted={async () => { setRunning(false); await refreshFromCache(); }}
189
244
  />
190
245
  );
191
246
  }
192
247
 
193
- /* ---- in-flight re-run ------------------------------------------ */
194
- if (running) {
195
- return (
196
- <div className="app">
197
- <div className="scanline-overlay" />
198
- <div className="app-shell">
199
- <div className="report">
200
- <RunProgress />
201
- </div>
202
- <ReportFooter cachedAt={cachedAt} />
203
- </div>
204
- </div>
205
- );
206
- }
207
-
208
248
  /* ---- main report ----------------------------------------------- */
249
+ // Note: on re-audit from a cached state, we deliberately stay on the
250
+ // MainReport instead of swapping to RunProgress. The sticky
251
+ // AuditProgressStrip provides the in-flight indicator, and the soft
252
+ // refresh on completion (via refreshFromCache) avoids a full reload.
209
253
  return (
210
254
  <MainReport
211
255
  result={result}
@@ -213,6 +257,10 @@ export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Pr
213
257
  params={params}
214
258
  projectFromUrl={projectFromUrl}
215
259
  totalCatalogSize={totalCatalogSize}
260
+ isRunning={running}
261
+ rerunStatus={rerunStatus}
262
+ onRerun={(source) => startRerun(source)}
263
+ onDismissRerun={dismissRerunStatus}
216
264
  />
217
265
  );
218
266
  }
@@ -223,9 +271,23 @@ interface MainReportProps {
223
271
  params: RunAuditOptions | undefined;
224
272
  projectFromUrl?: string;
225
273
  totalCatalogSize: number;
274
+ isRunning: boolean;
275
+ rerunStatus: RerunStatus;
276
+ onRerun: (source: RerunSource) => void;
277
+ onDismissRerun: () => void;
226
278
  }
227
279
 
228
- function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize }: MainReportProps) {
280
+ function MainReport({
281
+ result,
282
+ cachedAt,
283
+ params,
284
+ projectFromUrl,
285
+ totalCatalogSize,
286
+ isRunning,
287
+ rerunStatus,
288
+ onRerun,
289
+ onDismissRerun,
290
+ }: MainReportProps) {
229
291
  const { capture } = usePostHog();
230
292
  const project = useMemo(() => inferProjectName(result, projectFromUrl), [result, projectFromUrl]);
231
293
  // Seed classification with the project name so the behaviour fingerprint
@@ -288,9 +350,16 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize
288
350
 
289
351
  return (
290
352
  <div className="app">
353
+ <AuditProgressStrip status={rerunStatus} onDismiss={onDismissRerun} />
291
354
  <div className="scanline-overlay" />
292
355
  <div className="app-shell">
293
356
  <div className="report">
357
+ <TopAuditBar
358
+ mode="cached"
359
+ cachedAt={cachedAt}
360
+ isRunning={isRunning}
361
+ onRerun={() => onRerun("top_bar")}
362
+ />
294
363
  <IdentitySection
295
364
  ref={identityFrameRef}
296
365
  archetypeKey={classification.archetype}
@@ -312,7 +381,11 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize
312
381
  archetypeKey={classification.archetype}
313
382
  project={project}
314
383
  />
315
- <ReturnSection result={result} />
384
+ <ReturnSection
385
+ result={result}
386
+ isRunning={isRunning}
387
+ onRerun={() => onRerun("return_section")}
388
+ />
316
389
  <FindingsSection findings={findings} />
317
390
  <PoliciesSection result={result} projected={projected} projectedGrade={projectedGrade} />
318
391
  </div>
@@ -333,19 +406,34 @@ function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize
333
406
  interface ShellEmptyProps {
334
407
  running: boolean;
335
408
  mode?: "no-cache" | "zero-sessions";
409
+ topBar: {
410
+ mode: TopAuditBarMode;
411
+ cachedAt: string | null;
412
+ isRunning: boolean;
413
+ onRerun: () => void;
414
+ };
415
+ rerunStatus: RerunStatus;
416
+ onDismissRerun: () => void;
336
417
  onStarted: () => void;
337
418
  onCompleted: () => Promise<void> | void;
338
419
  }
339
420
 
340
- function ShellEmpty({ running, mode = "no-cache", onStarted, onCompleted }: ShellEmptyProps) {
421
+ function ShellEmpty({ running, mode = "no-cache", topBar, rerunStatus, onDismissRerun, onStarted, onCompleted }: ShellEmptyProps) {
341
422
  // Use the archetype "optimist" sigil for the empty-state visual so the
342
423
  // page doesn't render with a dead box. EmptyState itself is unchanged
343
424
  // from the previous build.
344
425
  return (
345
426
  <div className="app">
427
+ <AuditProgressStrip status={rerunStatus} onDismiss={onDismissRerun} />
346
428
  <div className="scanline-overlay" />
347
429
  <div className="app-shell">
348
430
  <div className="report">
431
+ <TopAuditBar
432
+ mode={topBar.mode}
433
+ cachedAt={topBar.cachedAt}
434
+ isRunning={topBar.isRunning}
435
+ onRerun={topBar.onRerun}
436
+ />
349
437
  {running ? (
350
438
  <RunProgress />
351
439
  ) : (
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Sticky banner pinned to the top of the viewport during a re-audit run.
5
+ * Lifted from the bottom `[ scanning… ]` button so the user has a clear,
6
+ * persistent signal that work is in progress regardless of scroll
7
+ * position.
8
+ *
9
+ * Visual vocabulary matches the page:
10
+ * - pink hard-offset left border (echoes `.share-btn:hover`),
11
+ * - mono lowercase,
12
+ * - thin pulse on the right edge driven by a CSS keyframe so we don't
13
+ * fake progress precision we don't have.
14
+ *
15
+ * Reuses the existing `RerunError.kind` discrimination to render a red
16
+ * error strip with kind-specific copy when a run dies.
17
+ */
18
+ import React, { useEffect, useState } from "react";
19
+ import type { RerunError } from "./rerun-button";
20
+
21
+ export type RerunStatus =
22
+ | { kind: "idle" }
23
+ | { kind: "running"; startedAt: number }
24
+ | { kind: "failed"; reason: RerunError["kind"]; failedAt: number };
25
+
26
+ interface Props {
27
+ status: RerunStatus;
28
+ /** Lets the user dismiss the red error strip without re-trying. */
29
+ onDismiss: () => void;
30
+ }
31
+
32
+ const ERROR_COPY: Record<RerunError["kind"], string> = {
33
+ timeout: "audit took too long — try again",
34
+ network: "network hiccup — check your connection",
35
+ post_failed: "audit failed to start — try again",
36
+ };
37
+
38
+ export function formatElapsed(ms: number): string {
39
+ if (ms < 0) return "00:00";
40
+ const totalSeconds = Math.floor(ms / 1000);
41
+ const mm = Math.floor(totalSeconds / 60).toString().padStart(2, "0");
42
+ const ss = (totalSeconds % 60).toString().padStart(2, "0");
43
+ return `${mm}:${ss}`;
44
+ }
45
+
46
+ export function AuditProgressStrip({ status, onDismiss }: Props) {
47
+ // Tick once a second only while running. setInterval is cheaper than
48
+ // requestAnimationFrame for a one-second display update.
49
+ const [now, setNow] = useState(() => Date.now());
50
+ useEffect(() => {
51
+ if (status.kind !== "running") return;
52
+ const t = setInterval(() => setNow(Date.now()), 1000);
53
+ return () => clearInterval(t);
54
+ }, [status.kind]);
55
+
56
+ if (status.kind === "idle") return null;
57
+
58
+ if (status.kind === "running") {
59
+ const elapsed = Math.max(0, now - status.startedAt);
60
+ return (
61
+ <div className="audit-progress-strip" role="status" aria-live="polite">
62
+ <div className="audit-progress-strip__inner">
63
+ <span className="audit-progress-strip__label">
64
+ <span className="audit-progress-strip__spinner" aria-hidden="true" />
65
+ re-auditing your sessions
66
+ </span>
67
+ <span className="audit-progress-strip__elapsed">
68
+ {formatElapsed(elapsed)}
69
+ </span>
70
+ </div>
71
+ <div className="audit-progress-strip__pulse" aria-hidden="true" />
72
+ </div>
73
+ );
74
+ }
75
+
76
+ // kind === "failed"
77
+ return (
78
+ <div
79
+ className="audit-progress-strip audit-progress-strip--failed"
80
+ role="alert"
81
+ >
82
+ <div className="audit-progress-strip__inner">
83
+ <span className="audit-progress-strip__label">
84
+ <span className="audit-progress-strip__x" aria-hidden="true">×</span>
85
+ {ERROR_COPY[status.reason]}
86
+ </span>
87
+ <button
88
+ type="button"
89
+ className="audit-progress-strip__dismiss"
90
+ onClick={onDismiss}
91
+ aria-label="dismiss"
92
+ >
93
+ [ dismiss ]
94
+ </button>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
@@ -63,7 +63,10 @@ export async function triggerRun(scanParams: ScanParams): Promise<void> {
63
63
  throw new RerunError(isAbortError(err) ? "timeout" : "network", "audit run request failed");
64
64
  }
65
65
 
66
- // Poll status until running flips false.
66
+ // Poll status until running flips false. The status route returns
67
+ // `cachedAt`, which the polling caller can compare against the prior
68
+ // value to detect "fresh result available" — but for `triggerRun`'s
69
+ // purposes the `running: false` flip is the success signal.
67
70
  const startedAt = Date.now();
68
71
  while (Date.now() - startedAt < MAX_POLL_MS) {
69
72
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
@@ -25,10 +25,14 @@ import type { AuditResult } from "@/src/audit/types";
25
25
  import { usePostHog } from "@/contexts/PostHogContext";
26
26
  import { isAbortError } from "@/lib/fetch-with-timeout";
27
27
  import { AuthDialog, type AuthedUser } from "./auth-dialog";
28
- import { RerunError, triggerRun } from "./rerun-button";
29
28
 
30
29
  interface Props {
31
30
  result: AuditResult;
31
+ /** Lifted by AuditDashboard so the bottom button and the top-of-page
32
+ * re-audit bar share state — only one rerun at a time. */
33
+ isRunning: boolean;
34
+ /** Lifted handler. Drives the sticky progress strip + soft refresh. */
35
+ onRerun: () => void;
32
36
  }
33
37
 
34
38
  const BULK_INSTALL_CMD = "failproofai policies --install";
@@ -59,7 +63,7 @@ function formatNextAudit(unixSecs: number): string {
59
63
  });
60
64
  }
61
65
 
62
- export function ReturnSection({ result }: Props) {
66
+ export function ReturnSection({ result, isRunning, onRerun }: Props) {
63
67
  const { capture } = usePostHog();
64
68
  const hasUnenabled = result.results.some(
65
69
  (r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0,
@@ -70,7 +74,6 @@ export function ReturnSection({ result }: Props) {
70
74
  const [reminder, setReminder] = useState<Reminder | null>(null);
71
75
  const [dialogOpen, setDialogOpen] = useState(false);
72
76
  const [reminderBusy, setReminderBusy] = useState(false);
73
- const [rerunBusy, setRerunBusy] = useState(false);
74
77
  const ctaShownRef = useRef(false);
75
78
  /** Throttle gate for focus/visibility-triggered refreshStatus calls —
76
79
  * rapid alt-tabbing would otherwise hit /api/auth/status (two disk
@@ -233,30 +236,15 @@ export function ReturnSection({ result }: Props) {
233
236
  [capture, persistReminder],
234
237
  );
235
238
 
236
- const handleRerun = useCallback(async () => {
237
- if (rerunBusy) return;
238
- capture("audit_rerun_clicked", {
239
- source: "return_section",
240
- since: "30d",
241
- });
242
- setRerunBusy(true);
243
- try {
244
- await triggerRun({ cli: [], since: "30d" });
245
- // Reload the page after the run so the cached result + dashboard cache
246
- // get re-hydrated against the new scan. Cheaper than threading state.
247
- window.location.reload();
248
- } catch (err) {
249
- const kind = err instanceof RerunError ? err.kind : "network";
250
- capture("audit_rerun_failed", {
251
- kind,
252
- source: "return_section",
253
- since: "30d",
254
- cli_filter: "all",
255
- });
256
- } finally {
257
- setRerunBusy(false);
258
- }
259
- }, [capture, rerunBusy]);
239
+ // Re-audit is now lifted into AuditDashboard so the top-of-page bar and
240
+ // this bottom button share state — concurrent clicks are impossible and
241
+ // success path soft-refreshes the report rather than reloading the page.
242
+ // PostHog `source: "return_section"` capture happens inside the lifted
243
+ // handler via the `source` argument it receives.
244
+ const handleRerun = useCallback(() => {
245
+ if (isRunning) return;
246
+ onRerun();
247
+ }, [isRunning, onRerun]);
260
248
 
261
249
  const authed = authStatus.kind === "authed";
262
250
  const hasReminder = authed && reminder !== null;
@@ -328,7 +316,7 @@ export function ReturnSection({ result }: Props) {
328
316
  showSetReminder={!hasReminder}
329
317
  setReminderDisabled={reminderBusy}
330
318
  reminderBusy={reminderBusy}
331
- rerunBusy={rerunBusy}
319
+ rerunBusy={isRunning}
332
320
  hasUnenabled={hasUnenabled}
333
321
  copied={copied}
334
322
  onSetReminder={handleSetReminder}
@@ -341,7 +329,7 @@ export function ReturnSection({ result }: Props) {
341
329
  showSetReminder
342
330
  setReminderDisabled={authStatus.kind === "unknown" || reminderBusy}
343
331
  reminderBusy={reminderBusy}
344
- rerunBusy={rerunBusy}
332
+ rerunBusy={isRunning}
345
333
  hasUnenabled={hasUnenabled}
346
334
  copied={copied}
347
335
  onSetReminder={handleSetReminder}
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Top-of-page re-audit affordance. Renders as a thin strip directly above
5
+ * the IdentitySection — the first thing the eye lands on after the page
6
+ * background. Mirrors `.arch-mast`'s dashed-bottom-border vocabulary so
7
+ * the bar reads as a sibling band rather than a new component family.
8
+ *
9
+ * Three rendered states:
10
+ *
11
+ * - "cached" — shows `audited 3d ago` + a green pulse if fresh, an
12
+ * amber `expires in 14h — re-audit to refresh` chip when
13
+ * inside the last 24h of the 7-day window, and the
14
+ * `[ re-audit ]` action on the right.
15
+ * - "expired" — collapses to a single banner: `your audit expired —
16
+ * re-audit to refresh`, same action on the right.
17
+ * - "empty" — `no audit yet`, action on the right. Mirrors the
18
+ * existing empty-state CTA so the page never reads as
19
+ * dead at first glance.
20
+ *
21
+ * Action button reuses `.share-btn` for visual continuity with the
22
+ * existing `[ re-audit now ]` at the bottom of the report (return-section).
23
+ */
24
+ import React, { useEffect, useState } from "react";
25
+
26
+ const HOUR_MS = 60 * 60_000;
27
+ const DAY_MS = 24 * HOUR_MS;
28
+ const TTL_MS = 7 * DAY_MS;
29
+ const EXPIRY_NOTE_THRESHOLD_MS = DAY_MS;
30
+
31
+ export type TopAuditBarMode = "cached" | "expired" | "empty";
32
+
33
+ export interface TopAuditBarProps {
34
+ mode: TopAuditBarMode;
35
+ /** ISO timestamp from the dashboard cache (or the expired-meta probe).
36
+ * Required for "cached" and "expired"; ignored for "empty". */
37
+ cachedAt: string | null;
38
+ /** True while a re-audit is in flight. Disables the button and swaps
39
+ * the label to `[ scanning… ]`. */
40
+ isRunning: boolean;
41
+ onRerun: () => void;
42
+ }
43
+
44
+ export function relativeTimeAgo(now: number, then: number): string {
45
+ const ageMs = Math.max(0, now - then);
46
+ if (ageMs < 90_000) return "just now";
47
+ if (ageMs < 60 * 60_000) return `${Math.round(ageMs / 60_000)}m ago`;
48
+ if (ageMs < DAY_MS) return `${Math.round(ageMs / HOUR_MS)}h ago`;
49
+ return `${Math.round(ageMs / DAY_MS)}d ago`;
50
+ }
51
+
52
+ export function timeUntilExpiry(now: number, cachedAtMs: number): string {
53
+ const remainingMs = Math.max(0, cachedAtMs + TTL_MS - now);
54
+ if (remainingMs < 60_000) return "<1m";
55
+ if (remainingMs < 60 * 60_000) return `${Math.round(remainingMs / 60_000)}m`;
56
+ if (remainingMs < DAY_MS) return `${Math.round(remainingMs / HOUR_MS)}h`;
57
+ return `${Math.round(remainingMs / DAY_MS)}d`;
58
+ }
59
+
60
+ export function TopAuditBar({ mode, cachedAt, isRunning, onRerun }: TopAuditBarProps) {
61
+ // Re-render once a minute so the relative-time string stays current
62
+ // without forcing a full page poll. Cheap — one state bump per minute.
63
+ const [now, setNow] = useState(() => Date.now());
64
+ useEffect(() => {
65
+ const t = setInterval(() => setNow(Date.now()), 60_000);
66
+ return () => clearInterval(t);
67
+ }, []);
68
+
69
+ const cachedAtMs = cachedAt ? new Date(cachedAt).getTime() : null;
70
+ const validCachedAt = cachedAtMs !== null && !Number.isNaN(cachedAtMs);
71
+ const ageMs = validCachedAt ? now - cachedAtMs : null;
72
+ const remainingMs = validCachedAt ? cachedAtMs + TTL_MS - now : null;
73
+ const expiringSoon =
74
+ mode === "cached"
75
+ && remainingMs !== null
76
+ && remainingMs <= EXPIRY_NOTE_THRESHOLD_MS
77
+ && remainingMs > 0;
78
+
79
+ const buttonLabel = isRunning ? "[ scanning… ]" : "[ re-audit ]";
80
+
81
+ // The bar is a single horizontal band: left = status, center = optional
82
+ // amber note (only when expiring soon), right = action button. On narrow
83
+ // viewports it wraps to two rows.
84
+ return (
85
+ <div
86
+ className={
87
+ "top-audit-bar"
88
+ + (mode === "expired" ? " top-audit-bar--expired" : "")
89
+ + (mode === "empty" ? " top-audit-bar--empty" : "")
90
+ }
91
+ role="region"
92
+ aria-label="audit status"
93
+ >
94
+ <div className="top-audit-bar__left">
95
+ {mode === "cached" && validCachedAt && ageMs !== null && (
96
+ <>
97
+ <span className="top-audit-bar__label">
98
+ <span className="top-audit-bar__glyph">━━</span> last audit
99
+ </span>
100
+ <span
101
+ className={
102
+ "top-audit-bar__time"
103
+ + (ageMs < HOUR_MS ? " top-audit-bar__time--fresh" : "")
104
+ }
105
+ >
106
+ <span className="top-audit-bar__dot" aria-hidden="true" />
107
+ audited {relativeTimeAgo(now, cachedAtMs!)}
108
+ </span>
109
+ </>
110
+ )}
111
+ {mode === "expired" && (
112
+ <>
113
+ <span className="top-audit-bar__label top-audit-bar__label--warn">
114
+ <span className="top-audit-bar__glyph">━━</span> audit expired
115
+ </span>
116
+ <span className="top-audit-bar__expired-copy">
117
+ your audit aged past 7d — re-audit to refresh
118
+ </span>
119
+ </>
120
+ )}
121
+ {mode === "empty" && (
122
+ <>
123
+ <span className="top-audit-bar__label">
124
+ <span className="top-audit-bar__glyph">━━</span> no audit yet
125
+ </span>
126
+ <span className="top-audit-bar__time">
127
+ run one to see your archetype
128
+ </span>
129
+ </>
130
+ )}
131
+ </div>
132
+
133
+ {expiringSoon && remainingMs !== null && (
134
+ <div className="top-audit-bar__center" aria-live="polite">
135
+ <span className="top-audit-bar__amber-dot" aria-hidden="true" />
136
+ expires in {timeUntilExpiry(now, cachedAtMs!)} — re-audit to refresh
137
+ </div>
138
+ )}
139
+
140
+ <div className="top-audit-bar__right">
141
+ <button
142
+ type="button"
143
+ className="share-btn top-audit-bar__btn"
144
+ onClick={onRerun}
145
+ disabled={isRunning}
146
+ data-state={isRunning ? "scanning" : "idle"}
147
+ >
148
+ {buttonLabel}
149
+ </button>
150
+ </div>
151
+ </div>
152
+ );
153
+ }