Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions scripts/test/feedback-widget-reach.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// A user of FleetCrown must be able to report a bug in FleetCrown.
//
// The widget was scoped to eleven public marketing routes, on the stated
// grounds that "in-app feedback already has Loki". That was not true:
// insertSiteFeedback has exactly one caller — the widget's ingest route — and
// Loki has no feedback capability at all. So every signed-in user, on every
// app page, had no structured way to report anything. The omission was
// invisible, which is the failure mode this whole product exists to fix.
//
// The rule is now an exclusion list, so a NEW page ships with feedback by
// default instead of silently shipping without it. This test pins that
// direction: the app surfaces must be covered, the excluded ones must stay
// excluded, and Loki must not be cited as the in-app answer while it cannot
// file anything.
// Run: npx tsx scripts/test/feedback-widget-reach.ts
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import {
isFeedbackWidgetRoute,
FEEDBACK_WIDGET_EXCLUDED_PREFIXES,
} from "../../src/config/feedback-widget";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");

let pass = 0;
let fail = 0;
function ok(cond: boolean, label: string) {
if (cond) {
pass++;
} else {
fail++;
console.error(`✗ ${label}`);
}
}

// The surfaces a signed-in operator actually works on. Each of these is a
// place someone can hit a bug, so each must be able to report one.
const APP_SURFACES = [
"/today",
"/control",
"/projects",
"/projects/5c0ea00f-d098-49a0-aae5-c83b8f4be79c",
"/feedback",
"/activity",
"/goals",
"/people",
"/crew",
"/money",
"/habits",
"/prompts",
"/system",
"/settings",
"/loki",
"/approvals",
];
for (const path of APP_SURFACES) {
ok(isFeedbackWidgetRoute(path), `widget reaches ${path}`);
}

// Public pages keep it too — that was never in question, but a rewrite of the
// matcher could easily drop them.
for (const path of ["/", "/pricing", "/thoughts", "/thoughts/some-essay"]) {
ok(isFeedbackWidgetRoute(path), `widget still reaches public ${path}`);
}

// The exclusions must actually exclude, including nested paths.
for (const p of FEEDBACK_WIDGET_EXCLUDED_PREFIXES) {
ok(!isFeedbackWidgetRoute(p), `excluded: ${p}`);
ok(!isFeedbackWidgetRoute(`${p}/nested`), `excluded: ${p}/nested`);
}

// A near-miss must NOT be excluded — prefix matching that swallows unrelated
// routes is how an allowlist quietly loses pages.
ok(isFeedbackWidgetRoute("/terminals"), "/terminals is not caught by the /terminal exclusion");
ok(isFeedbackWidgetRoute("/settings"), "/settings is an app page, not an auth page");

// The claim that justified the old scoping must not silently return. If Loki
// ever does gain the ability to file feedback, this test should be revisited
// deliberately rather than the assumption creeping back.
const loki = readFileSync(join(ROOT, "src/lib/loki-core.ts"), "utf8");
ok(
!/insertSiteFeedback/.test(loki),
"loki-core still cannot file feedback — the reason the widget must reach the app",
);

// The widget mounts from the ROOT layout, so coverage is not per-page opt-in.
const layout = readFileSync(join(ROOT, "src/app/layout.tsx"), "utf8");
ok(
/DogfoodFeedbackWidget/.test(layout),
"the widget is mounted in the root layout, so new pages get it for free",
);

