@universelle/sentinel-report 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog — @universelle/sentinel-report
2
+
3
+ ## 0.3.0 — 2026-10-24
4
+
5
+ ### Privacy
6
+ For sites whose paths carry secrets (an invitation token in `/u/<token>`, a personal link, internal ids), three
7
+ options in `createReporter`, `useSentinelReport` and `capture()` clean everything that leaves the page. Recording
8
+ stays raw in memory; the cleaning happens when the context is built (`context()`, `send()`):
9
+
10
+ - **`route(pathname) => template`**. Applies to `page.path`, to every entry of `trail` and to every `network[].path`.
11
+ It always receives the path without its query string or fragment. If it throws or returns something that isn't a
12
+ string, that path goes as `"/(otra)"`, never raw. Consecutive pages with the same template become one entry.
13
+ - **The title.** With `route`, `page.title` goes as `""`, because the title can carry the page's data. `title: true`
14
+ sends it anyway.
15
+ - **Console messages.** With `route`, every absolute URL becomes its origin (`https://host/`). Every path at the start
16
+ of the message, or after a space, a bracket or a quote, goes through `route`. Then **`redact(message)`**, if given,
17
+ for anything else (UUIDs, tokens…). If `redact` throws, the message goes empty.
18
+ - **Without these options nothing changes**: the body is the same as in 0.2.0.
19
+
20
+ Unchanged: the body's format, the endpoint, `credentials: "omit"`, the limit of 20 entries, no dependencies and no
21
+ install scripts.
22
+
23
+ ## 0.2.0 — 2026-10-24
24
+ - SENTINEL's mark for your own branding: `<SentinelMark>`, `<sentinel-mark>`, `markSvg()` and `MARK_SHAPES`.
25
+
26
+ ## 0.1.0 — 2026-10-24
27
+ - First release: the core (`capture`, `createReporter`), the `<sentinel-report>` web component and React
28
+ (`<ReportButton>`, `useSentinelReport`).
package/README.md CHANGED
@@ -125,6 +125,55 @@ import { createReporter } from "@universelle/sentinel-report";
125
125
  await createReporter({ site: "your-site" }).send({ kind: "wish", text: "Export the table" });
126
126
  ```
127
127
 
128
+ ## Privacy: paths with secrets
129
+
130
+ If your paths carry secrets (an invitation token in `/u/<token>`, a personal link, internal ids), pass `route`. It
131
+ turns each real path into its template, so SENTINEL only receives templates. It applies to the page, every page of
132
+ the trail, every failed request and the paths and URLs inside console messages, and the page's title is dropped.
133
+ `redact` cleans console messages further.
134
+
135
+ ```tsx
136
+ const route = (pathname: string) => toTemplate(pathname) ?? "/(otra)"; // e.g. "/u/[invitation]"
137
+ const redact = (message: string) => message.replace(UUID, "[id]");
138
+
139
+ const { send } = useSentinelReport({ site: "your-site", route, redact });
140
+ // or: createReporter({ site: "your-site", route, redact }); or once for the page: capture({ route, redact })
141
+ ```
142
+
143
+ A `route` that throws, or doesn't return a string, sends `"/(otra)"`, never the raw path. Without these options,
144
+ everything is sent as in 0.2.0. See [CHANGELOG.md](CHANGELOG.md).
145
+
146
+ ## Branding: SENTINEL's mark
147
+
148
+ For your own button, a footer or a "Protected by SENTINEL" line. The eye and the star take the text colour
149
+ (`currentColor`), so the mark works on light and dark backgrounds; the moon is always the brand's green.
150
+
151
+ ```tsx
152
+ import { SentinelMark, useSentinelReport } from "@universelle/sentinel-report/react";
153
+
154
+ <button onClick={…}>
155
+ <SentinelMark size={20} /> Report a problem
156
+ </button>
157
+
158
+ // title makes it an image with a name, for screen readers:
159
+ <SentinelMark size={32} title="SENTINEL" />
160
+ ```
161
+
162
+ Without React:
163
+
164
+ ```html
165
+ <script type="module">import "@universelle/sentinel-report/element";</script>
166
+ <span style="color: #6b3fc4">Made with <sentinel-mark size="20" label="SENTINEL"></sentinel-mark></span>
167
+ ```
168
+
169
+ ```js
170
+ import { markSvg, MARK_SHAPES } from "@universelle/sentinel-report";
171
+
172
+ button.innerHTML = `${markSvg({ size: 20 })} Report`;
173
+ ```
174
+
175
+ `markSvg` gives you the `<svg>` as a string. `MARK_SHAPES` gives you its inside, for your own `<svg viewBox="0 0 100 100">`.
176
+
128
177
  ## Notes
129
178
 
130
179
  - **Content Security Policy:** allow `connect-src https://sentinel.cervilla.es`, plus `script-src` if you use the
