react-linear-feedback 0.3.0 → 0.4.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A drop-in feedback widget that turns a drawn box + note into a Linear issue with an annotated screenshot.** Use it as a React component, or embed it on **any site** — Framer, Webflow, plain HTML — with a single script tag.
6
6
 
7
- A user opens the widget, **drags a box** over the page, picks a type (**Bug / Improvement**), writes a note — and it captures a screenshot and files a Linear issue. Self-contained styling, no design system required.
7
+ A user opens the widget, **drags a box** over the page, picks a type (**Bug / Improvement**), writes a note — and it captures a screenshot and files a Linear issue. Self-contained styling, no design system required. While a region is being selected or the composer is open, the page behind is frozen — scrolling is locked and clicks outside the composer are blocked; Esc cancels.
8
8
 
9
9
  <!-- Demo: record a short GIF and drop it here → ![demo](docs/demo.gif) -->
10
10
  > _Draw a box anywhere → type a note → it lands in Linear with the annotated screenshot and page context._
@@ -147,7 +147,7 @@ No npm, no build step, no React on the page — the embed bundle (~23KB gz, serv
147
147
 
148
148
  ```html
149
149
  <script
150
- src="https://cdn.jsdelivr.net/npm/react-linear-feedback@0.3.0/dist/embed/linear-feedback.js"
150
+ src="https://cdn.jsdelivr.net/npm/react-linear-feedback@0.4.0/dist/embed/linear-feedback.js"
151
151
  data-endpoint="https://your-app.example.com/api/feedback"
152
152
  data-brand-color="#7f56d9"
153
153
  data-token="your-embed-token"
@@ -186,7 +186,7 @@ Options that aren't expressible as data attributes (like custom `types`) go thro
186
186
  ],
187
187
  };
188
188
  </script>
189
- <script src="https://cdn.jsdelivr.net/npm/react-linear-feedback@0.3.0/dist/embed/linear-feedback.js" data-endpoint="..." defer></script>
189
+ <script src="https://cdn.jsdelivr.net/npm/react-linear-feedback@0.4.0/dist/embed/linear-feedback.js" data-endpoint="..." defer></script>
190
190
  ```
191
191
 
192
192
  ```js
@@ -338,6 +338,7 @@ Submissions never throw in the UI — failures are logged to the browser console
338
338
  - **CORS**: cross-origin pages (script-tag embeds) need their origin in `allowedOrigins`, and the browser must reach the endpoint with both `OPTIONS` and `POST`.
339
339
  - `runtime = "nodejs"` is set on the Next.js route (Edge has no `Buffer`).
340
340
  - On a **Vite SPA**, `POST /api/feedback` 404s under `vite dev` unless you add the [`linearFeedback` Vite plugin](#vite-spa) (or run `vercel dev`). In production it's served by your deployed function.
341
+ - **`FUNCTION_INVOCATION_FAILED` on Vercel but local dev works?** Your function file itself may not parse — the Vite plugin serves the dev endpoint without ever loading it, so syntax errors there only surface when deployed. Check it directly: `node -e "import('./api/feedback.mjs')"`. (One real-world culprit: a `*/` sequence inside a block comment — e.g. a `**/*` glob — terminates the comment early.)
341
342
  - The expected Linear **labels exist** (otherwise the issue is created without a label, with a warning).
342
343
 
343
344
  ## License
@@ -2215,6 +2215,8 @@ var CSS2 = `
2215
2215
 
2216
2216
  .lfb-rect { position: absolute; border: 2px solid var(--lfb-rect); background: rgba(255,0,85,0.12); border-radius: 3px; pointer-events: none; }
2217
2217
 
2218
+ .lfb-shield { position: fixed; inset: 0; pointer-events: auto; cursor: default; }
2219
+
2218
2220
  .lfb-anchor { position: absolute; pointer-events: auto; }
2219
2221
 
2220
2222
  .lfb-card {
@@ -2229,6 +2231,7 @@ var CSS2 = `
2229
2231
  line-height: 1.4;
2230
2232
  }
2231
2233
  .lfb-composer { position: absolute; top: 100%; left: 0; margin-top: 8px; width: 320px; max-width: calc(100vw - 32px); }
2234
+ .lfb-composer--above { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 8px; }
2232
2235
 
2233
2236
  .lfb-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
2234
2237
  .lfb-eyebrow { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--lfb-fg-tertiary); }
@@ -2314,6 +2317,8 @@ function ensureStyles() {
2314
2317
  // src/react/feedback-widget.tsx
2315
2318
  var MIN_DRAG = 12;
2316
2319
  var DEFAULT_BOX = { width: 220, height: 130 };
2320
+ var COMPOSER_WIDTH = 320;
2321
+ var COMPOSER_EST_HEIGHT = 320;
2317
2322
  var DEFAULT_TYPES = [
2318
2323
  { id: "bug", label: "Bug", color: "#ef4444", icon: "bug" },
2319
2324
  { id: "improvement", label: "Improvement", color: "#22c55e", icon: "improvement" }
@@ -2342,6 +2347,11 @@ function FeedbackWidget({
2342
2347
  const [drag, setDrag] = d2(null);
2343
2348
  const textareaRef = A2(null);
2344
2349
  const nameInputRef = A2(null);
2350
+ const nameEditRef = A2(null);
2351
+ const submittingRef = A2(false);
2352
+ submittingRef.current = submitting;
2353
+ const editingNameRef = A2(false);
2354
+ editingNameRef.current = editingName;
2345
2355
  y2(() => {
2346
2356
  ensureStyles();
2347
2357
  try {
@@ -2351,12 +2361,19 @@ function FeedbackWidget({
2351
2361
  }
2352
2362
  }, [nameStorageKey]);
2353
2363
  y2(() => {
2354
- if (mode.kind !== "drawing" && mode.kind !== "naming") return;
2364
+ if (mode.kind === "idle") return;
2355
2365
  const onKey = (e3) => {
2356
- if (e3.key === "Escape") {
2357
- setMode({ kind: "idle" });
2358
- setDrag(null);
2366
+ if (e3.key !== "Escape" || submittingRef.current) return;
2367
+ if (editingNameRef.current) {
2368
+ setEditingName(false);
2369
+ return;
2359
2370
  }
2371
+ setMode({ kind: "idle" });
2372
+ setDrag(null);
2373
+ setText("");
2374
+ setError(null);
2375
+ setSubmitting(false);
2376
+ setEditingName(false);
2360
2377
  };
2361
2378
  document.addEventListener("keydown", onKey);
2362
2379
  return () => document.removeEventListener("keydown", onKey);
@@ -2371,6 +2388,22 @@ function FeedbackWidget({
2371
2388
  return () => window.clearTimeout(t3);
2372
2389
  }
2373
2390
  }, [mode.kind]);
2391
+ y2(() => {
2392
+ if (editingName) nameEditRef.current?.focus();
2393
+ }, [editingName]);
2394
+ y2(() => {
2395
+ if (mode.kind !== "drawing" && mode.kind !== "composing") return;
2396
+ const el = document.documentElement;
2397
+ const prevOverflow = el.style.overflow;
2398
+ const prevPaddingRight = el.style.paddingRight;
2399
+ const scrollbar = window.innerWidth - el.clientWidth;
2400
+ el.style.overflow = "hidden";
2401
+ if (scrollbar > 0) el.style.paddingRight = `${scrollbar}px`;
2402
+ return () => {
2403
+ el.style.overflow = prevOverflow;
2404
+ el.style.paddingRight = prevPaddingRight;
2405
+ };
2406
+ }, [mode.kind]);
2374
2407
  const persistName = (value) => {
2375
2408
  try {
2376
2409
  window.localStorage.setItem(nameStorageKey, value);
@@ -2424,12 +2457,17 @@ function FeedbackWidget({
2424
2457
  vy = end.y - vh / 2;
2425
2458
  }
2426
2459
  const rect = { x: Math.max(0, vx + window.scrollX), y: Math.max(0, vy + window.scrollY), width: vw, height: vh };
2460
+ const fitsBelow = vy + vh + COMPOSER_EST_HEIGHT <= window.innerHeight;
2461
+ const fitsAbove = vy >= COMPOSER_EST_HEIGHT;
2462
+ const placement = !fitsBelow && fitsAbove ? "above" : "below";
2463
+ const maxAnchorX = window.scrollX + Math.max(16, window.innerWidth - COMPOSER_WIDTH - 16);
2464
+ const anchorX = Math.min(rect.x, maxAnchorX);
2427
2465
  setDrag(null);
2428
2466
  setText("");
2429
2467
  setIssueType(types[0]?.id ?? "bug");
2430
2468
  setError(null);
2431
2469
  setEditingName(false);
2432
- setMode({ kind: "composing", rect });
2470
+ setMode({ kind: "composing", rect, placement, anchorX });
2433
2471
  };
2434
2472
  const cancelComposer = () => {
2435
2473
  setMode({ kind: "idle" });
@@ -2476,93 +2514,102 @@ function FeedbackWidget({
2476
2514
  } : null;
2477
2515
  return /* @__PURE__ */ u3("div", { className: "lfb-root", style: rootStyle, children: [
2478
2516
  /* @__PURE__ */ u3("div", { ...overlayProps, className: "lfb-doc-layer", children: mode.kind === "composing" && /* @__PURE__ */ u3(S, { children: [
2517
+ /* @__PURE__ */ u3("div", { className: "lfb-shield", "aria-hidden": "true" }),
2479
2518
  /* @__PURE__ */ u3("div", { className: "lfb-rect", style: { left: mode.rect.x, top: mode.rect.y, width: mode.rect.width, height: mode.rect.height } }),
2480
- /* @__PURE__ */ u3("div", { className: "lfb-anchor", style: { left: mode.rect.x, top: mode.rect.y + mode.rect.height }, children: /* @__PURE__ */ u3("form", { className: "lfb-card lfb-composer", onSubmit: handleSubmit, children: [
2481
- /* @__PURE__ */ u3("div", { className: "lfb-row", children: [
2482
- /* @__PURE__ */ u3("span", { className: "lfb-eyebrow", children: "New feedback" }),
2483
- /* @__PURE__ */ u3("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Cancel", onClick: cancelComposer, children: /* @__PURE__ */ u3(XIcon, {}) })
2484
- ] }),
2485
- /* @__PURE__ */ u3("div", { style: { marginTop: 12 }, children: [
2486
- /* @__PURE__ */ u3("span", { className: "lfb-field-label", children: "Issue type" }),
2487
- /* @__PURE__ */ u3("div", { className: "lfb-types", children: types.map((t3) => {
2488
- const Icon = TYPE_ICONS[t3.icon ?? "dot"];
2489
- return /* @__PURE__ */ u3(
2490
- "button",
2519
+ /* @__PURE__ */ u3(
2520
+ "div",
2521
+ {
2522
+ className: "lfb-anchor",
2523
+ style: { left: mode.anchorX, top: mode.placement === "below" ? mode.rect.y + mode.rect.height : mode.rect.y },
2524
+ children: /* @__PURE__ */ u3("form", { className: `lfb-card lfb-composer${mode.placement === "above" ? " lfb-composer--above" : ""}`, onSubmit: handleSubmit, children: [
2525
+ /* @__PURE__ */ u3("div", { className: "lfb-row", children: [
2526
+ /* @__PURE__ */ u3("span", { className: "lfb-eyebrow", children: "New feedback" }),
2527
+ /* @__PURE__ */ u3("button", { type: "button", className: "lfb-iconbtn", "aria-label": "Cancel", onClick: cancelComposer, children: /* @__PURE__ */ u3(XIcon, {}) })
2528
+ ] }),
2529
+ /* @__PURE__ */ u3("div", { style: { marginTop: 12 }, children: [
2530
+ /* @__PURE__ */ u3("span", { className: "lfb-field-label", children: "Issue type" }),
2531
+ /* @__PURE__ */ u3("div", { className: "lfb-types", children: types.map((t3) => {
2532
+ const Icon = TYPE_ICONS[t3.icon ?? "dot"];
2533
+ return /* @__PURE__ */ u3(
2534
+ "button",
2535
+ {
2536
+ type: "button",
2537
+ className: "lfb-type",
2538
+ "aria-pressed": issueType === t3.id,
2539
+ onClick: () => setIssueType(t3.id),
2540
+ children: [
2541
+ /* @__PURE__ */ u3("span", { className: "lfb-swatch", style: { background: t3.color }, children: /* @__PURE__ */ u3(Icon, { size: 14 }) }),
2542
+ t3.label
2543
+ ]
2544
+ },
2545
+ t3.id
2546
+ );
2547
+ }) })
2548
+ ] }),
2549
+ /* @__PURE__ */ u3(
2550
+ "textarea",
2491
2551
  {
2492
- type: "button",
2493
- className: "lfb-type",
2494
- "aria-pressed": issueType === t3.id,
2495
- onClick: () => setIssueType(t3.id),
2496
- children: [
2497
- /* @__PURE__ */ u3("span", { className: "lfb-swatch", style: { background: t3.color }, children: /* @__PURE__ */ u3(Icon, { size: 14 }) }),
2498
- t3.label
2499
- ]
2500
- },
2501
- t3.id
2502
- );
2503
- }) })
2504
- ] }),
2505
- /* @__PURE__ */ u3(
2506
- "textarea",
2507
- {
2508
- ref: textareaRef,
2509
- className: "lfb-textarea",
2510
- placeholder: "What's on your mind?",
2511
- value: text,
2512
- onChange: (e3) => setText(e3.target.value),
2513
- maxLength: 5e3,
2514
- required: true,
2515
- disabled: submitting,
2516
- rows: 3,
2517
- onKeyDown: (e3) => {
2518
- if (e3.key === "Enter" && (e3.metaKey || e3.ctrlKey)) {
2519
- e3.preventDefault();
2520
- handleSubmit(e3);
2552
+ ref: textareaRef,
2553
+ className: "lfb-textarea",
2554
+ placeholder: "What's on your mind?",
2555
+ value: text,
2556
+ onChange: (e3) => setText(e3.target.value),
2557
+ maxLength: 5e3,
2558
+ required: true,
2559
+ disabled: submitting,
2560
+ rows: 3,
2561
+ onKeyDown: (e3) => {
2562
+ if (e3.key === "Enter" && (e3.metaKey || e3.ctrlKey)) {
2563
+ e3.preventDefault();
2564
+ handleSubmit(e3);
2565
+ }
2566
+ }
2521
2567
  }
2522
- }
2523
- }
2524
- ),
2525
- editingName ? /* @__PURE__ */ u3(
2526
- "input",
2527
- {
2528
- className: "lfb-input",
2529
- type: "text",
2530
- value: name,
2531
- autoFocus: true,
2532
- maxLength: 80,
2533
- onChange: (e3) => setName(e3.target.value),
2534
- onBlur: () => {
2535
- const t3 = name.trim();
2536
- if (t3) persistName(t3);
2537
- setEditingName(false);
2538
- },
2539
- onKeyDown: (e3) => {
2540
- if (e3.key === "Enter") {
2541
- e3.preventDefault();
2542
- e3.target.blur();
2543
- }
2544
- if (e3.key === "Escape") {
2545
- e3.preventDefault();
2546
- setEditingName(false);
2568
+ ),
2569
+ editingName ? /* @__PURE__ */ u3(
2570
+ "input",
2571
+ {
2572
+ ref: nameEditRef,
2573
+ className: "lfb-input",
2574
+ type: "text",
2575
+ value: name,
2576
+ maxLength: 80,
2577
+ onChange: (e3) => setName(e3.target.value),
2578
+ onBlur: () => {
2579
+ const t3 = name.trim();
2580
+ if (t3) persistName(t3);
2581
+ setEditingName(false);
2582
+ },
2583
+ onKeyDown: (e3) => {
2584
+ if (e3.key === "Enter") {
2585
+ e3.preventDefault();
2586
+ e3.target.blur();
2587
+ }
2588
+ if (e3.key === "Escape") {
2589
+ e3.preventDefault();
2590
+ e3.stopPropagation();
2591
+ setEditingName(false);
2592
+ }
2593
+ }
2547
2594
  }
2548
- }
2549
- }
2550
- ) : /* @__PURE__ */ u3("div", { className: "lfb-namerow", children: [
2551
- /* @__PURE__ */ u3("span", { children: [
2552
- "Posting as ",
2553
- /* @__PURE__ */ u3("span", { className: "lfb-name", children: name || "anonymous" })
2554
- ] }),
2555
- /* @__PURE__ */ u3("button", { type: "button", className: "lfb-link", onClick: () => setEditingName(true), children: [
2556
- /* @__PURE__ */ u3(EditIcon, { size: 12 }),
2557
- "change"
2595
+ ) : /* @__PURE__ */ u3("div", { className: "lfb-namerow", children: [
2596
+ /* @__PURE__ */ u3("span", { children: [
2597
+ "Posting as ",
2598
+ /* @__PURE__ */ u3("span", { className: "lfb-name", children: name || "anonymous" })
2599
+ ] }),
2600
+ /* @__PURE__ */ u3("button", { type: "button", className: "lfb-link", onClick: () => setEditingName(true), children: [
2601
+ /* @__PURE__ */ u3(EditIcon, { size: 12 }),
2602
+ "change"
2603
+ ] })
2604
+ ] }),
2605
+ error && /* @__PURE__ */ u3("p", { className: "lfb-error", children: error }),
2606
+ /* @__PURE__ */ u3("div", { className: "lfb-actions", children: [
2607
+ /* @__PURE__ */ u3("button", { type: "button", className: "lfb-btn lfb-btn-ghost", onClick: cancelComposer, disabled: submitting, children: "Cancel" }),
2608
+ /* @__PURE__ */ u3("button", { type: "submit", className: "lfb-btn lfb-btn-primary", disabled: submitting || !text.trim(), children: submitting ? "Sending\u2026" : "Send to Linear" })
2609
+ ] })
2558
2610
  ] })
2559
- ] }),
2560
- error && /* @__PURE__ */ u3("p", { className: "lfb-error", children: error }),
2561
- /* @__PURE__ */ u3("div", { className: "lfb-actions", children: [
2562
- /* @__PURE__ */ u3("button", { type: "button", className: "lfb-btn lfb-btn-ghost", onClick: cancelComposer, disabled: submitting, children: "Cancel" }),
2563
- /* @__PURE__ */ u3("button", { type: "submit", className: "lfb-btn lfb-btn-primary", disabled: submitting || !text.trim(), children: submitting ? "Sending\u2026" : "Send to Linear" })
2564
- ] })
2565
- ] }) })
2611
+ }
2612
+ )
2566
2613
  ] }) }),
2567
2614
  /* @__PURE__ */ u3("div", { ...overlayProps, className: "lfb-fixed-layer", children: [
2568
2615
  mode.kind === "drawing" && /* @__PURE__ */ u3("div", { className: "lfb-draw", role: "presentation", onMouseDown: onDrawMouseDown, onMouseMove: onDrawMouseMove, onMouseUp: onDrawMouseUp, children: [