@fixback/sdk 0.1.0 → 0.2.0

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.
package/dist/init.d.ts CHANGED
@@ -1,10 +1,28 @@
1
1
  import { type IdentityInputs } from "./boot";
2
+ import { type BeforeBreadcrumb, type BreadcrumbLevel } from "./breadcrumbs";
3
+ import type { BeforeSend } from "./scrub";
2
4
  /**
3
5
  * The hosted Fixback API origin the SDK talks to by default. A self-hosted or
4
6
  * local deployment overrides it with `apiUrl` (the dashboard's install snippet
5
7
  * pre-fills the right value for the Project).
6
8
  */
7
9
  export declare const DEFAULT_API_URL = "https://api.fixback.dev";
10
+ /**
11
+ * Trace buffer tuning (spec §C). These are configurable **starting points** from
12
+ * research (N ≈ 30, `warn`/`error`/`assert`, optional ~60 s age cap) — never
13
+ * frozen magic numbers. Pass `false` for {@link InitOptions.trace} to disable
14
+ * capture entirely.
15
+ */
16
+ export interface TraceOptions {
17
+ /** Keep at most this many crumbs (oldest drop). Defaults to 30. */
18
+ readonly maxBreadcrumbs?: number;
19
+ /** Optional age cap in ms (e.g. `60000`); off by default. */
20
+ readonly maxAgeMs?: number;
21
+ /** Console levels captured. Defaults to `warn`/`error`/`assert`. */
22
+ readonly consoleLevels?: readonly BreadcrumbLevel[];
23
+ /** Per-crumb filter: mute a category, edit a crumb, or drop it (`null`). */
24
+ readonly beforeBreadcrumb?: BeforeBreadcrumb;
25
+ }
8
26
  /** Options for {@link init}. Only `key` is required. */
