@fixback/sdk 0.2.0 → 0.4.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,5 +1,6 @@
1
1
  import { type IdentityInputs } from "./boot";
2
- import { type BeforeBreadcrumb, type BreadcrumbLevel } from "./breadcrumbs";
2
+ import { type BeforeBreadcrumb, type BreadcrumbLevel, type TraceStream } from "./breadcrumbs";
3
+ import { type ReplayOptions } from "./replay";
3
4
  import type { BeforeSend } from "./scrub";
4
5
  /**
5
6
  * The hosted Fixback API origin the SDK talks to by default. A self-hosted or
@@ -8,17 +9,39 @@ import type { BeforeSend } from "./scrub";
8
9
  */
9
10
  export declare const DEFAULT_API_URL = "https://api.fixback.dev";
10
11
  /**
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
12
+ * Does `url` address one of the SDK's **own** API endpoints? Every request the SDK
13
+ * issues lives under two namespaces beneath the API origin: `/api/ingest/*`boot
14
+ * (`bootEndpoint`, boot.ts) and feedback (`feedbackEndpoint`, submit.ts) and
15
+ * `/api/invites/*` (`inviteStatusEndpoint` / `redeemEndpoint`, invite.ts).
16
+ *
17
+ * The trace's network capture ignores **only** these, never the whole origin. That
18
+ * distinction matters when the instrumented app and the Fixback API share an origin — a
19
+ * self-hosted deployment, or Fixback running its own SDK on its own dashboard — where
20
+ * ignoring the origin wholesale would swallow all of the app's own requests and leave the
21
+ * Network tab empty. When the app and the API sit on different origins (the common case)
22
+ * this matches nothing extra, since the app's origin is never `apiUrl`. `url` is the raw
23
+ * request URL — absolute for every call the SDK makes — so a prefix test over the
24
+ * normalised origin is exact.
25
+ */
26
+ export declare function isFixbackApiRequest(apiUrl: string, url: string): boolean;
27
+ /**
28
+ * Trace buffer tuning (spec #122 §A). These are configurable **starting points**
29
+ * from the grill — three per-stream ring budgets (network ≈ 100 / console ≈ 80 /
30
+ * breadcrumbs ≈ 40), a shared age cap (~3 min, on), and **all** console levels
31
+ * captured (eviction priority, not capture, keeps warn/error above the chatter) —
32
+ * never frozen magic numbers. Pass `false` for {@link InitOptions.trace} to disable
14
33
  * capture entirely.
15
34
  */
16
35
  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. */
36
+ /**
37
+ * Per-stream ring budgets (network / console / breadcrumbs); each stream evicts
38
+ * independently, so a chatty stream can't drop another's lead-up. Any stream
39
+ * omitted uses its default.
40
+ */
41
+ readonly budgets?: Partial<Record<TraceStream, number>>;
42
+ /** Shared age cap in ms. Defaults to ~3 min (on); pass `0` to disable age pruning. */
20
43
  readonly maxAgeMs?: number;
21
- /** Console levels captured. Defaults to `warn`/`error`/`assert`. */
44
+ /** Console levels captured. Defaults to **all** levels (log/info/warn/error/assert/debug). */
22
45
  readonly consoleLevels?: readonly BreadcrumbLevel[];
23
46
  /** Per-crumb filter: mute a category, edit a crumb, or drop it (`null`). */
24
47
  readonly beforeBreadcrumb?: BeforeBreadcrumb;
@@ -47,6 +70,26 @@ export interface InitOptions extends IdentityInputs {
47
70
  readonly scrub?: boolean;
48
71
  /** Trace buffer tuning, or `false` to turn the buffer off entirely. */
49
72
  readonly trace?: TraceOptions | false;
73
+ /**
74
+ * Buffered session-replay tuning (issue #189, ADR-0024) — masking defaults on:
75
+ * all inputs, all text, SDK chrome blocked — or `false` to turn the recorder
76
+ * off entirely regardless of the served config.
77
+ */
78
+ readonly replay?: ReplayOptions | false;
79
+ /**
80
+ * The dev-side override of the Project's server-served capture config (spec #122
81
+ * §L; ticket #138). Capture is **default-on** and normally governed per-project
82
+ * from the dashboard, surfaced on the boot answer; set a stream here to override
83
+ * what the server serves for it — `{ network: false }` turns network capture off
84
+ * even where the Project leaves it on, and a stream left unset follows the served
85
+ * config. Independent of {@link trace} `false`, which turns the whole buffer off,
86
+ * and of {@link replay} `false`, which turns the recorder off.
87
+ */
88
+ readonly capture?: {
89
+ readonly console?: boolean;
90
+ readonly network?: boolean;
91
+ readonly replay?: boolean;
92
+ };
50
93
  /**
51
94
  * Automatic error capture — the SDK files uncaught exceptions / unhandled
52
95
  * rejections as `source: auto` Feedback with no prompt (spec §E, ADR-0011).
@@ -55,6 +98,17 @@ export interface InitOptions extends IdentityInputs {
55
98
  * never filed where a manual report would be refused.
56
99
  */
57
100
  readonly autoCapture?: boolean;
101
+ /**
102
+ * The host app's **Release** — a build identifier (a git SHA, a semver, a
103
+ * `name@version`) stamped into every submission's environment (#117,
104
+ * ADR-0024). Set it to the same value the build uploaded sourcemaps under
105
+ * (`npx fixback sourcemaps upload --release <v>`), and auto-captured errors
106
+ * gain a symbolicated **code-area pointer** on their Issues. Optional: without
107
+ * it (or without uploaded sourcemaps) everything behaves exactly as before.
108
+ * An invalid value (whitespace, slashes, over 100 chars) is ignored with a
109
+ * console warning.
110
+ */
111
+ readonly release?: string;
58
112
  }
