@solhun/feedback-kit-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -95,13 +95,22 @@ interface DiagNetworkEntry {
95
95
  url: string;
96
96
  /** 응답을 못 받았으면(네트워크 실패·중단) null. */
97
97
  status: number | null;
98
+ /**
99
+ * 2xx 였는가. status 로 계산할 수 있지만 따로 싣는다 —
100
+ * 제보를 훑는 사람이 실패한 요청만 눈으로 골라내는 게 목적이다.
101
+ * 응답 자체를 못 받았으면 false.
102
+ */
103
+ ok: boolean;
104
+ /** 네트워크 실패·중단의 사유. 성공했으면 null. 응답 본문은 담지 않는다. */
105
+ errorMessage: string | null;
98
106
  durationMs: number;
99
107
  /** 기록 시각(ISO 8601). */
100
108
  at: string;
101
109
  }
102
110
  /** 콘솔 기록 1건. `console.warn` / `console.error` 만 대상. */
103
111
  interface DiagLogEntry {
104
- level: "warn" | "error";
112
+ /** `log` 은 captureLogs: "all" 때만 담긴다. */
113
+ level: "log" | "warn" | "error";
105
114
  message: string;
106
115
  at: string;
107
116
  }
@@ -517,14 +526,16 @@ declare const MAX_LOG_MESSAGE_CHARS = 2000;
517
526
  interface DiagnosticsInstallOpts {
518
527
  /** true 를 반환하면 그 요청은 기록하지 않는다(위젯 자신의 전송 등). */
519
528
  excludeMatcher?: DiagnosticsExcludeMatcher;
529
+ /**
530
+ * 콘솔 로그를 어디까지 담을지. 기본 `problems`(warn·error 만).
531
+ * `all` 은 log·info 까지 담는다 — 흐름 추적엔 좋지만 평범한 로그가 많은 앱에서는
532
+ * 그게 제보를 덮어 정작 중요한 경고가 묻힌다. 그래서 프로젝트마다 정한다.
533
+ */
534
+ logs?: "problems" | "all" | "off";
520
535
  /** 시각 주입(테스트용). */
521
536
  now?: () => number;
522
537
  iso?: (n: number) => string;
523
538
  }
