tell-cecil 0.0.0-stage → 0.0.1

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 Tell Cecil
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,126 @@
1
- # Temporary Holding Version
1
+ # Tell Cecil
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
+ Embeddable feedback widget. Visitors click a button, pick an element on the page, describe the problem, and Tell Cecil files it in your tracker with the selector, accessible name and page context. MIT licensed, no dependencies, about 8 KB gzipped.
4
+
5
+ > **Alpha.** Works with GitHub. Expect changes.
6
+
7
+ <!-- TODO: demo GIF -->
8
+
9
+ > **If your GitHub repo is public, every report is public.** Use a private repo unless you want comments and page paths visible to everyone. See [Privacy](#privacy).
10
+
11
+ ## How it works
12
+
13
+ The widget collects the report in the browser and posts it to a small relay you run. The relay creates the GitHub issue with a server-side token, so reporters need no GitHub account and your token and labels never reach the page. The success reply includes the issue link, which shows the repo name to the reporter; set `exposeIssueUrl: false` on the project in `createRelay` to turn that off (the widget then shows no link).
14
+
15
+ ## Quick start
16
+
17
+ **1. Run the relay.** Needs Node 20 or later and a [fine-grained GitHub token](https://github.com/settings/personal-access-tokens/new) with access to one repository and Issues: read and write.
18
+
19
+ ```sh
20
+ export CECIL_GITHUB_TOKEN=github_pat_... # fine-grained, issues: read/write on one repo
21
+ export CECIL_GITHUB_REPO=owner/repo
22
+ export CECIL_PROJECT_KEY=pk_abc
23
+ export CECIL_ORIGINS=https://yoursite.com # comma-separated exact origins
24
+ npx tell-cecil # http://localhost:8787
25
+ ```
26
+
27
+ The same variables can live in a `.env` file in the folder you run it from (Node 20.12+). Optional: `CECIL_LABELS`, `PORT`, `CECIL_TRUST_PROXY=1`. Defaults are project key `dev` and origin `http://localhost:5173`.
28
+
29
+ To run the relay inside your own app instead, `npm i tell-cecil` and call `createRelay` from `tell-cecil/relay`. It is a `(Request) => Response` function, so it also runs on Cloudflare Workers, Vercel, Deno or Bun. The source repo has a Cloudflare Worker example (`examples/cloudflare-worker`) and a `Dockerfile` for the Node runner (`docker build -t tell-cecil . && docker run --env-file .env -p 8787:8787 tell-cecil`).
30
+
31
+ From source: `npm install && npm run build` produces `dist/cecil.js` (script tag), `dist/cecil.mjs` (ES module), `dist/relay.mjs`, `dist/server.mjs` and type declarations in `dist/types`. `npm run relay` runs the relay from `src/` for development.
32
+
33
+ **Rate limiting and proxies.** The relay limits per project key and client IP (default 10 per minute), plus a per-project cap (`rateLimit.projectMax`, default 60 per minute), and answers 429 with a `retry-after` header. By default the Node runner ignores any client-supplied `x-forwarded-for` and `cf-connecting-ip` and uses the socket address. If you run it behind a reverse proxy you control, set `CECIL_TRUST_PROXY=1` to use the proxy's `x-forwarded-for` (the last entry is the one your proxy added). When calling `createRelay` yourself, pass `clientIp: (req) => string` to choose the client identity; the default prefers `cf-connecting-ip`, else the last `x-forwarded-for` entry, else `"unknown"`.
34
+
35
+ **Limits are weak by design.** The rate-limit store is in memory, per process. On Cloudflare Workers that means per isolate, which is weak: put Cloudflare rate-limiting rules or a WAF in front of the relay for real protection. The per-project cap also means a flood can lock legitimate reporters out for that window; that trade-off protects the GitHub token's quota.
36
+
37
+ **2. Add the widget.** Load it from a CDN (or copy `dist/cecil.js` to your site):
38
+
39
+ ```html
40
+ <script src="https://cdn.jsdelivr.net/npm/tell-cecil@0.0.1/dist/cecil.js"
41
+ data-endpoint="https://relay.example.com"
42
+ data-project-key="pk_abc"></script>
43
+ ```
44
+
45
+ From a bundler, `import { init } from "tell-cecil"` and call `init({ endpoint, projectKey })`. The endpoint may be relative (`/cecil`) when the relay sits behind your own domain; it must be https unless it is localhost.
46
+
47
+ If reports fail, the browser console says why. A 403 means the project key is wrong or the page's origin isn't in the relay's `CECIL_ORIGINS`. The relay refuses to start on a malformed origin (trailing slash or path), a repo that isn't `owner/repo`, an empty token or no projects.
48
+
49
+ **Who reported it.** Add `data-user="jane@corp.example"`, or in code `init({ ..., user: () => currentUser.email })`. The issue shows it as `Reporter: ... (self-reported)`. The browser supplies this value, so it is not verified. Anyone who can reach the relay can claim any name, so keep the relay behind your VPN or SSO proxy.
50
+
51
+ The relay checks the project key and the exact `Origin` against an allowlist, rate limits, validates the report, then calls the GitHub API. The project key is public, not a secret. Origin checks stop other websites in browsers, not scripts, so your real protection is the rate limit and a scoped token (issues: write on one repo).
52
+
53
+ ## Configuration reference
54
+
55
+ **Widget `init(config)`** (from `dist/cecil.mjs`) and the matching `<script>` data attributes (`dist/cecil.js`):
56
+
57
+ | `init` option | Data attribute | Description |
58
+ | --- | --- | --- |
59
+ | `endpoint` (required) | `data-endpoint` | Relay URL. Relative is allowed; must be https unless localhost, 127.0.0.1 or [::1]. |
60
+ | `projectKey` (required) | `data-project-key` | Public key the relay maps to a repo and origin allowlist. |
61
+ | `user` | `data-user` | Self-reported reporter, a string or a function called on each send. Unverified. |
62
+ | `privacy.exclude` | `data-exclude` | Selectors that can't be picked (comma-separated in the attribute). |
63
+ | `privacy.mask` | `data-mask` | Selectors whose text and name are left out. |
64
+ | `privacy.url` | `data-full-url` (presence) | Rewrites the page URL before sending. Default: origin and path only. The attribute sends the full URL. |
65
+
66
+ `init` returns a function that removes the widget. Calling it twice returns the existing one.
67
+
68
+ **Node runner (`dist/server.mjs`) environment variables:**
69
+
70
+ | Variable | Default | Description |
71
+ | --- | --- | --- |
72
+ | `CECIL_GITHUB_TOKEN` (required) | | Fine-grained token, issues read/write on one repo. |
73
+ | `CECIL_GITHUB_REPO` (required) | | `owner/repo`. |
74
+ | `CECIL_PROJECT_KEY` | `dev` | Public project key. |
75
+ | `CECIL_ORIGINS` | `http://localhost:5173` | Comma-separated exact origins allowed to submit. |
76
+ | `CECIL_LABELS` | none | Comma-separated labels added to each issue. |
77
+ | `CECIL_TRUST_PROXY` | off | `1` uses the proxy's `x-forwarded-for` for rate limiting. |
78
+ | `PORT` | `8787` | Listen port. |
79
+
80
+ **`createRelay(options)`:**
81
+
82
+ | Option | Default | Description |
83
+ | --- | --- | --- |
84
+ | `projects` (required) | | Map of project key to `{ origins, github: { repo, token, labels? }, exposeIssueUrl? }`. `exposeIssueUrl` defaults to true. |
85
+ | `rateLimit` | `{ max: 10, windowMs: 60000, projectMax: 60 }` | `max` per project key and client IP, `projectMax` per project key, per window. In memory, per instance. |
86
+ | `clientIp` | `cf-connecting-ip`, else last `x-forwarded-for`, else `"unknown"` | Chooses the client identity for rate limiting. |
87
+ | `fetch` | global `fetch` | Used for the GitHub call; injectable for tests. |
88
+ | `now` | `Date.now` | Clock, injectable for tests. |
89
+
90
+ ## What the reporter sees
91
+
92
+ Click **Feedback**, then choose **Element** (hover and click, or Tab and Enter) or **Page**, write a comment, and send. Esc exits. The button can be dragged to any corner. Area selection is planned.
93
+
94
+ ## Privacy
95
+
96
+ **If your repo is public, every report is public.** Use a private repo unless you want reporters' comments and page paths visible to everyone.
97
+
98
+ Tell Cecil never reads input values, editable content or page HTML. It sends the selector, role, accessible name, a short text excerpt, a few attributes, up to four ancestors, and the element's position.
99
+
100
+ - `data-cecil-private` on an element: it and its children can't be picked.
101
+ - `data-cecil-mask` on an element: it can be picked, but its text and name are left out.
102
+ - All `input`, `textarea`, `select` and `contenteditable` content is masked by default: the element is reported, but never its value, text or selected option. Form controls keep their label (`aria-label`, `label`, or `placeholder`) as the name; `contenteditable` and `data-cecil-mask` content gets no name. A container is marked redacted only if it holds `data-cecil-private`, your own `exclude` matches, `data-cecil-mask`/your `mask` matches or `contenteditable` content; form fields, scripts and hidden inputs don't count.
103
+ - The page URL is sent without its query string and hash. To send the full URL, use `data-full-url` on the script tag, or `privacy: { url: (href) => ... }` in `init` to rewrite it yourself.
104
+ - Add your own selectors: `data-exclude=".admin, #billing"` and `data-mask=".account-number"` (comma-separated), or `privacy: { exclude: [...], mask: [...] }` in `init`.
105
+
106
+ ## Content Security Policy
107
+
108
+ The widget adds its styles as a constructable stylesheet (falling back to a `<style>` element in older browsers), so it works under a strict `style-src`. Your `connect-src` must include the relay's origin, and `script-src` must allow wherever you host `cecil.js`.
109
+
110
+ ## Develop
111
+
112
+ ```sh
113
+ npm install
114
+ npm test
115
+ npm run build # dist/cecil.js, dist/cecil.mjs, dist/relay.mjs, dist/server.mjs, dist/types
116
+ npm run dev # demo page (index.html) on http://localhost:5173; run `npm run relay` beside it
117
+ ```
118
+
119
+ ## Limits
120
+
121
+ - GitHub only; no screenshots; the rate limit is in memory per instance.
122
+ - Not yet: area selection, screenshots, other trackers.
123
+
124
+ ## License
125
+
126
+ MIT
package/dist/cecil.js ADDED
@@ -0,0 +1,80 @@
1
+ var Cecil=(function(W){"use strict";const ft=["script","style","template","input[type=hidden]","[data-cecil-private]","[data-cecil]"],J=["input","textarea","select"],ht=['[contenteditable]:not([contenteditable="false"])',"[data-cecil-mask]"],mt=["id","data-testid","data-test","name","role","type"],gt=["data-testid","data-test"],V=t=>{try{return document.createDocumentFragment().querySelector(t),!0}catch{return console.warn("cecil: ignoring invalid selector",t),!1}},F={},G=new WeakMap;function H(t){let e=G.get(t);if(!e){const n=[...ht,...(t.mask??[]).filter(V)],a=(t.exclude??[]).filter(V);e={exclude:[...ft,...a].join(","),mask:[...J,...n].join(","),hide:n.join(","),hidden:["[data-cecil-private]",...a].join(",")},G.set(t,e)}return e}const Q=(t,e)=>t.matches(e);function S(t,e){for(let n=t;n;){if(n.closest(e))return!0;const a=n.getRootNode();n=a instanceof ShadowRoot?a.host:null}return!1}function bt(t,e=F){return S(t,H(e).exclude)}const v=(t,e)=>(t==null?void 0:t.replace(/\s+/g," ").trim().slice(0,e))||void 0,z=t=>`"${t.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/[\n\r\f]/g," ")}"`,R=t=>CSS.escape(t);function q(t,e,n,a){let s="";const r=(d,l=!1)=>{if(!(s.length>=n*2)){if(d.nodeType===3)s+=` ${d.textContent??""}`;else if(d.nodeType===1){const p=d;if(p===a||!l&&(Q(p,e.exclude)||Q(p,e.mask)))return;p.childNodes.forEach(g=>r(g))}}};return r(t,!0),v(s,n)}function Z(t){var a,s,r;const e=(a=v(t.getAttribute("role"),100))==null?void 0:a.toLowerCase();if(e)return e;const n=t.tagName.toLowerCase();if(n==="a")return t.hasAttribute("href")?"link":void 0;if(/^h[1-6]$/.test(n))return"heading";if(n==="input"){const d=(t.getAttribute("type")??"text").toLowerCase();return{checkbox:"checkbox",radio:"radio",button:"button",submit:"button",reset:"button",range:"slider",search:"searchbox"}[d]??(d==="hidden"?void 0:"textbox")}return n==="section"?(s=t.getAttribute("aria-label"))!=null&&s.trim()||(r=t.getAttribute("aria-labelledby"))!=null&&r.trim()?"region":void 0:{button:"button",textarea:"textbox",select:"combobox",option:"option",img:"img",nav:"navigation",main:"main",form:"form",li:"listitem",ul:"list",ol:"list",table:"table",dialog:"dialog",header:"banner",footer:"contentinfo",aside:"complementary"}[n]}const xt=/^(button|input|meter|output|progress|select|textarea)$/;function tt(t,e){const n=t.getRootNode()instanceof ShadowRoot?t.getRootNode():t.ownerDocument,a=(l,p)=>l&&!S(l,e.exclude)&&!S(l,e.mask)?q(l,e,1e3,p):void 0,s=(t.getAttribute("aria-labelledby")??"").split(/\s+/).slice(0,8).map(l=>l?a(n.getElementById(l)):void 0).filter(Boolean).join(" "),r=t.getAttribute("id"),d=xt.test(t.tagName.toLowerCase());return v(t.getAttribute("aria-label"),1e3)??v(s,1e3)??(r?a(n.querySelector(`label[for=${z(r)}]`)):void 0)??(d?a(t.closest("label"),t):void 0)}function et(t,e){return S(t,e.mask)?J.includes(t.tagName.toLowerCase())&&!S(t,e.hide)?tt(t,e)??v(t.getAttribute("placeholder"),1e3):void 0:tt(t,e)??v(t.getAttribute("placeholder"),1e3)??v(t.getAttribute("alt"),1e3)??v(t.getAttribute("title"),1e3)??q(t,e,1e3)}function yt(t,e=F){const n=H(e);return et(t,n)||`<${t.tagName.toLowerCase()}>`}function j(t,e){try{const n=t.getRootNode(),a=("querySelectorAll"in n?n:t.ownerDocument).querySelectorAll(e);return a.length===1&&a[0]===t}catch{return!1}}function nt(t){var n;const e=[];for(let a=t;a&&e.length<16&&(e.length<8||!j(t,e.join(" > ")));a=a.parentElement){const s=a.tagName.toLowerCase();if(s==="html"||s==="body")break;const r=v(a.getAttribute("id"),500);if(r&&j(a,`#${R(r)}`)){e.unshift(`#${R(r)}`);break}const d=[...((n=a.parentElement)==null?void 0:n.children)??[]].filter(l=>l.tagName===a.tagName);e.unshift(d.length>1?`${s}:nth-of-type(${d.indexOf(a)+1})`:s)}return e.join(" > ")||t.tagName.toLowerCase()}function vt(t,e,n){const a=[];for(const d of gt){const l=v(t.getAttribute(d),500);l&&j(t,`[${d}=${z(l)}]`)&&a.push(`[${d}=${z(l)}]`)}const s=v(t.getAttribute("id"),500);s&&j(t,`#${R(s)}`)&&a.push(`#${R(s)}`),e&&n&&a.push(`role=${e} name=${z(n.slice(0,100))}`);const r=nt(t);return a.includes(r)||a.push(r),a.slice(0,8)}function wt(t){const e=[];for(let n=t.parentElement;n&&e.length<4;n=n.parentElement){const a=n.tagName.toLowerCase();if(a==="html"||a==="body")break;const s=Z(n);e.push(s&&s!==a?`${a}[role=${s}]`:a)}return e.length?e:void 0}function kt(t,e=F){const n=H(e);if(S(t,n.exclude))throw new Error("This area is private and cannot be captured.");const a=S(t,n.mask),s=Z(t),r=et(t,n),d=a?void 0:q(t,n,500),l=vt(t,s,r),p={};for(const w of mt){const $=v(t.getAttribute(w),500);$&&(p[w]=$)}const g=t.getBoundingClientRect(),L=t.querySelector(n.hidden)!==null||t.querySelector(n.hide)!==null,f=wt(t),E={selector:l.find(w=>!w.startsWith("role="))??nt(t),tag:t.tagName.toLowerCase(),locators:l,rect:{x:Math.round(g.x),y:Math.round(g.y),width:Math.round(g.width),height:Math.round(g.height)},redacted:a||L};return s&&(E.role=s),r&&(E.name=r),d&&(E.text=d),Object.keys(p).length&&(E.attributes=p),f&&(E.ancestors=f),E}function Et(t,e={}){if(e.url)return e.url(t);const n=new URL(t);return n.origin+n.pathname}function ot(t,e){let n=document.elementFromPoint(t,e);for(;n!=null&&n.shadowRoot;){const a=n.shadowRoot.elementFromPoint(t,e);if(!a||a===n)break;n=a}return n}function B(t,e,n){Object.assign(t.style,{display:"block",left:`${e.x}px`,top:`${e.y}px`,width:`${e.width}px`,height:`${e.height}px`}),t.firstElementChild.textContent=n.replace(/\s+/g," ").trim().slice(0,120)}const P=(t,e)=>Math.min(Math.max(t,0),e);function Ct({host:t,box:e,mode:n,privacy:a,done:s}){let r,d;const l=o=>o.composedPath().includes(t),p=o=>!!o&&o!==t&&!t.contains(o)&&!bt(o,a);function g(o){if(d=o&&p(o)?o:void 0,!d){e.style.display="none";return}const u=d.getBoundingClientRect();B(e,u,yt(d,a))}function L(o){const u=P(o.clientX,innerWidth),y=P(o.clientY,innerHeight);return{x:Math.min(r.x,u),y:Math.min(r.y,y),width:Math.abs(u-r.x),height:Math.abs(y-r.y)}}const f=o=>{l(o)||(o.preventDefault(),o.stopImmediatePropagation())},E=o=>{l(o)||(n==="element"?g(ot(o.clientX,o.clientY)):r&&B(e,L(o),"Selected area"))},w=o=>{f(o),!(n!=="area"||o.button!==0||l(o))&&(r={x:P(o.clientX,innerWidth),y:P(o.clientY,innerHeight)})},$=o=>{if(f(o),n!=="area"||!r)return;const u=L(o);if(r=void 0,St(),u.width<8||u.height<8){e.style.display="none";return}C(),s({area:{x:Math.round(u.x),y:Math.round(u.y),width:Math.round(u.width),height:Math.round(u.height)}})},X=o=>{if(l(o))return;if(n==="area"){o.preventDefault(),o.stopImmediatePropagation();return}const u=d??ot(o.clientX,o.clientY);p(u)&&(o.preventDefault(),o.stopImmediatePropagation(),C(),s({el:u}))},K=o=>{n==="element"&&o.target instanceof Element&&g(o.target)},T=o=>{if((o.key==="Escape"||o.key==="Enter"||o.key===" ")&&!(o.key!=="Escape"&&l(o))&&f(o),o.key==="Escape"){C(),s(null);return}const y=document.activeElement;n!=="element"||o.key!=="Enter"&&o.key!==" "||!y||y===document.body||!p(y)||o.key!==" "&&(C(),s({el:y}))},x=o=>{(o.key===" "||o.key==="Enter")&&!l(o)&&f(o)},m=o=>{l(o)||o.stopImmediatePropagation()},b=()=>n==="element"&&d&&g(d),M=o=>{const u=o?"addEventListener":"removeEventListener";document[u]("pointermove",E,!0),document[u]("pointerdown",w,!0),document[u]("pointerup",$,!0),document[u]("mousedown",f,!0),document[u]("mouseup",f,!0),document[u]("touchstart",m,!0),document[u]("touchend",m,!0),document[u]("keyup",x,!0),document[u]("click",X,!0),document[u]("focusin",K,!0),document[u]("keydown",T,!0),window[u]("scroll",b,!0),window[u]("resize",b)};function C(){M(!1),e.style.display="none"}return M(!0),C}function St(){const t=e=>{e.preventDefault(),e.stopImmediatePropagation()};document.addEventListener("click",t,!0),setTimeout(()=>document.removeEventListener("click",t,!0),250)}const Lt=4e3;function $t(t,e,n){return{comment:t,url:Et(location.href,n),element:(e==null?void 0:e.kind)==="element"?e.info:void 0,area:(e==null?void 0:e.kind)==="area"?e.rect:void 0,userAgent:navigator.userAgent,viewport:{width:innerWidth,height:innerHeight},timestamp:new Date().toISOString()}}const it=`
2
+ :host{all:initial;--c:#3e63dd;--t:#191c22;--m:#697180;--b:#e4e7ec;--sh:0 16px 42px rgba(24,29,39,.2);font-family:system-ui,-apple-system,sans-serif;color:var(--t)}
3
+ *,*:before,*:after{box-sizing:border-box;font-family:inherit}
4
+ button{font:inherit;cursor:pointer}
5
+ button:focus-visible,textarea:focus-visible{outline:2px solid var(--c);outline-offset:2px}
6
+ [hidden]{display:none!important}
7
+ .dock{position:fixed;z-index:2147483003;display:flex;align-items:center;gap:8px}
8
+ .dock[data-h=left]{flex-direction:row-reverse}
9
+ .fab{display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 15px;border:0;border-radius:12px;background:#21242b;color:#fff;font-size:13px;font-weight:600;box-shadow:var(--sh);touch-action:none}
10
+ .fab svg{width:17px;height:17px}
11
+ .bar{position:fixed;right:20px;bottom:20px;z-index:2147483003;display:flex;align-items:center;gap:10px;min-height:42px;padding:6px 8px 6px 12px;border:1px solid var(--b);border-radius:12px;background:#fff;box-shadow:var(--sh);font-size:12px}
12
+ .hint{font-weight:600;white-space:nowrap}
13
+ .modes{display:flex;gap:2px;padding:2px;border-radius:8px;background:#f1f3f6}
14
+ .mode{height:28px;padding:0 9px;border:0;border-radius:6px;background:transparent;color:#7c8493;font-size:12px}
15
+ .mode:disabled{opacity:.45;cursor:not-allowed}
16
+ .mode.on{background:#fff;color:var(--t);box-shadow:0 1px 3px rgba(24,29,39,.14)}
17
+ .kbd{height:19px;padding:1px 5px;border:1px solid #d4d8df;border-bottom-width:2px;border-radius:5px;background:#fff;color:var(--m);font:10px/15px ui-monospace,monospace}
18
+ .x{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border:0;border-radius:7px;background:transparent;color:var(--m)}
19
+ .x:hover{background:#f8f9fb;color:var(--t)}
20
+ .box{position:fixed;display:none;z-index:2147483000;border:2px solid var(--c);border-radius:3px;background:rgba(62,99,221,.1);box-shadow:0 0 0 5px rgba(62,99,221,.14);pointer-events:none}
21
+ .box.on{box-shadow:0 0 0 4px rgba(62,99,221,.11)}
22
+ .tag{position:absolute;left:-2px;top:-29px;max-width:260px;height:23px;padding:3px 7px;border-radius:5px;background:var(--c);color:#fff;font-size:11px;font-weight:600;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
23
+ .panel{position:fixed;z-index:2147483003;width:min(340px,calc(100vw - 32px));border:1px solid var(--b);border-radius:12px;background:#fff;box-shadow:var(--sh)}
24
+ .head{display:flex;align-items:center;justify-content:space-between;padding:12px 12px 0 14px}
25
+ h2{margin:0;font-size:14px;font-weight:650}
26
+ .body{display:flex;flex-direction:column;gap:10px;padding:10px 14px 14px}
27
+ .trow{display:flex;align-items:center;gap:7px;min-width:0}
28
+ .chip{display:inline-flex;align-items:center;min-width:0;max-width:245px;height:25px;padding:0 8px;border:1px solid rgba(62,99,221,.3);border-radius:7px;background:#eef2fd;color:var(--c);font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
29
+ .link{border:0;background:transparent;color:var(--m);padding:3px;font-size:12px}
30
+ textarea{width:100%;min-height:88px;resize:vertical;padding:9px 10px;border:1px solid #cfd4dc;border-radius:7px;color:var(--t);background:#fff;font-size:13px;line-height:1.5}
31
+ .status{min-height:17px;margin:0;color:var(--m);font-size:12px}
32
+ .status.err{color:#b42318}
33
+ .actions{display:flex;justify-content:flex-end;gap:7px}
34
+ .btn{height:32px;padding:0 11px;border:1px solid #d5d9e0;border-radius:7px;background:#fff;color:#353b45;font-size:12px;font-weight:600}
35
+ .btn.primary{border-color:var(--c);background:var(--c);color:#fff}
36
+ .btn:disabled{opacity:.55;cursor:wait}
37
+ .toast{display:flex;align-items:center;gap:8px;height:38px;padding:0 13px;border-radius:10px;background:#21242b;color:#f2f3f5;box-shadow:var(--sh);font-size:13px}
38
+ .toast .ok{color:#6ec493;font-weight:700}
39
+ .toast a{color:#9db4ff}
40
+ .sr{position:absolute!important;width:1px!important;height:1px!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important}
41
+ @media(max-width:640px){
42
+ .bar{left:12px!important;right:12px!important;bottom:12px!important;top:auto!important;flex-wrap:wrap}
43
+ .hint{flex:1}.kbd{display:none}
44
+ .dock{flex-direction:column;align-items:flex-end}
45
+ .dock[data-h=left]{flex-direction:column;align-items:flex-start}
46
+ .dock[data-v=top]{flex-direction:column-reverse}
47
+ .panel{left:12px!important;right:12px!important;bottom:12px!important;top:auto!important;width:auto}
48
+ .toast{max-width:calc(100vw - 40px)}}
49
+ `,Mt=`
50
+ <div class="box"><span class="tag"></span></div>
51
+ <section class="bar" aria-label="Cecil target selection" hidden>
52
+ <span class="hint">Select something to comment on</span>
53
+ <div class="modes">
54
+ <button class="mode" data-mode="element" type="button">Element</button>
55
+ <button class="mode" data-mode="page" type="button">Page</button>
56
+ <button class="mode" data-mode="area" type="button" disabled title="Coming soon">Area</button>
57
+ </div>
58
+ <span class="kbd" aria-hidden="true">esc</span>
59
+ <button class="x" data-act="close" type="button" aria-label="Exit feedback mode">✕</button>
60
+ </section>
61
+ <section class="panel" role="dialog" aria-modal="false" aria-labelledby="ttl" hidden>
62
+ <div class="head"><h2 id="ttl">New feedback</h2><button class="x" data-act="close" type="button" aria-label="Close feedback">✕</button></div>
63
+ <div class="body">
64
+ <div class="trow"><span class="chip"></span><button class="link" data-act="change" type="button">Change</button></div>
65
+ <textarea aria-label="Feedback" placeholder="What should be improved?"></textarea>
66
+ <p class="status" role="status"></p>
67
+ <div class="actions">
68
+ <button class="btn" data-act="close" type="button">Cancel</button>
69
+ <button class="btn primary" data-act="send" type="button">Send feedback</button>
70
+ </div>
71
+ </div>
72
+ </section>
73
+ <div class="dock">
74
+ <div class="toast" hidden><span class="ok">✓</span><span class="msg">Feedback sent — thank you</span><a target="_blank" rel="noopener" hidden>View issue</a></div>
75
+ <button class="fab" type="button" aria-expanded="false" aria-label="Open feedback">
76
+ <svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.2C3 4.4 4.4 3 6.2 3h5.6C13.6 3 15 4.4 15 6.2v3.1c0 1.8-1.4 3.2-3.2 3.2H8.4L5.2 15v-2.7A3.2 3.2 0 0 1 3 9.3z"/></svg>
77
+ <span>Feedback</span>
78
+ </button>
79
+ </div>
80
+ <p class="sr" aria-live="polite"></p>`,at="cecil:corner",At=["top-left","top-right","bottom-left","bottom-right"],rt=(t,e,n)=>Math.min(Math.max(t,e),n);function Tt(t,e,n){const a=document.createElement("div");a.setAttribute("data-cecil","");const s=a.attachShadow({mode:"closed"});s.innerHTML=Mt;try{const i=new CSSStyleSheet;i.replaceSync(it),s.adoptedStyleSheets=[i]}catch{const i=document.createElement("style");i.textContent=it,s.prepend(i)}const r=i=>s.querySelector(i),d=r(".box"),l=r(".bar"),p=r(".panel"),g=r(".dock"),L=r(".toast"),f=r(".fab"),E=r(".sr"),w=r("textarea");w.maxLength=Lt;const $=r(".status"),X=r(".chip"),K=r('[data-act="change"]'),T=r('[data-act="send"]');document.body.append(a);let x="idle",m,b,M="element",C,o="bottom-right";try{const i=localStorage.getItem(at);i&&At.includes(i)&&(o=i)}catch{}const u=i=>E.textContent=i,y=(i,c=!1)=>{$.textContent=i,$.classList.toggle("err",c)},U=i=>i.kind==="element"?i.info.name??i.info.text??`<${i.info.tag}>`:i.kind==="area"?"Selected area":"Entire page";function ct(){const i=o.endsWith("left")?"left":"right",c=o.startsWith("top")?"top":"bottom";g.dataset.h=i,g.dataset.v=c;for(const h of[g,l])h.style.left=i==="left"?"20px":"auto",h.style.right=i==="right"?"20px":"auto",h.style.top=c==="top"?"20px":"auto",h.style.bottom=c==="bottom"?"20px":"auto"}function N(){if(x!=="composing"||!m)return;const i=Math.min(340,innerWidth-32),c=p.offsetHeight||248,h=m.kind==="element"?m.el.isConnected?m.el.getBoundingClientRect():m.info.rect:m.kind==="area"?m.rect:void 0;if(!h){d.style.display="none",p.style.left=o.endsWith("left")?"16px":`${Math.max(16,innerWidth-i-16)}px`,p.style.top=o.startsWith("top")?"16px":`${Math.max(16,innerHeight-c-16)}px`;return}B(d,h,U(m)),d.classList.add("on");const pt=h.y+h.height+12;p.style.left=`${rt(h.x,16,Math.max(16,innerWidth-i-16))}px`,p.style.top=`${pt+c<=innerHeight-16?pt:rt(h.y-c-12,16,Math.max(16,innerHeight-c-16))}px`}function A(){const i=x==="picking";l.hidden=!i,p.hidden=x!=="composing",L.hidden=x!=="sent",f.hidden=i||x==="composing",f.setAttribute("aria-expanded",String(x!=="idle")),r(".hint").textContent=M==="area"?"Drag to select an area":"Select something to comment on",s.querySelectorAll(".mode").forEach(c=>{const h=c.dataset.mode===M&&i;c.classList.toggle("on",h),c.setAttribute("aria-pressed",String(h))}),x==="composing"&&m?(X.textContent=U(m),K.textContent=m.kind==="page"?"Select element":"Change",y(""),N(),w.focus()):(d.classList.remove("on"),i||(d.style.display="none"))}function Y(i){clearTimeout(C),b==null||b(),M=i,x="picking",m=void 0,u(i==="area"?"Area mode. Drag a rectangle. Press Escape to exit.":"Hover or Tab to an element, then click or press Enter. Press Escape to exit."),A(),b=Ct({host:a,box:d,mode:i,privacy:e,done:c=>{if(b=void 0,!c)return O();try{m="el"in c?{kind:"element",el:c.el,info:kt(c.el,e)}:{kind:"area",rect:c.area}}catch(h){return dt(),y(h instanceof Error?h.message:"That could not be captured.",!0)}x="composing",u(`${U(m)} selected. Feedback form opened.`),A()}})}function dt(){b==null||b(),b=void 0,m={kind:"page"},x="composing",A()}function O(){const i=s.activeElement!==null;clearTimeout(C),b==null||b(),b=void 0,x="idle",m=void 0,A(),i&&f.focus()}async function lt(){if(T.disabled)return;const i=w.value.trim();if(!i)return y("Please enter your feedback first.",!0);T.disabled=!0,y("Sending…");try{const{url:c}=await t($t(i,m,e));w.value="";const h=L.querySelector("a");h.hidden=!c,c&&(h.href=c),x="sent",u("Feedback sent. Thank you."),A(),(c?h:f).focus(),C=setTimeout(O,c?5e3:1400)}catch(c){y(c.status===429?"Too many reports. Please try again in a minute.":"Could not send. Please try again.",!0)}finally{T.disabled=!1}}let k,_=!1;f.addEventListener("pointerdown",i=>{var c;i.button===0&&(k={id:i.pointerId,x:i.clientX,y:i.clientY,moved:!1},(c=f.setPointerCapture)==null||c.call(f,i.pointerId))}),f.addEventListener("pointermove",i=>{if(!k||k.id!==i.pointerId)return;const c=i.clientX-k.x,h=i.clientY-k.y;Math.hypot(c,h)>=6&&(k.moved=!0),k.moved&&(f.style.transform=`translate(${c}px,${h}px)`)}),f.addEventListener("pointerup",i=>{if(!k||k.id!==i.pointerId)return;const c=k.moved;if(k=void 0,f.style.transform="",!!c){_=!0,o=`${i.clientY<innerHeight/2?"top":"bottom"}-${i.clientX<innerWidth/2?"left":"right"}`;try{localStorage.setItem(at,o)}catch{}ct(),u(`Feedback button moved to the ${o.replace("-"," ")}.`)}}),f.addEventListener("pointercancel",()=>{k=void 0,f.style.transform=""}),f.addEventListener("click",()=>{_?_=!1:Y("element")}),s.addEventListener("click",i=>{const c=i.target.closest("[data-act],[data-mode]");c&&(c.dataset.act==="close"?O():c.dataset.act==="change"?Y("element"):c.dataset.act==="send"?lt():c.dataset.mode==="page"?dt():c.dataset.mode&&Y(c.dataset.mode))}),w.addEventListener("keydown",i=>{i.key==="Enter"&&(i.metaKey||i.ctrlKey)&&(i.preventDefault(),lt())});const ut=i=>{i.key!=="Escape"||x!=="composing"||!i.composedPath().includes(a)||(i.stopPropagation(),O())};return document.addEventListener("keydown",ut,!0),addEventListener("resize",N),addEventListener("scroll",N,!0),ct(),A(),()=>{O(),document.removeEventListener("keydown",ut,!0),removeEventListener("resize",N),removeEventListener("scroll",N,!0),a.remove()}}const Nt=15e3;function Ot({endpoint:t,projectKey:e}){const n=new URL(t,location.href),a=["localhost","127.0.0.1","[::1]"].includes(n.hostname);if(n.protocol!=="https:"&&!a)throw new Error("cecil: relay endpoint must use https");return async s=>{const r=new AbortController,d=setTimeout(()=>r.abort(),Nt);try{const l=await fetch(n,{method:"POST",headers:{"content-type":"text/plain"},body:JSON.stringify({projectKey:e,report:s}),signal:r.signal});if(!l.ok)throw console.warn(`cecil: relay responded ${l.status}${l.status===403?" (check the projectKey and the relay's origin allowlist)":""}`),Object.assign(new Error(`cecil: relay responded ${l.status}`),{status:l.status});return await l.json().catch(()=>({}))}finally{clearTimeout(d)}}}let D;function st(t){if(D)return console.warn("cecil: already initialised"),D;const e=Ot(t),{user:n}=t;let a,s=!1;const r=()=>{s||(a=Tt(d=>e({...d,user:typeof n=="function"?n():n}),t.privacy))};return document.body?r():document.addEventListener("DOMContentLoaded",r,{once:!0}),D=()=>{s=!0,document.removeEventListener("DOMContentLoaded",r),a==null||a(),D=void 0}}function zt(t){const{endpoint:e,projectKey:n,user:a,exclude:s,mask:r,fullUrl:d}=t.dataset;if(!e||!n)return null;const l=p=>p==null?void 0:p.split(",").map(g=>g.trim()).filter(Boolean);return{endpoint:e,projectKey:n,user:a,privacy:{exclude:l(s),mask:l(r),url:d===void 0?void 0:p=>p}}}const I=typeof document>"u"?null:document.currentScript;if(I&&"dataset"in I&&I.dataset.endpoint){const t=zt(I);t?st(t):console.warn("cecil: set data-endpoint and data-project-key")}return W.init=st,Object.defineProperty(W,Symbol.toStringTag,{value:"Module"}),W})({});