59
113
  /**
60
114
  * Options for {@link redeem} — the explicit form of the `?fixback_invite=` URL
@@ -29,8 +29,8 @@ export interface Launcher {
29
29
  * Mount the launcher into `target` (typically `document.body`). Everything the
30
30
  * launcher draws — the **Feedback** pill, its **tuck** control, the edge **nub**
31
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
32
+ * toast — lives inside one open Shadow DOM, so its styles are fully isolated
33
+ * from the host page and vice-versa. The host element is
34
34
  * fixed-positioned and out of flow, so mounting never shifts the host page's
35
35
  * layout. Only one launcher can exist at a time — an earlier one is removed first.
36
36
  *
@@ -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-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";
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-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-mark {\n margin: 12px 14px 10px;\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,6 +1,8 @@
1
1
  import type { IdentityInputs, ProjectGate, ReporterTier } from "./boot";
2
2
  import type { Breadcrumb } from "./breadcrumbs";
3
+ import type { ReporterDisplay } from "./invite";
3
4
  import { type ElementPicker, type ElementPickerOptions } from "./element-picker";
5
+ import type { ReplaySource } from "./replay";
4
6
  import { type BeforeSend } from "./scrub";
5
7
  import { type Capture, type CaptureOptions } from "./screenshot";
6
8
  import { type SubmitInput, type SubmitResult } from "./submit";
@@ -38,6 +40,14 @@ export interface OverlayConfig {
38
40
  */
39
41
  readonly tier?: ReporterTier | null;
40
42
  readonly sdkVersion?: string;
43
+ /** The host app's Release (#117, ADR-0024), validated by `init`; rides in the environment. */
44
+ readonly release?: string;
45
+ /**
46
+ * The Reporter's self-provided display name / email (spec §F), when the SDK holds
47
+ * them (from a redemption in this session or a prior one). They ride along with the
48
+ * report so a Member sees who filed it — display only, never a trust signal.
49
+ */
50
+ readonly display?: ReporterDisplay;
41
51
  /** Where to mount the overlay host. Defaults to `document.body`. */
42
52
  readonly target?: HTMLElement;
43
53
  readonly doc?: Document;