package/dist/core.d.ts CHANGED
@@ -30,6 +30,17 @@ export type Report = {
30
30
  text: string;
31
31
  email?: string;
32
32
  };
33
+ /** How a site cleans what is sent (0.3.0). Without `route`, everything goes as in 0.2.0. */
34
+ export type Privacy = {
35
+ /** A real path (`/u/AbC…`) to its template (`/u/[invitation]`). If it throws or returns no string: `"/(otra)"`. */
36
+ route?: (pathname: string) => string;
37
+ /** Every console message, after the default cleaning (with `route`: URLs to their origin, paths to templates). */
38
+ redact?: (message: string) => string;
39
+ /** Send `document.title` (default: yes without `route`, no with it: the title can carry the page's data). */
40
+ title?: boolean;
41
+ };
42
+ /** What goes in place of a path the site's `route` can't turn into a template. */
43
+ export declare const UNKNOWN_ROUTE = "/(otra)";
33
44
  export type Reporter = {
34
45
  /** Sends a report; rejects with an `Error` whose message is the HTTP status (or the network error). */
35
46
  send(report: Report): Promise<void>;
@@ -41,16 +52,25 @@ type Capture = {
41
52
  network: FailedRequest[];
42
53
  trail: string[];
43
54
  fetch: typeof fetch;
55
+ privacy: Privacy;
44
56
  };
45
57
  declare global {
46
58
  interface Window {
47
59
  __sentinelCapture?: Capture;
48
60
  }
49
61
  }
50
- /** Starts recording the visit's context (once per page; later calls return the same recording). */
51
- export declare function capture(): Capture;
62
+ /**
63
+ * Starts recording the visit's context (once per page; later calls return the same recording). The privacy options
64
+ * given here are the page's default for every reporter that doesn't bring its own; a later call can still set them.
65
+ */
66
+ export declare function capture(privacy?: Privacy): Capture;
67
+ /**
68
+ * A console message as it may leave: with `route`, each absolute URL becomes its origin and each path (at the start,
69
+ * or after a space, a bracket or a quote) its template; then the site's own `redact`, if any.
70
+ */
71
+ export declare function cleanMessage(message: string, privacy: Privacy): string;
52
72
  export declare function createReporter(options: {
53
73
  site: string;
54
74
  endpoint?: string;
55
- }): Reporter;
75
+ } & Privacy): Reporter;
56
76
  export {};
package/dist/core.js CHANGED
@@ -1,7 +1,14 @@
1
1
  // The core, with no framework: it records the technical context of the visit (console errors, failed requests,
2
2
  // pages visited) and sends a report to SENTINEL. What it never sends: the query string or fragment of any URL (they
3
3
  // can carry tokens) or anything typed into the site. Installed once per page, whoever asks first.
