@universelle/sentinel-report 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Universelle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,164 @@
1
- # Temporary Holding Version
1
+ # @universelle/sentinel-report
2
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.
3
+ [SENTINEL](https://sentinel.cervilla.es)'s report button: let real users tell you **what fails** («Algo falla») or
4
+ **what they miss** («Echo en falta…»), with the technical context of their visit.
5
+
6
+ With the user's text it sends only:
7
+
8
+ - the page's **path**, never its query string or fragment, which can carry tokens;
9
+ - the browser: user agent, language, viewport and screen;
10
+ - the visit's last **console errors and warnings**;
11
+ - the visit's **failed requests**: path and status;
12
+ - the **pages visited**, including client-side navigation.
13
+
14
+ It never sends anything typed into your site. The receiving SENTINEL only accepts reports from the site's own origin,
15
+ with rate limits.
16
+
17
+ ```sh
18
+ npm i @universelle/sentinel-report
19
+ ```
20
+
21
+ ## The ready-made button (any site or framework)
22
+
23
+ ```js
24
+ import "@universelle/sentinel-report/element";
25
+ ```
26
+
27
+ ```html
28
+ <sentinel-report site="your-site"></sentinel-report>
29
+ ```
30
+
31
+ Or with no install, one line in your `<head>`:
32
+
33
+ ```html
34
+ <script src="https://sentinel.cervilla.es/widget.js" data-site="your-site" async></script>
35
+ ```
36
+
37
+ ### Attributes
38
+
39
+ | Attribute | Values |
40
+ |---|---|
41
+ | `site` | Your site's name in SENTINEL (required). |
42
+ | `endpoint` | Another SENTINEL (default `https://sentinel.cervilla.es`). |
43
+ | `label`, `heading` | Text of the button and of the panel's title. |
44
+ | `position` | `bottom-right` (default), `bottom-left`, `inline`. |
45
+ | `theme` | `auto` (default, follows the system), `light`, `dark`. |
46
+ | `hide-mark` | Hides SENTINEL's mark on the button. |
47
+
48
+ For any other word (for example another language), set the `texts` property:
49
+ `element.texts = { heading: "Tell us", send: "Send" }`.
50
+
51
+ ### Styling
52
+
53
+ Set CSS custom properties on the element:
54
+
55
+ | Property | What it styles |
56
+ |---|---|
57
+ | `--sentinel-accent`, `--sentinel-accent-fg` | The button and the selected option. |
58
+ | `--sentinel-bg`, `--sentinel-fg`, `--sentinel-muted`, `--sentinel-line` | The panel. |
59
+ | `--sentinel-ok`, `--sentinel-error` | The status messages. |
60
+ | `--sentinel-radius`, `--sentinel-font`, `--sentinel-offset`, `--sentinel-z` | Corners, font, distance to the edge, stacking. |
61
+
62
+ Any piece can also be styled with `::part()`: `button`, `mark`, `label`, `panel`, `header`, `title`, `close`, `kinds`,
63
+ `kind`, `text`, `email`, `note`, `send` and `status`.
64
+
65
+ ```css
66
+ sentinel-report {
67
+ --sentinel-accent: #1f7a3f;
68
+ --sentinel-radius: 6px;
69
+ }
70
+ sentinel-report::part(panel) {
71
+ border: 2px solid #1f7a3f;
72
+ }
73
+ ```
74
+
75
+ ### Your own trigger
76
+
77
+ Put any element in the `trigger` slot. Clicking it opens the panel, and the default button isn't shown:
78
+
79
+ ```html
80
+ <sentinel-report site="your-site" position="inline">
81
+ <a slot="trigger" href="#report">Report a problem with this page</a>
82
+ </sentinel-report>
83
+ ```
84
+
85
+ ### Events and methods
86
+
87
+ - Events: `sentinel-sent` (`detail.kind`) and `sentinel-error` (`detail.error`). Both bubble and are composed.
88
+ - Methods: `open()` and `close()`.
89
+
90
+ ## React
91
+
92
+ ```tsx
93
+ import { ReportButton } from "@universelle/sentinel-report/react";
94
+
95
+ <ReportButton
96
+ site="your-site"
97
+ label="Give us your opinion"
98
+ style={{ "--sentinel-accent": "#6b3fc4" }}
99
+ onSent={(kind) => track("report", kind)}
100
+ />
101
+
102
+ // Your own trigger:
103
+ <ReportButton site="your-site" position="inline">
104
+ <a href="#report">Report a problem</a>
105
+ </ReportButton>
106
+ ```
107
+
108
+ ### Your own form
109
+
110
+ `useSentinelReport` records the visit and sends; the design is all yours:
111
+
112
+ ```tsx
113
+ import { useSentinelReport } from "@universelle/sentinel-report/react";
114
+
115
+ const { send, status } = useSentinelReport({ site: "your-site" });
116
+ // status: "idle" | "sending" | "sent" | "error"
117
+ await send({ kind: "bug", text, email });
118
+ ```
119
+
120
+ Without React:
121
+
122
+ ```js
123
+ import { createReporter } from "@universelle/sentinel-report";
124
+
125
+ await createReporter({ site: "your-site" }).send({ kind: "wish", text: "Export the table" });
126
+ ```
127
+
128
+ ## Branding: SENTINEL's mark
129
+
130
+ For your own button, a footer or a "Protected by SENTINEL" line. The eye and the star take the text colour
131
+ (`currentColor`), so the mark works on light and dark backgrounds; the moon is always the brand's green.
132
+
133
+ ```tsx
134
+ import { SentinelMark, useSentinelReport } from "@universelle/sentinel-report/react";
135
+
136
+ <button onClick={…}>
137
+ <SentinelMark size={20} /> Report a problem
138
+ </button>
139
+
140
+ // title makes it an image with a name, for screen readers:
141
+ <SentinelMark size={32} title="SENTINEL" />
142
+ ```
143
+
144
+ Without React:
145
+
146
+ ```html
147
+ <script type="module">import "@universelle/sentinel-report/element";</script>
148
+ <span style="color: #6b3fc4">Made with <sentinel-mark size="20" label="SENTINEL"></sentinel-mark></span>
149
+ ```
150
+
151
+ ```js
152
+ import { markSvg, MARK_SHAPES } from "@universelle/sentinel-report";
153
+
154
+ button.innerHTML = `${markSvg({ size: 20 })} Report`;
155
+ ```
156
+
157
+ `markSvg` gives you the `<svg>` as a string. `MARK_SHAPES` gives you its inside, for your own `<svg viewBox="0 0 100 100">`.
158
+
159
+ ## Notes
160
+
161
+ - **Content Security Policy:** allow `connect-src https://sentinel.cervilla.es`, plus `script-src` if you use the
162
+ one-line widget.
163
+ - **Load it early.** Context is recorded from the moment the package loads, so errors from before that are not seen.
164
+ - No dependencies. React (≥ 18) is an optional peer.
package/dist/core.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ export declare const DEFAULT_ENDPOINT = "https://sentinel.cervilla.es";
2
+ export type ReportKind = "bug" | "wish";
3
+ export type ConsoleEntry = {
4
+ level: "error" | "warn";
5
+ message: string;
6
+ at: string;
7
+ };
8
+ export type FailedRequest = {
9
+ path: string;
10
+ status: number;
11
+ };
12
+ /** What goes with the text: the same shape SENTINEL validates. */
13
+ export type Context = {
14
+ page: {
15
+ path: string;
16
+ title: string;
17
+ };
18
+ device: {
19
+ userAgent: string;
20
+ language: string;
21
+ viewport: string;
22
+ screen: string;
23
+ };
24
+ console: ConsoleEntry[];
25
+ network: FailedRequest[];
26
+ trail: string[];
27
+ };
28
+ export type Report = {
29
+ kind: ReportKind;
30
+ text: string;
31
+ email?: string;
32
+ };
33
+ export type Reporter = {
34
+ /** Sends a report; rejects with an `Error` whose message is the HTTP status (or the network error). */
35
+ send(report: Report): Promise<void>;
36
+ /** What would go with a report right now. */
37
+ context(): Context;
38
+ };
39
+ type Capture = {
40
+ logs: ConsoleEntry[];
41
+ network: FailedRequest[];
42
+ trail: string[];
43
+ fetch: typeof fetch;
44
+ };
45
+ declare global {
46
+ interface Window {
47
+ __sentinelCapture?: Capture;
48
+ }
49
+ }
50
+ /** Starts recording the visit's context (once per page; later calls return the same recording). */
51
+ export declare function capture(): Capture;
52
+ export declare function createReporter(options: {
53
+ site: string;
54
+ endpoint?: string;
55
+ }): Reporter;
56
+ export {};
package/dist/core.js ADDED
@@ -0,0 +1,108 @@
1
+ // The core, with no framework: it records the technical context of the visit (console errors, failed requests,
2
+ // pages visited) and sends a report to SENTINEL. What it never sends: the query string or fragment of any URL (they
3
+ // can carry tokens) or anything typed into the site. Installed once per page, whoever asks first.
4
+ export const DEFAULT_ENDPOINT = "https://sentinel.cervilla.es";
5
+ const MAX = 20;
6
+ function clip(text) {
7
+ return String(text).replace(/\s+/g, " ").slice(0, 300);
8
+ }
9
+ function pathOf(url) {
10
+ try {
11
+ return new URL(url, location.href).pathname;
12
+ }
13
+ catch {
14
+ return "?";
15
+ }
16
+ }
17
+ function push(list, item) {
18
+ list.push(item);
19
+ if (list.length > MAX)
20
+ list.shift();
21
+ }
22
+ function describe(value) {
23
+ if (value instanceof Error)
24
+ return value.message;
25
+ if (typeof value === "string")
26
+ return value;
27
+ try {
28
+ return JSON.stringify(value) ?? String(value);
29
+ }
30
+ catch {
31
+ return String(value);
32
+ }
33
+ }
34
+ function watchConsole(capture) {
35
+ const pageConsole = window.console;
36
+ for (const level of ["error", "warn"]) {
37
+ const original = pageConsole[level];
38
+ pageConsole[level] = (...args) => {
39
+ push(capture.logs, { level, message: clip(args.map(describe).join(" ")), at: new Date().toISOString() });
40
+ original.apply(pageConsole, args);
41
+ };
42
+ }
43
+ window.addEventListener("error", (event) => push(capture.logs, { level: "error", message: clip(event.message || "error"), at: new Date().toISOString() }));
44
+ window.addEventListener("unhandledrejection", (event) => push(capture.logs, { level: "error", message: clip(describe(event.reason ?? "promesa rechazada")), at: new Date().toISOString() }));
45
+ }
46
+ function watchFetch(capture) {
47
+ const original = capture.fetch;
48
+ window.fetch = async (...args) => {
49
+ const url = args[0] instanceof Request ? args[0].url : String(args[0]);
50
+ try {
51
+ const response = await original(...args);
52
+ if (response.status >= 400)
53
+ push(capture.network, { path: pathOf(url), status: response.status });
54
+ return response;
55
+ }
56
+ catch (error) {
57
+ push(capture.network, { path: pathOf(url), status: 0 });
58
+ throw error;
59
+ }
60
+ };
61
+ }
62
+ function watchNavigation(capture) {
63
+ const visit = () => {
64
+ if (capture.trail[capture.trail.length - 1] !== location.pathname)
65
+ push(capture.trail, location.pathname);
66
+ };
67
+ for (const method of ["pushState", "replaceState"]) {
68
+ const original = history[method];
69
+ history[method] = function (...args) {
70
+ const result = original.apply(this, args);
71
+ visit();
72
+ return result;
73
+ };
74
+ }
75
+ window.addEventListener("popstate", visit);
76
+ }
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)
80
+ return window.__sentinelCapture;
81
+ const recording = { logs: [], network: [], trail: [location.pathname], fetch: window.fetch.bind(window) };
82
+ window.__sentinelCapture = recording;
83
+ watchConsole(recording);
84
+ watchFetch(recording);
85
+ watchNavigation(recording);
86
+ return recording;
87
+ }
88
+ export function createReporter(options) {
89
+ const recording = capture();
90
+ 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
+ });
98
+ return {
99
+ context,
100
+ async send(report) {
101
+ const body = { kind: report.kind, text: report.text.trim(), ...(report.email?.trim() ? { email: report.email.trim() } : {}), ...context() };
102
+ // With the page's own fetch from before it was watched: SENTINEL's request is not part of the visit.
103
+ const response = await recording.fetch(url, { method: "POST", mode: "cors", credentials: "omit", keepalive: true, headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
104
+ if (!response.ok)
105
+ throw new Error(String(response.status));
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,52 @@
1
+ export type Texts = {
2
+ label: string;
3
+ heading: string;
4
+ bug: string;
5
+ wish: string;
6
+ bugPlaceholder: string;
7
+ wishPlaceholder: string;
8
+ email: string;
9
+ note: string;
10
+ send: string;
11
+ sent: string;
12
+ failed: string;
13
+ tooShort: string;
14
+ close: string;
15
+ };
16
+ export declare const DEFAULT_TEXTS: Texts;
17
+ export declare class SentinelReport extends HTMLElement {
18
+ static observedAttributes: string[];
19
+ private reporter;
20
+ private kind;
21
+ private custom;
22
+ private readonly root;
23
+ constructor();
24
+ /** Every word of the component; set any of them (e.g. for another language). */
25
+ get texts(): Texts;
26
+ set texts(value: Partial<Texts>);
27
+ connectedCallback(): void;
28
+ attributeChangedCallback(): void;
29
+ private get panel();
30
+ open(): void;
31
+ close(): void;
32
+ private render;
33
+ private wire;
34
+ private say;
35
+ private submit;
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
+ }
47
+ declare global {
48
+ interface HTMLElementTagNameMap {
49
+ "sentinel-report": SentinelReport;
50
+ "sentinel-mark": SentinelMark;
51
+ }
52
+ }
@@ -0,0 +1,184 @@
1
+ // `<sentinel-report site="…">`: the report button as a web component, for any site or framework. Importing this
2
+ // module defines it. Stylable from outside without touching its insides:
3
+ // - CSS custom properties: --sentinel-accent, --sentinel-accent-fg (the button), --sentinel-bg, --sentinel-fg,
4
+ // --sentinel-muted, --sentinel-line (the panel), --sentinel-radius, --sentinel-font, --sentinel-offset, --sentinel-z;
5
+ // - `::part()`: button, mark, label, panel, header, title, close, kinds, kind, text, email, note, send, status;
6
+ // - your own trigger, in the `trigger` slot (then the default button isn't shown);
7
+ // - attributes: site, endpoint, label, heading, position (bottom-right | bottom-left | inline), theme
8
+ // (auto | light | dark), hide-mark; and the `texts` property for every other word.
9
+ // Events: `sentinel-sent` (detail: { kind }) and `sentinel-error` (detail: { error }). Methods: open(), close().
10
+ import { capture, createReporter } from "./core.js";
11
+ import { markSvg } from "./logo.js";
12
+ import { MARK } from "./mark.js";
13
+ export const DEFAULT_TEXTS = {
14
+ label: "¿Algo falla?",
15
+ heading: "Cuéntanos",
16
+ bug: "Algo falla",
17
+ wish: "Echo en falta…",
18
+ bugPlaceholder: "Qué intentabas hacer y qué ha pasado",
19
+ wishPlaceholder: "Qué te gustaría poder hacer aquí",
20
+ email: "Tu email, si quieres respuesta (opcional)",
21
+ note: "Con tu mensaje enviamos la página en la que estás, tu navegador y los errores técnicos de esta visita. Nada de lo que hayas escrito en la web.",
22
+ send: "Enviar",
23
+ sent: "Gracias. Lo revisamos.",
24
+ failed: "No se ha podido enviar. Prueba otra vez en un momento.",
25
+ tooShort: "Cuéntanos un poco más (al menos 5 letras).",
26
+ close: "Cerrar",
27
+ };
28
+ const STYLE = `
29
+ :host{--_accent:var(--sentinel-accent,#17201b);--_accent-fg:var(--sentinel-accent-fg,#fff);--_bg:var(--sentinel-bg,#fff);--_fg:var(--sentinel-fg,#17201b);--_muted:var(--sentinel-muted,#69736c);--_line:var(--sentinel-line,#e3e7e2);--_ok:var(--sentinel-ok,#1f7a3f);--_err:var(--sentinel-error,#c4321f);--_radius:var(--sentinel-radius,14px);--_offset:var(--sentinel-offset,16px);
30
+ position:fixed;right:max(var(--_offset),env(safe-area-inset-right,0px));bottom:max(var(--_offset),env(safe-area-inset-bottom,0px));z-index:var(--sentinel-z,2147483000);display:grid;justify-items:end;gap:10px;font-family:var(--sentinel-font,Inter,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif);color:var(--_fg)}
31
+ :host([position="bottom-left"]){right:auto;left:max(var(--_offset),env(safe-area-inset-left,0px));justify-items:start}
32
+ :host([position="inline"]){position:static;justify-items:start}
33
+ @media (prefers-color-scheme:dark){:host(:not([theme="light"])){--_bg:var(--sentinel-bg,#1a201c);--_fg:var(--sentinel-fg,#e8ece8);--_muted:var(--sentinel-muted,#9aa49d);--_line:var(--sentinel-line,#2c342e);--_ok:var(--sentinel-ok,#5cc983);--_err:var(--sentinel-error,#ff6b57)}}
34
+ :host([theme="dark"]){--_bg:var(--sentinel-bg,#1a201c);--_fg:var(--sentinel-fg,#e8ece8);--_muted:var(--sentinel-muted,#9aa49d);--_line:var(--sentinel-line,#2c342e);--_ok:var(--sentinel-ok,#5cc983);--_err:var(--sentinel-error,#ff6b57)}
35
+ *{box-sizing:border-box;font-family:inherit}
36
+ [hidden]{display:none!important}
37
+ .open{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:99px;padding:9px 16px 9px 12px;background:var(--_accent);color:var(--_accent-fg);font-size:14px;font-weight:600;cursor:pointer;box-shadow:0 4px 18px rgba(0,0,0,.18)}
38
+ .mark{width:28px;height:28px;flex:none;margin:-2px 0}
39
+ :host([hide-mark]) .mark{display:none}
40
+ .panel{width:min(340px,calc(100vw - 32px));background:var(--_bg);color:var(--_fg);border:1px solid var(--_line);border-radius:var(--_radius);box-shadow:0 10px 40px rgba(0,0,0,.2);padding:16px;display:grid;gap:12px}
41
+ .head{display:flex;justify-content:space-between;align-items:center}.title{font-size:15px;font-weight:700;margin:0}
42
+ .x{border:0;background:none;color:var(--_muted);font-size:20px;line-height:1;cursor:pointer;padding:2px 6px}
43
+ .kinds{display:grid;grid-template-columns:1fr 1fr;gap:6px}
44
+ .kind{border:1px solid var(--_line);background:none;color:var(--_fg);border-radius:calc(var(--_radius) - 5px);padding:8px;font-size:13px;cursor:pointer}
45
+ .kind[aria-pressed="true"]{border-color:var(--_accent);background:var(--_accent);color:var(--_accent-fg);font-weight:600}
46
+ textarea,input{width:100%;font-size:14px;padding:9px 10px;border:1px solid var(--_line);border-radius:calc(var(--_radius) - 5px);background:var(--_bg);color:var(--_fg)}
47
+ textarea{min-height:96px;resize:vertical}
48
+ .note{font-size:12px;color:var(--_muted);margin:0;line-height:1.4}
49
+ .send{border:0;border-radius:calc(var(--_radius) - 5px);padding:10px;background:var(--_accent);color:var(--_accent-fg);font-size:14px;font-weight:600;cursor:pointer}
50
+ .send:disabled{opacity:.5;cursor:wait}
51
+ .status{font-size:14px;margin:0}.status.ok{color:var(--_ok)}.status.err{color:var(--_err)}
52
+ `;
53
+ function escape(text) {
54
+ return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
55
+ }
56
+ export class SentinelReport extends HTMLElement {
57
+ static observedAttributes = ["site", "endpoint", "label", "heading"];
58
+ reporter;
59
+ kind = "bug";
60
+ custom = {};
61
+ root;
62
+ constructor() {
63
+ super();
64
+ this.root = this.attachShadow({ mode: "open" });
65
+ }
66
+ /** Every word of the component; set any of them (e.g. for another language). */
67
+ get texts() {
68
+ return { ...DEFAULT_TEXTS, ...this.custom, ...(this.getAttribute("label") ? { label: this.getAttribute("label") } : {}), ...(this.getAttribute("heading") ? { heading: this.getAttribute("heading") } : {}) };
69
+ }
70
+ set texts(value) {
71
+ this.custom = value;
72
+ this.render();
73
+ }
74
+ connectedCallback() {
75
+ // Recording starts as soon as the button is on the page, so the report carries the visit's errors.
76
+ capture();
77
+ this.render();
78
+ }
79
+ attributeChangedCallback() {
80
+ this.reporter = undefined;
81
+ if (this.isConnected)
82
+ this.render();
83
+ }
84
+ get panel() {
85
+ return this.root.querySelector(".panel");
86
+ }
87
+ open() {
88
+ this.panel.hidden = false;
89
+ this.root.querySelector(".open")?.setAttribute("hidden", "");
90
+ this.root.querySelector("textarea")?.focus();
91
+ }
92
+ close() {
93
+ this.panel.hidden = true;
94
+ if (!this.root.querySelector('slot[name="trigger"]')?.assignedElements().length)
95
+ this.root.querySelector(".open")?.removeAttribute("hidden");
96
+ }
97
+ render() {
98
+ const t = this.texts;
99
+ this.root.innerHTML = `<style>${STYLE}</style>
100
+ <div class="panel" part="panel" role="dialog" aria-modal="false" aria-label="${escape(t.heading)}" hidden>
101
+ <div class="head" part="header"><p class="title" part="title">${escape(t.heading)}</p><button class="x" part="close" aria-label="${escape(t.close)}">×</button></div>
102
+ <div class="kinds" part="kinds" role="group"><button class="kind" part="kind" data-kind="bug" aria-pressed="${this.kind === "bug"}">${escape(t.bug)}</button><button class="kind" part="kind" data-kind="wish" aria-pressed="${this.kind === "wish"}">${escape(t.wish)}</button></div>
103
+ <textarea part="text" maxlength="2000" aria-label="${escape(t.heading)}" placeholder="${escape(this.kind === "bug" ? t.bugPlaceholder : t.wishPlaceholder)}"></textarea>
104
+ <input part="email" type="email" maxlength="200" placeholder="${escape(t.email)}" aria-label="${escape(t.email)}">
105
+ <p class="note" part="note">${escape(t.note)}</p>
106
+ <button class="send" part="send">${escape(t.send)}</button><p class="status" part="status" role="status" hidden></p>
107
+ </div>
108
+ <slot name="trigger"><button class="open" part="button" aria-haspopup="dialog">${MARK}<span part="label">${escape(t.label)}</span></button></slot>`;
109
+ this.wire();
110
+ }
111
+ wire() {
112
+ const $ = (selector) => this.root.querySelector(selector);
113
+ const slot = $('slot[name="trigger"]');
114
+ slot.addEventListener("click", () => this.open());
115
+ $(".x").addEventListener("click", () => this.close());
116
+ this.panel.addEventListener("keydown", (event) => event.key === "Escape" && this.close());
117
+ for (const button of this.root.querySelectorAll(".kind")) {
118
+ button.addEventListener("click", () => {
119
+ this.kind = button.dataset.kind;
120
+ for (const other of this.root.querySelectorAll(".kind"))
121
+ other.setAttribute("aria-pressed", String(other === button));
122
+ $("textarea").placeholder = this.kind === "bug" ? this.texts.bugPlaceholder : this.texts.wishPlaceholder;
123
+ });
124
+ }
125
+ $(".send").addEventListener("click", () => void this.submit());
126
+ }
127
+ say(message, tone) {
128
+ const status = this.root.querySelector(".status");
129
+ status.textContent = message;
130
+ status.className = `status ${tone}`;
131
+ status.hidden = false;
132
+ }
133
+ async submit() {
134
+ const text = this.root.querySelector("textarea");
135
+ const email = this.root.querySelector("input");
136
+ const send = this.root.querySelector(".send");
137
+ if (text.value.trim().length < 5)
138
+ return this.say(this.texts.tooShort, "err");
139
+ const site = this.getAttribute("site");
140
+ if (!site)
141
+ return this.say(this.texts.failed, "err");
142
+ this.reporter ??= createReporter({ site, endpoint: this.getAttribute("endpoint") ?? undefined });
143
+ send.disabled = true;
144
+ try {
145
+ await this.reporter.send({ kind: this.kind, text: text.value, email: email.value });
146
+ text.value = "";
147
+ this.say(this.texts.sent, "ok");
148
+ this.dispatchEvent(new CustomEvent("sentinel-sent", { detail: { kind: this.kind }, bubbles: true, composed: true }));
149
+ setTimeout(() => {
150
+ this.root.querySelector(".status").hidden = true;
151
+ this.close();
152
+ }, 2500);
153
+ }
154
+ catch (error) {
155
+ this.say(this.texts.failed, "err");
156
+ this.dispatchEvent(new CustomEvent("sentinel-error", { detail: { error }, bubbles: true, composed: true }));
157
+ }
158
+ finally {
159
+ send.disabled = false;
160
+ }
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
+ }
181
+ if (typeof customElements !== "undefined" && customElements.get("sentinel-report") === undefined)
182
+ customElements.define("sentinel-report", SentinelReport);
183
+ if (typeof customElements !== "undefined" && customElements.get("sentinel-mark") === undefined)
184
+ customElements.define("sentinel-mark", SentinelMark);
@@ -0,0 +1,3 @@
1
+ export { capture, createReporter, DEFAULT_ENDPOINT } from "./core.js";
2
+ export { MARK_SHAPES, markSvg } from "./logo.js";
3
+ export type { Context, ConsoleEntry, FailedRequest, Report, ReportKind, Reporter } from "./core.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @universelle/sentinel-report: the core, with no framework. For the ready-made button, `…/element` (any site) or
2
+ // `…/react`; to build your own form, `createReporter` here or `useSentinelReport` in React.
3
+ export { capture, createReporter, DEFAULT_ENDPOINT } 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 ADDED
@@ -0,0 +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)\"/>";
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 ADDED
@@ -0,0 +1,4 @@
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)\"/>";
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>";
@@ -0,0 +1,69 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type Report } from "./core.js";
3
+ import type { Texts } from "./element.js";
4
+ import "./element.js";
5
+ export type ReportButtonProps = {
6
+ site: string;
7
+ endpoint?: string;
8
+ label?: string;
9
+ heading?: string;
10
+ position?: "bottom-right" | "bottom-left" | "inline";
11
+ theme?: "auto" | "light" | "dark";
12
+ hideMark?: boolean;
13
+ texts?: Partial<Texts>;
14
+ className?: string;
15
+ /** CSS custom properties too: `{ "--sentinel-accent": "#6b3fc4" }`. */
16
+ style?: CSSProperties & Record<`--sentinel-${string}`, string>;
17
+ onSent?: (kind: Report["kind"]) => void;
18
+ onError?: (error: unknown) => void;
19
+ /** Your own trigger instead of the default button: clicking it opens the form. */
20
+ children?: ReactNode;
21
+ };
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>;
59
+ export type ReportStatus = "idle" | "sending" | "sent" | "error";
60
+ /** For your own form: records the visit from the first render and sends with its context. */
61
+ export declare function useSentinelReport(options: {
62
+ site: string;
63
+ endpoint?: string;
64
+ }): {
65
+ send: (report: Report) => Promise<boolean>;
66
+ status: ReportStatus;
67
+ error: unknown;
68
+ context: () => import("./core.js").Context | undefined;
69
+ };
package/dist/react.js ADDED
@@ -0,0 +1,58 @@
1
+ // React: the ready-made button (`<ReportButton>`, the web component with typed props and events) and a hook for your
2
+ // own form (`useSentinelReport`, which records the context and sends; the look is all yours).
3
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { capture, createReporter } from "./core.js";
5
+ import { MARK_SHAPES } from "./mark.js";
6
+ import "./element.js";
7
+ export function ReportButton({ site, endpoint, label, heading, position, theme, hideMark, texts, className, style, onSent, onError, children }) {
8
+ const ref = useRef(null);
9
+ useEffect(() => {
10
+ const element = ref.current;
11
+ if (element === null)
12
+ return;
13
+ if (texts)
14
+ element.texts = texts;
15
+ const sent = (event) => onSent?.(event.detail.kind);
16
+ const failed = (event) => onError?.(event.detail.error);
17
+ element.addEventListener("sentinel-sent", sent);
18
+ element.addEventListener("sentinel-error", failed);
19
+ return () => {
20
+ element.removeEventListener("sentinel-sent", sent);
21
+ element.removeEventListener("sentinel-error", failed);
22
+ };
23
+ }, [texts, onSent, onError]);
24
+ const attributes = { ref, site, endpoint, label, heading, position, theme, class: className, style };
25
+ if (hideMark)
26
+ attributes["hide-mark"] = "";
27
+ return createElement("sentinel-report", attributes, children === undefined ? undefined : createElement("span", { slot: "trigger", style: { display: "contents" } }, children));
28
+ }
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
+ /** For your own form: records the visit from the first render and sends with its context. */
35
+ export function useSentinelReport(options) {
36
+ const reporter = useMemo(() => (typeof window === "undefined" ? undefined : createReporter(options)), [options.site, options.endpoint]);
37
+ const [status, setStatus] = useState("idle");
38
+ const [error, setError] = useState(undefined);
39
+ useEffect(() => {
40
+ capture();
41
+ }, []);
42
+ const send = useCallback(async (report) => {
43
+ if (reporter === undefined)
44
+ return false;
45
+ setStatus("sending");
46
+ try {
47
+ await reporter.send(report);
48
+ setStatus("sent");
49
+ return true;
50
+ }
51
+ catch (failure) {
52
+ setError(failure);
53
+ setStatus("error");
54
+ return false;
55
+ }
56
+ }, [reporter]);
57
+ return { send, status, error, context: () => reporter?.context() };
58
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@universelle/sentinel-report",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "./dist/element.js",
9
+ "./src/element.ts",
10
+ "./src/widget.ts"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./element": {
18
+ "types": "./dist/element.d.ts",
19
+ "default": "./dist/element.js"
20
+ },
21
+ "./react": {
22
+ "types": "./dist/react.d.ts",
23
+ "default": "./dist/react.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=18"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "@types/react": "19.2.2",
44
+ "react": "19.2.0",
45
+ "react-dom": "19.2.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "keywords": [
51
+ "sentinel",
52
+ "bug report",
53
+ "feedback",
54
+ "web component",
55
+ "react"
56
+ ],
57
+ "homepage": "https://sentinel.cervilla.es",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/JavierCervilla/Sentinel.git",
61
+ "directory": "packages/report"
62
+ }
63
+ }