@@ -45,6 +55,8 @@ export interface OverlayConfig {
45
55
  readonly deps?: Partial<OverlayDeps>;
46
56
  /** The trace buffer whose snapshot rides on each report (spec §C). */
47
57
  readonly buffer?: BreadcrumbSource | null;
58
+ /** The replay recorder whose buffered window rides on each report (ADR-0024). */
59
+ readonly replay?: ReplaySource | null;
48
60
  /** Per-project client scrub hook, run at the `beforeSend` choke point. */
49
61
  readonly beforeSend?: BeforeSend;
50
62
  /** Run the built-in default scrubbers. Defaults to `true` (private-by-default). */
@@ -0,0 +1,101 @@
1
+ import { type eventWithTime } from "@rrweb/types";
2
+ /**
3
+ * Buffered session replay — the rrweb capture half of issue #189 (ADR-0024).
4
+ *
5
+ * The recorder runs continuously from mount ("the moment before matters",
6
+ * VISION principle 4) but never streams: rrweb events accumulate in **two
7
+ * checkout-delimited buckets**, and every {@link REPLAY_CHECKOUT_MS} rrweb takes
8
+ * a fresh full DOM snapshot (a "checkout") that rotates the buckets. A report's
9
+ * {@link ReplayRecorder.snapshot} is the concatenation of both buckets, so it
10
+ * always starts at a full snapshot and carries between one and two checkout
11
+ * intervals of lead-up — the bounded-window posture the Trace already follows,
12
+ * never an unbounded full-session stream.
13
+ *
14
+ * Masking is applied by rrweb **at serialization time, inside the page** — the
15
+ * same client-side, private-by-default boundary as the screenshot and the Trace
16
+ * (spec 0003 §C, VISION §5): every input is masked (`maskAllInputs`) and, by
17
+ * default, **all text** is masked (`maskTextSelector: "*"`), so the recording
18
+ * that leaves the browser carries layout and interaction, not content. A
19
+ * Project that accepts the trade-off can narrow the text mask via
20
+ * {@link ReplayOptions.maskTextSelector} (rrweb 2.1 has no per-element unmask
21
+ * selector, so narrowing the mask selector is the escape hatch). The SDK's own
22
+ * chrome — launcher and overlay hosts — is blocked from the recording entirely,
23
+ * mirroring how the screenshot strips it.
24
+ */
25
+ /**
26
+ * How often rrweb takes a checkout full snapshot, which is also the bucket
27
+ * rotation period: a snapshot carries 1–2 of these intervals of lead-up
28
+ * (30–60 s), Sentry's buffered-replay ballpark.
29
+ */
30
+ export declare const REPLAY_CHECKOUT_MS = 30000;
31
+ /**
32
+ * The SDK's own host elements (dom.ts markers), excluded from the recording the
33
+ * way the screenshot's clone walk strips them.
34
+ */
35
+ export declare const FIXBACK_BLOCK_SELECTOR = "[data-fixback-root],[data-fixback-overlay]";
36
+ /**
37
+ * Media elements blocked from the recording by default (Sentry's `blockAllMedia`
38
+ * posture, adopted): text masking cannot redact what an image shows, so media
39
+ * renders as a same-size placeholder in replay unless a Project opts out via
40
+ * {@link ReplayOptions.blockMedia}.
41
+ */
42
+ export declare const FIXBACK_MEDIA_SELECTOR = "img,image,svg,video,object,picture,embed,map,audio";
43
+ /** Tuning for the replay recorder. All optional; the defaults are the contract. */
44
+ export interface ReplayOptions {
45
+ /**
46
+ * Mask **all** text content (default `true`, private-by-default). `false`
47
+ * records text in the clear — inputs stay masked regardless.
48
+ */
49
+ readonly maskText?: boolean;
50
+ /**
51
+ * Which elements' text rrweb masks when {@link maskText} is on. Defaults to
52
+ * `"*"` (everything); narrow it to unmask known-safe regions.
53
+ */
54
+ readonly maskTextSelector?: string;
55
+ /**
56
+ * Block media content — images, video, audio, SVG — from the recording
57
+ * (default `true`, {@link FIXBACK_MEDIA_SELECTOR}): what a picture shows
58
+ * cannot be text-masked, so media is a placeholder in replay. `false` records
59
+ * it (an internal/beta Project trading privacy for fidelity, VISION §5).
60
+ */
61
+ readonly blockMedia?: boolean;
62
+ /** Extra elements to exclude from the recording, alongside the SDK's own chrome. */
63
+ readonly blockSelector?: string;
64
+ }
65
+ /** One rrweb event as recorded; the wire payload is an array of these. */
66
+ export type ReplayEvent = eventWithTime;
67
+ /** The buffered window a report ships: rrweb events starting at a full snapshot. */
68
+ export interface ReplaySnapshot {
69
+ readonly events: readonly ReplayEvent[];
70
+ }
71
+ /** A read-only view of the recorder the report surfaces attach from. */
72
+ export interface ReplaySource {
73
+ /** The current buffered window, or `null` when nothing replayable exists. */
74
+ snapshot(): ReplaySnapshot | null;
75
+ }
76
+ /** A running replay recorder. */
77
+ export interface ReplayRecorder extends ReplaySource {
78
+ /** Stop recording and release the buffers. Safe to call more than once. */
79
+ stop(): void;
80
+ }
81
+ /** The slice of rrweb's record options the recorder sets. */
82
+ export interface ReplayRecordOptions {
83
+ readonly emit: (event: ReplayEvent, isCheckout?: boolean) => void;
84
+ readonly checkoutEveryNms?: number;
85
+ readonly maskAllInputs?: boolean;
86
+ readonly maskTextSelector?: string;
87
+ readonly blockSelector?: string;
88
+ readonly slimDOMOptions?: "all";
89
+ }
90
+ /**
91
+ * The slice of rrweb's `record` the recorder consumes; injectable for tests.
92
+ * (A concrete signature — rrweb's own is generic, which mocks can't satisfy.)
93
+ */
94
+ export type RecordFn = (options: ReplayRecordOptions) => (() => void) | undefined;
95
+ /**
96
+ * Start the buffered replay recorder. Never throws: an rrweb failure (an exotic
97
+ * DOM, an unsupported environment) degrades to a recorder whose `snapshot()` is
98
+ * `null`, so a Fixback problem never surfaces on the host page and a report
99
+ * simply ships without replay.
100
+ */
101
+ export declare function startReplayRecorder(options?: ReplayOptions, recordFn?: RecordFn): ReplayRecorder;
package/dist/report.d.ts CHANGED
@@ -10,8 +10,6 @@
10
10
  */
11
11
  import { type Annotation, type Mark, type Rect } from "./annotation";
12
12
  import type { Breadcrumb } from "./breadcrumbs";
13
- /** The Kind a Reporter tags a report with. Mirrors the server's `ISSUE_KINDS`. */
14
- export type IssueKind = "bug" | "improvement" | "idea";
15
13
  /**
16
14
  * Where a Feedback came from — a human in the overlay (`reporter`, the default) or
17
15
  * the SDK's automatic error capture (`auto`). Mirrors the server's `FEEDBACK_SOURCES`;
@@ -42,7 +40,34 @@ export interface CaptureEnvironment {
42
40
  readonly viewportHeight?: number;
43
41
  readonly browser?: string;
44
42
  readonly sdkVersion?: string;
43
+ /**
44
+ * The host app's **Release** — the build identifier the builder configured at
45
+ * `init` (#117, ADR-0024). Uploaded sourcemaps are keyed by it, so the server
46
+ * can symbolicate this session's stack traces against the exact build that
47
+ * produced them. Omitted when the Project doesn't set one.
48
+ */
49
+ readonly release?: string;
50
+ }
51
+ /**
52
+ * One structured frame of a captured stack trace (#117, ADR-0024), top of stack
53
+ * first — the server's `errorFrames` wire shape (mirrors `@fixback/shared`'s
54
+ * `CapturedStackFrame` by value, per this file's vendoring rule). `file` is the
55
+ * scrubbed script URL; `line`/`column` are 1-based as browsers report them.
56
+ */
57
+ export interface CapturedFrame {
58
+ readonly file: string;
59
+ readonly line: number;
60
+ readonly column: number | null;
61
+ readonly function: string | null;
45
62
  }
