@snutig/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@snutig/react",
3
+ "version": "0.1.0",
4
+ "description": "React bindings for the snutig CAPTCHA widget (<snutig-captcha>), pre-themed to match the snutig WordPress/Elementor plugin.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://snutig.de",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/snutiggmbh/snutig-captcha.git",
10
+ "directory": "packages/react"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/snutiggmbh/snutig-captcha/issues"
14
+ },
15
+ "keywords": [
16
+ "captcha",
17
+ "proof-of-work",
18
+ "bot-protection",
19
+ "react",
20
+ "nextjs",
21
+ "web-components",
22
+ "privacy",
23
+ "gdpr",
24
+ "snutig"
25
+ ],
26
+ "type": "module",
27
+ "main": "./dist/index.cjs",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "sideEffects": [
31
+ "./dist/theme.css"
32
+ ],
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "require": "./dist/index.cjs"
38
+ },
39
+ "./theme.css": "./dist/theme.css"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "src",
44
+ "LICENSE",
45
+ "README.md"
46
+ ],
47
+ "peerDependencies": {
48
+ "react": ">=18"
49
+ },
50
+ "devDependencies": {
51
+ "@types/react": "^19.0.0",
52
+ "esbuild": "^0.28.0",
53
+ "react": "^19.0.0",
54
+ "react-dom": "^19.0.0",
55
+ "typescript": "^5.9.3"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "scripts": {
61
+ "build": "node build.mjs && tsc -p tsconfig.json",
62
+ "typecheck": "tsc -p tsconfig.json --noEmit",
63
+ "test": "node ssr-smoke.mjs"
64
+ }
65
+ }
@@ -0,0 +1,325 @@
1
+ import {
2
+ createElement,
3
+ forwardRef,
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useInsertionEffect,
8
+ useLayoutEffect,
9
+ useRef,
10
+ } from "react";
11
+ import type { CSSProperties, Ref } from "react";
12
+ import { DEFAULT_API_URL, challengeEndpoint, loadSnutigWidget } from "./loader.js";
13
+ import { LABELS, LABEL_ATTRIBUTES } from "./labels.js";
14
+ import type { SnutigLabels, SnutigLocale } from "./labels.js";
15
+ import { injectSnutigTheme } from "./theme.js";
16
+ import type {
17
+ SnutigCaptchaElement,
18
+ SnutigErrorDetail,
19
+ SolveResult,
20
+ } from "./types.js";
21
+
22
+ /**
23
+ * Inline properties that hidden mode touches — ours (`display`) plus the ones the
24
+ * widget writes when it degrades an unlicensed invisible widget into a floating
25
+ * badge. Cleared together so `hidden` can be toggled off from either state.
26
+ */
27
+ const HIDDEN_STYLE_PROPERTIES = [
28
+ "display",
29
+ "visibility",
30
+ "opacity",
31
+ "position",
32
+ "inset-inline-end",
33
+ "inset-block-end",
34
+ "z-index",
35
+ ] as const;
36
+
37
+ /**
38
+ * Hiding has to happen before the first paint, or an invisible widget flashes
39
+ * visible for a frame — but `useLayoutEffect` warns when React renders this
40
+ * component on a server. Nothing to hide there, so fall back to `useEffect`,
41
+ * which is never reached during SSR either.
42
+ */
43
+ const useBeforePaintEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
44
+
45
+ export interface SnutigCaptchaProps {
46
+ /** Website key from the dashboard (Websites → Keys). */
47
+ siteKey: string;
48
+ /**
49
+ * snutig captcha API base URL. Defaults to the managed service
50
+ * (`https://api.captcha.snutig.dev`) — set it only for a staging deployment.
51
+ */
52
+ apiUrl?: string;
53
+ /**
54
+ * Full challenge endpoint base, overriding `apiUrl` + `siteKey`
55
+ * (`<apiUrl>/<siteKey>/`). Rarely needed — for a reverse-proxied path.
56
+ */
57
+ endpoint?: string;
58
+ /**
59
+ * `name` of the hidden input the token is written to, for plain form posts.
60
+ * Defaults to `snutig-token`.
61
+ */
62
+ fieldName?: string;
63
+ /**
64
+ * Blocks native form submission until solved, via constraint validation
65
+ * (the widget participates as a form-associated element).
66
+ */
67
+ required?: boolean;
68
+ /**
69
+ * Invisible mode: no visible widget — nothing for the user to click, so the
70
+ * solve has to be started from the ref (`solve()`, typically in `onSubmit`).
71
+ *
72
+ * Plan-gated (`hiddenMode`). A site that is not licensed for invisible solving
73
+ * gets `widget.hiddenMode: false` on its challenge, and the widget then
74
+ * degrades to a visible badge in the bottom-right corner rather than letting a
75
+ * form ship without a check — so this never *breaks*, it only stops being
76
+ * invisible.
77
+ *
78
+ * `required` does not apply: constraint validation cannot report a problem on
79
+ * an element the user cannot see (the browser refuses to submit and shows
80
+ * nothing), so it is ignored here.
81
+ */
82
+ hidden?: boolean;
83
+ /** Solver worker count. Defaults to `navigator.hardwareConcurrency`. */
84
+ workers?: number;
85
+ /** Label set. Defaults to `"de"` — the wording the WordPress plugin ships. */
86
+ locale?: SnutigLocale;
87
+ /** Per-label overrides, merged over the locale's set. */
88
+ labels?: Partial<SnutigLabels>;
89
+ /**
90
+ * `"snutig"` (default) injects the brand theme and marks the wrapper for it.
91
+ * `"external"` marks the wrapper but injects nothing — for importing
92
+ * `@snutig/react/theme.css` through your own pipeline (or under a strict CSP).
93
+ * `"none"` leaves the widget unstyled even when another instance on the page is
94
+ * themed: the brand CSS targets the wrapper class, so an unthemed instance has to
95
+ * not carry it.
96
+ */
97
+ theme?: "snutig" | "external" | "none";
98
+ /** Help link, shown only after a failure. */
99
+ troubleshootingUrl?: string;
100
+ /** CSP nonce for the injected script + styles. */
101
+ nonce?: string;
102
+ className?: string;
103
+ style?: CSSProperties;
104
+ /** Solved — `token` goes to your server's `POST /:siteKey/siteverify`. */
105
+ onSolve?: (token: string) => void;
106
+ onError?: (error: SnutigErrorDetail) => void;
107
+ /** Solve progress, 0–100. */
108
+ onProgress?: (progress: number) => void;
109
+ /** Token cleared — on expiry, or via `reset()`. */
110
+ onReset?: () => void;
111
+ }
112
+
113
+ export interface SnutigCaptchaHandle {
114
+ /** Starts a solve (the widget also does this on click). */
115
+ solve(): Promise<SolveResult>;
116
+ /** Clears the token and the hidden field — call after a failed submit. */
117
+ reset(): void;
118
+ /** Current token, or `null` when unsolved. */
119
+ readonly token: string | null;
120
+ /** The underlying custom element, once mounted. */
121
+ readonly element: SnutigCaptchaElement | null;
122
+ }
123
+
124
+ /** Latest-callback ref, so listeners attach once instead of on every render. */
125
+ function useLatest<T>(value: T) {
126
+ const ref = useRef(value);
127
+ useInsertionEffect(() => {
128
+ ref.current = value;
129
+ });
130
+ return ref;
131
+ }
132
+
133
+ /**
134
+ * The snutig CAPTCHA widget as a React component.
135
+ *
136
+ * Wraps the `<snutig-captcha>` custom element served by the captcha server: this
137
+ * package loads that bundle, maps props to its attributes, and turns its
138
+ * `solve`/`error`/`progress`/`reset` DOM events into props. The widget renders
139
+ * itself into a shadow root and writes the token into a hidden input, so a plain
140
+ * `<form>` post carries it with no extra wiring.
141
+ *
142
+ * With `hidden` it renders nothing and solves only when `solve()` is called from
143
+ * the ref — the invisible mode, which is a plan entitlement the server asserts on
144
+ * every challenge.
145
+ *
146
+ * A token only proves the check ran in the browser — always verify it
147
+ * server-side with `POST <apiUrl>/<siteKey>/siteverify` before trusting the
148
+ * submission.
149
+ */
150
+ export const SnutigCaptcha = forwardRef(function SnutigCaptcha(
151
+ props: SnutigCaptchaProps,
152
+ ref: Ref<SnutigCaptchaHandle>,
153
+ ) {
154
+ const {
155
+ siteKey,
156
+ apiUrl = DEFAULT_API_URL,
157
+ endpoint,
158
+ fieldName = "snutig-token",
159
+ required = false,
160
+ hidden = false,
161
+ workers,
162
+ locale = "de",
163
+ labels,
164
+ theme = "snutig",
165
+ troubleshootingUrl,
166
+ nonce,
167
+ className,
168
+ style,
169
+ onSolve,
170
+ onError,
171
+ onProgress,
172
+ onReset,
173
+ } = props;
174
+
175
+ const elementRef = useRef<SnutigCaptchaElement | null>(null);
176
+ const handlers = useLatest({ onSolve, onError, onProgress, onReset });
177
+
178
+ if (theme === "snutig") injectSnutigTheme(nonce);
179
+ // The theme is a document-wide stylesheet keyed on this class, so opting out has to
180
+ // happen here: every wrapper used to get `snutig-captcha` regardless, and one themed
181
+ // instance therefore themed every "unstyled" one on the page too.
182
+ const wrapperClass = theme === "none" ? "snutig-captcha-unstyled" : "snutig-captcha";
183
+
184
+ // The themed wrapper is `display: inline-block`, so leaving it in flow around a
185
+ // display:none widget still contributes a line box — a stray gap of one
186
+ // line-height where the widget should be invisible. `contents` makes it generate
187
+ // no box at all while still inheriting the theme's custom properties down, and
188
+ // leaves the degraded badge's `position: fixed` resolving against the viewport
189
+ // (a `display: none` wrapper would instead suppress the badge entirely).
190
+ const wrapperStyle = hidden ? { ...style, display: "contents" } : style;
191
+
192
+ const resolvedEndpoint = endpoint ?? challengeEndpoint(siteKey, apiUrl);
193
+
194
+ const reportError = useCallback(
195
+ (code: SnutigErrorDetail["code"], message: string) => {
196
+ handlers.current.onError?.({ isSnutig: true, code, message });
197
+ },
198
+ [handlers],
199
+ );
200
+
201
+ useEffect(() => {
202
+ let cancelled = false;
203
+ loadSnutigWidget({ apiUrl, nonce }).catch((err: unknown) => {
204
+ if (cancelled) return;
205
+ reportError("script_load_failed", err instanceof Error ? err.message : String(err));
206
+ });
207
+ return () => {
208
+ cancelled = true;
209
+ };
210
+ }, [apiUrl, nonce, reportError]);
211
+
212
+ // Hiding is applied imperatively rather than through the `style` prop because the
213
+ // widget writes to these same properties to un-hide itself when the site is not
214
+ // licensed for invisible solving. React must not own them: a re-render would
215
+ // reapply `display: none` and fight the degrade back into invisibility.
216
+ useBeforePaintEffect(() => {
217
+ const el = elementRef.current;
218
+ if (!el || !hidden) return;
219
+ el.style.display = "none";
220
+ return () => {
221
+ // Clear everything either side may have set, so toggling `hidden` off after a
222
+ // degrade has fired does not leave the element pinned as a fixed badge.
223
+ for (const property of HIDDEN_STYLE_PROPERTIES) el.style.removeProperty(property);
224
+ };
225
+ }, [hidden]);
226
+
227
+ useEffect(() => {
228
+ if (!hidden || !required) return;
229
+ console.warn(
230
+ "[@snutig/react] `required` is ignored on a hidden <SnutigCaptcha>: the browser " +
231
+ "cannot report a validation error on an invisible control and blocks submission " +
232
+ "silently. Start the solve from the ref in onSubmit instead.",
233
+ );
234
+ }, [hidden, required]);
235
+
236
+ // Attached to the element directly: these are CustomEvents, which React's
237
+ // synthetic event system does not surface as props. Listeners survive the
238
+ // element's upgrade, so attaching before the bundle arrives is fine.
239
+ useEffect(() => {
240
+ const el = elementRef.current;
241
+ if (!el) return;
242
+
243
+ const onSolveEvent = (event: Event) => {
244
+ const { token } = (event as CustomEvent<{ token: string }>).detail;
245
+ handlers.current.onSolve?.(token);
246
+ };
247
+ const onErrorEvent = (event: Event) => {
248
+ handlers.current.onError?.((event as CustomEvent<SnutigErrorDetail>).detail);
249
+ };
250
+ const onProgressEvent = (event: Event) => {
251
+ const { progress } = (event as CustomEvent<{ progress: number }>).detail;
252
+ handlers.current.onProgress?.(progress);
253
+ };
254
+ const onResetEvent = () => handlers.current.onReset?.();
255
+
256
+ el.addEventListener("solve", onSolveEvent);
257
+ el.addEventListener("error", onErrorEvent);
258
+ el.addEventListener("progress", onProgressEvent);
259
+ el.addEventListener("reset", onResetEvent);
260
+
261
+ return () => {
262
+ el.removeEventListener("solve", onSolveEvent);
263
+ el.removeEventListener("error", onErrorEvent);
264
+ el.removeEventListener("progress", onProgressEvent);
265
+ el.removeEventListener("reset", onResetEvent);
266
+ };
267
+ }, [handlers, resolvedEndpoint, fieldName]);
268
+
269
+ useImperativeHandle(
270
+ ref,
271
+ () => ({
272
+ solve() {
273
+ const el = elementRef.current;
274
+ if (!el?.solve) {
275
+ return Promise.reject(new Error("The snutig captcha widget is not ready yet"));
276
+ }
277
+ return el.solve();
278
+ },
279
+ reset() {
280
+ elementRef.current?.reset?.();
281
+ },
282
+ get token() {
283
+ return elementRef.current?.token ?? null;
284
+ },
285
+ get element() {
286
+ return elementRef.current;
287
+ },
288
+ }),
289
+ [],
290
+ );
291
+
292
+ const i18nAttributes: Record<string, string> = {};
293
+ const resolved = { ...LABELS[locale], ...labels };
294
+ for (const [key, value] of Object.entries(resolved)) {
295
+ if (typeof value !== "string" || value === "") continue;
296
+ const attribute = LABEL_ATTRIBUTES[key as keyof SnutigLabels];
297
+ if (attribute) i18nAttributes[attribute] = value;
298
+ }
299
+
300
+ const element = createElement("snutig-captcha", {
301
+ ref: elementRef,
302
+ // `data-snutig-hidden-field-name` is only read in connectedCallback, so a
303
+ // change has to remount the element rather than update it in place.
304
+ key: `${resolvedEndpoint}|${fieldName}`,
305
+ "data-snutig-api-endpoint": resolvedEndpoint,
306
+ "data-snutig-hidden-field-name": fieldName,
307
+ ...(workers ? { "data-snutig-worker-count": String(workers) } : {}),
308
+ ...(troubleshootingUrl ? { "data-snutig-troubleshooting-url": troubleshootingUrl } : {}),
309
+ // Read by the widget only at solve time, to pick the degrade shape: an element
310
+ // marked invisible has no layout slot to reveal, so it floats as a badge.
311
+ ...(hidden ? { "data-snutig-invisible": "" } : {}),
312
+ // The widget checks `hasAttribute("required")`; "" renders a bare attribute.
313
+ ...(required && !hidden ? { required: "" } : {}),
314
+ ...i18nAttributes,
315
+ });
316
+
317
+ return createElement(
318
+ "div",
319
+ {
320
+ className: className ? `${wrapperClass} ${className}` : wrapperClass,
321
+ style: wrapperStyle,
322
+ },
323
+ element,
324
+ );
325
+ });
package/src/css.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ // esbuild inlines `*.css` imports as text (see build.mjs `loader`).
2
+ declare module "*.css" {
3
+ const css: string;
4
+ export default css;
5
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ export { SnutigCaptcha } from "./SnutigCaptcha.js";
2
+ export type { SnutigCaptchaHandle, SnutigCaptchaProps } from "./SnutigCaptcha.js";
3
+
4
+ export {
5
+ DEFAULT_API_URL,
6
+ challengeEndpoint,
7
+ loadSnutigWidget,
8
+ stripTrailingSlash,
9
+ widgetScriptUrl,
10
+ } from "./loader.js";
11
+ export type { LoadWidgetOptions } from "./loader.js";
12
+
13
+ export { LABEL_ATTRIBUTES, LABELS } from "./labels.js";
14
+ export type { SnutigLabels, SnutigLocale } from "./labels.js";
15
+
16
+ export { injectSnutigTheme, THEME_CSS } from "./theme.js";
17
+
18
+ export type {
19
+ SnutigCaptchaElement,
20
+ SnutigErrorCode,
21
+ SnutigErrorDetail,
22
+ SnutigProgressDetail,
23
+ SnutigSolveDetail,
24
+ SnutigWidgetErrorCode,
25
+ SnutigWindow,
26
+ SolveResult,
27
+ } from "./types.js";
package/src/labels.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Widget i18n. The widget reads each label from a `data-snutig-i18n-<key>`
3
+ * attribute and falls back to its own English string when the attribute is
4
+ * absent — so `locale: "en"` sets no attributes at all.
5
+ *
6
+ * The German set is the WordPress/Elementor plugin's wording verbatim (the three
7
+ * labels that plugin does not set are filled in here) so both integrations read
8
+ * identically to a customer.
9
+ */
10
+
11
+ export interface SnutigLabels {
12
+ /** Idle label, e.g. "Ich bin ein Mensch". */
13
+ initialState: string;
14
+ verifyingLabel: string;
15
+ solvedLabel: string;
16
+ errorLabel: string;
17
+ /** Constraint-validation message when `required` and unsolved. */
18
+ requiredLabel: string;
19
+ groupAriaLabel: string;
20
+ verifyAriaLabel: string;
21
+ verifyingAriaLabel: string;
22
+ verifiedAriaLabel: string;
23
+ errorAriaLabel: string;
24
+ /** Banner shown when WebAssembly is unavailable (JS fallback is much slower). */
25
+ wasmDisabled: string;
26
+ /** Link text, rendered only when `troubleshootingUrl` is set. */
27
+ troubleshootingLabel: string;
28
+ }
29
+
30
+ export type SnutigLocale = "de" | "en";
31
+
32
+ /** `keyof SnutigLabels` → the attribute the widget actually reads. */
33
+ export const LABEL_ATTRIBUTES: Record<keyof SnutigLabels, string> = {
34
+ initialState: "data-snutig-i18n-initial-state",
35
+ verifyingLabel: "data-snutig-i18n-verifying-label",
36
+ solvedLabel: "data-snutig-i18n-solved-label",
37
+ errorLabel: "data-snutig-i18n-error-label",
38
+ requiredLabel: "data-snutig-i18n-required-label",
39
+ groupAriaLabel: "data-snutig-i18n-group-aria-label",
40
+ verifyAriaLabel: "data-snutig-i18n-verify-aria-label",
41
+ verifyingAriaLabel: "data-snutig-i18n-verifying-aria-label",
42
+ verifiedAriaLabel: "data-snutig-i18n-verified-aria-label",
43
+ errorAriaLabel: "data-snutig-i18n-error-aria-label",
44
+ wasmDisabled: "data-snutig-i18n-wasm-disabled",
45
+ troubleshootingLabel: "data-snutig-i18n-troubleshooting-label",
46
+ };
47
+
48
+ export const LABELS: Record<SnutigLocale, Partial<SnutigLabels>> = {
49
+ de: {
50
+ initialState: "Ich bin ein Mensch",
51
+ verifyingLabel: "Wird überprüft …",
52
+ solvedLabel: "Verifiziert",
53
+ errorLabel: "Fehler – bitte erneut versuchen",
54
+ requiredLabel: "Bitte bestätigen Sie, dass Sie ein Mensch sind",
55
+ groupAriaLabel: "CAPTCHA-Überprüfung",
56
+ verifyAriaLabel: "Klicken, um zu bestätigen, dass Sie ein Mensch sind",
57
+ verifyingAriaLabel: "Wird überprüft, bitte warten",
58
+ verifiedAriaLabel:
59
+ "Wir haben bestätigt, dass Sie ein Mensch sind. Sie können nun fortfahren.",
60
+ errorAriaLabel: "Ein Fehler ist aufgetreten, bitte erneut versuchen",
61
+ wasmDisabled: "WASM aktivieren für deutlich schnellere Überprüfung",
62
+ troubleshootingLabel: "Hilfe",
63
+ },
64
+ // The widget's built-in defaults are English; setting nothing keeps them.
65
+ en: {},
66
+ };
package/src/loader.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { SnutigWindow } from "./types.js";
2
+
3
+ /**
4
+ * The snutig captcha API. snutig runs this as a managed service, so there is one
5
+ * of these and consumers do not host it — which is why `apiUrl` is optional
6
+ * everywhere it appears. Override it only to point at a staging deployment or a
7
+ * reverse proxy.
8
+ */
9
+ export const DEFAULT_API_URL = "https://api.captcha.snutig.dev";
10
+
11
+ /**
12
+ * Loads the widget bundle from the snutig captcha API that will also answer its
13
+ * challenges — the same origin rule the WordPress plugin follows
14
+ * (`/widget/v1/widget.js`, `access-control-allow-origin: *`). There is no CDN
15
+ * copy on purpose: the wire protocol is versioned with the server, and the bundle
16
+ * fetches `solver.wasm` relative to its own URL.
17
+ */
18
+ export interface LoadWidgetOptions {
19
+ /** API base URL. Defaults to {@link DEFAULT_API_URL}. */
20
+ apiUrl?: string;
21
+ /** Full script URL — overrides `apiUrl`. */
22
+ scriptUrl?: string;
23
+ /** CSP nonce, applied to the script tag and the widget's own injected CSS/worker. */
24
+ nonce?: string;
25
+ }
26
+
27
+ const ELEMENT_NAME = "snutig-captcha";
28
+ const SCRIPT_MARKER = "data-snutig-widget";
29
+
30
+ /** In-flight/settled loads, keyed by resolved script URL. */
31
+ const loads = new Map<string, Promise<void>>();
32
+
33
+ export function stripTrailingSlash(url: string): string {
34
+ return url.replace(/\/+$/, "");
35
+ }
36
+
37
+ /** `https://api.captcha.snutig.dev` → `…/widget/v1/widget.js` */
38
+ export function widgetScriptUrl(apiUrl: string = DEFAULT_API_URL): string {
39
+ return `${stripTrailingSlash(apiUrl)}/widget/v1/widget.js`;
40
+ }
41
+
42
+ /** `(https://api.captcha.snutig.dev, abc123)` → `https://api.captcha.snutig.dev/abc123/` */
43
+ export function challengeEndpoint(siteKey: string, apiUrl: string = DEFAULT_API_URL): string {
44
+ return `${stripTrailingSlash(apiUrl)}/${siteKey}/`;
45
+ }
46
+
47
+ /**
48
+ * Loads the widget bundle once per URL and resolves when `<snutig-captcha>` is
49
+ * defined. Safe to call from every component instance: concurrent calls share
50
+ * one promise, and an already-defined element (another instance, a
51
+ * server-rendered tag, a WordPress page) resolves immediately.
52
+ */
53
+ export function loadSnutigWidget(options: LoadWidgetOptions = {}): Promise<void> {
54
+ if (typeof document === "undefined") {
55
+ return Promise.reject(
56
+ new Error(
57
+ "loadSnutigWidget() needs a DOM — call it from an effect, not during server rendering",
58
+ ),
59
+ );
60
+ }
61
+
62
+ if (customElements.get(ELEMENT_NAME)) return Promise.resolve();
63
+
64
+ const url = options.scriptUrl ?? widgetScriptUrl(options.apiUrl);
65
+
66
+ const cached = loads.get(url);
67
+ if (cached) return cached;
68
+
69
+ const win = window as unknown as SnutigWindow;
70
+ // Must be set before the bundle evaluates: it stamps the CSS nonce onto the
71
+ // style it injects into its shadow root.
72
+ if (options.nonce) {
73
+ win.SNUTIG_CSS_NONCE = options.nonce;
74
+ win.SNUTIG_SCRIPT_NONCE = options.nonce;
75
+ }
76
+
77
+ const load = new Promise<void>((resolve, reject) => {
78
+ const settle = () => customElements.whenDefined(ELEMENT_NAME).then(() => resolve());
79
+ const fail = (tag: HTMLScriptElement) => {
80
+ // Drop the rejected promise so a later mount (or a recovered network) retries
81
+ // instead of replaying the failure forever — and drop the dead <script> with it.
82
+ // A script element that already fired `error` never fires it again and never
83
+ // re-fetches, so leaving it in the DOM would make the retry below adopt it and
84
+ // wait on events that can no longer arrive (a promise that never settles).
85
+ loads.delete(url);
86
+ tag.remove();
87
+ reject(new Error(`Failed to load the snutig captcha widget from ${url}`));
88
+ };
89
+
90
+ // Compare resolved `.src` rather than building an attribute selector: the tag
91
+ // may carry a relative URL, and URLs need no escaping this way.
92
+ const href = new URL(url, document.baseURI).href;
93
+ const existing = Array.from(
94
+ document.querySelectorAll<HTMLScriptElement>(`script[${SCRIPT_MARKER}]`),
95
+ ).find((tag) => tag.src === href);
96
+ if (existing) {
97
+ existing.addEventListener("load", settle, { once: true });
98
+ existing.addEventListener("error", () => fail(existing), { once: true });
99
+ // It may have finished before we got here.
100
+ if (customElements.get(ELEMENT_NAME)) resolve();
101
+ return;
102
+ }
103
+
104
+ const script = document.createElement("script");
105
+ script.src = url;
106
+ script.async = true;
107
+ script.setAttribute(SCRIPT_MARKER, "");
108
+ if (options.nonce) script.nonce = options.nonce;
109
+ script.addEventListener("load", settle, { once: true });
110
+ script.addEventListener("error", () => fail(script), { once: true });
111
+ document.head.appendChild(script);
112
+ });
113
+
114
+ loads.set(url, load);
115
+ return load;
116
+ }
package/src/theme.css ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Snutig brand theme for the widget.
3
+ *
4
+ * Kept byte-for-byte in sync with the WordPress/Elementor plugin's
5
+ * `assets/snutig-captcha.css` (snutiggmbh/snutig-captcha-wp) so a React form and
6
+ * an Elementor form render the identical widget. Only the Elementor-specific
7
+ * field-wrapper reset is omitted here.
8
+ *
9
+ * Brand:
10
+ * primary #99b596 (sage green)
11
+ * surface #000000 (black)
12
+ * text #ffffff (white)
13
+ */
14
+ .snutig-captcha {
15
+ display: inline-block;
16
+ max-width: 100%;
17
+
18
+ /* Brand tokens — reuse these if you embed the widget elsewhere. */
19
+ --snutig-primary: #99b596;
20
+ --snutig-primary-soft: rgba(153, 181, 150, 0.18);
21
+ --snutig-bg: #000000;
22
+ --snutig-fg: #ffffff;
23
+ --snutig-fg-dim: rgba(255, 255, 255, 0.55);
24
+ --snutig-border: rgba(255, 255, 255, 0.12);
25
+ --snutig-error: #d97171;
26
+ --snutig-error-soft: rgba(217, 113, 113, 0.3);
27
+
28
+ /* Widget surfaces (@snutig/widget custom properties) */
29
+ --snutig-background: var(--snutig-bg);
30
+ --snutig-color: var(--snutig-fg);
31
+ --snutig-border-color: var(--snutig-border);
32
+ --snutig-border-radius: 12px;
33
+ --snutig-widget-padding: 14px 18px;
34
+ --snutig-gap: 12px;
35
+ --snutig-font: inherit;
36
+
37
+ /* Checkbox */
38
+ --snutig-checkbox-background: transparent;
39
+ --snutig-checkbox-border: 1.5px solid var(--snutig-primary);
40
+ --snutig-checkbox-border-radius: 4px;
41
+ --snutig-checkbox-size: 20px;
42
+
43
+ /* Checkmark + cross drawn in brand colors (background sits over the dark widget). */
44
+ --snutig-checkmark: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2399b596' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 12 10 18 20 6'/></svg>");
45
+ --snutig-error-cross: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23d97171' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><line x1='6' y1='6' x2='18' y2='18'/><line x1='6' y1='18' x2='18' y2='6'/></svg>");
46
+
47
+ /* Spinner during solving */
48
+ --snutig-spinner-color: var(--snutig-primary);
49
+ --snutig-spinner-background-color: var(--snutig-primary-soft);
50
+ --snutig-spinner-thickness: 2px;
51
+
52
+ /* Focus + auxiliary */
53
+ --snutig-focus-ring: var(--snutig-primary);
54
+ --snutig-troubleshoot-color: var(--snutig-fg-dim);
55
+
56
+ /* Invalid state */
57
+ --snutig-invalid-border-color: var(--snutig-error);
58
+ --snutig-invalid-ring-color: var(--snutig-error-soft);
59
+ }