@fixback/sdk 0.1.0 → 0.3.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.
@@ -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";
@@ -27,12 +36,21 @@ export interface Capture {
27
36
  readonly height: number;
28
37
  readonly type: string;
29
38
  }
30
- /** Turns a serialised SVG of the view into image bytes (injectable for tests). */
31
- export type Rasterize = (svg: string, meta: {
39
+ /** The screenshot filename extension for a {@link Capture}'s content type (defaults to `png`). */
40
+ export declare function extensionFor(type: string): string;
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: {
32
50
  width: number;
33
51
  height: number;
34
52
  type: string;
35
- }) => Promise<Blob | null>;
53
+ } & CaptureHooks) => Promise<Blob | null>;
36
54
  /** Options for {@link captureView}. */
37
55
  export interface CaptureOptions {
38
56
  /** The element to capture. Defaults to the document element (the full view). */
@@ -41,12 +59,15 @@ export interface CaptureOptions {
41
59
  readonly win?: Window;
42
60
  /** Output content type. Defaults to `image/png`. */
43
61
  readonly type?: string;
44
- /** Override the raster step (the default draws via an SVG + `<canvas>`). */
45
- readonly rasterize?: Rasterize;
62
+ /** Override the raster backend (the default is `modern-screenshot`). */
63
+ readonly capture?: CaptureBackend;
46
64
  }
47
65
  /**
48
- * Capture the current view as a masked screenshot. Clones the target, removes the
49
- * SDK's own UI, masks input values **all before** serialising and rasterising —
50
- * 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.
51
71
  */
52
72
  export declare function captureView(options?: CaptureOptions): Promise<Capture | null>;
73
+ export {};
@@ -0,0 +1,61 @@
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, 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.
43
+ */
44
+ export declare function scrubUrl(url: string): string;
45
+ /**
46
+ * Apply the built-in default scrubbers to a report draft: strip the page URL,
47
+ * and scrub every crumb's URLs and redact PII from its text. The Reporter's own
48
+ * `comment` is intentionally left untouched — it is authored on purpose, not
49
+ * scraped. The screenshot and input values are masked elsewhere (at capture and
50
+ * at crumb creation); this is the final URL/PII sweep.
51
+ */
52
+ export declare function applyDefaultScrub(draft: ReportContent): ReportContent;
53
+ /**
54
+ * Run the report draft through the client scrub choke point: the default
55
+ * scrubbers first (unless `scrub` is `false`), then the optional per-project
56
+ * hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
57
+ * the hook drops the report. A hook that throws is treated as a no-op — the
58
+ * already-scrubbed draft is kept, so a buggy hook never breaks the report path
59
+ * nor leaks unscrubbed data.
60
+ */
61
+ export declare function runBeforeSend(draft: ReportContent, options?: BeforeSendOptions): ReportContent | null;
package/dist/styles.d.ts CHANGED
@@ -6,5 +6,15 @@
6
6
  * token values are **vendored** Signal design tokens — copied from
7
7
  * `packages/ui/src/tokens.css` rather than imported, because the SDK must not
8
8
  * depend on `@fixback/ui` at runtime (ticket #47). Keep them in sync by value.
9
+ *
10
+ * The launcher is the prototype's polished pill (spec 0003 §A): a bottom-right
11
+ * **Feedback** pill that **hover-peeks**, can be **tucked away** (sliding off
12
+ * behind an edge nub, with a corner hover-zone to bring it back), a first-visit
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.
15
+ *
16
+ * Motion is driven by two host-element attributes the mount toggles:
17
+ * `data-fb-hidden` (tucked away) and `data-fb-peeking` (peeked back on hover);
18
+ * `data-fb-reduce-motion` (or the OS `prefers-reduced-motion`) stills it all.
9
19
  */
10
- 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 --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.fb-launcher {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n box-sizing: border-box;\n height: 40px;\n margin: 0;\n padding: 0 16px;\n border: 0;\n border-radius: 999px;\n background: var(--fb-color-accent);\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 box-shadow:\n 0 6px 18px rgba(15, 23, 32, 0.16),\n 0 1px 2px rgba(15, 23, 32, 0.12);\n transition:\n background-color 120ms ease,\n transform 120ms ease;\n}\n\n.fb-launcher:hover {\n background: var(--fb-color-accent-hover);\n}\n\n.fb-launcher:active {\n transform: translateY(1px);\n}\n\n.fb-launcher:focus-visible {\n outline: 2px solid var(--fb-color-accent);\n outline-offset: 2px;\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@media (prefers-reduced-motion: reduce) {\n .fb-launcher {\n transition: 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}\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";
package/dist/submit.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { AutoReportBackoff } from "./auto-report-backoff";
1
2
  import type { IdentityInputs, ReporterTier } from "./boot";
2
3
  import type { ReportContent } from "./report";
3
4
  /** What ingest returns for an accepted submission (vendored server shape). */
@@ -27,13 +28,30 @@ export type SubmitResult = {
27
28
  readonly ok: false;
28
29
  readonly reason: "unreachable" | "refused";
29
30
  readonly status?: number;
31
+ } | {
32
+ /**
33
+ * A `source: auto` report shed under ingest backpressure (spec §E/§H) — either
34
+ * held locally because the window is still open, or answered `429` by ingest,
35
+ * which opened/extended the window. Carries the seconds left to wait. Manual
36
+ * reports never produce this.
37
+ */
38
+ readonly ok: false;
39
+ readonly reason: "backpressure";
40
+ readonly retryAfterSeconds: number;
30
41
  };
31
42
  /** Join an API base URL with the feedback path, tolerating a trailing slash. */
32
43
  export declare function feedbackEndpoint(apiUrl: string): string;
33
44
  /**
34
45
  * Submit a report to ingest. Assembles the `payload` JSON (key + identity +
35
- * content) and the optional `screenshot` file into a `FormData`, posts it, and
36
- * resolves to the recorded Feedback on success or a named failure otherwise. The
37
- * `fetchImpl` seam exists purely so the call is testable.
46
+ * content, with an explicit `source` stamped) and the optional full masked
47
+ * `screenshot` file into a `FormData`, posts it, and resolves to the recorded
48
+ * Feedback on success or a named failure otherwise.
49
+ *
50
+ * A `source: auto` report first consults the shared backpressure window: while it
51
+ * is open the report is dropped without a request (spec §E/§H). Ingest's `429` +
52
+ * `Retry-After` opens/extends that window (default 60 s if the header is absent).
53
+ * Manual reports (`source: reporter`, the default) never consult the window and a
54
+ * non-2xx for them stays a plain refusal. The `fetchImpl` and `backoff` seams exist
55
+ * purely so the call is testable.
38
56
  */
39
- export declare function submitReport(apiUrl: string, input: SubmitInput, fetchImpl?: typeof fetch): Promise<SubmitResult>;
57
+ export declare function submitReport(apiUrl: string, input: SubmitInput, fetchImpl?: typeof fetch, backoff?: AutoReportBackoff): Promise<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.3.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fixback/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,9 @@
40
40
  "publishConfig": {
41
41
  "access": "public"
42
42
  },
43
+ "dependencies": {
44
+ "modern-screenshot": "^4.7.0"
45
+ },
43
46
  "devDependencies": {
44
47
  "jsdom": "^30.0.1",
45
48
  "typescript": "5.9.3",