63
+ /**
64
+ * Validate + trim a builder-supplied release (#117, ADR-0024), mirroring the
65
+ * server's rules by value: 1–100 visible characters, no whitespace, control
66
+ * characters, or path separators, and not a reserved name. Returns `undefined`
67
+ * for an invalid value — the SDK drops it (with a warning at `init`) rather than
68
+ * shipping a value the server would discard.
69
+ */
70
+ export declare function normaliseRelease(value: string): string | undefined;
46
71
  /**
47
72
  * The JSON content of a feedback submission — the object serialised into the
48
73
  * multipart `payload` part next to the `key` and identity evidence. Every field
@@ -52,7 +77,15 @@ export interface CaptureEnvironment {
52
77
  */
53
78
  export interface ReportContent {
54
79
  readonly comment?: string;
55
- readonly kind?: IssueKind;
80
+ /**
81
+ * The Reporter's self-provided display **name / email** (spec §F), captured in the
82
+ * invite onboarding step and riding along with every submission so a Member sees
83
+ * who reported an Issue. **Display only, never a trust signal** — the server derives
84
+ * the tier from identity evidence alone and ignores these. Omitted when the Reporter
85
+ * gave none (a Public/anonymous visitor).
86
+ */
87
+ readonly reporterName?: string;
88
+ readonly reporterEmail?: string;
56
89
  readonly url?: string;
57
90
  readonly environment?: CaptureEnvironment;
58
91
  readonly annotation?: Annotation;
@@ -67,6 +100,13 @@ export interface ReportContent {
67
100
  readonly errorSignature?: string;
68
101
  /** For `source: auto` only — the running occurrence count within the session (spec §E). */
69
102
  readonly occurrences?: number;
103
+ /**
104
+ * For `source: auto` only — the captured error's parsed stack frames (#117,
105
+ * ADR-0024), top of stack first, capped client-side. The analysis worker
106
+ * matches them against the release's uploaded sourcemaps to write the Issue's
107
+ * Code-area pointer.
108
+ */
109
+ readonly errorFrames?: readonly CapturedFrame[];
70
110
  }
71
111
  /**
72
112
  * What the overlay hands to {@link assembleContent} when the Reporter sends. The
@@ -74,7 +114,6 @@ export interface ReportContent {
74
114
  * folds whatever is present into the structured {@link Annotation}.
75
115
  */
76
116
  export interface ReportDraft {
77
- readonly kind?: IssueKind;
78
117
  readonly comment?: string;
79
118
  readonly element?: SelectedElement;
80
119
  readonly region?: Rect;
@@ -82,6 +121,9 @@ export interface ReportDraft {
82
121
  readonly url?: string;
83
122
  readonly environment?: CaptureEnvironment;
84
123
  readonly trace?: readonly Breadcrumb[];
124
+ /** The Reporter's self-provided display name / email (spec §F), if the SDK holds them. */
125
+ readonly reporterName?: string;
126
+ readonly reporterEmail?: string;
85
127
  }
86
128
  /**
87
129
  * Read the capture environment off a window: the viewport size, the browser's
@@ -89,7 +131,7 @@ export interface ReportDraft {
89
131
  * positive number (a headless/zero viewport records nothing rather than an
90
132
  * invalid `0`, which the server would reject).
91
133
  */
92
- export declare function collectEnvironment(win: Window, sdkVersion: string): CaptureEnvironment;
134
+ export declare function collectEnvironment(win: Window, sdkVersion: string, release?: string): CaptureEnvironment;
93
135
  /**
94
136
  * Assemble the ingest content from what the Reporter composed. Empty pieces are
95
137
  * dropped rather than sent as blanks: a whitespace-only comment, an absent
@@ -1,15 +1,24 @@
1
1
  /**
2
- * Client-side masked screenshot capture (spec MVP §E; ticket #54).
2
+ * Client-side masked screenshot capture (spec MVP §E; ticket #54; ADR-0014).
3
3
  *
4
- * The capture is **private-by-default**: input values are masked and the SDK's
5
- * own UI is removed from a *clone* of the view **before** anything is rasterised,
6
- * so no unmasked text and none of Fixback's chrome ever reaches the image. The
7
- * approach is dependency-free — the cloned, masked DOM is serialised into an SVG
8
- * `<foreignObject>` and drawn onto a `<canvas>` honouring the SDK's "no runtime
9
- * dependencies / no host-page disturbance" constraints. The raster step is
10
- * injectable so the pipeline (and the masking-before-capture guarantee) is
11
- * testable without a real canvas, and it fails quietly: any problem resolves to
12
- * `null` and the report is simply sent without a screenshot.
4
+ * The capture is **private-by-default**: input values are masked and the SDK's own
5
+ * UI is excluded from a *clone* of the view **before** anything is rasterised, so no
6
+ * unmasked text and none of Fixback's chrome ever reaches the image.
7
+ *
8
+ * The rasterisation itself is delegated to `modern-screenshot` (ADR-0014): it clones
9
+ * the target, inlines the page's real styles **and** its fonts and images as data
10
+ * URIs, then draws the result through an SVG `<foreignObject>` onto a `<canvas>`.
11
+ * That closes the fidelity gap a hand-rolled `<foreignObject>` left open external
12
+ * stylesheets, web fonts, and same-origin images now render so the shot matches
13
+ * what the Reporter saw and lines up with the annotation's viewport-space marks.
14
+ *
15
+ * We keep the privacy guarantee by driving it through two of its hooks: `filter`
16
+ * drops Fixback's own host elements, and `onCloneNode` masks the clone's inputs —
17
+ * both run on the library's internal clone, before it embeds or serialises anything,
18
+ * so the live page is never touched and no real value reaches the raster. The backend
19
+ * is injectable so the pipeline (and the masking-before-capture guarantee) is testable
20
+ * without a real canvas, and it fails quietly: any problem resolves to `null` and the
21
+ * report is simply sent without a screenshot.
13
22
  */
14
23
  /** The character private input content is replaced with. */
15
24
  export declare const MASK_CHAR = "\u2022";
@@ -29,12 +38,19 @@ export interface Capture {
29
38
  }
30
39
  /** The screenshot filename extension for a {@link Capture}'s content type (defaults to `png`). */
31
40
  export declare function extensionFor(type: string): string;
32
- /** Turns a serialised SVG of the view into image bytes (injectable for tests). */
33
- export type Rasterize = (svg: string, meta: {
41
+ /** How the SDK prepares a capture: exclude Fixback's UI, mask the clone's inputs. */
42
+ interface CaptureHooks {
43
+ /** Keep a node in the shot? Returns `false` for Fixback's own host elements. */
44
+ readonly filter: (node: Node) => boolean;
45
+ /** Mask private input content on the library's internal clone, pre-raster. */
46
+ readonly onCloneNode: (clone: Node) => void;
47
+ }
48
+ /** Turns a DOM subtree into image bytes at a given size (injectable for tests). */
49
+ export type CaptureBackend = (target: Element, meta: {
34
50
  width: number;
35
51
  height: number;
36
52
  type: string;
37
- }) => Promise<Blob | null>;
53
+ } & CaptureHooks) => Promise<Blob | null>;
38
54
  /** Options for {@link captureView}. */
39
55
  export interface CaptureOptions {
40
56
  /** The element to capture. Defaults to the document element (the full view). */
@@ -43,12 +59,15 @@ export interface CaptureOptions {
43
59
  readonly win?: Window;
44
60
  /** Output content type. Defaults to `image/png`. */
45
61
  readonly type?: string;
46
- /** Override the raster step (the default draws via an SVG + `<canvas>`). */
47
- readonly rasterize?: Rasterize;
62
+ /** Override the raster backend (the default is `modern-screenshot`). */
63
+ readonly capture?: CaptureBackend;
48
64
  }
49
65
  /**
50
- * Capture the current view as a masked screenshot. Clones the target, removes the
51
- * SDK's own UI, masks input values **all before** serialising and rasterising —
52
- * then returns the image bytes, or `null` if capture wasn't possible.
66
+ * Capture the current view as a masked screenshot. Delegates the raster to the
67
+ * backend ({@link captureViaModernScreenshot} by default), which excludes the SDK's
68
+ * own UI and masks input values on its internal clone — **before** it embeds or
69
+ * serialises anything — then returns the image bytes, or `null` if capture wasn't
70
+ * possible.
53
71
  */
54
72
  export declare function captureView(options?: CaptureOptions): Promise<Capture | null>;
73
+ export {};
package/dist/scrub.d.ts CHANGED
@@ -35,10 +35,11 @@ export interface BeforeSendOptions {
35
35
  export declare function redactPii(text: string): string;
36
36
  /**
37
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.
38
+ * (`scheme://user:pass@host`), the entire query string, a token-bearing
39
+ * fragment (one that carries `key=value`), and PII (emails, long digit runs) in
40
+ * the **path segments** (#139). Plain hash routes (`#/checkout`) are kept. Works
41
+ * on absolute and relative URLs alike, with no dependency and no throw. The host
42
+ * (authority) is never redacted — only the path and any surviving fragment route.
42
43
  */
43
44
  export declare function scrubUrl(url: string): string;
44
45
  /**
package/dist/styles.d.ts CHANGED
@@ -11,10 +11,10 @@
11
11
  * **Feedback** pill that **hover-peeks**, can be **tucked away** (sliding off
12
12
  * behind an edge nub, with a corner hover-zone to bring it back), a first-visit
13
13
  * **welcome toast**, and a **reduce-motion** mode that stills the pulse. Every
14
- * piece — pill, nub, corner zone, toasts — lives in this one Shadow DOM.
14
+ * piece — pill, nub, corner zone, welcome toast — lives in this one Shadow DOM.
15
15
  *
16
16
  * Motion is driven by two host-element attributes the mount toggles:
17
17
  * `data-fb-hidden` (tucked away) and `data-fb-peeking` (peeked back on hover);
18
18
  * `data-fb-reduce-motion` (or the OS `prefers-reduced-motion`) stills it all.
19
19
  */
20
- export declare const LAUNCHER_STYLES = "\n:host {\n /* Vendored Signal tokens (packages/ui/src/tokens.css). */\n --fb-color-accent: #2f6fed;\n --fb-color-accent-hover: #245fd0;\n --fb-color-on-emphasis: #ffffff;\n --fb-color-text: #0f1720;\n /* Inverse (dark) surface for the toasts \u2014 Signal --fb-ink-900. */\n --fb-color-surface-inverse: #0f1720;\n --fb-font-sans: \"IBM Plex Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto,\n Helvetica, Arial, sans-serif;\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.4;\n -webkit-font-smoothing: antialiased;\n}\n\n/* The pill: Feedback button + divider + tuck control, anchored bottom-right. */\n.fb-launcher {\n position: fixed;\n right: 20px;\n bottom: 20px;\n z-index: 2;\n display: inline-flex;\n align-items: stretch;\n box-sizing: border-box;\n height: 40px;\n margin: 0;\n border-radius: 999px;\n background: var(--fb-color-accent);\n box-shadow:\n 0 6px 18px rgba(15, 23, 32, 0.16),\n 0 1px 2px rgba(15, 23, 32, 0.12);\n transform: translateX(0);\n transition:\n transform 340ms cubic-bezier(0.2, 0.8, 0.3, 1),\n background-color 120ms ease;\n}\n\n.fb-launcher:hover {\n background: var(--fb-color-accent-hover);\n}\n\n.fb-launcher__button {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n box-sizing: border-box;\n height: 100%;\n margin: 0;\n padding: 0 6px 0 16px;\n border: 0;\n background: transparent;\n color: var(--fb-color-on-emphasis);\n font-family: inherit;\n font-size: 13px;\n font-weight: 600;\n letter-spacing: 0.01em;\n cursor: pointer;\n}\n\n.fb-launcher__button:focus-visible,\n.fb-launcher__tuck:focus-visible {\n outline: 2px solid var(--fb-color-on-emphasis);\n outline-offset: -3px;\n border-radius: 999px;\n}\n\n.fb-launcher__icon {\n display: block;\n flex: none;\n width: 16px;\n height: 16px;\n}\n\n.fb-launcher__label {\n white-space: nowrap;\n}\n\n.fb-launcher__divider {\n width: 1px;\n margin: 9px 0;\n flex: none;\n background: rgba(255, 255, 255, 0.28);\n}\n\n.fb-launcher__tuck {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n width: 32px;\n height: 100%;\n padding: 0;\n border: 0;\n background: transparent;\n color: rgba(255, 255, 255, 0.82);\n cursor: pointer;\n}\n\n.fb-launcher__tuck:hover {\n color: var(--fb-color-on-emphasis);\n}\n\n.fb-launcher__tuck-icon {\n display: block;\n width: 14px;\n height: 14px;\n}\n\n/* The corner hover-zone that peeks a tucked launcher back \u2014 inert until tucked,\n * so it never swallows the host page's own bottom-right clicks. */\n.fb-launcher__peekzone {\n position: fixed;\n right: 0;\n bottom: 0;\n z-index: 1;\n width: 160px;\n height: 160px;\n pointer-events: none;\n}\n\n/* The edge nub: the visible re-reveal affordance, hidden until tucked. */\n.fb-launcher__nub {\n position: fixed;\n right: 0;\n bottom: 22px;\n z-index: 2;\n width: 13px;\n height: 42px;\n border-radius: 9px 0 0 9px;\n background: var(--fb-color-accent);\n box-shadow: -6px 5px 18px rgba(47, 111, 237, 0.4);\n cursor: pointer;\n opacity: 0;\n transform: translateX(10px);\n pointer-events: none;\n transition:\n opacity 220ms ease,\n transform 220ms ease;\n}\n\n/* Tucked and not peeking: slide the pill off, reveal the nub, arm the zone. */\n:host([data-fb-hidden]:not([data-fb-peeking])) .fb-launcher {\n transform: translateX(calc(100% + 30px));\n}\n\n:host([data-fb-hidden]:not([data-fb-peeking])) .fb-launcher__nub {\n opacity: 1;\n transform: none;\n pointer-events: auto;\n}\n\n:host([data-fb-hidden]) .fb-launcher__peekzone {\n pointer-events: auto;\n}\n\n/* Toasts: the first-visit welcome (above the pill) and the tuck-away hint. */\n.fb-launcher__welcome,\n.fb-launcher__hint {\n position: fixed;\n right: 20px;\n z-index: 5;\n box-sizing: border-box;\n color: #fff;\n font-size: 12px;\n line-height: 1.45;\n background: var(--fb-color-surface-inverse);\n border-radius: 10px;\n box-shadow: 0 12px 30px rgba(15, 40, 70, 0.32);\n animation: fbToast 300ms ease both;\n}\n\n.fb-launcher__welcome {\n bottom: 70px;\n max-width: 210px;\n padding: 9px 13px;\n}\n\n.fb-launcher__welcome strong {\n color: #8fc0ff;\n font-weight: 600;\n}\n\n.fb-launcher__hint {\n bottom: 20px;\n max-width: 232px;\n padding: 10px 13px;\n}\n\n/* The pulse that draws the eye while the welcome shows. */\n.fb-launcher--pulse {\n animation: fbPulse 1.8s ease-in-out 2;\n}\n\n@keyframes fbPulse {\n 0%,\n 100% {\n box-shadow:\n 0 6px 18px rgba(15, 23, 32, 0.16),\n 0 0 0 0 rgba(47, 111, 237, 0.45);\n }\n 50% {\n box-shadow:\n 0 6px 18px rgba(15, 23, 32, 0.16),\n 0 0 0 12px rgba(47, 111, 237, 0);\n }\n}\n\n@keyframes fbToast {\n from {\n opacity: 0;\n transform: translateY(14px);\n }\n to {\n opacity: 1;\n transform: none;\n }\n}\n\n/* Reduce motion \u2014 the explicit opt-in and the OS setting both still it all. */\n:host([data-fb-reduce-motion]) .fb-launcher,\n:host([data-fb-reduce-motion]) .fb-launcher__nub {\n transition: none;\n}\n\n:host([data-fb-reduce-motion]) .fb-launcher--pulse,\n:host([data-fb-reduce-motion]) .fb-launcher__welcome,\n:host([data-fb-reduce-motion]) .fb-launcher__hint {\n animation: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .fb-launcher,\n .fb-launcher__nub {\n transition: none;\n }\n .fb-launcher--pulse,\n .fb-launcher__welcome,\n .fb-launcher__hint {\n animation: none;\n }\n}\n";
20
+ export declare const LAUNCHER_STYLES = "\n:host {\n /* Vendored Signal tokens (packages/ui/src/tokens.css). */\n --fb-color-accent: #2f6fed;\n --fb-color-accent-hover: #245fd0;\n --fb-color-on-emphasis: #ffffff;\n --fb-color-text: #0f1720;\n /* Inverse (dark) surface for the toasts \u2014 Signal --fb-ink-900. */\n --fb-color-surface-inverse: #0f1720;\n --fb-font-sans: \"IBM Plex Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto,\n Helvetica, Arial, sans-serif;\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.4;\n -webkit-font-smoothing: antialiased;\n}\n\n/* The pill: Feedback button + divider + tuck control, anchored bottom-right. */\n.fb-launcher {\n position: fixed;\n right: 20px;\n bottom: 20px;\n z-index: 2;\n display: inline-flex;\n align-items: stretch;\n box-sizing: border-box;\n height: 40px;\n margin: 0;\n border-radius: 999px;\n background: var(--fb-color-accent);\n box-shadow:\n 0 6px 18px rgba(15, 23, 32, 0.16),\n 0 1px 2px rgba(15, 23, 32, 0.12);\n transform: translateX(0);\n transition:\n transform 340ms cubic-bezier(0.2, 0.8, 0.3, 1),\n background-color 120ms ease;\n}\n\n.fb-launcher:hover {\n background: var(--fb-color-accent-hover);\n}\n\n.fb-launcher__button {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n box-sizing: border-box;\n height: 100%;\n margin: 0;\n padding: 0 6px 0 16px;\n border: 0;\n background: transparent;\n color: var(--fb-color-on-emphasis);\n font-family: inherit;\n font-size: 13px;\n font-weight: 600;\n letter-spacing: 0.01em;\n cursor: pointer;\n}\n\n.fb-launcher__button:focus-visible,\n.fb-launcher__tuck:focus-visible {\n outline: 2px solid var(--fb-color-on-emphasis);\n outline-offset: -3px;\n border-radius: 999px;\n}\n\n.fb-launcher__icon {\n display: block;\n flex: none;\n width: 16px;\n height: 16px;\n}\n\n.fb-launcher__label {\n white-space: nowrap;\n}\n\n.fb-launcher__divider {\n width: 1px;\n margin: 9px 0;\n flex: none;\n background: rgba(255, 255, 255, 0.28);\n}\n\n.fb-launcher__tuck {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n width: 32px;\n height: 100%;\n padding: 0;\n border: 0;\n background: transparent;\n color: rgba(255, 255, 255, 0.82);\n cursor: pointer;\n}\n\n.fb-launcher__tuck:hover {\n color: var(--fb-color-on-emphasis);\n}\n\n.fb-launcher__tuck-icon {\n display: block;\n width: 14px;\n height: 14px;\n transition: transform 340ms cubic-bezier(0.2, 0.8, 0.3, 1);\n}\n\n/* Tucked away: mirror the chevron so it points left \u2014 back into view. */\n:host([data-fb-hidden]) .fb-launcher__tuck-icon {\n transform: scaleX(-1);\n}\n\n/* The corner hover-zone that peeks a tucked launcher back \u2014 inert until tucked,\n * so it never swallows the host page's own bottom-right clicks. */\n.fb-launcher__peekzone {\n position: fixed;\n right: 0;\n bottom: 0;\n z-index: 1;\n width: 160px;\n height: 160px;\n pointer-events: none;\n}\n\n/* The edge nub: the visible re-reveal affordance, hidden until tucked. */\n.fb-launcher__nub {\n position: fixed;\n right: 0;\n bottom: 22px;\n z-index: 2;\n width: 13px;\n height: 42px;\n border-radius: 9px 0 0 9px;\n background: var(--fb-color-accent);\n box-shadow: -6px 5px 18px rgba(47, 111, 237, 0.4);\n cursor: pointer;\n opacity: 0;\n transform: translateX(10px);\n pointer-events: none;\n transition:\n opacity 220ms ease,\n transform 220ms ease;\n}\n\n/* Tucked and not peeking: slide the pill off, reveal the nub, arm the zone. */\n:host([data-fb-hidden]:not([data-fb-peeking])) .fb-launcher {\n transform: translateX(calc(100% + 30px));\n}\n\n:host([data-fb-hidden]:not([data-fb-peeking])) .fb-launcher__nub {\n opacity: 1;\n transform: none;\n pointer-events: auto;\n}\n\n:host([data-fb-hidden]) .fb-launcher__peekzone {\n pointer-events: auto;\n}\n\n/* The first-visit welcome toast, above the pill. */\n.fb-launcher__welcome {\n position: fixed;\n right: 20px;\n bottom: 70px;\n z-index: 5;\n box-sizing: border-box;\n max-width: 210px;\n padding: 9px 13px;\n color: #fff;\n font-size: 12px;\n line-height: 1.45;\n background: var(--fb-color-surface-inverse);\n border-radius: 10px;\n box-shadow: 0 12px 30px rgba(15, 40, 70, 0.32);\n animation: fbToast 300ms ease both;\n}\n\n.fb-launcher__welcome strong {\n color: #8fc0ff;\n font-weight: 600;\n}\n\n/* The pulse that draws the eye while the welcome shows. */\n.fb-launcher--pulse {\n animation: fbPulse 1.8s ease-in-out 2;\n}\n\n@keyframes fbPulse {\n 0%,\n 100% {\n box-shadow:\n 0 6px 18px rgba(15, 23, 32, 0.16),\n 0 0 0 0 rgba(47, 111, 237, 0.45);\n }\n 50% {\n box-shadow:\n 0 6px 18px rgba(15, 23, 32, 0.16),\n 0 0 0 12px rgba(47, 111, 237, 0);\n }\n}\n\n@keyframes fbToast {\n from {\n opacity: 0;\n transform: translateY(14px);\n }\n to {\n opacity: 1;\n transform: none;\n }\n}\n\n/* Reduce motion \u2014 the explicit opt-in and the OS setting both still it all. */\n:host([data-fb-reduce-motion]) .fb-launcher,\n:host([data-fb-reduce-motion]) .fb-launcher__nub,\n:host([data-fb-reduce-motion]) .fb-launcher__tuck-icon {\n transition: none;\n}\n\n:host([data-fb-reduce-motion]) .fb-launcher--pulse,\n:host([data-fb-reduce-motion]) .fb-launcher__welcome {\n animation: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .fb-launcher,\n .fb-launcher__nub,\n .fb-launcher__tuck-icon {\n transition: none;\n }\n .fb-launcher--pulse,\n .fb-launcher__welcome {\n animation: none;\n }\n}\n";
package/dist/submit.d.ts CHANGED
@@ -13,12 +13,20 @@ export interface SubmitScreenshot {
13
13
  readonly blob: Blob;
14
14
  readonly filename?: string;
15
15
  }
16
+ /**
17
+ * The buffered replay window to attach, if any — the rrweb events a
18
+ * `ReplaySnapshot` holds (replay.ts). Serialized into its own binary part.
19
+ */
20
+ export interface SubmitReplay {
21
+ readonly events: readonly unknown[];
22
+ }
16
23
  /** Everything a single feedback submission carries. */
17
24
  export interface SubmitInput {
18
25
  readonly key: string;
19
26
  readonly identity?: IdentityInputs;
20
27
  readonly content: ReportContent;
21
28
  readonly screenshot?: SubmitScreenshot | null;
29
+ readonly replay?: SubmitReplay | null;
22
30
  }
23
31
  /** The outcome of a submission — never an exception. */
24
32
  export type SubmitResult = {
package/dist/version.d.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * The SDK's own version string, reported to ingest as `environment.sdkVersion`
3
- * (spec MVP §B/§E). Kept as a hand-maintained constant rather than imported from
4
- * `package.json`, so the bundle stays a single self-contained file with no JSON
5
- * import — keep it in step with `package.json` and the Changesets bump.
3
+ * (spec MVP §B/§E). Kept as an inlined constant rather than a runtime `package.json`
4
+ * import, so the published bundle stays a single self-contained file.
5
+ *
6
+ * It must equal `package.json`'s `version`. Two things keep it there: the Changesets
7
+ * `version` step syncs it automatically (`scripts/sync-sdk-version.mjs`, wired into
8
+ * `version-packages`), and `version.test.ts` fails the build if the two ever drift —
9
+ * so a stale dogfood version (the SDK reporting an old number on our own dashboard)
10
+ * can't slip through. Do not hand-edit this line to a value other than
11
+ * `package.json`'s version.
6
12
  */
7
- export declare const SDK_VERSION = "0.1.0";
13
+ export declare const SDK_VERSION = "0.4.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fixback/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Fixback capture SDK — a boot-gated, self-isolating on-page feedback launcher.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,11 @@
40
40
  "publishConfig": {
41
41
  "access": "public"
42
42
  },
43
+ "dependencies": {
44
+ "@rrweb/record": "^2.1.1",
45
+ "@rrweb/types": "^2.1.1",
46
+ "modern-screenshot": "^4.7.0"
47
+ },
43
48
  "devDependencies": {
44
49
  "jsdom": "^30.0.1",
45
50
  "typescript": "5.9.3",