4
+ //
5
+ // Privacy (0.3.0): sites whose paths carry secrets (`/u/<invitation token>`) pass `route`, which turns a real path
6
+ // into its template (`/u/[invitation]`). What is recorded stays raw in memory; everything that leaves (`context()`,
7
+ // `send()`) goes through it: the page, every page of the trail, every failed request, and the paths and URLs inside
8
+ // console messages; and the page's title is dropped. `redact` cleans console messages further.
4
9
  export const DEFAULT_ENDPOINT = "https://sentinel.cervilla.es";
10
+ /** What goes in place of a path the site's `route` can't turn into a template. */
11
+ export const UNKNOWN_ROUTE = "/(otra)";
5
12
  const MAX = 20;
6
13
  function clip(text) {
7
14
  return String(text).replace(/\s+/g, " ").slice(0, 300);
@@ -74,27 +81,83 @@ function watchNavigation(capture) {
74
81
  }
75
82
  window.addEventListener("popstate", visit);
76
83
  }
77
- /** Starts recording the visit's context (once per page; later calls return the same recording). */
78
- export function capture() {
79
- if (window.__sentinelCapture !== undefined)
84
+ /**
85
+ * Starts recording the visit's context (once per page; later calls return the same recording). The privacy options
86
+ * given here are the page's default for every reporter that doesn't bring its own; a later call can still set them.
87
+ */
88
+ export function capture(privacy = {}) {
89
+ if (window.__sentinelCapture !== undefined) {
90
+ if (privacy.route !== undefined || privacy.redact !== undefined || privacy.title !== undefined)
91
+ window.__sentinelCapture.privacy = privacy;
80
92
  return window.__sentinelCapture;
81
- const recording = { logs: [], network: [], trail: [location.pathname], fetch: window.fetch.bind(window) };
93
+ }
94
+ const recording = { logs: [], network: [], trail: [location.pathname], fetch: window.fetch.bind(window), privacy };
82
95
  window.__sentinelCapture = recording;
83
96
  watchConsole(recording);
84
97
  watchFetch(recording);
85
98
  watchNavigation(recording);
86
99
  return recording;
87
100
  }
101
+ /** A path through the site's `route`, never raw once a route is given: what fails goes as `"/(otra)"`. */
102
+ function template(route, pathname) {
103
+ if (route === undefined)
104
+ return pathname;
105
+ try {
106
+ const mapped = route(pathname.split(/[?#]/)[0]);
107
+ return typeof mapped === "string" ? mapped : UNKNOWN_ROUTE;
108
+ }
109
+ catch {
110
+ return UNKNOWN_ROUTE;
111
+ }
112
+ }
113
+ /**
114
+ * A console message as it may leave: with `route`, each absolute URL becomes its origin and each path (at the start,
115
+ * or after a space, a bracket or a quote) its template; then the site's own `redact`, if any.
116
+ */
117
+ export function cleanMessage(message, privacy) {
118
+ let clean = message;
119
+ if (privacy.route !== undefined) {
120
+ clean = clean.replace(/https?:\/\/[^\s"'()<>]+/g, (url) => {
121
+ try {
122
+ return `${new URL(url).origin}/`;
123
+ }
124
+ catch {
125
+ return "[url]";
126
+ }
127
+ });
128
+ clean = clean.replace(/(^|[\s("'])(\/[^\s"'()<>]*)/g, (_match, before, pathname) => `${before}${template(privacy.route, pathname)}`);
129
+ }
130
+ if (privacy.redact !== undefined) {
131
+ try {
132
+ const redacted = privacy.redact(clean);
133
+ clean = typeof redacted === "string" ? redacted : "";
134
+ }
135
+ catch {
136
+ clean = "";
137
+ }
138
+ }
139
+ return clean;
140
+ }
141
+ /** The trail through `route`; consecutive pages with the same template become one. */
142
+ function trailOf(trail, route) {
143
+ return trail.map((pathname) => template(route, pathname)).filter((pathname, index, all) => index === 0 || all[index - 1] !== pathname);
144
+ }
88
145
  export function createReporter(options) {
89
146
  const recording = capture();
90
147
  const url = `${(options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "")}/reports/${encodeURIComponent(options.site)}`;
91
- const context = () => ({
92
- page: { path: location.pathname, title: document.title.slice(0, 200) },
93
- device: { userAgent: navigator.userAgent.slice(0, 300), language: navigator.language, viewport: `${innerWidth}x${innerHeight}`, screen: `${screen.width}x${screen.height}` },
94
- console: recording.logs.slice(),
95
- network: recording.network.slice(),
96
- trail: recording.trail.slice(),
97
- });
148
+ const own = options.route !== undefined || options.redact !== undefined || options.title !== undefined;
149
+ const context = () => {
150
+ // The reporter's own options, else the page's default (from `capture()`), read when sending.
151
+ const privacy = own ? { route: options.route, redact: options.redact, title: options.title } : recording.privacy;
152
+ const withTitle = privacy.title ?? privacy.route === undefined;
153
+ return {
154
+ page: { path: template(privacy.route, location.pathname), title: withTitle ? document.title.slice(0, 200) : "" },
155
+ device: { userAgent: navigator.userAgent.slice(0, 300), language: navigator.language, viewport: `${innerWidth}x${innerHeight}`, screen: `${screen.width}x${screen.height}` },
156
+ console: recording.logs.map((entry) => ({ ...entry, message: cleanMessage(entry.message, privacy) })),
157
+ network: recording.network.map((entry) => ({ ...entry, path: template(privacy.route, entry.path) })),
158
+ trail: privacy.route === undefined ? recording.trail.slice() : trailOf(recording.trail, privacy.route),
159
+ };
160
+ };
98
161
  return {
99
162
  context,
100
163
  async send(report) {
package/dist/element.d.ts CHANGED
@@ -34,8 +34,19 @@ export declare class SentinelReport extends HTMLElement {
34
34
  private say;
35
35
  private submit;
36
36
  }
37
+ /**
38
+ * `<sentinel-mark size="24" label="…">`: SENTINEL's mark on its own, for your branding without React. It takes the
39
+ * text colour (`color` on the element); `label` makes it an image with that name, otherwise it is decorative.
40
+ */
41
+ export declare class SentinelMark extends HTMLElement {
42
+ static observedAttributes: string[];
43
+ connectedCallback(): void;
44
+ attributeChangedCallback(): void;
45
+ private render;
46
+ }
37
47
  declare global {
38
48
  interface HTMLElementTagNameMap {
39
49
  "sentinel-report": SentinelReport;
50
+ "sentinel-mark": SentinelMark;
40
51
  }
41
52
  }
package/dist/element.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // (auto | light | dark), hide-mark; and the `texts` property for every other word.
9
9
  // Events: `sentinel-sent` (detail: { kind }) and `sentinel-error` (detail: { error }). Methods: open(), close().
10
10
  import { capture, createReporter } from "./core.js";
11
+ import { markSvg } from "./logo.js";
11
12
  import { MARK } from "./mark.js";
12
13
  export const DEFAULT_TEXTS = {
13
14
  label: "¿Algo falla?",
@@ -159,5 +160,25 @@ export class SentinelReport extends HTMLElement {
159
160
  }
160
161
  }
161
162
  }
163
+ /**
164
+ * `<sentinel-mark size="24" label="…">`: SENTINEL's mark on its own, for your branding without React. It takes the
165
+ * text colour (`color` on the element); `label` makes it an image with that name, otherwise it is decorative.
166
+ */
167
+ export class SentinelMark extends HTMLElement {
168
+ static observedAttributes = ["size", "label"];
169
+ connectedCallback() {
170
+ this.render();
171
+ }
172
+ attributeChangedCallback() {
173
+ if (this.isConnected)
174
+ this.render();
175
+ }
176
+ render() {
177
+ this.style.display ||= "inline-flex";
178
+ this.innerHTML = markSvg({ size: this.getAttribute("size") ?? "1em", title: this.getAttribute("label") ?? undefined });
179
+ }
180
+ }
162
181
  if (typeof customElements !== "undefined" && customElements.get("sentinel-report") === undefined)
163
182
  customElements.define("sentinel-report", SentinelReport);
183
+ if (typeof customElements !== "undefined" && customElements.get("sentinel-mark") === undefined)
184
+ customElements.define("sentinel-mark", SentinelMark);
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export { capture, createReporter, DEFAULT_ENDPOINT } from "./core.js";
2
- export type { Context, ConsoleEntry, FailedRequest, Report, ReportKind, Reporter } from "./core.js";
1
+ export { capture, cleanMessage, createReporter, DEFAULT_ENDPOINT, UNKNOWN_ROUTE } from "./core.js";
2
+ export { MARK_SHAPES, markSvg } from "./logo.js";
3
+ export type { Context, ConsoleEntry, FailedRequest, Privacy, Report, ReportKind, Reporter } from "./core.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  // @universelle/sentinel-report: the core, with no framework. For the ready-made button, `…/element` (any site) or
2
2
  // `…/react`; to build your own form, `createReporter` here or `useSentinelReport` in React.
3
- export { capture, createReporter, DEFAULT_ENDPOINT } from "./core.js";
3
+ export { capture, cleanMessage, createReporter, DEFAULT_ENDPOINT, UNKNOWN_ROUTE } from "./core.js";
4
+ export { MARK_SHAPES, markSvg } from "./logo.js";
package/dist/logo.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { MARK_SHAPES } from "./mark.js";
2
+ export { MARK_SHAPES };
3
+ /** The mark as an `<svg>`: decorative by default; with `title`, an image with that name for screen readers. */
4
+ export declare function markSvg(options?: {
5
+ size?: number | string;
6
+ title?: string;
7
+ className?: string;
8
+ }): string;
package/dist/logo.js ADDED
@@ -0,0 +1,14 @@
1
+ // SENTINEL's mark for your own branding (a button of your own, a footer…): as an SVG string. The eye and the star take
2
+ // the text colour (currentColor), so it works on light and dark backgrounds; the moon is always the brand's green.
3
+ import { MARK_SHAPES } from "./mark.js";
4
+ export { MARK_SHAPES };
5
+ function escape(text) {
6
+ return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
7
+ }
8
+ /** The mark as an `<svg>`: decorative by default; with `title`, an image with that name for screen readers. */
9
+ export function markSvg(options = {}) {
10
+ const size = options.size ?? 24;
11
+ const label = options.title ? ` role="img" aria-label="${escape(options.title)}"` : ' aria-hidden="true" focusable="false"';
12
+ const cls = options.className ? ` class="${escape(options.className)}"` : "";
13
+ return `<svg${cls} viewBox="0 0 100 100" width="${escape(String(size))}" height="${escape(String(size))}"${label}>${options.title ? `<title>${escape(options.title)}</title>` : ""}${MARK_SHAPES}</svg>`;
14
+ }
package/dist/mark.d.ts CHANGED
@@ -1 +1,2 @@
1
+ export declare const MARK_SHAPES = "<path d=\"M14 50 Q50 14 86 50 Q50 86 14 50Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"7\" stroke-linejoin=\"round\"/><path d=\"M51.1 40.2 A11 11 0 1 0 59.77 53.22 A7.86 7.86 0 0 1 51.1 40.2Z\" fill=\"#3fbf6f\"/><path d=\"M20.9258 28C20.6615 27.82 20.4223 27.6072 20.2143 27.3671C19.9961 26.9641 19.8024 26.549 19.6343 26.1238C17.3476 21.0763 13.2746 16.9981 8.15841 14.6333C7.94143 14.5501 7.7297 14.4545 7.5243 14.347C6.80512 13.9024 6.82832 13.2921 7.57843 12.84C7.74361 12.7589 7.91411 12.6884 8.08881 12.6291C13.3854 10.5226 17.6112 6.45934 19.8431 1.32691C20.0751 0.814541 20.0906 0.0309247 20.8562 0.000785591C21.6217 -0.0293535 21.7377 0.814541 21.9852 1.35704C23.0822 3.90828 24.6892 6.22141 26.7125 8.16176C28.7358 10.1021 31.135 11.6309 33.7704 12.6592C33.9933 12.7315 34.2104 12.8196 34.42 12.9229C35.1933 13.3524 35.1933 13.925 34.42 14.3621C33.9406 14.6333 33.3992 14.8066 32.8734 15.0628C28.0444 17.4681 24.2329 21.4457 22.1012 26.3047C21.9511 26.6872 21.7755 27.0597 21.5753 27.4198C21.3859 27.6401 21.1675 27.8352 20.9258 28Z\" fill=\"currentColor\" transform=\"translate(57.64 44.71) scale(0.3367) translate(-21 -14)\"/>";
1
2
  export declare const MARK = "<svg class=\"mark\" part=\"mark\" viewBox=\"0 0 100 100\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M14 50 Q50 14 86 50 Q50 86 14 50Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"7\" stroke-linejoin=\"round\"/><path d=\"M51.1 40.2 A11 11 0 1 0 59.77 53.22 A7.86 7.86 0 0 1 51.1 40.2Z\" fill=\"#3fbf6f\"/><path d=\"M20.9258 28C20.6615 27.82 20.4223 27.6072 20.2143 27.3671C19.9961 26.9641 19.8024 26.549 19.6343 26.1238C17.3476 21.0763 13.2746 16.9981 8.15841 14.6333C7.94143 14.5501 7.7297 14.4545 7.5243 14.347C6.80512 13.9024 6.82832 13.2921 7.57843 12.84C7.74361 12.7589 7.91411 12.6884 8.08881 12.6291C13.3854 10.5226 17.6112 6.45934 19.8431 1.32691C20.0751 0.814541 20.0906 0.0309247 20.8562 0.000785591C21.6217 -0.0293535 21.7377 0.814541 21.9852 1.35704C23.0822 3.90828 24.6892 6.22141 26.7125 8.16176C28.7358 10.1021 31.135 11.6309 33.7704 12.6592C33.9933 12.7315 34.2104 12.8196 34.42 12.9229C35.1933 13.3524 35.1933 13.925 34.42 14.3621C33.9406 14.6333 33.3992 14.8066 32.8734 15.0628C28.0444 17.4681 24.2329 21.4457 22.1012 26.3047C21.9511 26.6872 21.7755 27.0597 21.5753 27.4198C21.3859 27.6401 21.1675 27.8352 20.9258 28Z\" fill=\"currentColor\" transform=\"translate(57.64 44.71) scale(0.3367) translate(-21 -14)\"/></svg>";
package/dist/mark.js CHANGED
@@ -1,2 +1,4 @@
1
- // SENTINEL's mark (generated from the main repo's src/brand.ts by `pnpm brand`; do not edit by hand).
1
+ // SENTINEL's mark (generated from the main repo's src/brand.ts by `pnpm brand`; do not edit by hand). The eye and
2
+ // the star take the text colour (currentColor); the moon is always the brand's green.
3
+ export const MARK_SHAPES = "<path d=\"M14 50 Q50 14 86 50 Q50 86 14 50Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"7\" stroke-linejoin=\"round\"/><path d=\"M51.1 40.2 A11 11 0 1 0 59.77 53.22 A7.86 7.86 0 0 1 51.1 40.2Z\" fill=\"#3fbf6f\"/><path d=\"M20.9258 28C20.6615 27.82 20.4223 27.6072 20.2143 27.3671C19.9961 26.9641 19.8024 26.549 19.6343 26.1238C17.3476 21.0763 13.2746 16.9981 8.15841 14.6333C7.94143 14.5501 7.7297 14.4545 7.5243 14.347C6.80512 13.9024 6.82832 13.2921 7.57843 12.84C7.74361 12.7589 7.91411 12.6884 8.08881 12.6291C13.3854 10.5226 17.6112 6.45934 19.8431 1.32691C20.0751 0.814541 20.0906 0.0309247 20.8562 0.000785591C21.6217 -0.0293535 21.7377 0.814541 21.9852 1.35704C23.0822 3.90828 24.6892 6.22141 26.7125 8.16176C28.7358 10.1021 31.135 11.6309 33.7704 12.6592C33.9933 12.7315 34.2104 12.8196 34.42 12.9229C35.1933 13.3524 35.1933 13.925 34.42 14.3621C33.9406 14.6333 33.3992 14.8066 32.8734 15.0628C28.0444 17.4681 24.2329 21.4457 22.1012 26.3047C21.9511 26.6872 21.7755 27.0597 21.5753 27.4198C21.3859 27.6401 21.1675 27.8352 20.9258 28Z\" fill=\"currentColor\" transform=\"translate(57.64 44.71) scale(0.3367) translate(-21 -14)\"/>";
2
4
  export const MARK = "<svg class=\"mark\" part=\"mark\" viewBox=\"0 0 100 100\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M14 50 Q50 14 86 50 Q50 86 14 50Z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"7\" stroke-linejoin=\"round\"/><path d=\"M51.1 40.2 A11 11 0 1 0 59.77 53.22 A7.86 7.86 0 0 1 51.1 40.2Z\" fill=\"#3fbf6f\"/><path d=\"M20.9258 28C20.6615 27.82 20.4223 27.6072 20.2143 27.3671C19.9961 26.9641 19.8024 26.549 19.6343 26.1238C17.3476 21.0763 13.2746 16.9981 8.15841 14.6333C7.94143 14.5501 7.7297 14.4545 7.5243 14.347C6.80512 13.9024 6.82832 13.2921 7.57843 12.84C7.74361 12.7589 7.91411 12.6884 8.08881 12.6291C13.3854 10.5226 17.6112 6.45934 19.8431 1.32691C20.0751 0.814541 20.0906 0.0309247 20.8562 0.000785591C21.6217 -0.0293535 21.7377 0.814541 21.9852 1.35704C23.0822 3.90828 24.6892 6.22141 26.7125 8.16176C28.7358 10.1021 31.135 11.6309 33.7704 12.6592C33.9933 12.7315 34.2104 12.8196 34.42 12.9229C35.1933 13.3524 35.1933 13.925 34.42 14.3621C33.9406 14.6333 33.3992 14.8066 32.8734 15.0628C28.0444 17.4681 24.2329 21.4457 22.1012 26.3047C21.9511 26.6872 21.7755 27.0597 21.5753 27.4198C21.3859 27.6401 21.1675 27.8352 20.9258 28Z\" fill=\"currentColor\" transform=\"translate(57.64 44.71) scale(0.3367) translate(-21 -14)\"/></svg>";
package/dist/react.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
- import { type Report } from "./core.js";
2
+ import { type Privacy, type Report } from "./core.js";
3
3
  import type { Texts } from "./element.js";
4
4
  import "./element.js";
5
5
  export type ReportButtonProps = {
@@ -20,12 +20,51 @@ export type ReportButtonProps = {
20
20
  children?: ReactNode;
21
21
  };
22
22
  export declare function ReportButton({ site, endpoint, label, heading, position, theme, hideMark, texts, className, style, onSent, onError, children }: ReportButtonProps): import("react").DOMElement<Record<string, unknown>, Element>;
23
+ export type SentinelMarkProps = {
24
+ /** Width and height (a number is px; default `1em`, the size of the text around it). */
25
+ size?: number | string;
26
+ /** A name for screen readers; without it, the mark is decorative. */
27
+ title?: string;
28
+ className?: string;
29
+ style?: CSSProperties;
30
+ };
31
+ /** SENTINEL's mark, for your own button or footer. It takes the text colour (`color`); the moon stays green. */
32
+ export declare function SentinelMark({ size, title, className, style }: SentinelMarkProps): import("react").DOMElement<{
33
+ dangerouslySetInnerHTML: {
34
+ __html: string;
35
+ };
36
+ role: string;
37
+ "aria-label": string;
38
+ "aria-hidden"?: undefined;
39
+ focusable?: undefined;
40
+ viewBox: string;
41
+ width: string | number;
42
+ height: string | number;
43
+ className: string | undefined;
44
+ style: CSSProperties | undefined;
45
+ } | {
46
+ dangerouslySetInnerHTML: {
47
+ __html: string;
48
+ };
49
+ "aria-hidden": boolean;
50
+ focusable: string;
51
+ role?: undefined;
52
+ "aria-label"?: undefined;
53
+ viewBox: string;
54
+ width: string | number;
55
+ height: string | number;
56
+ className: string | undefined;
57
+ style: CSSProperties | undefined;
58
+ }, Element>;
23
59
  export type ReportStatus = "idle" | "sending" | "sent" | "error";
24
- /** For your own form: records the visit from the first render and sends with its context. */
60
+ /**
61
+ * For your own form: records the visit from the first render and sends with its context. `route`, `redact` and
62
+ * `title` clean what is sent (see `Privacy`); pass stable functions (defined outside the component).
63
+ */
25
64
  export declare function useSentinelReport(options: {
26
65
  site: string;
27
66
  endpoint?: string;
28
- }): {
67
+ } & Privacy): {
29
68
  send: (report: Report) => Promise<boolean>;
30
69
  status: ReportStatus;
31
70
  error: unknown;
package/dist/react.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // own form (`useSentinelReport`, which records the context and sends; the look is all yours).
3
3
  import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { capture, createReporter } from "./core.js";
5
+ import { MARK_SHAPES } from "./mark.js";
5
6
  import "./element.js";
6
7
  export function ReportButton({ site, endpoint, label, heading, position, theme, hideMark, texts, className, style, onSent, onError, children }) {
7
8
  const ref = useRef(null);
@@ -25,9 +26,17 @@ export function ReportButton({ site, endpoint, label, heading, position, theme,
25
26
  attributes["hide-mark"] = "";
26
27
  return createElement("sentinel-report", attributes, children === undefined ? undefined : createElement("span", { slot: "trigger", style: { display: "contents" } }, children));
27
28
  }
28
- /** For your own form: records the visit from the first render and sends with its context. */
29
+ /** SENTINEL's mark, for your own button or footer. It takes the text colour (`color`); the moon stays green. */
30
+ export function SentinelMark({ size = "1em", title, className, style }) {
31
+ const label = title ? { role: "img", "aria-label": title } : { "aria-hidden": true, focusable: "false" };
32
+ return createElement("svg", { viewBox: "0 0 100 100", width: size, height: size, className, style, ...label, dangerouslySetInnerHTML: { __html: `${title ? `<title>${title.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`)}</title>` : ""}${MARK_SHAPES}` } });
33
+ }
34
+ /**
35
+ * For your own form: records the visit from the first render and sends with its context. `route`, `redact` and
36
+ * `title` clean what is sent (see `Privacy`); pass stable functions (defined outside the component).
37
+ */
29
38
  export function useSentinelReport(options) {
30
- const reporter = useMemo(() => (typeof window === "undefined" ? undefined : createReporter(options)), [options.site, options.endpoint]);
39
+ const reporter = useMemo(() => (typeof window === "undefined" ? undefined : createReporter(options)), [options.site, options.endpoint, options.route, options.redact, options.title]);
31
40
  const [status, setStatus] = useState("idle");
32
41
  const [error, setError] = useState(undefined);
33
42
  useEffect(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@universelle/sentinel-report",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "SENTINEL's report button: let real users tell you what fails or what they miss, with the technical context of their visit.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "dist",
28
28
  "README.md",
29
+ "CHANGELOG.md",
29
30
  "LICENSE"
30
31
  ],
31
32
  "scripts": {