qapture2 0.2.0 → 0.2.2

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
@@ -7,7 +7,7 @@ A human tester walks your live web app, annotates elements or regions (auto-scre
7
7
  No model is bundled. No API keys. No network calls. The widget is 100% client-side and keyless; notes live in the tester's browser (IndexedDB) until they export. The CLI scaffolder is deterministic and AI-free. **The AI is yours.**
8
8
 
9
9
  ```bash
10
- npm install qapture
10
+ npm install qapture2
11
11
  ```
12
12
 
13
13
  ---
@@ -32,8 +32,8 @@ npm install qapture
32
32
  ### React (any)
33
33
 
34
34
  ```tsx
35
- import { Qapture } from 'qapture';
36
- import type { QaConfig } from 'qapture';
35
+ import { Qapture } from 'qapture2';
36
+ import type { QaConfig } from 'qapture2';
37
37
 
38
38
  const config: QaConfig = {
39
39
  namespace: 'my-app',
@@ -57,11 +57,11 @@ function App() {
57
57
 
58
58
  ### Next.js App Router
59
59
 
60
- `qapture/next` re-exports the same component but ships with a `'use client'` directive prepended to its bundle output — no extra wrapper file needed:
60
+ `qapture2/next` re-exports the same component but ships with a `'use client'` directive prepended to its bundle output — no extra wrapper file needed:
61
61
 
62
62
  ```tsx
63
63
  // app/layout.tsx
64
- import { Qapture } from 'qapture/next';
64
+ import { Qapture } from 'qapture2/next';
65
65
  import config from '../qa.config';
66
66
 
67
67
  export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -78,10 +78,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
78
78
 
79
79
  ### Standalone (non-React)
80
80
 
81
- For apps without React — plain HTML, Vue, Svelte, Astro islands, etc. Use the imperative `initQaStudio()` from `qapture/standalone`:
81
+ For apps without React — plain HTML, Vue, Svelte, Astro islands, etc. Use the imperative `initQaStudio()` from `qapture2/standalone`:
82
82
 
83
83
  ```js
84
- import { initQaStudio } from 'qapture/standalone';
84
+ import { initQaStudio } from 'qapture2/standalone';
85
85
 
86
86
  const instance = initQaStudio({ namespace: 'my-app', brand: { label: 'My App' } });
87
87
 
@@ -295,8 +295,8 @@ The preamble degrades gracefully — sections with no data are marked `(not prov
295
295
  The CLI scaffolds `qa.config`, the agent skill, and `AGENTS.md` into any repository. It is **deterministic, AI-free, and network-free** — no model call, no network request, no `require()`-ing of target project files.
296
296
 
297
297
  ```bash
298
- npx qapture init [target-dir] [--force]
299
- npx qapture version
298
+ npx qapture2 init [target-dir] [--force]
299
+ npx qapture2 version
300
300
  ```
301
301
 
302
302
  `target-dir` defaults to the current directory. `--force` overwrites existing `qa.config.*` and `qa.preamble.md` (SKILL.md is always refreshed regardless).
@@ -343,7 +343,7 @@ The **hotkey** (default: `Shift+Alt+Q`) toggles the panel open/closed regardless
343
343
 
344
344
  - **Peer dependencies:** React >= 18, ReactDOM >= 18.
345
345
  - **SSR-safe:** `Qapture`, `initQaStudio()`, and `<qapture-widget>` all guard `typeof window` and return no-ops on the server. Nothing is rendered server-side.
346
- - **Next.js App Router:** use `qapture/next` (which has `'use client'` baked into its bundle output) rather than `qapture` directly. This prevents the "attempted to call a Client Component from the Server" error.
346
+ - **Next.js App Router:** use `qapture2/next` (which has `'use client'` baked into its bundle output) rather than `qapture2` directly. This prevents the "attempted to call a Client Component from the Server" error.
347
347
  - **Node >= 18** required for the CLI.
348
348
  - Heavy dependencies (`jszip`, `html2canvas`) are loaded as **lazy code-split chunks** — they do not affect initial page load and are only fetched when the user triggers a capture or export action.
349
349
 
@@ -361,7 +361,7 @@ The **hotkey** (default: `Shift+Alt+Q`) toggles the panel open/closed regardless
361
361
 
362
362
  - **html2canvas captures the visible light DOM only.** Content inside *other* custom elements that have their own shadow roots (not qapture's own) will not appear in screenshots. This is a limitation of html2canvas, not qapture.
363
363
  - **`position: fixed` may shift on transformed ancestors.** If any ancestor of `document.body` has a CSS `transform`, `perspective`, or `will-change` property applied, `position: fixed` elements — including the QA panel — may be offset from their expected position. This is standard CSS containment behaviour.
364
- - **Next.js App Router requires `qapture/next`.** Importing from `qapture` in a Server Component context will produce a "use client" error. Use the `/next` entry point.
364
+ - **Next.js App Router requires `qapture2/next`.** Importing from `qapture2` in a Server Component context will produce a "use client" error. Use the `/next` entry point.
365
365
  - **Config changes after mount are ignored.** `<Qapture>` mounts once on first render (`useEffect` with `[]` deps) and ignores subsequent prop changes. To apply a new config, destroy the instance and remount.
366
366
  - **One instance per page.** Calling `initQaStudio()` or rendering `<Qapture>` multiple times without calling `destroy()` first will append multiple widget hosts to `<body>`.
367
367
 
@@ -381,7 +381,7 @@ See [SECURITY.md](./SECURITY.md) for the full security model and vulnerability r
381
381
  ## Uninstall
382
382
 
383
383
  1. Remove `<Qapture />` (or `initQaStudio()` calls) from your codebase.
384
- 2. Uninstall the package: `npm uninstall qapture`.
384
+ 2. Uninstall the package: `npm uninstall qapture2`.
385
385
  3. Optionally delete the IndexedDB left behind — open the browser console on your app's origin and run:
386
386
 
387
387
  ```js
@@ -1,4 +1,4 @@
1
- import React, { createContext, useEffect, Component, useState, useCallback, useReducer, useContext, useRef, useLayoutEffect } from 'react';
1
+ import React, { createContext, useEffect, Component, useState, useCallback, useRef, useReducer, useContext, useLayoutEffect } from 'react';
2
2
  import ReactDOM from 'react-dom/client';
3
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
4
 
@@ -1563,28 +1563,165 @@ function Icon({
1563
1563
  function renderSvgElement(tag, attrs, key) {
1564
1564
  return React.createElement(tag, { key, ...attrs });
1565
1565
  }
1566
+ function isCoarsePointer() {
1567
+ if (typeof window === "undefined") return false;
1568
+ try {
1569
+ if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) return true;
1570
+ } catch {
1571
+ }
1572
+ return typeof navigator !== "undefined" && (navigator.maxTouchPoints || 0) > 0;
1573
+ }
1574
+ function useCoarsePointer() {
1575
+ const [coarse, setCoarse] = useState(() => isCoarsePointer());
1576
+ useEffect(() => {
1577
+ if (typeof window === "undefined" || !window.matchMedia) return;
1578
+ const mq = window.matchMedia("(pointer: coarse)");
1579
+ const on = () => setCoarse(isCoarsePointer());
1580
+ if (mq.addEventListener) mq.addEventListener("change", on);
1581
+ else if (mq.addListener) mq.addListener(on);
1582
+ return () => {
1583
+ if (mq.removeEventListener) mq.removeEventListener("change", on);
1584
+ else if (mq.removeListener) mq.removeListener(on);
1585
+ };
1586
+ }, []);
1587
+ return coarse;
1588
+ }
1589
+ var DEFAULT_LEFT = "calc(1.25rem + env(safe-area-inset-left))";
1590
+ var DEFAULT_BOTTOM = "calc(5rem + env(safe-area-inset-bottom))";
1591
+ var FAB_SIZE_PX = 56;
1592
+ var EDGE_MARGIN = 12;
1593
+ var DRAG_THRESHOLD = 8;
1594
+ var FAB_POS_KEY = "qapture:fabpos";
1595
+ function isFabPos(v) {
1596
+ if (!v || typeof v !== "object") return false;
1597
+ const o = v;
1598
+ return typeof o.left === "number" && Number.isFinite(o.left) && typeof o.bottom === "number" && Number.isFinite(o.bottom);
1599
+ }
1600
+ function loadFabPos() {
1601
+ if (typeof window === "undefined") return null;
1602
+ try {
1603
+ const raw = window.localStorage.getItem(FAB_POS_KEY);
1604
+ if (!raw) return null;
1605
+ const parsed = JSON.parse(raw);
1606
+ return isFabPos(parsed) ? parsed : null;
1607
+ } catch {
1608
+ return null;
1609
+ }
1610
+ }
1611
+ function saveFabPos(pos) {
1612
+ if (typeof window === "undefined") return;
1613
+ try {
1614
+ window.localStorage.setItem(FAB_POS_KEY, JSON.stringify(pos));
1615
+ } catch {
1616
+ }
1617
+ }
1618
+ function clampNum(v, lo, hi) {
1619
+ return Math.min(Math.max(v, lo), hi);
1620
+ }
1621
+ function clampFabPos(p, w = FAB_SIZE_PX, h = FAB_SIZE_PX) {
1622
+ if (typeof window === "undefined") return p;
1623
+ const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - w - EDGE_MARGIN);
1624
+ const maxBottom = Math.max(EDGE_MARGIN, window.innerHeight - h - EDGE_MARGIN);
1625
+ return {
1626
+ left: clampNum(p.left, EDGE_MARGIN, maxLeft),
1627
+ bottom: clampNum(p.bottom, EDGE_MARGIN, maxBottom)
1628
+ };
1629
+ }
1566
1630
  function QaFab() {
1567
1631
  const { isOpen, setIsOpen, notes, captureActive, theme } = useQa();
1632
+ const coarse = useCoarsePointer();
1633
+ const [pos, setPos] = useState(() => loadFabPos());
1634
+ const dragRef = useRef(null);
1635
+ const didDragRef = useRef(false);
1568
1636
  if (captureActive) return null;
1637
+ const onPointerDown = (e) => {
1638
+ if (dragRef.current) return;
1639
+ didDragRef.current = false;
1640
+ const target = e.currentTarget;
1641
+ const rect = target.getBoundingClientRect();
1642
+ dragRef.current = {
1643
+ pointerId: e.pointerId,
1644
+ startX: e.clientX,
1645
+ startY: e.clientY,
1646
+ startLeft: rect.left,
1647
+ startBottom: window.innerHeight - rect.bottom,
1648
+ width: rect.width,
1649
+ height: rect.height,
1650
+ dragging: false
1651
+ };
1652
+ try {
1653
+ target.setPointerCapture(e.pointerId);
1654
+ } catch {
1655
+ }
1656
+ };
1657
+ const onPointerMove = (e) => {
1658
+ const d = dragRef.current;
1659
+ if (!d || d.pointerId !== e.pointerId) return;
1660
+ const dx = e.clientX - d.startX;
1661
+ const dy = e.clientY - d.startY;
1662
+ if (!d.dragging) {
1663
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
1664
+ d.dragging = true;
1665
+ }
1666
+ setPos(clampFabPos({ left: d.startLeft + dx, bottom: d.startBottom - dy }, d.width, d.height));
1667
+ };
1668
+ const endDrag = (e) => {
1669
+ const d = dragRef.current;
1670
+ if (!d || d.pointerId !== e.pointerId) return null;
1671
+ try {
1672
+ e.currentTarget.releasePointerCapture(e.pointerId);
1673
+ } catch {
1674
+ }
1675
+ dragRef.current = null;
1676
+ return d;
1677
+ };
1678
+ const onPointerUp = (e) => {
1679
+ const d = endDrag(e);
1680
+ if (!d) return;
1681
+ if (d.dragging) {
1682
+ const dx = e.clientX - d.startX;
1683
+ const dy = e.clientY - d.startY;
1684
+ const next = clampFabPos({ left: d.startLeft + dx, bottom: d.startBottom - dy }, d.width, d.height);
1685
+ setPos(next);
1686
+ saveFabPos(next);
1687
+ didDragRef.current = true;
1688
+ }
1689
+ };
1690
+ const onPointerCancel = (e) => {
1691
+ endDrag(e);
1692
+ };
1693
+ const handleClick = () => {
1694
+ if (didDragRef.current) {
1695
+ didDragRef.current = false;
1696
+ return;
1697
+ }
1698
+ setIsOpen(!isOpen);
1699
+ };
1700
+ const applied = coarse && pos ? clampFabPos(pos) : null;
1701
+ const fabStyle = {
1702
+ left: applied ? `${applied.left}px` : DEFAULT_LEFT,
1703
+ bottom: applied ? `${applied.bottom}px` : DEFAULT_BOTTOM,
1704
+ width: "3.5rem",
1705
+ height: "3.5rem",
1706
+ backgroundImage: `linear-gradient(135deg, ${theme.primary}, ${theme.accent})`,
1707
+ boxShadow: "0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04), 0 0 0 2px rgba(255,255,255,0.7)",
1708
+ zIndex: 9990
1709
+ };
1569
1710
  return /* @__PURE__ */ jsxs(
1570
1711
  "button",
1571
1712
  {
1572
1713
  type: "button",
1573
1714
  "data-qa-overlay": "true",
1574
1715
  dir: "ltr",
1575
- onClick: () => setIsOpen(!isOpen),
1716
+ onClick: handleClick,
1717
+ onPointerDown: coarse ? onPointerDown : void 0,
1718
+ onPointerMove: coarse ? onPointerMove : void 0,
1719
+ onPointerUp: coarse ? onPointerUp : void 0,
1720
+ onPointerCancel: coarse ? onPointerCancel : void 0,
1576
1721
  "aria-label": "Qapture \u2014 testing notes",
1577
1722
  title: "Qapture",
1578
- className: "qa-fixed qa-flex qa-items-center qa-justify-center qa-rounded-full qa-text-white qa-print-hidden qa-fab-btn",
1579
- style: {
1580
- left: "calc(1.25rem + env(safe-area-inset-left))",
1581
- bottom: "calc(5rem + env(safe-area-inset-bottom))",
1582
- width: "3.5rem",
1583
- height: "3.5rem",
1584
- backgroundImage: `linear-gradient(135deg, ${theme.primary}, ${theme.accent})`,
1585
- boxShadow: "0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04), 0 0 0 2px rgba(255,255,255,0.7)",
1586
- zIndex: 9990
1587
- },
1723
+ className: `qa-fixed qa-flex qa-items-center qa-justify-center qa-rounded-full qa-text-white qa-print-hidden qa-fab-btn${coarse ? " qa-touch-none" : ""}`,
1724
+ style: fabStyle,
1588
1725
  children: [
1589
1726
  !isOpen && /* @__PURE__ */ jsx(
1590
1727
  "span",
@@ -2547,6 +2684,20 @@ function panelReducer(state, action) {
2547
2684
  return state;
2548
2685
  }
2549
2686
  }
2687
+ var KEYBOARD_OVERLAP_THRESHOLD = 120;
2688
+ var KEYBOARD_LIFT_GAP = 12;
2689
+ var NON_TEXT_INPUT_TYPES = /* @__PURE__ */ new Set([
2690
+ "checkbox",
2691
+ "radio",
2692
+ "range",
2693
+ "button",
2694
+ "submit",
2695
+ "reset",
2696
+ "color",
2697
+ "file",
2698
+ "image"
2699
+ ]);
2700
+ var PANEL_TRANSITION_WITH_LIFT = "opacity 200ms cubic-bezier(0.4,0,0.2,1), transform 200ms cubic-bezier(0.4,0,0.2,1), bottom 200ms cubic-bezier(0.4,0,0.2,1)";
2550
2701
  function QaPanel() {
2551
2702
  const {
2552
2703
  isOpen,
@@ -2612,6 +2763,61 @@ function QaPanel() {
2612
2763
  mql.addListener(handleChange);
2613
2764
  return () => mql.removeListener(handleChange);
2614
2765
  }, []);
2766
+ const coarse = useCoarsePointer();
2767
+ const panelRef = useRef(null);
2768
+ const [keyboardLift, setKeyboardLift] = useState(0);
2769
+ const computeKeyboardLift = useCallback(() => {
2770
+ try {
2771
+ if (!coarse || isIpadLandscape) return 0;
2772
+ if (typeof window === "undefined") return 0;
2773
+ const vv = window.visualViewport;
2774
+ if (!vv) return 0;
2775
+ const overlap = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
2776
+ if (overlap <= KEYBOARD_OVERLAP_THRESHOLD) return 0;
2777
+ const panel = panelRef.current;
2778
+ if (!panel) return 0;
2779
+ const root = panel.getRootNode();
2780
+ const active = root.activeElement;
2781
+ if (!active || !panel.contains(active)) return 0;
2782
+ const tag = active.tagName;
2783
+ if (tag === "TEXTAREA") return Math.round(overlap) + KEYBOARD_LIFT_GAP;
2784
+ if (tag === "INPUT" && !NON_TEXT_INPUT_TYPES.has(active.type)) {
2785
+ return Math.round(overlap) + KEYBOARD_LIFT_GAP;
2786
+ }
2787
+ return 0;
2788
+ } catch {
2789
+ return 0;
2790
+ }
2791
+ }, [coarse, isIpadLandscape]);
2792
+ useEffect(() => {
2793
+ if (!coarse) return void 0;
2794
+ if (typeof window === "undefined" || typeof document === "undefined") return void 0;
2795
+ const vv = window.visualViewport;
2796
+ if (!vv) return void 0;
2797
+ let closeTimer;
2798
+ const recompute = () => setKeyboardLift(computeKeyboardLift());
2799
+ const recomputeSoon = () => {
2800
+ if (closeTimer !== void 0) clearTimeout(closeTimer);
2801
+ closeTimer = setTimeout(recompute, 80);
2802
+ };
2803
+ recompute();
2804
+ vv.addEventListener("resize", recompute);
2805
+ vv.addEventListener("scroll", recompute);
2806
+ document.addEventListener("focusin", recompute);
2807
+ document.addEventListener("focusout", recomputeSoon);
2808
+ return () => {
2809
+ if (closeTimer !== void 0) clearTimeout(closeTimer);
2810
+ vv.removeEventListener("resize", recompute);
2811
+ vv.removeEventListener("scroll", recompute);
2812
+ document.removeEventListener("focusin", recompute);
2813
+ document.removeEventListener("focusout", recomputeSoon);
2814
+ };
2815
+ }, [coarse, computeKeyboardLift]);
2816
+ useEffect(() => {
2817
+ if (!isOpen) setKeyboardLift(0);
2818
+ }, [isOpen]);
2819
+ const keyboardLiftActive = coarse && !isIpadLandscape;
2820
+ const appliedKeyboardLift = keyboardLiftActive ? keyboardLift : 0;
2615
2821
  if (phase === "hidden") return null;
2616
2822
  const openNaming = () => {
2617
2823
  setFilename(todayName());
@@ -2622,9 +2828,12 @@ function QaPanel() {
2622
2828
  void exportZip(filename);
2623
2829
  };
2624
2830
  const namingCoverage = naming ? computeCoverage(journey, guideChecked) : null;
2831
+ const restBottomRem = dir === "rtl" ? "9rem" : "8.75rem";
2832
+ const panelBottom = isIpadLandscape ? "0" : appliedKeyboardLift > 0 ? `calc(${restBottomRem} + env(safe-area-inset-bottom) + ${appliedKeyboardLift}px)` : `calc(${restBottomRem} + env(safe-area-inset-bottom))`;
2625
2833
  return /* @__PURE__ */ jsxs(
2626
2834
  "div",
2627
2835
  {
2836
+ ref: panelRef,
2628
2837
  "data-qa-overlay": "true",
2629
2838
  dir,
2630
2839
  onTransitionEnd: handleTransitionEnd,
@@ -2635,7 +2844,7 @@ function QaPanel() {
2635
2844
  left: isIpadLandscape ? "auto" : "calc(1rem + env(safe-area-inset-left))",
2636
2845
  right: isIpadLandscape ? "0" : void 0,
2637
2846
  top: isIpadLandscape ? "0" : void 0,
2638
- bottom: isIpadLandscape ? "0" : dir === "rtl" ? "calc(9rem + env(safe-area-inset-bottom))" : "calc(8.75rem + env(safe-area-inset-bottom))",
2847
+ bottom: panelBottom,
2639
2848
  height: isIpadLandscape ? "100dvh" : void 0,
2640
2849
  width: isIpadLandscape ? "min(92vw, 420px)" : void 0,
2641
2850
  // qa-max-h-74vh (class) would otherwise cap the sheet well short of
@@ -2645,7 +2854,12 @@ function QaPanel() {
2645
2854
  background: theme.surface,
2646
2855
  borderColor: `${theme.primary}22`,
2647
2856
  fontFamily: lang === "ar" ? "'Tajawal', sans-serif" : "'Nunito', system-ui, sans-serif",
2648
- zIndex: 9990
2857
+ zIndex: 9990,
2858
+ // Keyboard-avoidance lift (coarse/touch only — see effect above).
2859
+ // undefined ⇒ !keyboardLiftActive, so desktop and the iPad-landscape
2860
+ // side-sheet render this property exactly as before (the class's own
2861
+ // opacity/transform transition applies, untouched).
2862
+ transition: keyboardLiftActive ? PANEL_TRANSITION_WITH_LIFT : void 0
2649
2863
  },
2650
2864
  children: [
2651
2865
  /* @__PURE__ */ jsxs(
@@ -3029,29 +3243,6 @@ function getStableSelector(el) {
3029
3243
  }
3030
3244
  return nthOfTypePath(el);
3031
3245
  }
3032
- function isCoarsePointer() {
3033
- if (typeof window === "undefined") return false;
3034
- try {
3035
- if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) return true;
3036
- } catch {
3037
- }
3038
- return typeof navigator !== "undefined" && (navigator.maxTouchPoints || 0) > 0;
3039
- }
3040
- function useCoarsePointer() {
3041
- const [coarse, setCoarse] = useState(() => isCoarsePointer());
3042
- useEffect(() => {
3043
- if (typeof window === "undefined" || !window.matchMedia) return;
3044
- const mq = window.matchMedia("(pointer: coarse)");
3045
- const on = () => setCoarse(isCoarsePointer());
3046
- if (mq.addEventListener) mq.addEventListener("change", on);
3047
- else if (mq.addListener) mq.addListener(on);
3048
- return () => {
3049
- if (mq.removeEventListener) mq.removeEventListener("change", on);
3050
- else if (mq.removeListener) mq.removeListener(on);
3051
- };
3052
- }, []);
3053
- return coarse;
3054
- }
3055
3246
 
3056
3247
  // src/lib/scrollLock.ts
3057
3248
  var locked = false;
@@ -3073,7 +3264,7 @@ function unlockPageScroll() {
3073
3264
  if (document.body) document.body.style.overflow = prevBodyOverflow;
3074
3265
  locked = false;
3075
3266
  }
3076
- var DRAG_THRESHOLD = 6;
3267
+ var DRAG_THRESHOLD2 = 6;
3077
3268
  var TOUCH_DRAG_THRESHOLD = 12;
3078
3269
  var MIN_REGION_SIZE = 8;
3079
3270
  var REGION_HANDLES = [
@@ -3196,7 +3387,7 @@ function CaptureMode() {
3196
3387
  activePointerId.current = null;
3197
3388
  const d = dragRef.current;
3198
3389
  dragRef.current = null;
3199
- const threshold = pointerKind.current === "mouse" ? DRAG_THRESHOLD : TOUCH_DRAG_THRESHOLD;
3390
+ const threshold = pointerKind.current === "mouse" ? DRAG_THRESHOLD2 : TOUCH_DRAG_THRESHOLD;
3200
3391
  const moved = d !== null && Math.hypot(e.clientX - d.x0, e.clientY - d.y0) > threshold;
3201
3392
  scrollSnap.current = { x: window.scrollX, y: window.scrollY };
3202
3393
  if (moved && d) {
@@ -3819,5 +4010,5 @@ function Qapture({ config }) {
3819
4010
  }
3820
4011
 
3821
4012
  export { Qapture, initQaStudio };
3822
- //# sourceMappingURL=chunk-OPZF4IVW.js.map
3823
- //# sourceMappingURL=chunk-OPZF4IVW.js.map
4013
+ //# sourceMappingURL=chunk-L6VS36GE.js.map
4014
+ //# sourceMappingURL=chunk-L6VS36GE.js.map
@@ -1570,28 +1570,165 @@ function Icon({
1570
1570
  function renderSvgElement(tag, attrs, key) {
1571
1571
  return React__default.default.createElement(tag, { key, ...attrs });
1572
1572
  }
1573
+ function isCoarsePointer() {
1574
+ if (typeof window === "undefined") return false;
1575
+ try {
1576
+ if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) return true;
1577
+ } catch {
1578
+ }
1579
+ return typeof navigator !== "undefined" && (navigator.maxTouchPoints || 0) > 0;
1580
+ }
1581
+ function useCoarsePointer() {
1582
+ const [coarse, setCoarse] = React.useState(() => isCoarsePointer());
1583
+ React.useEffect(() => {
1584
+ if (typeof window === "undefined" || !window.matchMedia) return;
1585
+ const mq = window.matchMedia("(pointer: coarse)");
1586
+ const on = () => setCoarse(isCoarsePointer());
1587
+ if (mq.addEventListener) mq.addEventListener("change", on);
1588
+ else if (mq.addListener) mq.addListener(on);
1589
+ return () => {
1590
+ if (mq.removeEventListener) mq.removeEventListener("change", on);
1591
+ else if (mq.removeListener) mq.removeListener(on);
1592
+ };
1593
+ }, []);
1594
+ return coarse;
1595
+ }
1596
+ var DEFAULT_LEFT = "calc(1.25rem + env(safe-area-inset-left))";
1597
+ var DEFAULT_BOTTOM = "calc(5rem + env(safe-area-inset-bottom))";
1598
+ var FAB_SIZE_PX = 56;
1599
+ var EDGE_MARGIN = 12;
1600
+ var DRAG_THRESHOLD = 8;
1601
+ var FAB_POS_KEY = "qapture:fabpos";
1602
+ function isFabPos(v) {
1603
+ if (!v || typeof v !== "object") return false;
1604
+ const o = v;
1605
+ return typeof o.left === "number" && Number.isFinite(o.left) && typeof o.bottom === "number" && Number.isFinite(o.bottom);
1606
+ }
1607
+ function loadFabPos() {
1608
+ if (typeof window === "undefined") return null;
1609
+ try {
1610
+ const raw = window.localStorage.getItem(FAB_POS_KEY);
1611
+ if (!raw) return null;
1612
+ const parsed = JSON.parse(raw);
1613
+ return isFabPos(parsed) ? parsed : null;
1614
+ } catch {
1615
+ return null;
1616
+ }
1617
+ }
1618
+ function saveFabPos(pos) {
1619
+ if (typeof window === "undefined") return;
1620
+ try {
1621
+ window.localStorage.setItem(FAB_POS_KEY, JSON.stringify(pos));
1622
+ } catch {
1623
+ }
1624
+ }
1625
+ function clampNum(v, lo, hi) {
1626
+ return Math.min(Math.max(v, lo), hi);
1627
+ }
1628
+ function clampFabPos(p, w = FAB_SIZE_PX, h = FAB_SIZE_PX) {
1629
+ if (typeof window === "undefined") return p;
1630
+ const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - w - EDGE_MARGIN);
1631
+ const maxBottom = Math.max(EDGE_MARGIN, window.innerHeight - h - EDGE_MARGIN);
1632
+ return {
1633
+ left: clampNum(p.left, EDGE_MARGIN, maxLeft),
1634
+ bottom: clampNum(p.bottom, EDGE_MARGIN, maxBottom)
1635
+ };
1636
+ }
1573
1637
  function QaFab() {
1574
1638
  const { isOpen, setIsOpen, notes, captureActive, theme } = useQa();
1639
+ const coarse = useCoarsePointer();
1640
+ const [pos, setPos] = React.useState(() => loadFabPos());
1641
+ const dragRef = React.useRef(null);
1642
+ const didDragRef = React.useRef(false);
1575
1643
  if (captureActive) return null;
1644
+ const onPointerDown = (e) => {
1645
+ if (dragRef.current) return;
1646
+ didDragRef.current = false;
1647
+ const target = e.currentTarget;
1648
+ const rect = target.getBoundingClientRect();
1649
+ dragRef.current = {
1650
+ pointerId: e.pointerId,
1651
+ startX: e.clientX,
1652
+ startY: e.clientY,
1653
+ startLeft: rect.left,
1654
+ startBottom: window.innerHeight - rect.bottom,
1655
+ width: rect.width,
1656
+ height: rect.height,
1657
+ dragging: false
1658
+ };
1659
+ try {
1660
+ target.setPointerCapture(e.pointerId);
1661
+ } catch {
1662
+ }
1663
+ };
1664
+ const onPointerMove = (e) => {
1665
+ const d = dragRef.current;
1666
+ if (!d || d.pointerId !== e.pointerId) return;
1667
+ const dx = e.clientX - d.startX;
1668
+ const dy = e.clientY - d.startY;
1669
+ if (!d.dragging) {
1670
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
1671
+ d.dragging = true;
1672
+ }
1673
+ setPos(clampFabPos({ left: d.startLeft + dx, bottom: d.startBottom - dy }, d.width, d.height));
1674
+ };
1675
+ const endDrag = (e) => {
1676
+ const d = dragRef.current;
1677
+ if (!d || d.pointerId !== e.pointerId) return null;
1678
+ try {
1679
+ e.currentTarget.releasePointerCapture(e.pointerId);
1680
+ } catch {
1681
+ }
1682
+ dragRef.current = null;
1683
+ return d;
1684
+ };
1685
+ const onPointerUp = (e) => {
1686
+ const d = endDrag(e);
1687
+ if (!d) return;
1688
+ if (d.dragging) {
1689
+ const dx = e.clientX - d.startX;
1690
+ const dy = e.clientY - d.startY;
1691
+ const next = clampFabPos({ left: d.startLeft + dx, bottom: d.startBottom - dy }, d.width, d.height);
1692
+ setPos(next);
1693
+ saveFabPos(next);
1694
+ didDragRef.current = true;
1695
+ }
1696
+ };
1697
+ const onPointerCancel = (e) => {
1698
+ endDrag(e);
1699
+ };
1700
+ const handleClick = () => {
1701
+ if (didDragRef.current) {
1702
+ didDragRef.current = false;
1703
+ return;
1704
+ }
1705
+ setIsOpen(!isOpen);
1706
+ };
1707
+ const applied = coarse && pos ? clampFabPos(pos) : null;
1708
+ const fabStyle = {
1709
+ left: applied ? `${applied.left}px` : DEFAULT_LEFT,
1710
+ bottom: applied ? `${applied.bottom}px` : DEFAULT_BOTTOM,
1711
+ width: "3.5rem",
1712
+ height: "3.5rem",
1713
+ backgroundImage: `linear-gradient(135deg, ${theme.primary}, ${theme.accent})`,
1714
+ boxShadow: "0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04), 0 0 0 2px rgba(255,255,255,0.7)",
1715
+ zIndex: 9990
1716
+ };
1576
1717
  return /* @__PURE__ */ jsxRuntime.jsxs(
1577
1718
  "button",
1578
1719
  {
1579
1720
  type: "button",
1580
1721
  "data-qa-overlay": "true",
1581
1722
  dir: "ltr",
1582
- onClick: () => setIsOpen(!isOpen),
1723
+ onClick: handleClick,
1724
+ onPointerDown: coarse ? onPointerDown : void 0,
1725
+ onPointerMove: coarse ? onPointerMove : void 0,
1726
+ onPointerUp: coarse ? onPointerUp : void 0,
1727
+ onPointerCancel: coarse ? onPointerCancel : void 0,
1583
1728
  "aria-label": "Qapture \u2014 testing notes",
1584
1729
  title: "Qapture",
1585
- className: "qa-fixed qa-flex qa-items-center qa-justify-center qa-rounded-full qa-text-white qa-print-hidden qa-fab-btn",
1586
- style: {
1587
- left: "calc(1.25rem + env(safe-area-inset-left))",
1588
- bottom: "calc(5rem + env(safe-area-inset-bottom))",
1589
- width: "3.5rem",
1590
- height: "3.5rem",
1591
- backgroundImage: `linear-gradient(135deg, ${theme.primary}, ${theme.accent})`,
1592
- boxShadow: "0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04), 0 0 0 2px rgba(255,255,255,0.7)",
1593
- zIndex: 9990
1594
- },
1730
+ className: `qa-fixed qa-flex qa-items-center qa-justify-center qa-rounded-full qa-text-white qa-print-hidden qa-fab-btn${coarse ? " qa-touch-none" : ""}`,
1731
+ style: fabStyle,
1595
1732
  children: [
1596
1733
  !isOpen && /* @__PURE__ */ jsxRuntime.jsx(
1597
1734
  "span",
@@ -2554,6 +2691,20 @@ function panelReducer(state, action) {
2554
2691
  return state;
2555
2692
  }
2556
2693
  }
2694
+ var KEYBOARD_OVERLAP_THRESHOLD = 120;
2695
+ var KEYBOARD_LIFT_GAP = 12;
2696
+ var NON_TEXT_INPUT_TYPES = /* @__PURE__ */ new Set([
2697
+ "checkbox",
2698
+ "radio",
2699
+ "range",
2700
+ "button",
2701
+ "submit",
2702
+ "reset",
2703
+ "color",
2704
+ "file",
2705
+ "image"
2706
+ ]);
2707
+ var PANEL_TRANSITION_WITH_LIFT = "opacity 200ms cubic-bezier(0.4,0,0.2,1), transform 200ms cubic-bezier(0.4,0,0.2,1), bottom 200ms cubic-bezier(0.4,0,0.2,1)";
2557
2708
  function QaPanel() {
2558
2709
  const {
2559
2710
  isOpen,
@@ -2619,6 +2770,61 @@ function QaPanel() {
2619
2770
  mql.addListener(handleChange);
2620
2771
  return () => mql.removeListener(handleChange);
2621
2772
  }, []);
2773
+ const coarse = useCoarsePointer();
2774
+ const panelRef = React.useRef(null);
2775
+ const [keyboardLift, setKeyboardLift] = React.useState(0);
2776
+ const computeKeyboardLift = React.useCallback(() => {
2777
+ try {
2778
+ if (!coarse || isIpadLandscape) return 0;
2779
+ if (typeof window === "undefined") return 0;
2780
+ const vv = window.visualViewport;
2781
+ if (!vv) return 0;
2782
+ const overlap = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
2783
+ if (overlap <= KEYBOARD_OVERLAP_THRESHOLD) return 0;
2784
+ const panel = panelRef.current;
2785
+ if (!panel) return 0;
2786
+ const root = panel.getRootNode();
2787
+ const active = root.activeElement;
2788
+ if (!active || !panel.contains(active)) return 0;
2789
+ const tag = active.tagName;
2790
+ if (tag === "TEXTAREA") return Math.round(overlap) + KEYBOARD_LIFT_GAP;
2791
+ if (tag === "INPUT" && !NON_TEXT_INPUT_TYPES.has(active.type)) {
2792
+ return Math.round(overlap) + KEYBOARD_LIFT_GAP;
2793
+ }
2794
+ return 0;
2795
+ } catch {
2796
+ return 0;
2797
+ }
2798
+ }, [coarse, isIpadLandscape]);
2799
+ React.useEffect(() => {
2800
+ if (!coarse) return void 0;
2801
+ if (typeof window === "undefined" || typeof document === "undefined") return void 0;
2802
+ const vv = window.visualViewport;
2803
+ if (!vv) return void 0;
2804
+ let closeTimer;
2805
+ const recompute = () => setKeyboardLift(computeKeyboardLift());
2806
+ const recomputeSoon = () => {
2807
+ if (closeTimer !== void 0) clearTimeout(closeTimer);
2808
+ closeTimer = setTimeout(recompute, 80);
2809
+ };
2810
+ recompute();
2811
+ vv.addEventListener("resize", recompute);
2812
+ vv.addEventListener("scroll", recompute);
2813
+ document.addEventListener("focusin", recompute);
2814
+ document.addEventListener("focusout", recomputeSoon);
2815
+ return () => {
2816
+ if (closeTimer !== void 0) clearTimeout(closeTimer);
2817
+ vv.removeEventListener("resize", recompute);
2818
+ vv.removeEventListener("scroll", recompute);
2819
+ document.removeEventListener("focusin", recompute);
2820
+ document.removeEventListener("focusout", recomputeSoon);
2821
+ };
2822
+ }, [coarse, computeKeyboardLift]);
2823
+ React.useEffect(() => {
2824
+ if (!isOpen) setKeyboardLift(0);
2825
+ }, [isOpen]);
2826
+ const keyboardLiftActive = coarse && !isIpadLandscape;
2827
+ const appliedKeyboardLift = keyboardLiftActive ? keyboardLift : 0;
2622
2828
  if (phase === "hidden") return null;
2623
2829
  const openNaming = () => {
2624
2830
  setFilename(todayName());
@@ -2629,9 +2835,12 @@ function QaPanel() {
2629
2835
  void exportZip(filename);
2630
2836
  };
2631
2837
  const namingCoverage = naming ? computeCoverage(journey, guideChecked) : null;
2838
+ const restBottomRem = dir === "rtl" ? "9rem" : "8.75rem";
2839
+ const panelBottom = isIpadLandscape ? "0" : appliedKeyboardLift > 0 ? `calc(${restBottomRem} + env(safe-area-inset-bottom) + ${appliedKeyboardLift}px)` : `calc(${restBottomRem} + env(safe-area-inset-bottom))`;
2632
2840
  return /* @__PURE__ */ jsxRuntime.jsxs(
2633
2841
  "div",
2634
2842
  {
2843
+ ref: panelRef,
2635
2844
  "data-qa-overlay": "true",
2636
2845
  dir,
2637
2846
  onTransitionEnd: handleTransitionEnd,
@@ -2642,7 +2851,7 @@ function QaPanel() {
2642
2851
  left: isIpadLandscape ? "auto" : "calc(1rem + env(safe-area-inset-left))",
2643
2852
  right: isIpadLandscape ? "0" : void 0,
2644
2853
  top: isIpadLandscape ? "0" : void 0,
2645
- bottom: isIpadLandscape ? "0" : dir === "rtl" ? "calc(9rem + env(safe-area-inset-bottom))" : "calc(8.75rem + env(safe-area-inset-bottom))",
2854
+ bottom: panelBottom,
2646
2855
  height: isIpadLandscape ? "100dvh" : void 0,
2647
2856
  width: isIpadLandscape ? "min(92vw, 420px)" : void 0,
2648
2857
  // qa-max-h-74vh (class) would otherwise cap the sheet well short of
@@ -2652,7 +2861,12 @@ function QaPanel() {
2652
2861
  background: theme.surface,
2653
2862
  borderColor: `${theme.primary}22`,
2654
2863
  fontFamily: lang === "ar" ? "'Tajawal', sans-serif" : "'Nunito', system-ui, sans-serif",
2655
- zIndex: 9990
2864
+ zIndex: 9990,
2865
+ // Keyboard-avoidance lift (coarse/touch only — see effect above).
2866
+ // undefined ⇒ !keyboardLiftActive, so desktop and the iPad-landscape
2867
+ // side-sheet render this property exactly as before (the class's own
2868
+ // opacity/transform transition applies, untouched).
2869
+ transition: keyboardLiftActive ? PANEL_TRANSITION_WITH_LIFT : void 0
2656
2870
  },
2657
2871
  children: [
2658
2872
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -3036,29 +3250,6 @@ function getStableSelector(el) {
3036
3250
  }
3037
3251
  return nthOfTypePath(el);
3038
3252
  }
3039
- function isCoarsePointer() {
3040
- if (typeof window === "undefined") return false;
3041
- try {
3042
- if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) return true;
3043
- } catch {
3044
- }
3045
- return typeof navigator !== "undefined" && (navigator.maxTouchPoints || 0) > 0;
3046
- }
3047
- function useCoarsePointer() {
3048
- const [coarse, setCoarse] = React.useState(() => isCoarsePointer());
3049
- React.useEffect(() => {
3050
- if (typeof window === "undefined" || !window.matchMedia) return;
3051
- const mq = window.matchMedia("(pointer: coarse)");
3052
- const on = () => setCoarse(isCoarsePointer());
3053
- if (mq.addEventListener) mq.addEventListener("change", on);
3054
- else if (mq.addListener) mq.addListener(on);
3055
- return () => {
3056
- if (mq.removeEventListener) mq.removeEventListener("change", on);
3057
- else if (mq.removeListener) mq.removeListener(on);
3058
- };
3059
- }, []);
3060
- return coarse;
3061
- }
3062
3253
 
3063
3254
  // src/lib/scrollLock.ts
3064
3255
  var locked = false;
@@ -3080,7 +3271,7 @@ function unlockPageScroll() {
3080
3271
  if (document.body) document.body.style.overflow = prevBodyOverflow;
3081
3272
  locked = false;
3082
3273
  }
3083
- var DRAG_THRESHOLD = 6;
3274
+ var DRAG_THRESHOLD2 = 6;
3084
3275
  var TOUCH_DRAG_THRESHOLD = 12;
3085
3276
  var MIN_REGION_SIZE = 8;
3086
3277
  var REGION_HANDLES = [
@@ -3203,7 +3394,7 @@ function CaptureMode() {
3203
3394
  activePointerId.current = null;
3204
3395
  const d = dragRef.current;
3205
3396
  dragRef.current = null;
3206
- const threshold = pointerKind.current === "mouse" ? DRAG_THRESHOLD : TOUCH_DRAG_THRESHOLD;
3397
+ const threshold = pointerKind.current === "mouse" ? DRAG_THRESHOLD2 : TOUCH_DRAG_THRESHOLD;
3207
3398
  const moved = d !== null && Math.hypot(e.clientX - d.x0, e.clientY - d.y0) > threshold;
3208
3399
  scrollSnap.current = { x: window.scrollX, y: window.scrollY };
3209
3400
  if (moved && d) {
@@ -3827,5 +4018,5 @@ function Qapture({ config }) {
3827
4018
 
3828
4019
  exports.Qapture = Qapture;
3829
4020
  exports.initQaStudio = initQaStudio;
3830
- //# sourceMappingURL=chunk-CJQHPDT7.cjs.map
3831
- //# sourceMappingURL=chunk-CJQHPDT7.cjs.map
4021
+ //# sourceMappingURL=chunk-PBXQDL7C.cjs.map
4022
+ //# sourceMappingURL=chunk-PBXQDL7C.cjs.map
package/dist/index.cjs CHANGED
@@ -1,20 +1,20 @@
1
1
  'use strict';
2
2
 
3
- var chunkCJQHPDT7_cjs = require('./chunk-CJQHPDT7.cjs');
3
+ var chunkPBXQDL7C_cjs = require('./chunk-PBXQDL7C.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "QaStudio", {
8
8
  enumerable: true,
9
- get: function () { return chunkCJQHPDT7_cjs.Qapture; }
9
+ get: function () { return chunkPBXQDL7C_cjs.Qapture; }
10
10
  });
11
11
  Object.defineProperty(exports, "Qapture", {
12
12
  enumerable: true,
13
- get: function () { return chunkCJQHPDT7_cjs.Qapture; }
13
+ get: function () { return chunkPBXQDL7C_cjs.Qapture; }
14
14
  });
15
15
  Object.defineProperty(exports, "initQaStudio", {
16
16
  enumerable: true,
17
- get: function () { return chunkCJQHPDT7_cjs.initQaStudio; }
17
+ get: function () { return chunkPBXQDL7C_cjs.initQaStudio; }
18
18
  });
19
19
  //# sourceMappingURL=index.cjs.map
20
20
  //# sourceMappingURL=index.cjs.map
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-OPZF4IVW.js';
1
+ export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-L6VS36GE.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/dist/next.cjs CHANGED
@@ -1,21 +1,21 @@
1
1
  "use client";
2
2
  'use strict';
3
3
 
4
- var chunkCJQHPDT7_cjs = require('./chunk-CJQHPDT7.cjs');
4
+ var chunkPBXQDL7C_cjs = require('./chunk-PBXQDL7C.cjs');
5
5
 
6
6
 
7
7
 
8
8
  Object.defineProperty(exports, "QaStudio", {
9
9
  enumerable: true,
10
- get: function () { return chunkCJQHPDT7_cjs.Qapture; }
10
+ get: function () { return chunkPBXQDL7C_cjs.Qapture; }
11
11
  });
12
12
  Object.defineProperty(exports, "Qapture", {
13
13
  enumerable: true,
14
- get: function () { return chunkCJQHPDT7_cjs.Qapture; }
14
+ get: function () { return chunkPBXQDL7C_cjs.Qapture; }
15
15
  });
16
16
  Object.defineProperty(exports, "initQaStudio", {
17
17
  enumerable: true,
18
- get: function () { return chunkCJQHPDT7_cjs.initQaStudio; }
18
+ get: function () { return chunkPBXQDL7C_cjs.initQaStudio; }
19
19
  });
20
20
  //# sourceMappingURL=next.cjs.map
21
21
  //# sourceMappingURL=next.cjs.map
package/dist/next.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use client";
2
- export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-OPZF4IVW.js';
2
+ export { Qapture as QaStudio, Qapture, initQaStudio } from './chunk-L6VS36GE.js';
3
3
  //# sourceMappingURL=next.js.map
4
4
  //# sourceMappingURL=next.js.map
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkCJQHPDT7_cjs = require('./chunk-CJQHPDT7.cjs');
3
+ var chunkPBXQDL7C_cjs = require('./chunk-PBXQDL7C.cjs');
4
4
 
5
5
  // src/standalone.ts
6
6
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
@@ -33,7 +33,7 @@ if (typeof window !== "undefined" && typeof customElements !== "undefined" && !c
33
33
 
34
34
  Object.defineProperty(exports, "initQaStudio", {
35
35
  enumerable: true,
36
- get: function () { return chunkCJQHPDT7_cjs.initQaStudio; }
36
+ get: function () { return chunkPBXQDL7C_cjs.initQaStudio; }
37
37
  });
38
38
  //# sourceMappingURL=standalone.cjs.map
39
39
  //# sourceMappingURL=standalone.cjs.map
@@ -1,4 +1,4 @@
1
- export { initQaStudio } from './chunk-OPZF4IVW.js';
1
+ export { initQaStudio } from './chunk-L6VS36GE.js';
2
2
 
3
3
  // src/standalone.ts
4
4
  if (typeof window !== "undefined" && typeof customElements !== "undefined" && !customElements.get("qapture-widget")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qapture2",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Drop-in, AI-aware in-browser QA capture widget. A tester annotates the live app (element/region + auto-screenshot + note), tracks a graded testing journey, and exports a ZIP your own coding agent reads. Ships zero AI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "./package.json": "./package.json"
56
56
  },
57
57
  "bin": {
58
- "qapture": "./dist/bin/init.cjs"
58
+ "qapture2": "dist/bin/init.cjs"
59
59
  },
60
60
  "repository": {
61
61
  "type": "git",