@ganeshdole/brainbox-core 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ O'Saasy License
2
+
3
+ Copyright © 2026, Ganesh Dole.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ 1. The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ 2. No licensee or downstream recipient may use the Software (including
17
+ any modified or derivative versions) to directly compete with the
18
+ original Licensor by offering it to third parties as a hosted, managed,
19
+ or Software-as-a-Service (SaaS) product or cloud service where the
20
+ primary value of the service is the functionality of the Software itself.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
23
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
24
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
25
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
26
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
27
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
28
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @ganeshdole/brainbox-core
2
+
3
+ The Brainbox capture engine. Plain TypeScript, runs in the browser, no React
4
+ and no UI. It takes the screenshot, bakes the user's marks into it, records
5
+ the session, keeps the last console errors, and hands the finished report to
6
+ a `Transport`. Every Brainbox UI, including our own widget, is built on it.
7
+
8
+ Use it directly when you are building your own feedback UI. If you want
9
+ Brainbox's UI, see `@ganeshdole/brainbox-widget`; if you are in React, `@ganeshdole/brainbox-react`
10
+ holds an instance for you.
11
+
12
+ ## Install
13
+
14
+ Inside this monorepo the package is consumed from source:
15
+
16
+ ```json
17
+ { "dependencies": { "@ganeshdole/brainbox-core": "workspace:*" } }
18
+ ```
19
+
20
+ Outside it, from npm:
21
+
22
+ ```sh
23
+ npm install @ganeshdole/brainbox-core
24
+ ```
25
+
26
+ `pnpm build` emits `dist/` (JS plus `.d.ts`); the published package's
27
+ `exports` point at it (`publishConfig`).
28
+
29
+ ## Use
30
+
31
+ ```ts
32
+ import { createBrainbox, brainboxTransport } from "@ganeshdole/brainbox-core";
33
+
34
+ const brainbox = createBrainbox({
35
+ // Brainbox Cloud, or your own backend (see Transport below)
36
+ transport: brainboxTransport({ endpoint: "https://app.brainbox.sh/ingest", projectKey: "pk_..." }),
37
+ identity: { id: user.id, email: user.email },
38
+ onSubmitted: ({ id }) => console.log("filed", id),
39
+ });
40
+
41
+ // One report in progress. `exclude` is your own UI's root element, left out
42
+ // of the screenshot.
43
+ const draft = brainbox.draft({ exclude: panelEl });
44
+ draft.subscribe(() => render(draft.getState()));
45
+
46
+ draft.captureScreenshot(); // start rasterising the page now
47
+ draft.markUp(marks); // the marks' bounds become the region; baked in the background
48
+ await draft.submit({ text, audio }); // waits for the bake, then transport.send()
49
+ draft.cancel(); // drop everything; the draft can be used again
50
+
51
+ brainbox.destroy(); // restores console.error, cancels live drafts
52
+ ```
53
+
54
+ A recording instead of a screenshot:
55
+
56
+ ```ts
57
+ const rec = draft.startRecording({ onAutoStop: () => void rec.stop() });
58
+ rec.annotations.show(mark); // drawn into the page, so it shows in the replay
59
+ rec.setMicMuted(true);
60
+ await rec.stop(); // attaches the session and voice, takes a thumbnail
61
+ ```
62
+
63
+ `DraftState` is what a UI renders from: `screenshotUrl`, `screenshotPending`,
64
+ `screenshotFailed`, `hasSession`, `hasVoice`. It is a new object on every
65
+ change, so `useSyncExternalStore` and friends can compare by reference.
66
+
67
+ ## Transport
68
+
69
+ Core does not know about project keys or endpoints. `brainboxTransport()`
70
+ speaks the Brainbox backend's `/ingest` format. To send reports somewhere
71
+ else, implement the one-method interface:
72
+
73
+ ```ts
74
+ import type { Transport } from "@ganeshdole/brainbox-core";
75
+
76
+ const mine: Transport = {
77
+ async send(report) {
78
+ const fd = new FormData();
79
+ fd.append("json", JSON.stringify({ text: report.text, region: report.region, metadata: report.metadata }));
80
+ if (report.screenshot) fd.append("screenshot", report.screenshot, "screenshot.png");
81
+ const res = await fetch("/api/feedback", { method: "POST", body: fd, headers: { Authorization: token } });
82
+ return { id: (await res.json()).id };
83
+ },
84
+ };
85
+ ```
86
+
87
+ `Report` carries `text`, `region`, `metadata` (`CapturedMetadata` from
88
+ `@ganeshdole/brainbox-shared`), and the `screenshot`, `session` and `audio` blobs.
89
+
90
+ ## Rules
91
+
92
+ - Importing does nothing. `createBrainbox()` is the only way in, and
93
+ `destroy()` undoes everything it set up.
94
+ - No module-level state, so two instances on one page do not share buffers.
95
+ The one shared thing is the `console.error` hook, installed when the first
96
+ instance asks and removed when the last one goes.
97
+ - Privacy defaults in recordings: inputs are masked; `.rr-block`, `.rr-mask`
98
+ and `.rr-ignore` on host elements are honoured.
@@ -0,0 +1,35 @@
1
+ import { type Mark } from "./marks.ts";
2
+ /** Marks drawn into the host page during a recording. One set per recording,
3
+ * so cancelling one cannot wipe another's and nothing outlives `clear()`. */
4
+ export interface Annotations {
5
+ /** Put a mark on the page. */
6
+ show(m: Mark): void;
7
+ /** Remove the most recent mark. Returns whether there was one to remove. */
8
+ undo(): boolean;
9
+ /** How many marks are currently on the page. */
10
+ count(): number;
11
+ /** The marks at least partly in view, oldest first, in viewport
12
+ * coordinates - what a screenshot taken right now would show. */
13
+ inView(): Mark[];
14
+ /** Remove every mark immediately (cleared by the user, or the recording
15
+ * stopped or was cancelled). */
16
+ clear(): void;
17
+ }
18
+ /**
19
+ * Marks go on the host page itself (NOT the shadow DOM).
20
+ *
21
+ * The shadow host is `rr-block`ed so the widget's own chrome stays out of
22
+ * recordings; putting the mark in the host document instead means rrweb sees
23
+ * it as ordinary mutations and it replays in the dashboard with no
24
+ * replay-side support at all.
25
+ *
26
+ * Marks are anchored to the **document**, not the viewport, and they stay
27
+ * until the user clears them. An earlier version expired them after three
28
+ * seconds, which was the wrong answer to a real problem: a viewport-anchored
29
+ * mark starts pointing at whatever slid underneath it the moment the page
30
+ * scrolls. Converting to document coordinates fixes that properly - the mark
31
+ * rides the content it was drawn on - so there's nothing left for a timer to
32
+ * protect against, and the user gets to keep an annotation on screen for as
33
+ * long as they're still talking about it.
34
+ */
35
+ export declare function createAnnotations(): Annotations;
@@ -0,0 +1,152 @@
1
+ import { arrowHead, boundsOf, penPath, translateMark, FONT_STACK, STROKE_WIDTH, TEXT_HALO, TEXT_HALO_WIDTH, TEXT_SIZE, } from "./marks.js";
2
+ const SVG_NS = "http://www.w3.org/2000/svg";
3
+ /** `position` values that make an element a containing block for `absolute`
4
+ * descendants. Anything else - including jsdom's empty string for an unset
5
+ * property - leaves the document as the containing block. */
6
+ const POSITIONED = new Set(["relative", "absolute", "fixed", "sticky"]);
7
+ /** One mark as SVG built with the DOM API rather than React.
8
+ * It has to live in the host document (see `showMark`), which React isn't
9
+ * rendering, so this mirrors `MarkShape` node for node. */
10
+ function shapeFor(m) {
11
+ const el = (tag, attrs) => {
12
+ const node = document.createElementNS(SVG_NS, tag);
13
+ for (const [k, v] of Object.entries(attrs))
14
+ node.setAttribute(k, String(v));
15
+ return node;
16
+ };
17
+ switch (m.kind) {
18
+ case "box":
19
+ return el("rect", {
20
+ x: m.x,
21
+ y: m.y,
22
+ width: m.width,
23
+ height: m.height,
24
+ fill: "none",
25
+ stroke: m.color,
26
+ "stroke-width": STROKE_WIDTH,
27
+ });
28
+ case "arrow": {
29
+ const [tip, left, right] = arrowHead(m);
30
+ const g = el("g", {});
31
+ g.appendChild(el("line", {
32
+ x1: m.x1,
33
+ y1: m.y1,
34
+ x2: m.x2,
35
+ y2: m.y2,
36
+ stroke: m.color,
37
+ "stroke-width": STROKE_WIDTH,
38
+ "stroke-linecap": "round",
39
+ }));
40
+ g.appendChild(el("polygon", {
41
+ points: `${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`,
42
+ fill: m.color,
43
+ }));
44
+ return g;
45
+ }
46
+ case "pen":
47
+ return el("path", {
48
+ d: penPath(m),
49
+ fill: "none",
50
+ stroke: m.color,
51
+ "stroke-width": STROKE_WIDTH,
52
+ "stroke-linecap": "round",
53
+ "stroke-linejoin": "round",
54
+ });
55
+ case "text": {
56
+ const t = el("text", {
57
+ x: m.x,
58
+ y: m.y,
59
+ fill: m.color,
60
+ stroke: TEXT_HALO,
61
+ "stroke-width": TEXT_HALO_WIDTH,
62
+ "paint-order": "stroke",
63
+ "font-size": TEXT_SIZE,
64
+ "font-weight": 700,
65
+ "font-family": FONT_STACK,
66
+ });
67
+ t.textContent = m.text;
68
+ return t;
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Marks go on the host page itself (NOT the shadow DOM).
74
+ *
75
+ * The shadow host is `rr-block`ed so the widget's own chrome stays out of
76
+ * recordings; putting the mark in the host document instead means rrweb sees
77
+ * it as ordinary mutations and it replays in the dashboard with no
78
+ * replay-side support at all.
79
+ *
80
+ * Marks are anchored to the **document**, not the viewport, and they stay
81
+ * until the user clears them. An earlier version expired them after three
82
+ * seconds, which was the wrong answer to a real problem: a viewport-anchored
83
+ * mark starts pointing at whatever slid underneath it the moment the page
84
+ * scrolls. Converting to document coordinates fixes that properly - the mark
85
+ * rides the content it was drawn on - so there's nothing left for a timer to
86
+ * protect against, and the user gets to keep an annotation on screen for as
87
+ * long as they're still talking about it.
88
+ */
89
+ export function createAnnotations() {
90
+ /** Marks currently on the page, oldest first, each with the element that
91
+ * draws it and the mark itself in document coordinates. */
92
+ const live = [];
93
+ return {
94
+ show(m) {
95
+ const svg = document.createElementNS(SVG_NS, "svg");
96
+ svg.setAttribute("data-brainbox-highlight", "");
97
+ svg.setAttribute("aria-hidden", "true");
98
+ // Zero-sized with overflow visible: the shapes carry absolute document
99
+ // coordinates, so the container only has to establish the origin.
100
+ svg.setAttribute("style", "position:absolute;left:0;top:0;width:0;height:0;overflow:visible;" +
101
+ "pointer-events:none;z-index:2147483646;");
102
+ document.body.appendChild(svg);
103
+ // Measured while the container is still childless, so what comes back is
104
+ // its own 0x0 box rather than the ink bounds of a shape overflowing it.
105
+ const origin = originOffset(svg);
106
+ svg.appendChild(shapeFor(translateMark(m, window.scrollX - origin.x, window.scrollY - origin.y)));
107
+ live.push({ el: svg, mark: translateMark(m, window.scrollX, window.scrollY) });
108
+ },
109
+ undo() {
110
+ const last = live.pop();
111
+ last?.el.remove();
112
+ return !!last;
113
+ },
114
+ count: () => live.length,
115
+ inView() {
116
+ const w = window.innerWidth;
117
+ const h = window.innerHeight;
118
+ return live
119
+ .map(({ mark }) => translateMark(mark, -window.scrollX, -window.scrollY))
120
+ .filter((m) => {
121
+ const b = boundsOf(m);
122
+ return b.x < w && b.y < h && b.x + b.width > 0 && b.y + b.height > 0;
123
+ });
124
+ },
125
+ clear() {
126
+ for (const { el } of live)
127
+ el.remove();
128
+ live.length = 0;
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * How far the container's own origin sits from the document origin.
134
+ *
135
+ * `position:absolute` resolves against the nearest **positioned** ancestor, not
136
+ * the document. Usually nothing between the container and the root is
137
+ * positioned, the containing block is the document, and this is (0, 0) - but a
138
+ * host page with `body { position: relative }` (common enough to plan for) makes
139
+ * the body the containing block, and every mark lands offset by wherever the
140
+ * body's padding box starts. Measuring covers the margin, border and padding
141
+ * that put it there without having to enumerate them.
142
+ *
143
+ * Only the offset is corrected. A transformed `<body>` would also scale or
144
+ * rotate the marks, which no amount of translation fixes - and which the old
145
+ * `position:fixed` container got wrong too.
146
+ */
147
+ function originOffset(container) {
148
+ if (!POSITIONED.has(getComputedStyle(document.body).position))
149
+ return { x: 0, y: 0 };
150
+ const r = container.getBoundingClientRect();
151
+ return { x: r.left + window.scrollX, y: r.top + window.scrollY };
152
+ }
@@ -0,0 +1,8 @@
1
+ export interface AudioRecorder {
2
+ stop: () => Promise<Blob>;
3
+ /** The live mic stream, for a level meter. Analyse this rather than opening
4
+ * the mic a second time. */
5
+ stream: MediaStream;
6
+ }
7
+ /** Start recording from the mic; resolve a handle whose stop() yields the Blob. */
8
+ export declare function startAudioRecording(): Promise<AudioRecorder>;
package/dist/audio.js ADDED
@@ -0,0 +1,23 @@
1
+ const MIME = "audio/webm";
2
+ /** Start recording from the mic; resolve a handle whose stop() yields the Blob. */
3
+ export async function startAudioRecording() {
4
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
5
+ const supported = MediaRecorder.isTypeSupported(MIME);
6
+ const rec = new MediaRecorder(stream, supported ? { mimeType: MIME } : undefined);
7
+ const chunks = [];
8
+ rec.ondataavailable = (e) => {
9
+ if (e.data.size > 0)
10
+ chunks.push(e.data);
11
+ };
12
+ rec.start();
13
+ return {
14
+ stream,
15
+ stop: () => new Promise((resolve) => {
16
+ rec.onstop = () => {
17
+ stream.getTracks().forEach((t) => t.stop());
18
+ resolve(new Blob(chunks, { type: MIME }));
19
+ };
20
+ rec.stop();
21
+ }),
22
+ };
23
+ }
@@ -0,0 +1,19 @@
1
+ import type { Identity } from "@ganeshdole/brainbox-shared";
2
+ import { type Draft, type DraftOptions } from "./draft.ts";
3
+ import type { SubmitResult, Transport } from "./transport.ts";
4
+ export interface BrainboxOptions {
5
+ /** Where finished reports go. */
6
+ transport: Transport;
7
+ /** Who is filing. Can be set later with `identify()`. */
8
+ identity?: Identity;
9
+ /** Called after every successful submit. */
10
+ onSubmitted?: (result: SubmitResult) => void;
11
+ }
12
+ /** One embedded Brainbox. Collects console errors from the moment it is
13
+ * created and hands out drafts; `destroy()` undoes all of it. */
14
+ export interface Brainbox {
15
+ identify(identity: Identity): void;
16
+ draft(options?: DraftOptions): Draft;
17
+ destroy(): void;
18
+ }
19
+ export declare function createBrainbox({ transport, identity, onSubmitted }: BrainboxOptions): Brainbox;
@@ -0,0 +1,28 @@
1
+ import { createDraft } from "./draft.js";
2
+ import { captureErrors } from "./metadata.js";
3
+ export function createBrainbox({ transport, identity, onSubmitted }) {
4
+ let who = identity;
5
+ const errors = captureErrors();
6
+ const drafts = new Set();
7
+ return {
8
+ identify(next) {
9
+ who = next;
10
+ },
11
+ draft(options) {
12
+ const draft = createDraft({
13
+ transport,
14
+ identity: () => who,
15
+ consoleErrors: () => errors.snapshot(),
16
+ onSubmitted,
17
+ }, options);
18
+ drafts.add(draft);
19
+ return draft;
20
+ },
21
+ destroy() {
22
+ for (const draft of drafts)
23
+ draft.cancel();
24
+ drafts.clear();
25
+ errors.stop();
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,75 @@
1
+ import { type Mark } from "./marks.ts";
2
+ /** `@ganeshdole/brainbox-server`'s default screenshot cap. */
3
+ export declare const SCREENSHOT_BUDGET_BYTES: number;
4
+ export declare function captureScale(devicePixelRatio: number, documentPixels: number): number;
5
+ export declare function overBudget(bytes: number, pixelWidth: number, cssWidth: number): boolean;
6
+ /**
7
+ * A `filter` that drops everything outside the viewport.
8
+ *
9
+ * We rasterise the whole document and then crop to the viewport, so on a long
10
+ * page most of that work is discarded - a page 4x the viewport pays 4x for one
11
+ * screenshot, and the cost is CPU-bound cloning and per-node style resolution,
12
+ * not pixels. Off-screen elements cannot affect a viewport crop, so skipping
13
+ * them in the clone is free.
14
+ *
15
+ * The exception is escaping descendants: an absolutely positioned child is laid
16
+ * out against an ancestor, so pruning a parent by its own box could drop a child
17
+ * that was actually on screen. Ancestors of any escaping element are kept whole
18
+ * and everything else is judged on its own box.
19
+ *
20
+ * `exclude` (the UI's own host) is dropped from the clone outright, which is
21
+ * what keeps its chrome out of the shot - the live page never has to hide it.
22
+ */
23
+ export declare function viewportFilter(exclude?: HTMLElement): (node: Node) => boolean;
24
+ /** Whether a box can still affect a viewport-sized crop. Split out from the DOM
25
+ * walk so the edge cases are unit-testable. */
26
+ export declare function keepForViewport(r: {
27
+ top: number;
28
+ bottom: number;
29
+ left: number;
30
+ right: number;
31
+ width: number;
32
+ height: number;
33
+ }, vw: number, vh: number): boolean;
34
+ /** What to leave out of a screenshot. */
35
+ export interface CaptureOptions {
36
+ /** Left out of the cloned page, so the UI's own chrome never appears in the
37
+ * shot (ADR 0001). It stays visible on the live page throughout: hiding it
38
+ * would blank the UI for the seconds a capture takes. */
39
+ exclude?: HTMLElement;
40
+ }
41
+ /** modern-screenshot skips cross-origin sheets it can't read (e.g. Google Fonts)
42
+ * but fetches `@import`s, so re-declare them as imports to keep their fonts. */
43
+ export declare function unreadableSheetImports(sheets: Iterable<{
44
+ href: string | null;
45
+ readonly cssRules: unknown;
46
+ }>): string;
47
+ export declare function withReadableFonts<T>(fn: () => Promise<T>, sheets?: Iterable<{
48
+ href: string | null;
49
+ readonly cssRules: unknown;
50
+ }>): Promise<T>;
51
+ /** Plain viewport shot - the frozen page the user marks up, and the
52
+ * thumbnail/last-frame of a session recording. */
53
+ export declare function captureViewport(options?: CaptureOptions): Promise<Blob>;
54
+ /**
55
+ * Flatten the user's markup onto the frozen shot.
56
+ *
57
+ * This runs once, on the way out of the markup step, rather than on every
58
+ * stroke: the marks are vectors until here, so drawing stays instant and
59
+ * undoable and nothing has to re-rasterise the page. Mark coordinates are
60
+ * viewport CSS pixels, scaled to the shot's resolution.
61
+ */
62
+ export declare function bakeMarks(shot: Blob, marks: Mark[]): Promise<Blob>;
63
+ /**
64
+ * Which screenshot actually gets uploaded.
65
+ *
66
+ * Two sources disagree on purpose. `settled` is the last shot that resolved into
67
+ * state - what the composer is already showing - and `pending` is the freshest
68
+ * promise for one. Leaving the markup step swaps `pending` for the *baked* shot
69
+ * while `settled` still holds the plain capture taken before the user drew
70
+ * anything, so preferring `settled` would quietly upload the version with none
71
+ * of their markup on it. The promise wins whenever there is one; `settled` is
72
+ * the fallback for the flows that never had one (a recording's last frame) and
73
+ * for a capture that failed, where the plain shot is better than nothing.
74
+ */
75
+ export declare function pickScreenshot(pending: Promise<Blob> | null, settled: Blob | null): Promise<Blob | null>;