524
- /**
525
- * 진단 수집기. `install()` 로 글로벌을 패치하고 `uninstall()` 로 원복한다.
526
- * 생성 자체는 부작용이 없다 — `install()` 을 호출해야 패치가 일어난다.
527
- */
528
539
  declare class DiagnosticsCollector {
529
540
  readonly network: RingBuffer<DiagNetworkEntry>;
530
541
  readonly logs: RingBuffer<DiagLogEntry>;
@@ -534,7 +545,9 @@ declare class DiagnosticsCollector {
534
545
  private originalXhrSend;
535
546
  private originalWarn;
536
547
  private originalError;
548
+ private originalLog;
537
549
  private excludeMatcher;
550
+ private logScope;
538
551
  private now;
539
552
  private iso;
540
553
  /**
@@ -568,6 +581,7 @@ declare class DiagnosticsCollector {
568
581
  snapshot(): DiagnosticsPayload;
569
582
  /** 두 버퍼를 비운다. */
570
583
  clear(): void;
584
+ /** 실패 사유 한 줄. 예외 객체엔 요청 본문이 들어 있을 수 있어 메시지만, 길이도 자른다. */
571
585
  private recordNetwork;
572
586
  private recordLog;
573
587
  private isExcluded;
@@ -661,6 +675,16 @@ interface FeedbackConfig {
661
675
  captureScreenshot?: boolean;
662
676
  /** network·logs 링버퍼를 수집할지. false 면 아예 설치하지 않는다. */
663
677
  captureDiagnostics?: boolean;
678
+ /**
679
+ * 콘솔 로그를 담을 범위. 프로젝트마다 다르다 — 로그를 정돈해 쓰는 팀에는
680
+ * `all` 이 가장 유용하고, 평범한 로그가 수백 줄씩 나오는 앱에서는 그게
681
+ * 제보를 덮어 정작 중요한 경고가 묻힌다.
682
+ *
683
+ * - `problems`(기본): warn·error 만. 노이즈 없이 문제만 남는다.
684
+ * - `all`: log·info 까지 전부. 개발 중 흐름 추적에 유리하다.
685
+ * - `off`: 로그를 담지 않는다. 네트워크는 그대로 수집한다.
686
+ */
687
+ captureLogs?: LogCapture;
664
688
  /** 플로팅 버튼 위치. 모서리만 주면 여백은 기본값. */
665
689
  position?: WidgetCorner | Partial<WidgetPosition>;
666
690
  /** `internal` 판정을 프로젝트가 직접 할 때. 주면 `internalRoles` 보다 우선한다. */
@@ -672,6 +696,8 @@ interface FeedbackConfig {
672
696
  /** 경고 출구. 안 주면 `resolveConfig` 결과의 `warnings` 로만 남는다. */
673
697
  onWarn?: (warning: ConfigWarning) => void;
674
698
  }
699
+ /** 콘솔 로그 수집 범위. 프로젝트마다 정한다. */
700
+ type LogCapture = "problems" | "all" | "off";
675
701
  /** 기본값이 모두 채워진 설정. 위젯은 이것만 본다. */
676
702
  interface ResolvedConfig {
677
703
  token: string | null;
@@ -680,6 +706,7 @@ interface ResolvedConfig {
680
706
  visibility: Visibility;
681
707
  captureScreenshot: boolean;
682
708
  captureDiagnostics: boolean;
709
+ captureLogs: LogCapture;
683
710
  position: WidgetPosition;
684
711
  isInternal: VisibilityFn | null;
685
712
  internalRoles: readonly string[];
@@ -877,6 +904,11 @@ declare const MODAL_ACTION_SEND = "send";
877
904
  interface ReportModalState {
878
905
  open: boolean;
879
906
  comment: string;
907
+ /**
908
+ * 요소 지목 모드에서 고른 요소. 없으면 화면 전체에 대한 제보다.
909
+ * 이게 있으면 제보 종류가 `annotation` 이 되어 수집 쪽에서 지목 제보로 갈린다.
910
+ */
911
+ element: ElementInfo | null;
880
912
  priority: FeedbackPriority;
881
913
  screenshot: FeedbackScreenshot | null;
882
914
  screenshotStatus: ScreenshotStatus;
@@ -947,7 +979,9 @@ declare class ReportModalController {
947
979
  dispose(): void;
948
980
  /** WidgetController가 소유한 큐 lifecycle을 함께 정리한다. */
949
981
  stopQueue(): void;
950
- open(): Promise<void>;
982
+ open(opts?: {
983
+ element?: ElementInfo | null;
984
+ }): Promise<void>;
951
985
  /**
952
986
  * 닫기 요청. 쓰던 내용이 있으면 곧장 닫지 않고 확인을 받는다.
953
987
  * @returns 실제로 닫혔으면 `"closed"`, 확인이 필요하면 `"confirm"`.
@@ -1024,7 +1058,14 @@ declare class WidgetController {
1024
1058
  get isPicking(): boolean;
1025
1059
  subscribe(listener: WidgetListener): () => void;
1026
1060
  dispose(): void;
1027
- openReport(): Promise<void>;
1061
+ /**
1062
+ * @param opts.element 요소를 지목해서 열 때 그 요소. 지목 모드에서도 화면을
1063
+ * `modal` 로 바꾼다 — `modal.open()` 을 직접 부르면 상태만 열리고 화면은
1064
+ * `picking` 에 머물러 **모달이 렌더되지 않는다**.
1065
+ */
1066
+ openReport(opts?: {
1067
+ element?: ElementInfo | null;
1068
+ }): Promise<void>;
1028
1069
  /** 모달 닫기 요청. 쓰던 내용이 있으면 확인부터 받는다(화면은 그대로 모달). */
1029
1070
  closeReport(): "closed" | "confirm";
1030
1071
  confirmCloseReport(): void;
@@ -1191,6 +1232,20 @@ interface LassoEnvelope {
1191
1232
  y: number;
1192
1233
  } | null;
1193
1234
  sourceFile: string | null;
1235
+ /**
1236
+ * 대시보드가 구조화 필드로 읽는 자리. 본문 마크다운에도 같은 값이 들어가지만,
1237
+ * 거기 있는 건 사람이 읽는 용도라 화면의 뷰포트 칸이 비어 있었다(실측).
1238
+ */
1239
+ web: {
1240
+ url: string | null;
1241
+ route: string | null;
1242
+ viewport: {
1243
+ width: number | null;
1244
+ height: number | null;
1245
+ devicePixelRatio: number | null;
1246
+ } | null;
1247
+ userAgent: string | null;
1248
+ } | null;
1194
1249
  screenshotBase64: string | null;
1195
1250
  screenshotContentType: "image/jpeg" | "image/png" | null;
1196
1251
  network: unknown[];
package/dist/index.d.ts CHANGED
@@ -95,13 +95,22 @@ interface DiagNetworkEntry {
95
95
  url: string;
96
96
  /** 응답을 못 받았으면(네트워크 실패·중단) null. */
97
97
  status: number | null;
98
+ /**
99
+ * 2xx 였는가. status 로 계산할 수 있지만 따로 싣는다 —
100
+ * 제보를 훑는 사람이 실패한 요청만 눈으로 골라내는 게 목적이다.
101
+ * 응답 자체를 못 받았으면 false.
102
+ */
103
+ ok: boolean;
104
+ /** 네트워크 실패·중단의 사유. 성공했으면 null. 응답 본문은 담지 않는다. */
105
+ errorMessage: string | null;
98
106
  durationMs: number;
99
107
  /** 기록 시각(ISO 8601). */
100
108
  at: string;
101
109
  }
102
110
  /** 콘솔 기록 1건. `console.warn` / `console.error` 만 대상. */
103
111
  interface DiagLogEntry {
104
- level: "warn" | "error";
112
+ /** `log` 은 captureLogs: "all" 때만 담긴다. */
113
+ level: "log" | "warn" | "error";
105
114
  message: string;
106
115
  at: string;
107
116
  }
@@ -517,14 +526,16 @@ declare const MAX_LOG_MESSAGE_CHARS = 2000;
517
526
  interface DiagnosticsInstallOpts {
518
527
  /** true 를 반환하면 그 요청은 기록하지 않는다(위젯 자신의 전송 등). */
519
528
  excludeMatcher?: DiagnosticsExcludeMatcher;
529
+ /**
530
+ * 콘솔 로그를 어디까지 담을지. 기본 `problems`(warn·error 만).
531
+ * `all` 은 log·info 까지 담는다 — 흐름 추적엔 좋지만 평범한 로그가 많은 앱에서는
532
+ * 그게 제보를 덮어 정작 중요한 경고가 묻힌다. 그래서 프로젝트마다 정한다.
533
+ */
534
+ logs?: "problems" | "all" | "off";
520
535
  /** 시각 주입(테스트용). */
521
536
  now?: () => number;
522
537
  iso?: (n: number) => string;
523
538
  }
524
- /**
525
- * 진단 수집기. `install()` 로 글로벌을 패치하고 `uninstall()` 로 원복한다.
526
- * 생성 자체는 부작용이 없다 — `install()` 을 호출해야 패치가 일어난다.
527
- */
528
539
  declare class DiagnosticsCollector {
529
540
  readonly network: RingBuffer<DiagNetworkEntry>;
530
541
  readonly logs: RingBuffer<DiagLogEntry>;
@@ -534,7 +545,9 @@ declare class DiagnosticsCollector {
534
545
  private originalXhrSend;
535
546
  private originalWarn;
536
547
  private originalError;
548
+ private originalLog;
537
549
  private excludeMatcher;
550
+ private logScope;
538
551
  private now;
539
552
  private iso;
540
553
  /**
@@ -568,6 +581,7 @@ declare class DiagnosticsCollector {
568
581
  snapshot(): DiagnosticsPayload;
569
582
  /** 두 버퍼를 비운다. */
570
583
  clear(): void;
584
+ /** 실패 사유 한 줄. 예외 객체엔 요청 본문이 들어 있을 수 있어 메시지만, 길이도 자른다. */
571
585
  private recordNetwork;
572
586
  private recordLog;
573
587
  private isExcluded;
@@ -661,6 +675,16 @@ interface FeedbackConfig {
661
675
  captureScreenshot?: boolean;
662
676
  /** network·logs 링버퍼를 수집할지. false 면 아예 설치하지 않는다. */
663
677
  captureDiagnostics?: boolean;
678
+ /**
679
+ * 콘솔 로그를 담을 범위. 프로젝트마다 다르다 — 로그를 정돈해 쓰는 팀에는
680
+ * `all` 이 가장 유용하고, 평범한 로그가 수백 줄씩 나오는 앱에서는 그게
681
+ * 제보를 덮어 정작 중요한 경고가 묻힌다.
682
+ *
683
+ * - `problems`(기본): warn·error 만. 노이즈 없이 문제만 남는다.
684
+ * - `all`: log·info 까지 전부. 개발 중 흐름 추적에 유리하다.
685
+ * - `off`: 로그를 담지 않는다. 네트워크는 그대로 수집한다.
686
+ */
687
+ captureLogs?: LogCapture;
664
688
  /** 플로팅 버튼 위치. 모서리만 주면 여백은 기본값. */
665
689
  position?: WidgetCorner | Partial<WidgetPosition>;
666
690
  /** `internal` 판정을 프로젝트가 직접 할 때. 주면 `internalRoles` 보다 우선한다. */
@@ -672,6 +696,8 @@ interface FeedbackConfig {
672
696
  /** 경고 출구. 안 주면 `resolveConfig` 결과의 `warnings` 로만 남는다. */
673
697
  onWarn?: (warning: ConfigWarning) => void;
674
698
  }
699
+ /** 콘솔 로그 수집 범위. 프로젝트마다 정한다. */
700
+ type LogCapture = "problems" | "all" | "off";
675
701
  /** 기본값이 모두 채워진 설정. 위젯은 이것만 본다. */
676
702
  interface ResolvedConfig {
677
703
  token: string | null;
@@ -680,6 +706,7 @@ interface ResolvedConfig {
680
706
  visibility: Visibility;
681
707
  captureScreenshot: boolean;
682
708
  captureDiagnostics: boolean;
709
+ captureLogs: LogCapture;
683
710
  position: WidgetPosition;
684
711
  isInternal: VisibilityFn | null;
685
712
  internalRoles: readonly string[];
@@ -877,6 +904,11 @@ declare const MODAL_ACTION_SEND = "send";
877
904
  interface ReportModalState {
878
905
  open: boolean;
879
906
  comment: string;
907
+ /**
908
+ * 요소 지목 모드에서 고른 요소. 없으면 화면 전체에 대한 제보다.
909
+ * 이게 있으면 제보 종류가 `annotation` 이 되어 수집 쪽에서 지목 제보로 갈린다.
910
+ */
911
+ element: ElementInfo | null;
880
912
  priority: FeedbackPriority;
881
913
  screenshot: FeedbackScreenshot | null;
882
914
  screenshotStatus: ScreenshotStatus;
@@ -947,7 +979,9 @@ declare class ReportModalController {
947
979
  dispose(): void;
948
980
  /** WidgetController가 소유한 큐 lifecycle을 함께 정리한다. */
949
981
  stopQueue(): void;
950
- open(): Promise<void>;
982
+ open(opts?: {
983
+ element?: ElementInfo | null;
984
+ }): Promise<void>;
951
985
  /**
952
986
  * 닫기 요청. 쓰던 내용이 있으면 곧장 닫지 않고 확인을 받는다.
953
987
  * @returns 실제로 닫혔으면 `"closed"`, 확인이 필요하면 `"confirm"`.
@@ -1024,7 +1058,14 @@ declare class WidgetController {
1024
1058
  get isPicking(): boolean;
1025
1059
  subscribe(listener: WidgetListener): () => void;
1026
1060
  dispose(): void;
1027
- openReport(): Promise<void>;
1061
+ /**
1062
+ * @param opts.element 요소를 지목해서 열 때 그 요소. 지목 모드에서도 화면을
1063
+ * `modal` 로 바꾼다 — `modal.open()` 을 직접 부르면 상태만 열리고 화면은
1064
+ * `picking` 에 머물러 **모달이 렌더되지 않는다**.
1065
+ */
1066
+ openReport(opts?: {
1067
+ element?: ElementInfo | null;
1068
+ }): Promise<void>;
1028
1069
  /** 모달 닫기 요청. 쓰던 내용이 있으면 확인부터 받는다(화면은 그대로 모달). */
1029
1070
  closeReport(): "closed" | "confirm";
1030
1071
  confirmCloseReport(): void;
@@ -1191,6 +1232,20 @@ interface LassoEnvelope {
1191
1232
  y: number;
1192
1233
  } | null;
1193
1234
  sourceFile: string | null;
1235
+ /**
1236
+ * 대시보드가 구조화 필드로 읽는 자리. 본문 마크다운에도 같은 값이 들어가지만,
1237
+ * 거기 있는 건 사람이 읽는 용도라 화면의 뷰포트 칸이 비어 있었다(실측).
1238
+ */
1239
+ web: {
1240
+ url: string | null;
1241
+ route: string | null;
1242
+ viewport: {
1243
+ width: number | null;
1244
+ height: number | null;
1245
+ devicePixelRatio: number | null;
1246
+ } | null;
1247
+ userAgent: string | null;
1248
+ } | null;
1194
1249
  screenshotBase64: string | null;
1195
1250
  screenshotContentType: "image/jpeg" | "image/png" | null;
1196
1251
  network: unknown[];
package/dist/index.js CHANGED
@@ -627,6 +627,10 @@ var RingBuffer = class {
627
627
  var NETWORK_BUFFER_LIMIT = 30;
628
628
  var LOG_BUFFER_LIMIT = 50;
629
629
  var MAX_LOG_MESSAGE_CHARS = 2e3;
630
+ function failureReason(error) {
631
+ const message = error instanceof Error ? error.message : String(error);
632
+ return message.slice(0, 200) || "\uB124\uD2B8\uC6CC\uD06C \uC2E4\uD328";
633
+ }
630
634
  var DiagnosticsCollector = class {
631
635
  constructor(opts = {}) {
632
636
  this.installed = false;
@@ -636,7 +640,9 @@ var DiagnosticsCollector = class {
636
640
  this.originalXhrSend = null;
637
641
  this.originalWarn = null;
638
642
  this.originalError = null;
643
+ this.originalLog = null;
639
644
  this.excludeMatcher = null;
645
+ this.logScope = "problems";
640
646
  /**
641
647
  * 패치된 fetch 안에서 동기적으로 XHR 이 열리는 깊이.
642
648
  *
@@ -669,6 +675,7 @@ var DiagnosticsCollector = class {
669
675
  if (this.installed) return;
670
676
  this.installed = true;
671
677
  this.excludeMatcher = opts.excludeMatcher ?? null;
678
+ this.logScope = opts.logs ?? "problems";
672
679
  if (opts.now) this.now = opts.now;
673
680
  if (opts.iso) this.iso = opts.iso;
674
681
  this.patchFetch();
@@ -694,8 +701,10 @@ var DiagnosticsCollector = class {
694
701
  const c = g.console;
695
702
  if (c && this.originalWarn) c.warn = this.originalWarn;
696
703
  if (c && this.originalError) c.error = this.originalError;
704
+ if (c && this.originalLog) c.log = this.originalLog;
697
705
  this.originalWarn = null;
698
706
  this.originalError = null;
707
+ this.originalLog = null;
699
708
  this.excludeMatcher = null;
700
709
  this.fetchDepth = 0;
701
710
  }
@@ -712,11 +721,15 @@ var DiagnosticsCollector = class {
712
721
  this.logs.clear();
713
722
  }
714
723
  // ── 내부: 기록 ────────────────────────────────────────────
715
- recordNetwork(method, url, status, startedAt) {
724
+ /** 실패 사유 줄. 예외 객체엔 요청 본문이 들어 있을 수 있어 메시지만, 길이도 자른다. */
725
+ recordNetwork(method, url, status, startedAt, errorMessage = null) {
716
726
  this.network.push({
717
727
  method,
718
728
  url,
719
729
  status,
730
+ // 제보를 훑는 사람이 실패한 요청만 눈으로 골라낼 수 있게 따로 싣는다.
731
+ ok: status !== null && status >= 200 && status < 300,
732
+ errorMessage,
720
733
  durationMs: Math.max(0, Math.round(this.now() - startedAt)),
721
734
  at: this.iso(startedAt)
722
735
  });
@@ -753,7 +766,7 @@ var DiagnosticsCollector = class {
753
766
  pending = Promise.resolve(originalFetch(input, init));
754
767
  } catch (err) {
755
768
  self.fetchDepth -= 1;
756
- if (!excluded) self.recordNetwork(method, url, null, start);
769
+ if (!excluded) self.recordNetwork(method, url, null, start, failureReason(err));
757
770
  throw err;
758
771
  }
759
772
  self.fetchDepth -= 1;
@@ -771,7 +784,7 @@ var DiagnosticsCollector = class {
771
784
  return raw;
772
785
  },
773
786
  (err) => {
774
- if (!excluded) self.recordNetwork(method, url, null, start);
787
+ if (!excluded) self.recordNetwork(method, url, null, start, failureReason(err));
775
788
  throw err;
776
789
  }
777
790
  );
@@ -819,6 +832,7 @@ var DiagnosticsCollector = class {
819
832
  };
820
833
  }
821
834
  patchConsole() {
835
+ if (this.logScope === "off") return;
822
836
  const c = globalThis.console;
823
837
  if (!c) return;
824
838
  const self = this;
@@ -838,6 +852,14 @@ var DiagnosticsCollector = class {
838
852
  orig(...args);
839
853
  };
840
854
  }
855
+ if (this.logScope === "all" && typeof c.log === "function") {
856
+ const orig = c.log.bind(c);
857
+ this.originalLog = orig;
858
+ c.log = function patchedLog(...args) {
859
+ self.recordLog("log", args);
860
+ orig(...args);
861
+ };
862
+ }
841
863
  }
842
864
  };
843
865
  var sharedDiagnostics = new DiagnosticsCollector();
@@ -1056,6 +1078,7 @@ function resolveConfig(config = {}) {
1056
1078
  visibility: normalizeVisibility(config.visibility, warn),
1057
1079
  captureScreenshot: config.captureScreenshot ?? true,
1058
1080
  captureDiagnostics: config.captureDiagnostics ?? true,
1081
+ captureLogs: config.captureLogs ?? "problems",
1059
1082
  position: normalizePosition(config.position, warn),
1060
1083
  isInternal: typeof config.isInternal === "function" ? config.isInternal : null,
1061
1084
  internalRoles: config.internalRoles ?? DEFAULT_INTERNAL_ROLES,
@@ -1337,6 +1360,7 @@ var ReportModalController = class {
1337
1360
  this.captureGeneration = 0;
1338
1361
  this.state = {
1339
1362
  open: false,
1363
+ element: null,
1340
1364
  comment: "",
1341
1365
  priority: "unset",
1342
1366
  screenshot: null,
@@ -1402,7 +1426,7 @@ var ReportModalController = class {
1402
1426
  this.queue.stop?.();
1403
1427
  }
1404
1428
  // ── 열기/닫기 ─────────────────────────────────────────────────────────────
1405
- async open() {
1429
+ async open(opts = {}) {
1406
1430
  if (this.state.open) return;
1407
1431
  const wasPending = this.state.submitStatus === "pending" && this.lastReport !== null;
1408
1432
  const pendingReport = wasPending ? this.lastReport : null;
@@ -1411,6 +1435,8 @@ var ReportModalController = class {
1411
1435
  this.patch({
1412
1436
  open: true,
1413
1437
  comment: pendingReport?.comment ?? "",
1438
+ // 요소를 지목해서 연 경우엔 그 요소를 물고 간다. 대기 항목 복원이 우선이다.
1439
+ element: pendingReport?.element ?? opts.element ?? null,
1414
1440
  priority: pendingReport?.priority ?? "unset",
1415
1441
  screenshot: pendingReport?.screenshot ?? null,
1416
1442
  screenshotStatus: "none",
@@ -1570,12 +1596,13 @@ var ReportModalController = class {
1570
1596
  }
1571
1597
  this.patch({ submitStatus: "sending", submitMessage: null });
1572
1598
  const parts = {
1573
- kind: "report",
1599
+ // 요소를 지목했으면 지목 제보다. 수집 쪽이 이 값으로 갈린다.
1600
+ kind: this.state.element ? "annotation" : "report",
1574
1601
  comment: this.state.comment,
1575
1602
  priority: this.state.priority,
1576
1603
  screenshot: this.state.screenshot,
1577
1604
  pin: this.state.pin,
1578
- element: null
1605
+ element: this.state.element
1579
1606
  };
1580
1607
  let outcome;
1581
1608
  try {
@@ -1727,9 +1754,14 @@ var WidgetController = class {
1727
1754
  this.listeners.clear();
1728
1755
  }
1729
1756
  // ── 플로팅 버튼 ↔ 모달 ────────────────────────────────────────────────────
1730
- async openReport() {
1757
+ /**
1758
+ * @param opts.element 요소를 지목해서 열 때 그 요소. 지목 모드에서도 화면을
1759
+ * `modal` 로 바꾼다 — `modal.open()` 을 직접 부르면 상태만 열리고 화면은
1760
+ * `picking` 에 머물러 **모달이 렌더되지 않는다**.
1761
+ */
1762
+ async openReport(opts = {}) {
1731
1763
  this.screen = "modal";
1732
- await this.modal.open();
1764
+ await this.modal.open(opts);
1733
1765
  this.emit();
1734
1766
  }
1735
1767
  /** 모달 닫기 요청. 쓰던 내용이 있으면 확인부터 받는다(화면은 그대로 모달). */
@@ -2053,6 +2085,19 @@ function parseRetryAfterMs(res) {
2053
2085
  const seconds = Number(raw.trim());
2054
2086
  return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1e3) : null;
2055
2087
  }
2088
+ function reasonFrom(text) {
2089
+ try {
2090
+ const body = JSON.parse(text);
2091
+ const code = typeof body.code === "string" ? body.code : null;
2092
+ const message = typeof body.message === "string" ? body.message : null;
2093
+ const joined = [code, message].filter(Boolean).join(": ");
2094
+ if (joined) return ` \u2014 ${joined.slice(0, 200)}`;
2095
+ } catch {
2096
+ const head = text.trim().slice(0, 120);
2097
+ if (head) return ` \u2014 ${head}`;
2098
+ }
2099
+ return "";
2100
+ }
2056
2101
  function fail(retryable, extra) {
2057
2102
  return { ok: false, id: null, retryable, retryAfterMs: null, ...extra };
2058
2103
  }
@@ -2107,6 +2152,12 @@ function buildLassoEnvelope(report) {
2107
2152
  comment: renderReportBody(report),
2108
2153
  pin: report.pin ? { x: report.pin.x, y: report.pin.y } : null,
2109
2154
  sourceFile: el?.attributes["data-feedback-source"] ?? null,
2155
+ web: clientType === "web" ? {
2156
+ url: c.url,
2157
+ route: c.screen,
2158
+ viewport: c.web?.viewport ?? null,
2159
+ userAgent: c.web?.userAgent ?? null
2160
+ } : null,
2110
2161
  screenshotBase64: report.screenshot?.base64 ?? null,
2111
2162
  screenshotContentType: report.screenshot?.contentType ?? null,
2112
2163
  // 서버가 없으면 빈 배열로 취급하지만, 미연동과 "없었음"을 구분해 보낸다.
@@ -2162,29 +2213,38 @@ function createLassoAdapter(opts = {}) {
2162
2213
  return fail(true);
2163
2214
  }
2164
2215
  const status = res.status;
2216
+ let text = "";
2217
+ try {
2218
+ text = await res.text();
2219
+ } catch {
2220
+ }
2165
2221
  if (status < 200 || status >= 300) {
2222
+ const reason = reasonFrom(text);
2166
2223
  if (status === 429) {
2167
- emit("feedback-kit: \uC218\uC9D1 \uD55C\uB3C4\uC5D0 \uAC78\uB838\uB2E4(429). \uC7A0\uC2DC \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4.");
2224
+ emit(`feedback-kit: \uC218\uC9D1 \uD55C\uB3C4\uC5D0 \uAC78\uB838\uB2E4(429). \uC7A0\uC2DC \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4.${reason}`);
2168
2225
  return fail(true, { retryAfterMs: parseRetryAfterMs(res), rateLimited: true });
2169
2226
  }
2170
2227
  const retryable = RETRYABLE_STATUS.has(status);
2171
2228
  emit(
2172
- `feedback-kit: \uC218\uC9D1 \uC751\uB2F5 ${status}${retryable ? " (\uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4)" : " (\uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4)"}`
2229
+ `feedback-kit: \uC218\uC9D1 \uC751\uB2F5 ${status}${retryable ? " (\uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4)" : " (\uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4)"}${reason}`
2173
2230
  );
2174
2231
  return fail(retryable, { retryAfterMs: retryable ? parseRetryAfterMs(res) : null });
2175
2232
  }
2176
2233
  let parsed;
2177
2234
  try {
2178
- const text = await res.text();
2179
2235
  const value = JSON.parse(text);
2180
2236
  if (value === null || typeof value !== "object") throw new Error("\uAC1D\uCCB4\uAC00 \uC544\uB2D8");
2181
2237
  parsed = value;
2182
2238
  } catch {
2183
- emit(`feedback-kit: \uC218\uC9D1 \uC751\uB2F5 ${status} \uC758 \uBCF8\uBB38\uC744 \uC77D\uC9C0 \uBABB\uD588\uB2E4. \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4.`);
2239
+ emit(
2240
+ `feedback-kit: \uC218\uC9D1 \uC751\uB2F5 ${status} \uC758 \uBCF8\uBB38\uC774 JSON \uC774 \uC544\uB2C8\uB2E4. \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4.${reasonFrom(text)}`
2241
+ );
2184
2242
  return fail(true);
2185
2243
  }
2186
2244
  if (parsed.ok !== true) {
2187
- emit(`feedback-kit: \uC218\uC9D1 \uC11C\uBC84\uAC00 \uC81C\uBCF4\uB97C \uAC70\uC808\uD588\uB2E4(${status}). \uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4.`);
2245
+ emit(
2246
+ `feedback-kit: \uC218\uC9D1 \uC11C\uBC84\uAC00 \uC81C\uBCF4\uB97C \uAC70\uC808\uD588\uB2E4(${status}). \uC7AC\uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4.${reasonFrom(text)}`
2247
+ );
2188
2248
  return fail(false);
2189
2249
  }
2190
2250
  return { ok: true, id: idFrom(parsed), retryable: false, retryAfterMs: null };