9
27
  export interface InitOptions extends IdentityInputs {
10
28
  /** The Project's **publishable** key — an identifier that ships in the page. */
@@ -13,6 +31,39 @@ export interface InitOptions extends IdentityInputs {
13
31
  readonly apiUrl?: string;
14
32
  /** Where to mount the launcher. Defaults to `document.body`. */
15
33
  readonly target?: HTMLElement;
34
+ /**
35
+ * Still the launcher's pulse and motion. An explicit opt-in that complements
36
+ * the visitor's OS-level `prefers-reduced-motion`, which the launcher already
37
+ * honours on its own.
38
+ */
39
+ readonly reduceMotion?: boolean;
40
+ /**
41
+ * The synchronous, network-free client scrub hook every report passes through
42
+ * before transport (spec §C). Mutate the draft to scrub further, or return
43
+ * `null` to drop the report. Runs after the default scrubbers.
44
+ */
45
+ readonly beforeSend?: BeforeSend;
46
+ /** Run the built-in default scrubbers. Defaults to `true` (private-by-default). */
47
+ readonly scrub?: boolean;
48
+ /** Trace buffer tuning, or `false` to turn the buffer off entirely. */
49
+ readonly trace?: TraceOptions | false;
50
+ /**
51
+ * Automatic error capture — the SDK files uncaught exceptions / unhandled
52
+ * rejections as `source: auto` Feedback with no prompt (spec §E, ADR-0011).
53
+ * **Default-on across all Gates**; set `false` for the per-project toggle that
54
+ * turns it off. It is gated by boot's `canSubmit` either way, so an auto-error is
55
+ * never filed where a manual report would be refused.
56
+ */
57
+ readonly autoCapture?: boolean;
58
+ }
59
+ /**
60
+ * Options for {@link redeem} — the explicit form of the `?fixback_invite=` URL
61
+ * detection {@link init} performs. Identical to {@link InitOptions} plus the
62
+ * `token` to redeem (the value the invite link carried).
63
+ */
64
+ export interface RedeemOptions extends InitOptions {
65
+ /** The invite token to redeem — the `?fixback_invite=` value from the link. */
66
+ readonly token: string;
16
67
  }
17
68
  /** A running SDK instance. */
18
69
  export interface FixbackInstance {
@@ -23,12 +74,20 @@ export interface FixbackInstance {
23
74
  * Boot the SDK and mount the launcher when — and only when — a submission would
24
75
  * be accepted for this key, origin, and Gate.
25
76
  *
26
- * On load the SDK calls the ingest boot endpoint; it mounts the launcher solely
27
- * when the answer's `canSubmit` is true, so a Reporter is never shown a launcher
28
- * a submission would be refused (origin off the allowlist, or the Gate turns their
29
- * tier away). Activating the launcher opens the report overlay (ticket #54) — wired
30
- * to the launcher's `fixback:launch` seamfrom which a Reporter files a complete
31
- * report. Everything is wrapped so a Fixback problem unreachable, refused, or an
32
- * unexpected error resolves to a no-op instance and never surfaces on the host page.
77
+ * On load the SDK first checks the page URL for an invite token
78
+ * (`?fixback_invite=`, spec §F); when one is present and live it renders the
79
+ * onboarding modal and defers the launcher until the tester redeems. Otherwise it
80
+ * calls the ingest boot endpoint and mounts the launcher solely when the answer's
81
+ * `canSubmit` is true so a Reporter is never shown a launcher a submission would
82
+ * be refused, and on an Invited / Internal Gate the launcher stays absent until
83
+ * redemption. Everything is wrapped so a Fixback problem unreachable, refused,
84
+ * or an unexpected error — resolves to a no-op instance.
33
85
  */
34
86
  export declare function init(options: InitOptions): Promise<FixbackInstance>;
87
+ /**
88
+ * Redeem an invite token explicitly, the programmatic equivalent of landing on a
89
+ * page with `?fixback_invite=<token>`. Renders the onboarding modal, and on
90
+ * confirm redeems, persists the `reporterId`, and mounts the launcher (spec §F).
91
+ * Use it when the token reaches the page some way other than the URL.
92
+ */
93
+ export declare function redeem(options: RedeemOptions): Promise<FixbackInstance>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Invite redemption & reporter identity (spec §F).
3
+ *
4
+ * The zero-integration path for an invited tester: detect an invite token on the
5
+ * page (`?fixback_invite=<token>`) or an explicit `redeem()` call, read the
6
+ * Invite's public status, and — on confirmation — redeem it into a persisted
7
+ * `reporterId` the SDK thereafter presents as an identity input.
8
+ *
9
+ * This module is the wire + storage half (pure enough to unit-test directly): the
10
+ * two invite endpoints, the localStorage persistence scoped by publishable key,
11
+ * and the URL token detection / one-time consumption. The onboarding modal is
12
+ * `onboarding.ts`; `init` wires the two together.
13
+ *
14
+ * Like `boot.ts`, the wire types are a **vendored** slice of the server contract
15
+ * (`apps/api/src/invites/invite-redemption.controller.ts`) — the SDK never imports
16
+ * the private server package. Keep them in lock-step with that controller.
17
+ */
18
+ import type { ReporterTier } from "./boot";
19
+ /** The Invite's shape. Mirrors the server's `InviteKind`. */
20
+ export type InviteKind = "targeted" | "shared";
21
+ /** The dead/unknown states a status or redeem read can resolve to. */
22
+ export type InviteDeadStatus = "revoked" | "expired" | "exhausted" | "not_found";
23
+ /**
24
+ * The public status of an Invite (`GET /api/invites/:token`). A live (`pending`)
25
+ * Invite reveals the minimal facts the onboarding modal needs — the Project, the
26
+ * Invite's shape, and the **server-derived** tier the redemption would grant; a
27
+ * dead or unknown token reveals only its state.
28
+ */
29
+ export type InviteStatusAnswer = {
30
+ readonly status: "pending";
31
+ readonly projectId: string;
32
+ readonly kind: InviteKind;
33
+ readonly tier: ReporterTier;
34
+ } | {
35
+ readonly status: InviteDeadStatus;
36
+ };
37
+ /**
38
+ * The result of redeeming an Invite (`POST /api/invites/:token/redeem`): the
39
+ * minted Reporter (its handle + server-derived tier + Project), or why it was
40
+ * refused.
41
+ */
42
+ export type RedeemAnswer = {
43
+ readonly status: "redeemed";
44
+ readonly reporterId: string;
45
+ readonly tier: ReporterTier;
46
+ readonly projectId: string;
47
+ } | {
48
+ readonly status: InviteDeadStatus;
49
+ };
50
+ /**
51
+ * The self-provided display fields captured in the onboarding modal. They ride
52
+ * along as a Reporter's chosen name / email — **never** a trust signal (the tier
53
+ * is always server-derived, spec §F).
54
+ */
55
+ export interface ReporterDisplay {
56
+ readonly name?: string;
57
+ readonly email?: string;
58
+ }
59
+ /** A persisted redeemed Reporter: the server handle plus the display fields. */
60
+ export interface StoredReporter extends ReporterDisplay {
61
+ readonly reporterId: string;
62
+ }
63
+ /** The URL query parameter that carries an invite token. */
64
+ export declare const INVITE_QUERY_PARAM = "fixback_invite";
65
+ /**
66
+ * Read the invite token from a page URL's `?fixback_invite=` param. Returns the
67
+ * token, or `null` when absent, empty, or the URL cannot be parsed — never throws.
68
+ */
69
+ export declare function readInviteToken(href: string): string | null;
70
+ /**
71
+ * Strip the invite token from the address bar (one-time consumption, spec §F) via
72
+ * `history.replaceState`, so a reload or a shared link cannot re-trigger — or
73
+ * re-consume — an already-redeemed Invite. Other params, the path, and the hash
74
+ * are preserved. Best-effort: it never throws into the host page.
75
+ */
76
+ export declare function stripInviteToken(win: Window): void;
77
+ /**
78
+ * Persist a redeemed Reporter for `key`. The `reporterId` is the identity the SDK
79
+ * presents on later boots; the display name / email ride along as chosen fields.
80
+ * Best-effort — storage being unavailable (private mode) is never fatal.
81
+ */
82
+ export declare function persistReporter(key: string, reporter: StoredReporter, store?: Storage | null): void;
83
+ /**
84
+ * Read the redeemed Reporter persisted for `key`, or `null` when none is stored,
85
+ * the record is malformed, or it carries no `reporterId`. Never throws.
86
+ */
87
+ export declare function readStoredReporter(key: string, store?: Storage | null): StoredReporter | null;
88
+ /** Join an API base URL with the invite status path, tolerating a trailing slash. */
89
+ export declare function inviteStatusEndpoint(apiUrl: string, token: string): string;
90
+ /** Join an API base URL with the redeem path for a token. */
91
+ export declare function redeemEndpoint(apiUrl: string, token: string): string;
92
+ /**
93
+ * Read an Invite's public status. Resolves to the answer, or `null` when Fixback
94
+ * could not be reached or the body was not a recognised answer. The endpoint
95
+ * answers 200 for a live or dead Invite and 404 (with a JSON body) for an unknown
96
+ * token, so the body — not the HTTP status — is what the caller narrows on. Never
97
+ * throws: an outage stays invisible to the host page.
98
+ */
99
+ export declare function fetchInviteStatus(apiUrl: string, token: string, fetchImpl?: typeof fetch): Promise<InviteStatusAnswer | null>;
100
+ /**
101
+ * Redeem an Invite by token, minting and returning a Reporter. Resolves to the
102
+ * answer, or `null` on an unreachable API or an unrecognised body. Never throws.
103
+ */
104
+ export declare function redeemInviteToken(apiUrl: string, token: string, fetchImpl?: typeof fetch): Promise<RedeemAnswer | null>;
@@ -2,22 +2,42 @@
2
2
  export declare const ROOT_ATTRIBUTE = "data-fixback-root";
3
3
  /**
4
4
  * Dispatched from the host element when the launcher is activated. The report
5
- * overlay this opens is a later ticket (#54); for now the launcher is the mounted
6
- * entry point, and this event is the seam the overlay will hang off. It is
7
- * `composed` so host-page listeners outside the Shadow DOM can hear it.
5
+ * overlay this opens is wired in `init`; this event is the seam it hangs off. It
6
+ * is `composed` so host-page listeners outside the Shadow DOM can hear it.
8
7
  */
9
8
  export declare const LAUNCH_EVENT = "fixback:launch";
9
+ /** Options that shape a mounted launcher. */
10
+ export interface LauncherOptions {
11
+ /**
12
+ * The Project's publishable key. Scopes the first-visit welcome flag so the
13
+ * welcome greets once per key per browser.
14
+ */
15
+ readonly key?: string;
16
+ /**
17
+ * Still the pulse and motion. An explicit opt-in that complements the OS-level
18
+ * `prefers-reduced-motion` (which the styles already honour on their own).
19
+ */
20
+ readonly reduceMotion?: boolean;
21
+ }
10
22
  /** A mounted launcher and the handle needed to remove it again. */
11
23
  export interface Launcher {
12
24
  readonly host: HTMLElement;
25
+ /** Clears the launcher's pending timers. Called by {@link unmountLauncher}. */
26
+ readonly dispose: () => void;
13
27
  }
14
28
  /**
15
- * Mount the launcher into `target` (typically `document.body`). The visible
16
- * button lives inside an open Shadow DOM so its styles are fully isolated from
17
- * the host page and vice-versa; the host element itself is fixed-positioned and
18
- * out of flow, so mounting never shifts the host page's layout. Only one launcher
19
- * can exist at a time an earlier one is removed first.
29
+ * Mount the launcher into `target` (typically `document.body`). Everything the
30
+ * launcher draws the **Feedback** pill, its **tuck** control, the edge **nub**
31
+ * and corner **hover-zone** that peek it back, and the first-visit **welcome**
32
+ * and tuck-away **hint** toasts lives inside one open Shadow DOM, so its styles
33
+ * are fully isolated from the host page and vice-versa. The host element is
34
+ * fixed-positioned and out of flow, so mounting never shifts the host page's
35
+ * layout. Only one launcher can exist at a time — an earlier one is removed first.
36
+ *
37
+ * Tuck/peek state is expressed as `data-fb-hidden` / `data-fb-peeking` attributes
38
+ * on the host, which the Shadow DOM stylesheet animates; `reduceMotion` sets
39
+ * `data-fb-reduce-motion`, which (with the OS `prefers-reduced-motion`) stills it.
20
40
  */
21
- export declare function mountLauncher(target: HTMLElement): Launcher;
41
+ export declare function mountLauncher(target: HTMLElement, options?: LauncherOptions): Launcher;
22
42
  /** Remove a mounted launcher. Safe to call more than once. */
23
43
  export declare function unmountLauncher(launcher: Launcher): void;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Styles for the invite onboarding modal (spec §F) — the Signal look of the
3
+ * frozen Reporter prototype (`docs/design/Fixback Reporter.dc.html`), scoped to
4
+ * the modal's own Shadow DOM so the host page is never touched and never touches
5
+ * it. Tokens mirror `overlay-styles.ts` so launcher, overlay, and modal read as
6
+ * one system.
7
+ */
8
+ export declare const ONBOARDING_STYLES = "\n:host {\n --fb-color-accent: #2f6fed;\n --fb-color-accent-hover: #245fd0;\n --fb-color-on-emphasis: #ffffff;\n --fb-color-ink: #0f1720;\n --fb-color-text: #1a2530;\n --fb-color-muted: #5a6875;\n --fb-color-faint: #9aa7b2;\n --fb-color-hint: #b7c1cb;\n --fb-color-border: #dce3ea;\n --fb-color-border-soft: #e6ebf0;\n --fb-color-surface: #ffffff;\n --fb-color-card: #f6f8fa;\n --fb-color-accent-bg: #eaf1fe;\n --fb-color-success: #2f9e5b;\n --fb-font-sans: \"IBM Plex Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto,\n Helvetica, Arial, sans-serif;\n --fb-font-mono: \"IBM Plex Mono\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas,\n monospace;\n all: initial;\n}\n\n*, *::before, *::after { box-sizing: border-box; }\n\n.fb-ob-backdrop {\n position: fixed;\n inset: 0;\n z-index: 2147483010;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 24px;\n background: rgba(15, 23, 32, 0.55);\n backdrop-filter: blur(3px);\n font-family: var(--fb-font-sans);\n color: var(--fb-color-text);\n animation: fb-ob-fade 0.25s ease both;\n}\n\n.fb-ob-panel {\n width: 400px;\n max-width: 100%;\n background: var(--fb-color-surface);\n border-radius: 16px;\n box-shadow: 0 30px 70px rgba(15, 40, 70, 0.4);\n overflow: hidden;\n animation: fb-ob-pop 0.32s cubic-bezier(0.2, 0.8, 0.3, 1) both;\n}\n\n.fb-ob-head {\n padding: 22px 24px 0;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n.fb-ob-mark {\n width: 22px;\n height: 22px;\n border-radius: 6px;\n background: var(--fb-color-accent);\n flex: none;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.fb-ob-mark::after {\n content: \"\";\n width: 7px;\n height: 7px;\n border-radius: 2px;\n background: #fff;\n}\n.fb-ob-brand { font-size: 15px; font-weight: 600; color: var(--fb-color-ink); }\n.fb-ob-chip {\n margin-left: auto;\n font-family: var(--fb-font-mono);\n font-size: 10px;\n color: #8a97a3;\n border: 1px solid var(--fb-color-border-soft);\n border-radius: 6px;\n padding: 2px 7px;\n}\n\n.fb-ob-body { padding: 16px 24px 8px; }\n.fb-ob-title {\n font-size: 19px;\n font-weight: 700;\n color: var(--fb-color-ink);\n letter-spacing: -0.01em;\n}\n.fb-ob-lede {\n font-size: 13.5px;\n color: var(--fb-color-muted);\n line-height: 1.5;\n margin: 7px 0 0;\n}\n.fb-ob-lede strong { color: var(--fb-color-text); }\n\n.fb-ob-card {\n margin: 16px 0;\n padding: 13px 14px;\n background: var(--fb-color-card);\n border: 1px solid var(--fb-color-border-soft);\n border-radius: 10px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n}\n.fb-ob-row { display: flex; align-items: center; gap: 10px; }\n.fb-ob-row + .fb-ob-row {\n border-top: 1px solid #eef2f6;\n padding-top: 10px;\n}\n.fb-ob-rowlabel {\n font-family: var(--fb-font-mono);\n font-size: 10px;\n color: var(--fb-color-faint);\n width: 70px;\n flex: none;\n}\n.fb-ob-site { font-size: 13px; color: var(--fb-color-text); font-weight: 500; }\n.fb-ob-tier {\n font-size: 11px;\n font-weight: 600;\n color: var(--fb-color-accent);\n background: var(--fb-color-accent-bg);\n padding: 3px 9px;\n border-radius: 6px;\n}\n.fb-ob-tiernote { font-size: 11.5px; color: #8a97a3; }\n\n.fb-ob-fieldlabel {\n display: block;\n font-family: var(--fb-font-mono);\n font-size: 10px;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n color: var(--fb-color-faint);\n margin-bottom: 6px;\n}\n.fb-ob-fields { display: flex; gap: 8px; }\n.fb-ob-input {\n height: 38px;\n padding: 0 12px;\n border: 1px solid var(--fb-color-border);\n border-radius: 9px;\n font-family: inherit;\n font-size: 13px;\n color: var(--fb-color-text);\n outline: none;\n min-width: 0;\n}\n.fb-ob-input:focus { border-color: var(--fb-color-accent); }\n.fb-ob-name { flex: 1; }\n.fb-ob-email { flex: 1.3; font-family: var(--fb-font-mono); font-size: 12.5px; color: var(--fb-color-muted); }\n\n.fb-ob-privacy {\n display: flex;\n align-items: flex-start;\n gap: 8px;\n margin-top: 14px;\n font-size: 11.5px;\n color: #8a97a3;\n line-height: 1.5;\n}\n.fb-ob-privacy svg { flex: none; margin-top: 1px; }\n.fb-ob-privacy strong { color: var(--fb-color-muted); }\n\n.fb-ob-foot { padding: 16px 24px 22px; }\n.fb-ob-confirm {\n width: 100%;\n height: 44px;\n border: 0;\n border-radius: 11px;\n background: var(--fb-color-accent);\n color: var(--fb-color-on-emphasis);\n font-family: inherit;\n font-size: 14px;\n font-weight: 600;\n cursor: pointer;\n}\n.fb-ob-confirm:hover { background: var(--fb-color-accent-hover); }\n.fb-ob-confirm:disabled { opacity: 0.6; cursor: default; }\n\n@keyframes fb-ob-fade { from { opacity: 0; } to { opacity: 1; } }\n@keyframes fb-ob-pop {\n from { opacity: 0; transform: translateY(8px) scale(0.98); }\n to { opacity: 1; transform: none; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .fb-ob-backdrop, .fb-ob-panel { animation: none; }\n}\n";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The invite onboarding modal (spec §F) — the SDK-rendered redemption screen an
3
+ * invited tester sees when a page carries an invite token. It shows the site
4
+ * they were invited to, the **server-derived** access tier the redemption grants,
5
+ * a private-by-default note, and a "Continue as" name / email, then confirms.
6
+ *
7
+ * Built to the frozen Signal Reporter prototype
8
+ * (`docs/design/Fixback Reporter.dc.html`). It mounts lazily inside its own open
9
+ * Shadow DOM so the host page is fully isolated, and — like the launcher and
10
+ * overlay — never throws into the host page. It renders only data the SDK
11
+ * truthfully holds: the site's own origin and the server-derived tier. The name /
12
+ * email are **self-provided display fields**, never a trust signal (§F).
13
+ */
14
+ import type { ReporterTier } from "./boot";
15
+ import type { ReporterDisplay } from "./invite";
16
+ /** Marks the modal's host element in the light DOM, so it is findable and unique. */
17
+ export declare const ONBOARDING_ATTRIBUTE = "data-fixback-onboard";
18
+ /** Configuration for {@link createOnboardingModal}. */
19
+ export interface OnboardingConfig {
20
+ /** The site the tester was invited to — its origin (`window.location.host`). */
21
+ readonly origin: string;
22
+ /** The **server-derived** tier the redemption grants (from the invite status). */
23
+ readonly tier: ReporterTier;
24
+ /** Prefill the Continue-as fields from a previously stored display identity. */
25
+ readonly defaults?: ReporterDisplay;
26
+ /** Called with the self-provided display fields when the tester confirms. */
27
+ readonly onConfirm: (display: ReporterDisplay) => void;
28
+ /** Where to mount the modal host. Defaults to `document.body`. */
29
+ readonly target?: HTMLElement;
30
+ /** The document to build in. Defaults to the target's owner document. */
31
+ readonly doc?: Document;
32
+ }
33
+ /** A mounted onboarding modal. */
34
+ export interface OnboardingModal {
35
+ destroy(): void;
36
+ readonly host: HTMLElement;
37
+ }
38
+ /**
39
+ * Render the onboarding modal into `target` (default `document.body`) and return a
40
+ * handle to remove it. Confirming reads the name / email, fires `onConfirm` once
41
+ * (further clicks are ignored while the caller redeems), and leaves teardown to
42
+ * the caller so it can strip the token and mount the launcher first.
43
+ */
44
+ export declare function createOnboardingModal(config: OnboardingConfig): OnboardingModal;
@@ -6,4 +6,4 @@
6
6
  * vendored Signal tokens (copied from `packages/ui/src/tokens.css`, not imported —
7
7
  * the SDK must not depend on `@fixback/ui` at runtime). Keep them in sync by value.
8
8
  */
9
- export declare const OVERLAY_STYLES = "\n:host {\n --fb-color-accent: #2f6fed;\n --fb-color-accent-hover: #245fd0;\n --fb-color-on-emphasis: #ffffff;\n --fb-color-ink: #0f1720;\n --fb-color-text: #1a2530;\n --fb-color-muted: #5a6875;\n --fb-color-faint: #9aa7b2;\n --fb-color-border: #e0e6ec;\n --fb-color-border-soft: #e6ebf0;\n --fb-color-surface: #ffffff;\n --fb-color-bug: #e5484d;\n --fb-color-bug-bg: #fdecec;\n --fb-color-impr: #2f6fed;\n --fb-color-impr-bg: #eaf1fe;\n --fb-color-idea: #8b5cf6;\n --fb-color-idea-bg: #f2ecfe;\n --fb-color-success: #2f9e5b;\n --fb-font-sans: \"IBM Plex Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto,\n Helvetica, Arial, sans-serif;\n --fb-font-mono: \"IBM Plex Mono\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas,\n monospace;\n\n display: block;\n color: var(--fb-color-text);\n font-family: var(--fb-font-sans);\n font-size: 13px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n* { box-sizing: border-box; }\n\n.fb-ov-panel {\n width: 300px;\n max-width: calc(100vw - 40px);\n background: var(--fb-color-surface);\n border: 1px solid var(--fb-color-border);\n border-radius: 13px;\n box-shadow: 0 18px 44px rgba(20, 40, 70, 0.2);\n overflow: hidden;\n}\n\n.fb-ov-head {\n display: flex;\n align-items: center;\n gap: 9px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--fb-color-border-soft);\n}\n.fb-ov-mark-sq {\n width: 13px;\n height: 13px;\n border-radius: 4px;\n background: var(--fb-color-accent);\n flex: none;\n}\n.fb-ov-brand { font-size: 14px; font-weight: 600; color: var(--fb-color-ink); }\n.fb-ov-close {\n margin-left: auto;\n border: 0;\n background: none;\n color: var(--fb-color-faint);\n font-size: 16px;\n line-height: 1;\n padding: 2px 4px;\n cursor: pointer;\n border-radius: 6px;\n}\n.fb-ov-close:hover { color: var(--fb-color-muted); background: #f4f7fa; }\n\n.fb-ov-tabs { display: flex; gap: 6px; padding: 12px 14px 6px; }\n.fb-ov-tab {\n flex: 1;\n text-align: center;\n font-size: 11px;\n font-weight: 600;\n padding: 6px;\n border-radius: 7px;\n border: 1px solid var(--fb-color-border);\n background: var(--fb-color-surface);\n color: var(--fb-color-muted);\n cursor: pointer;\n font-family: inherit;\n}\n.fb-ov-tab:hover { border-color: #cfd8e2; }\n.fb-ov-tab.is-active[data-kind=\"bug\"] {\n background: var(--fb-color-bug-bg); color: var(--fb-color-bug); border-color: transparent;\n}\n.fb-ov-tab.is-active[data-kind=\"improvement\"] {\n background: var(--fb-color-impr-bg); color: var(--fb-color-impr); border-color: transparent;\n}\n.fb-ov-tab.is-active[data-kind=\"idea\"] {\n background: var(--fb-color-idea-bg); color: var(--fb-color-idea); border-color: transparent;\n}\n\n.fb-ov-mark {\n margin: 10px 14px;\n min-height: 52px;\n border-radius: 9px;\n border: 1px solid var(--fb-color-border-soft);\n background: repeating-linear-gradient(135deg, #f4f7fa, #f4f7fa 7px, #eaeff4 7px, #eaeff4 14px);\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n gap: 6px;\n padding: 10px 12px;\n}\n.fb-ov-selector {\n display: none;\n max-width: 100%;\n font-family: var(--fb-font-mono);\n font-size: 10px;\n color: var(--fb-color-on-emphasis);\n background: var(--fb-color-accent);\n padding: 3px 8px;\n border-radius: 5px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.fb-ov-mark.has-element .fb-ov-selector { display: inline-block; }\n.fb-ov-mark-caption { font-size: 10px; color: var(--fb-color-faint); font-family: var(--fb-font-mono); }\n\n.fb-ov-comment {\n display: block;\n width: calc(100% - 28px);\n margin: 0 14px 10px;\n min-height: 62px;\n resize: vertical;\n font-family: inherit;\n font-size: 13px;\n color: var(--fb-color-text);\n border: 1px solid var(--fb-color-border-soft);\n border-radius: 9px;\n padding: 10px 11px;\n}\n.fb-ov-comment::placeholder { color: var(--fb-color-faint); }\n.fb-ov-comment:focus-visible { outline: 2px solid var(--fb-color-accent); outline-offset: 1px; }\n\n.fb-ov-status { padding: 0 14px; font-size: 11px; min-height: 0; }\n.fb-ov-status.is-error { color: var(--fb-color-bug); }\n\n.fb-ov-tools { display: flex; align-items: center; gap: 8px; padding: 8px 14px 14px; }\n.fb-ov-pickbtn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-family: inherit;\n font-size: 12px;\n color: var(--fb-color-muted);\n background: var(--fb-color-surface);\n border: 1px solid var(--fb-color-border);\n border-radius: 8px;\n padding: 8px 11px;\n cursor: pointer;\n}\n.fb-ov-pickbtn:hover { border-color: #cfd8e2; }\n.fb-ov-pickbtn.is-active {\n color: var(--fb-color-accent);\n border-color: var(--fb-color-accent);\n background: var(--fb-color-impr-bg);\n}\n.fb-ov-pickbtn__glyph { font-size: 14px; line-height: 1; }\n\n.fb-ov-send {\n margin-left: auto;\n font-family: inherit;\n font-size: 13px;\n font-weight: 600;\n color: var(--fb-color-on-emphasis);\n background: var(--fb-color-accent);\n border: 0;\n border-radius: 8px;\n padding: 9px 18px;\n cursor: pointer;\n}\n.fb-ov-send:hover { background: var(--fb-color-accent-hover); }\n.fb-ov-send:disabled { opacity: 0.6; cursor: default; }\n\n.fb-ov-done { display: none; padding: 24px 18px; text-align: center; }\n.fb-ov-panel.is-sent .fb-ov-form { display: none; }\n.fb-ov-panel.is-sent .fb-ov-done { display: block; }\n.fb-ov-done__check {\n width: 40px; height: 40px; margin: 0 auto 12px;\n border-radius: 50%;\n background: #e7f6ee; color: var(--fb-color-success);\n display: flex; align-items: center; justify-content: center;\n font-size: 20px; font-weight: 700;\n}\n.fb-ov-done__title { font-size: 15px; font-weight: 600; color: var(--fb-color-ink); }\n.fb-ov-done__sub { font-size: 12px; color: var(--fb-color-muted); margin-top: 4px; }\n\n.fb-ov-pick { position: fixed; inset: 0; pointer-events: none; z-index: 2147483002; display: none; }\n.fb-ov-panel.is-picking + .fb-ov-pick { display: block; }\n.fb-ov-panel.is-picking { visibility: hidden; }\n.fb-ov-highlight {\n position: absolute;\n border: 2px dashed var(--fb-color-accent);\n border-radius: 6px;\n box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.14);\n transition: all 60ms ease;\n}\n.fb-ov-hint {\n position: absolute;\n top: 16px;\n left: 50%;\n transform: translateX(-50%);\n font-family: var(--fb-font-mono);\n font-size: 11px;\n color: var(--fb-color-on-emphasis);\n background: var(--fb-color-ink);\n padding: 6px 12px;\n border-radius: 7px;\n box-shadow: 0 8px 20px rgba(20, 40, 70, 0.25);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .fb-ov-highlight { transition: none; }\n}\n";
9
+ export declare const OVERLAY_STYLES = "\n:host {\n --fb-color-accent: #2f6fed;\n --fb-color-accent-hover: #245fd0;\n --fb-color-on-emphasis: #ffffff;\n --fb-color-ink: #0f1720;\n --fb-color-text: #1a2530;\n --fb-color-muted: #5a6875;\n --fb-color-faint: #9aa7b2;\n --fb-color-border: #e0e6ec;\n --fb-color-border-soft: #e6ebf0;\n --fb-color-surface: #ffffff;\n --fb-color-bug: #e5484d;\n --fb-color-bug-bg: #fdecec;\n --fb-color-impr: #2f6fed;\n --fb-color-impr-bg: #eaf1fe;\n --fb-color-idea: #8b5cf6;\n --fb-color-idea-bg: #f2ecfe;\n --fb-color-success: #2f9e5b;\n --fb-font-sans: \"IBM Plex Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto,\n Helvetica, Arial, sans-serif;\n --fb-font-mono: \"IBM Plex Mono\", ui-monospace, \"SFMono-Regular\", Menlo, Consolas,\n monospace;\n\n display: block;\n color: var(--fb-color-text);\n font-family: var(--fb-font-sans);\n font-size: 13px;\n line-height: 1.45;\n -webkit-font-smoothing: antialiased;\n}\n\n* { box-sizing: border-box; }\n\n.fb-ov-panel {\n width: 300px;\n max-width: calc(100vw - 40px);\n background: var(--fb-color-surface);\n border: 1px solid var(--fb-color-border);\n border-radius: 13px;\n box-shadow: 0 18px 44px rgba(20, 40, 70, 0.2);\n overflow: hidden;\n}\n\n.fb-ov-head {\n display: flex;\n align-items: center;\n gap: 9px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--fb-color-border-soft);\n}\n.fb-ov-mark-sq {\n width: 13px;\n height: 13px;\n border-radius: 4px;\n background: var(--fb-color-accent);\n flex: none;\n}\n.fb-ov-brand { font-size: 14px; font-weight: 600; color: var(--fb-color-ink); }\n.fb-ov-tier {\n flex: none;\n font-family: var(--fb-font-mono);\n font-size: 9.5px;\n color: var(--fb-color-faint);\n border: 1px solid var(--fb-color-border-soft);\n border-radius: 5px;\n padding: 1px 6px;\n}\n.fb-ov-close {\n margin-left: auto;\n border: 0;\n background: none;\n color: var(--fb-color-faint);\n font-size: 16px;\n line-height: 1;\n padding: 2px 4px;\n cursor: pointer;\n border-radius: 6px;\n}\n.fb-ov-close:hover { color: var(--fb-color-muted); background: #f4f7fa; }\n\n.fb-ov-tabs { display: flex; gap: 6px; padding: 12px 14px 6px; }\n.fb-ov-tab {\n flex: 1;\n text-align: center;\n font-size: 11px;\n font-weight: 600;\n padding: 6px;\n border-radius: 7px;\n border: 1px solid var(--fb-color-border);\n background: var(--fb-color-surface);\n color: var(--fb-color-muted);\n cursor: pointer;\n font-family: inherit;\n}\n.fb-ov-tab:hover { border-color: #cfd8e2; }\n.fb-ov-tab.is-active[data-kind=\"bug\"] {\n background: var(--fb-color-bug-bg); color: var(--fb-color-bug); border-color: transparent;\n}\n.fb-ov-tab.is-active[data-kind=\"improvement\"] {\n background: var(--fb-color-impr-bg); color: var(--fb-color-impr); border-color: transparent;\n}\n.fb-ov-tab.is-active[data-kind=\"idea\"] {\n background: var(--fb-color-idea-bg); color: var(--fb-color-idea); border-color: transparent;\n}\n\n.fb-ov-mark {\n margin: 10px 14px;\n min-height: 52px;\n border-radius: 9px;\n border: 1px solid var(--fb-color-border-soft);\n background: repeating-linear-gradient(135deg, #f4f7fa, #f4f7fa 7px, #eaeff4 7px, #eaeff4 14px);\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n gap: 6px;\n padding: 10px 12px;\n}\n.fb-ov-selector {\n display: none;\n max-width: 100%;\n font-family: var(--fb-font-mono);\n font-size: 10px;\n color: var(--fb-color-on-emphasis);\n background: var(--fb-color-accent);\n padding: 3px 8px;\n border-radius: 5px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.fb-ov-mark.has-element .fb-ov-selector { display: inline-block; }\n.fb-ov-mark-caption { font-size: 10px; color: var(--fb-color-faint); font-family: var(--fb-font-mono); }\n\n.fb-ov-comment {\n display: block;\n width: calc(100% - 28px);\n margin: 0 14px 10px;\n min-height: 62px;\n resize: vertical;\n font-family: inherit;\n font-size: 13px;\n color: var(--fb-color-text);\n border: 1px solid var(--fb-color-border-soft);\n border-radius: 9px;\n padding: 10px 11px;\n}\n.fb-ov-comment::placeholder { color: var(--fb-color-faint); }\n.fb-ov-comment:focus-visible { outline: 2px solid var(--fb-color-accent); outline-offset: 1px; }\n\n.fb-ov-status { padding: 0 14px; font-size: 11px; min-height: 0; }\n.fb-ov-status.is-error { color: var(--fb-color-bug); }\n\n.fb-ov-tools { display: flex; align-items: center; gap: 7px; padding: 8px 14px 14px; }\n.fb-ov-pickbtn,\n.fb-ov-capturebtn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-family: inherit;\n font-size: 12px;\n color: var(--fb-color-muted);\n background: var(--fb-color-surface);\n border: 1px solid var(--fb-color-border);\n border-radius: 8px;\n padding: 8px 11px;\n cursor: pointer;\n}\n.fb-ov-capturebtn { padding: 8px 9px; }\n.fb-ov-pickbtn:hover,\n.fb-ov-capturebtn:hover { border-color: #cfd8e2; }\n.fb-ov-pickbtn.is-active,\n.fb-ov-capturebtn.is-active,\n.fb-ov-capturebtn.is-attached {\n color: var(--fb-color-accent);\n border-color: var(--fb-color-accent);\n background: var(--fb-color-impr-bg);\n}\n.fb-ov-pickbtn__glyph { font-size: 14px; line-height: 1; }\n.fb-ov-icon { display: block; }\n\n.fb-ov-send {\n margin-left: auto;\n font-family: inherit;\n font-size: 13px;\n font-weight: 600;\n color: var(--fb-color-on-emphasis);\n background: var(--fb-color-accent);\n border: 0;\n border-radius: 8px;\n padding: 9px 18px;\n cursor: pointer;\n}\n.fb-ov-send:hover { background: var(--fb-color-accent-hover); }\n.fb-ov-send:disabled { opacity: 0.6; cursor: default; }\n\n.fb-ov-done { display: none; padding: 24px 18px; text-align: center; }\n.fb-ov-panel.is-sent .fb-ov-form { display: none; }\n.fb-ov-panel.is-sent .fb-ov-done { display: block; }\n.fb-ov-done__check {\n width: 40px; height: 40px; margin: 0 auto 12px;\n border-radius: 50%;\n background: #e7f6ee; color: var(--fb-color-success);\n display: flex; align-items: center; justify-content: center;\n font-size: 20px; font-weight: 700;\n}\n.fb-ov-done__title { font-size: 15px; font-weight: 600; color: var(--fb-color-ink); }\n.fb-ov-done__sub { font-size: 12px; color: var(--fb-color-muted); margin-top: 4px; }\n\n/* The panel is hidden while any full-screen marking layer is active. */\n.fb-ov-panel.is-marking { visibility: hidden; }\n\n.fb-ov-pick,\n.fb-ov-capture,\n.fb-ov-draw { position: fixed; inset: 0; z-index: 2147483002; display: none; }\n.fb-ov-pick.is-visible,\n.fb-ov-capture.is-visible,\n.fb-ov-draw.is-visible { display: block; }\n\n/* Element-pick highlight layer \u2014 visual only, never intercepts host events. */\n.fb-ov-pick { pointer-events: none; }\n.fb-ov-highlight {\n position: absolute;\n border: 2px dashed var(--fb-color-accent);\n border-radius: 6px;\n box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.14);\n transition: all 60ms ease;\n}\n.fb-ov-hint {\n position: absolute;\n top: 16px;\n left: 50%;\n transform: translateX(-50%);\n font-family: var(--fb-font-mono);\n font-size: 11px;\n color: var(--fb-color-on-emphasis);\n background: var(--fb-color-ink);\n padding: 6px 12px;\n border-radius: 7px;\n box-shadow: 0 8px 20px rgba(20, 40, 70, 0.25);\n}\n\n/* Region-capture layer \u2014 a dim wash with a live selection box. */\n.fb-ov-capture { cursor: crosshair; }\n.fb-ov-capture-dim { position: absolute; inset: 0; background: rgba(15, 23, 32, 0.28); }\n.fb-ov-capture-sel {\n position: absolute;\n border: 2px solid var(--fb-color-accent);\n border-radius: 4px;\n box-shadow: 0 0 0 100vmax rgba(15, 23, 32, 0.32);\n}\n\n/* Draw layer \u2014 the mark SVG, the region frame, the label input, and the toolbar. */\n.fb-ov-draw-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }\n.fb-ov-draw-frame {\n position: absolute;\n border: 1px solid rgba(47, 111, 237, 0.5);\n border-radius: 4px;\n box-shadow: 0 0 0 100vmax rgba(15, 23, 32, 0.18);\n pointer-events: none;\n}\n.fb-ov-draw-text {\n position: absolute;\n display: none;\n font-family: var(--fb-font-sans);\n font-size: 15px;\n font-weight: 600;\n color: var(--fb-color-bug);\n background: rgba(255, 255, 255, 0.92);\n border: 1px dashed var(--fb-color-bug);\n border-radius: 4px;\n padding: 2px 6px;\n outline: none;\n z-index: 3;\n}\n.fb-ov-draw-toolbar {\n position: fixed;\n left: 50%;\n bottom: 26px;\n transform: translateX(-50%);\n display: flex;\n align-items: center;\n gap: 5px;\n background: var(--fb-color-ink);\n border-radius: 12px;\n padding: 7px;\n box-shadow: 0 16px 40px rgba(15, 40, 70, 0.4);\n}\n.fb-ov-drawtool {\n width: 34px;\n height: 34px;\n display: flex;\n align-items: center;\n justify-content: center;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: #c4ccd4;\n cursor: pointer;\n font-family: inherit;\n}\n.fb-ov-drawtool:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }\n.fb-ov-drawtool.is-active { background: var(--fb-color-accent); color: var(--fb-color-on-emphasis); }\n.fb-ov-drawtool__t { font-weight: 700; font-size: 14px; line-height: 1; }\n.fb-ov-draw-divider { width: 1px; height: 22px; background: #2a343e; margin: 0 3px; }\n.fb-ov-draw-undo {\n width: 34px;\n height: 34px;\n display: flex;\n align-items: center;\n justify-content: center;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: #c4ccd4;\n cursor: pointer;\n}\n.fb-ov-draw-undo:hover:not(:disabled) { background: rgba(255, 255, 255, 0.08); color: #fff; }\n.fb-ov-draw-undo:disabled { opacity: 0.4; cursor: default; }\n.fb-ov-draw-cancel {\n height: 34px;\n padding: 0 12px;\n border: 0;\n border-radius: 8px;\n background: transparent;\n color: var(--fb-color-faint);\n font-family: inherit;\n font-size: 12.5px;\n cursor: pointer;\n}\n.fb-ov-draw-cancel:hover { color: #fff; }\n.fb-ov-draw-attach {\n height: 34px;\n padding: 0 15px;\n border: 0;\n border-radius: 8px;\n background: var(--fb-color-accent);\n color: var(--fb-color-on-emphasis);\n font-family: inherit;\n font-size: 12.5px;\n font-weight: 600;\n cursor: pointer;\n}\n.fb-ov-draw-attach:hover { background: var(--fb-color-accent-hover); }\n\n@media (prefers-reduced-motion: reduce) {\n .fb-ov-highlight { transition: none; }\n}\n";
package/dist/overlay.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { IdentityInputs } from "./boot";
1
+ import type { IdentityInputs, ProjectGate, ReporterTier } from "./boot";
2
+ import type { Breadcrumb } from "./breadcrumbs";
2
3
  import { type ElementPicker, type ElementPickerOptions } from "./element-picker";
4
+ import { type BeforeSend } from "./scrub";
3
5
  import { type Capture, type CaptureOptions } from "./screenshot";
4
6
  import { type SubmitInput, type SubmitResult } from "./submit";
5
7
  /** Marks the overlay's host element in the light DOM (skipped by capture/picker). */
@@ -13,17 +15,40 @@ export interface OverlayDeps {
13
15
  readonly submitReport: SubmitReportFn;
14
16
  readonly startElementPicker: StartPickerFn;
15
17
  }
18
+ /** A read-only view of the trace buffer the overlay attaches to a report. */
19
+ export interface BreadcrumbSource {
20
+ snapshot(): readonly Breadcrumb[];
21
+ }
16
22
  /** Configuration for {@link createOverlay}. */
17
23
  export interface OverlayConfig {
18
24
  readonly apiUrl: string;
19
25
  readonly key: string;
20
26
  readonly identity?: IdentityInputs;
27
+ /**
28
+ * The Project's Gate, forwarded verbatim from the boot answer. Reserved for
29
+ * Gate-aware launcher / redemption behaviour (spec §A/§F); the overlay itself
30
+ * does not read it yet, but `init` plumbs it through here (ticket #85 / §G).
31
+ */
32
+ readonly gate?: ProjectGate;
33
+ /**
34
+ * The Reporter's **server-derived** trust tier from the boot answer, or `null`
35
+ * when a presented identity was refused. Rendered as a **display-only** chip in
36
+ * the overlay header (spec §G) — never a trust signal, never read for a decision,
37
+ * and never sent back to the server. When `null`/absent no tier is asserted.
38
+ */
39
+ readonly tier?: ReporterTier | null;
21
40
  readonly sdkVersion?: string;
22
41
  /** Where to mount the overlay host. Defaults to `document.body`. */
23
42
  readonly target?: HTMLElement;
24
43
  readonly doc?: Document;
25
44
  readonly win?: Window;
26
45
  readonly deps?: Partial<OverlayDeps>;
46
+ /** The trace buffer whose snapshot rides on each report (spec §C). */
47
+ readonly buffer?: BreadcrumbSource | null;
48
+ /** Per-project client scrub hook, run at the `beforeSend` choke point. */
49
+ readonly beforeSend?: BeforeSend;
50
+ /** Run the built-in default scrubbers. Defaults to `true` (private-by-default). */
51
+ readonly scrub?: boolean;
27
52
  }
28
53
  /** A mounted overlay the launcher opens. */
29
54
  export interface OverlayController {
@@ -34,11 +59,16 @@ export interface OverlayController {
34
59
  }
35
60
  /**
36
61
  * Create the report overlay — the on-page panel a Reporter files a report from,
37
- * built to the frozen Signal overlay (`1d`). It mounts lazily inside its own
38
- * Shadow DOM (isolated from the host page, and marked so the screenshot and
39
- * element-picker skip it), opens on the launcher's `fixback:launch` seam, and on
40
- * Send captures a masked screenshot, assembles the Annotation, and submits to
41
- * ingest showing a confirmation on success and failing quietly otherwise.
62
+ * built to the frozen Signal Reporter prototype (`docs/design/Fixback Reporter.dc.html`).
63
+ * It mounts lazily inside its own Shadow DOM (isolated from the host page, and
64
+ * marked so the screenshot and element-picker skip it), opens on the launcher's
65
+ * `fixback:launch` seam, and offers three **composable, optional** marking layers
66
+ * over one full masked screenshot (spec §B): **element-pick**, **region-capture**
67
+ * (drag), and **draw** (arrow / box / pen / text, with undo / cancel / attach). On
68
+ * Send it captures the masked screenshot, assembles the structured Annotation
69
+ * (`{ element?, region?, marks? }`, spec §D — marks stay vector, never baked into
70
+ * the PNG), and submits to ingest — showing a confirmation on success and failing
71
+ * quietly otherwise.
42
72
  */
43
73
  export declare function createOverlay(config: OverlayConfig): OverlayController;
44
74
  export {};
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Region-capture — drag a rectangle over the page to focus the report on one area
3
+ * (spec 0003 §B, the Reporter prototype's capture layer). It produces a `region`
4
+ * rect in screenshot (viewport) coordinates; Escape or a too-small drag abandons
5
+ * it. One of the overlay's three composable, optional marking layers, alongside
6
+ * element-pick and draw.
7
+ *
8
+ * Listeners are attached in the **capture phase** so the drag is intercepted before
9
+ * any host-page handler sees it. It is armed deliberately — the overlay lays a
10
+ * full-screen capture layer over the page (and hides the panel) while it runs — so
11
+ * it captures whatever the Reporter drags, its own layer included. Purely a producer
12
+ * of a `Rect`: it draws nothing; the overlay renders the live selection box from
13
+ * `onProgress`.
14
+ */
15
+ import { type Rect } from "./annotation";
16
+ /** Options for {@link startRegionCapture}. */
17
+ export interface RegionCaptureOptions {
18
+ /** Document to attach to. Defaults to the global `document`. */
19
+ readonly doc?: Document;
20
+ /** Called on each drag update with the current rect (to draw a selection box). */
21
+ readonly onProgress?: (rect: Rect) => void;
22
+ /** Called with the captured rect when the drag ends large enough to keep. */
23
+ readonly onComplete: (rect: Rect) => void;
24
+ /** Called when the capture is abandoned (Escape, or a drag below the floor). */
25
+ readonly onCancel?: () => void;
26
+ /** Minimum width and height, in px, to count as a capture. Defaults to 12. */
27
+ readonly minSize?: number;
28
+ }
29
+ /** A running region-capture; call {@link RegionCapture.stop} to detach it. */
30
+ export interface RegionCapture {
31
+ stop(): void;
32
+ }
33
+ /**
34
+ * Enter region-capture mode: the next drag on the host page draws out a rectangle,
35
+ * reported live through `onProgress` and committed through `onComplete` on release
36
+ * (when it clears the size floor). Escape, or a drag too small to be meaningful,
37
+ * calls `onCancel`. `stop()` (called automatically on complete/cancel) detaches all
38
+ * listeners.
39
+ */
40
+ export declare function startRegionCapture(options: RegionCaptureOptions): RegionCapture;
package/dist/report.d.ts CHANGED
@@ -8,8 +8,16 @@
8
8
  * (`apps/api/src/ingest/ingest.controller.ts` — `feedbackContentBody`) and the
9
9
  * `SubmissionContent` it parses (`apps/api/src/ingest/reporter-identity.ts`).
10
10
  */
11
+ import { type Annotation, type Mark, type Rect } from "./annotation";
12
+ import type { Breadcrumb } from "./breadcrumbs";
11
13
  /** The Kind a Reporter tags a report with. Mirrors the server's `ISSUE_KINDS`. */
12
14
  export type IssueKind = "bug" | "improvement" | "idea";
15
+ /**
16
+ * Where a Feedback came from — a human in the overlay (`reporter`, the default) or
17
+ * the SDK's automatic error capture (`auto`). Mirrors the server's `FEEDBACK_SOURCES`;
18
+ * the server derives trust independently and ignores anything else the client claims.
19
+ */
20
+ export type FeedbackSource = "reporter" | "auto";
13
21
  /** The picked element's viewport rectangle, as the server's annotation `rect`. */
14
22
  export interface ElementRect {
15
23
  readonly x: number;
@@ -18,9 +26,9 @@ export interface ElementRect {
18
26
  readonly height: number;
19
27
  }
20
28
  /**
21
- * The Annotation — the element a Reporter pointed at: a stable CSS selector, a
22
- * readable DOM path, the tag, and the bounding rect. Exactly the server's
23
- * `annotation` object shape.
29
+ * The picked element — the `element` layer of an {@link Annotation}: a stable CSS
30
+ * selector, a readable DOM path, the tag, and the bounding rect. Exactly the
31
+ * server's annotation `element` object shape.
24
32
  */
25
33
  export interface SelectedElement {
26
34
  readonly selector: string;
@@ -39,23 +47,41 @@ export interface CaptureEnvironment {
39
47
  * The JSON content of a feedback submission — the object serialised into the
40
48
  * multipart `payload` part next to the `key` and identity evidence. Every field
41
49
  * is optional: none of it feeds the server's trust decision, so a submission may
42
- * carry any subset. `annotation` is the selected element; the screenshot is a
43
- * separate binary part, never part of this JSON.
50
+ * carry any subset. `annotation` is the structured `{ element?, region?, marks? }`
51
+ * (spec §D); the screenshot is a separate binary part, never part of this JSON.
44
52
  */
45
53
  export interface ReportContent {
46
54
  readonly comment?: string;
47
55
  readonly kind?: IssueKind;
48
56
  readonly url?: string;
49
57
  readonly environment?: CaptureEnvironment;
50
- readonly annotation?: SelectedElement;
58
+ readonly annotation?: Annotation;
59
+ /** The masked breadcrumb trace buffer that rode on this report (spec §C). */
60
+ readonly trace?: readonly Breadcrumb[];
61
+ /**
62
+ * Provenance (spec §D/§E). Omitted for a manual report — the transport stamps the
63
+ * `reporter` default on the wire; set to `auto` by the SDK's error capture.
64
+ */
65
+ readonly source?: FeedbackSource;
66
+ /** For `source: auto` only — the SDK's per-session error fingerprint (spec §E). */
67
+ readonly errorSignature?: string;
68
+ /** For `source: auto` only — the running occurrence count within the session (spec §E). */
69
+ readonly occurrences?: number;
51
70
  }
52
- /** What the overlay hands to {@link assembleContent} when the Reporter sends. */
71
+ /**
72
+ * What the overlay hands to {@link assembleContent} when the Reporter sends. The
73
+ * three marking layers arrive flat (`element` / `region` / `marks`); `assembleContent`
74
+ * folds whatever is present into the structured {@link Annotation}.
75
+ */
53
76
  export interface ReportDraft {
54
77
  readonly kind?: IssueKind;
55
78
  readonly comment?: string;
56
79
  readonly element?: SelectedElement;
80
+ readonly region?: Rect;
81
+ readonly marks?: ReadonlyArray<Mark>;
57
82
  readonly url?: string;
58
83
  readonly environment?: CaptureEnvironment;
84
+ readonly trace?: readonly Breadcrumb[];
59
85
  }
60
86
  /**
61
87
  * Read the capture environment off a window: the viewport size, the browser's
@@ -27,6 +27,8 @@ export interface Capture {
27
27
  readonly height: number;
28
28
  readonly type: string;
29
29
  }
30
+ /** The screenshot filename extension for a {@link Capture}'s content type (defaults to `png`). */
31
+ export declare function extensionFor(type: string): string;
30
32
  /** Turns a serialised SVG of the view into image bytes (injectable for tests). */
31
33
  export type Rasterize = (svg: string, meta: {
32
34
  width: number;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The single client-side **scrub choke point** every report passes through
3
+ * before transport (spec 0003 §C; research `sentry-error-capture-findings.md`
4
+ * §7.4) — the SDK's `beforeSend` equivalent.
5
+ *
6
+ * Masking is the SDK's job, done in the browser before anything leaves the page.
7
+ * The screenshot is masked at capture, and breadcrumbs never record a value or a
8
+ * body at the source; `runBeforeSend` is the **last** gate over the assembled
9
+ * report. Its default scrubbers are **on**: they strip credentials, query
10
+ * strings, and bearer tokens from URLs, and redact obvious PII (emails, long
11
+ * digit runs, bearer tokens) from crumb and error text. The result is then handed
12
+ * to an optional per-project hook that can mutate it further or drop the whole
13
+ * report by returning `null`.
14
+ *
15
+ * The hook is **synchronous and network-free** by contract, and both manual
16
+ * (overlay) and automatic (error-capture) reports run through the very same
17
+ * choke point. A project relaxes the defaults with `scrub: false`, or reshapes
18
+ * the draft in its own hook — never a silent raw send.
19
+ */
20
+ import type { ReportContent } from "./report";
21
+ /** The per-project client scrub hook. Return `null` to drop the whole report. */
22
+ export type BeforeSend = (draft: ReportContent) => ReportContent | null;
23
+ /** Options for {@link runBeforeSend}. */
24
+ export interface BeforeSendOptions {
25
+ /** The per-project hook, run **after** the default scrubbers. */
26
+ readonly hook?: BeforeSend | null;
27
+ /** Run the built-in default scrubbers first. Defaults to `true`. */
28
+ readonly scrub?: boolean;
29
+ }
30
+ /**
31
+ * Redact obvious PII from free text: email addresses, `Bearer <token>` /
32
+ * `token <value>` pairs, and long digit runs. Conservative by design — it keeps
33
+ * the shape of the message readable while removing the sensitive spans.
34
+ */
35
+ export declare function redactPii(text: string): string;
36
+ /**
37
+ * Strip the sensitive parts of a URL: userinfo credentials
38
+ * (`scheme://user:pass@host`), the entire query string, and a token-bearing
39
+ * fragment (one that carries `key=value`). Plain hash routes (`#/checkout`) are
40
+ * kept. Works on absolute and relative URLs alike, with no dependency and no
41
+ * throw.
42
+ */
43
+ export declare function scrubUrl(url: string): string;
44
+ /**
45
+ * Apply the built-in default scrubbers to a report draft: strip the page URL,
46
+ * and scrub every crumb's URLs and redact PII from its text. The Reporter's own
47
+ * `comment` is intentionally left untouched — it is authored on purpose, not
48
+ * scraped. The screenshot and input values are masked elsewhere (at capture and
49
+ * at crumb creation); this is the final URL/PII sweep.
50
+ */
51
+ export declare function applyDefaultScrub(draft: ReportContent): ReportContent;
52
+ /**
53
+ * Run the report draft through the client scrub choke point: the default
54
+ * scrubbers first (unless `scrub` is `false`), then the optional per-project
55
+ * hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
56
+ * the hook drops the report. A hook that throws is treated as a no-op — the
57
+ * already-scrubbed draft is kept, so a buggy hook never breaks the report path
58
+ * nor leaks unscrubbed data.
59
+ */
60
+ export declare function runBeforeSend(draft: ReportContent, options?: BeforeSendOptions): ReportContent | null;