console.log(`${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
12 changes: 8 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider } from "@/components/shell/ThemeProvider";
import { PublicFeedbackWidget } from "@/components/shell/PublicFeedbackWidget";
import { DogfoodFeedbackWidget } from "@/components/shell/DogfoodFeedbackWidget";
import { APP_DESCRIPTION, APP_NAME, APP_URL } from "@/config/brand";
import { PALETTE } from "@/lib/palette";
import "./globals.css";
Expand Down Expand Up @@ -78,10 +78,14 @@ export default function RootLayout({
<SessionProvider>
<ThemeProvider>{children}</ThemeProvider>
</SessionProvider>
{/* Dogfood: FleetCrown's own feedback widget on public pages, active
only where FEEDBACK_WIDGET_TOKEN is provisioned (see config/feedback-widget.ts). */}
{/* Dogfood: FleetCrown's own feedback widget, on every surface except a
short excluded list (config/feedback-widget.ts). Active only where
FEEDBACK_WIDGET_TOKEN is provisioned. It sits in the ROOT layout on
purpose — a signed-in user hitting a bug in the app had no way to
report it, because the widget was scoped to marketing pages and
Loki cannot file feedback. */}
{process.env.FEEDBACK_WIDGET_TOKEN && (
<PublicFeedbackWidget token={process.env.FEEDBACK_WIDGET_TOKEN} />
<DogfoodFeedbackWidget token={process.env.FEEDBACK_WIDGET_TOKEN} />
)}
</body>
</html>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import { usePathname } from "next/navigation";
import { isFeedbackWidgetRoute } from "@/config/feedback-widget";

/**
* Dogfood embed of FleetCrown's own feedback widget on the public pages
* (docs/architecture/feedback-widget.md, Phase 4). Injects the exact same
* Dogfood embed of FleetCrown's own feedback widget — on EVERY surface, not
* just the public pages (see config/feedback-widget.ts for the short list of
* exclusions and why each one is excluded). Injects the exact same
* /widget.js script tag a customer site would use — same code path, same
* ingest API — pointed at the project the FEEDBACK_WIDGET_TOKEN env var
* belongs to. Rendered by the root layout only when that env var is set.
*
* Imperative injection (not a JSX <script>) so route changes can mount and
* unmount it cleanly along with the host element the widget creates.
*/
export function PublicFeedbackWidget({ token }: { token: string }) {
export function DogfoodFeedbackWidget({ token }: { token: string }) {
const pathname = usePathname();
const show = isFeedbackWidgetRoute(pathname ?? "");

Expand Down
68 changes: 48 additions & 20 deletions src/config/feedback-widget.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,55 @@
/**
* SSOT: which routes show FleetCrown's own dogfood feedback widget
* (docs/architecture/feedback-widget.md, Phase 4). Public marketing/content
* surface only — the widget FAB would collide with the app shell's mobile
* nav, and in-app feedback already has Loki.
* SSOT: where FleetCrown shows its OWN feedback widget.
*
* "/" is matched exactly; everything else by prefix.
* This used to be an allowlist of eleven public marketing routes, justified in
* a comment that gave two reasons. Both were checked, and neither holds:
*
* "the widget FAB would collide with the app shell's mobile nav"
* — true when written. The widget now measures its corner and steps out
* of the way: rectangle-matching for the desktop Loki FAB (56px at
* bottom:28, which the feedback FAB overlapped exactly), and an
* interactive hit-test below 480px for the mobile nav, which is
* `inset-x-3` and therefore too wide for the rectangle scan to treat as
* an obstacle. See widget/placement.ts.
*
* "in-app feedback already has Loki"
* — not true. `insertSiteFeedback` has exactly one caller, the widget's
* ingest route. Loki cannot file feedback; the word does not appear in
* loki-core.ts. Telling the assistant "this button is broken" produces
* no site_feedback row, nothing in the triage inbox, and nothing
* dispatchable. A signed-in user's only route was to tell the operator
* out of band.
*
* So the rule is inverted: the widget renders everywhere EXCEPT a short list of
* surfaces where a floating button actively harms the task. An allowlist meant
* every new page shipped without a way to report a bug on it, and nobody
* noticed because the omission is invisible — the whole reason this product
* exists is that unreported problems stay unfixed.
*/

/**
* Surfaces that deliberately have no widget, each with the reason.
* Matched exactly or by `/prefix/`.
*/
export const FEEDBACK_WIDGET_PUBLIC_PREFIXES = [
"/",
"/thoughts",
"/frontier",
"/mission",
"/philosophy",
"/roadmap",
"/pricing",
"/download",
"/whitepaper",
"/investors",
"/releases",
export const FEEDBACK_WIDGET_EXCLUDED_PREFIXES = [
// A full-height PTY. A floating button over live terminal output covers the
// thing the operator is reading, and the terminal has its own composer.
"/terminal",
// Not in the product yet. Feedback here would be about the door, not the
// room, and an anonymous FAB on a credential form is the wrong invitation.
"/sign-in",
"/sign-up",
"/forgot-password",
"/reset-password",
"/verify-email",
"/setup",
"/invite",
// The widget itself. Rendering the launcher on the page that documents the
// launcher makes "is this yours or the demo's?" an unanswerable question.
"/docs/feedback-widget",
] as const;

export function isFeedbackWidgetRoute(pathname: string): boolean {
return FEEDBACK_WIDGET_PUBLIC_PREFIXES.some((p) =>
p === "/" ? pathname === "/" : pathname === p || pathname.startsWith(`${p}/`),
);
const path = pathname || "/";
return !FEEDBACK_WIDGET_EXCLUDED_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`));
}
Loading