@solhun/feedback-kit-core 0.5.0 → 0.6.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.cjs CHANGED
@@ -33,6 +33,16 @@ __export(index_exports, {
33
33
  FLOATING_BUTTON_ID: () => FLOATING_BUTTON_ID,
34
34
  FeedbackQueue: () => FeedbackQueue,
35
35
  FocusRing: () => FocusRing,
36
+ HINT_DISPLAY_DEFAULT: () => HINT_DISPLAY_DEFAULT,
37
+ HINT_DISPLAY_MAX: () => HINT_DISPLAY_MAX,
38
+ HINT_DISPLAY_MIN: () => HINT_DISPLAY_MIN,
39
+ HINT_DRAFT_MAX_CHARS: () => HINT_DRAFT_MAX_CHARS,
40
+ HINT_FAIL_COOLDOWN_MS: () => HINT_FAIL_COOLDOWN_MS,
41
+ HINT_LABEL_MAX_CHARS: () => HINT_LABEL_MAX_CHARS,
42
+ HINT_MAX_ITEMS: () => HINT_MAX_ITEMS,
43
+ HINT_TIMEOUT_MS: () => HINT_TIMEOUT_MS,
44
+ HINT_TTL_MS: () => HINT_TTL_MS,
45
+ HintProvider: () => HintProvider,
36
46
  KEEPALIVE_BODY_LIMIT_BYTES: () => KEEPALIVE_BODY_LIMIT_BYTES,
37
47
  LASSO_DEFAULT_ENDPOINT: () => LASSO_DEFAULT_ENDPOINT,
38
48
  LOG_BUFFER_LIMIT: () => LOG_BUFFER_LIMIT,
@@ -41,6 +51,7 @@ __export(index_exports, {
41
51
  MAX_QUEUE_ITEMS: () => MAX_QUEUE_ITEMS,
42
52
  MODAL_ACTION_ATTACH: () => MODAL_ACTION_ATTACH,
43
53
  MODAL_ACTION_CANCEL: () => MODAL_ACTION_CANCEL,
54
+ MODAL_ACTION_HINT_TOGGLE: () => MODAL_ACTION_HINT_TOGGLE,
44
55
  MODAL_ACTION_PICK: () => MODAL_ACTION_PICK,
45
56
  MODAL_ACTION_PIN: () => MODAL_ACTION_PIN,
46
57
  MODAL_ACTION_REMOVE_SCREENSHOT: () => MODAL_ACTION_REMOVE_SCREENSHOT,
@@ -54,6 +65,9 @@ __export(index_exports, {
54
65
  REDACTED: () => REDACTED,
55
66
  ReportModalController: () => ReportModalController,
56
67
  RingBuffer: () => RingBuffer,
68
+ SCORE_ELEMENT: () => SCORE_ELEMENT,
69
+ SCORE_PATH_EXACT: () => SCORE_PATH_EXACT,
70
+ SCORE_PATH_PATTERN: () => SCORE_PATH_PATTERN,
57
71
  SCREENSHOT_FAILED_MESSAGE: () => SCREENSHOT_FAILED_MESSAGE,
58
72
  SCREENSHOT_MAX_BASE64_BYTES: () => SCREENSHOT_MAX_BASE64_BYTES,
59
73
  SCREENSHOT_QUALITY_STEPS: () => SCREENSHOT_QUALITY_STEPS,
@@ -63,6 +77,7 @@ __export(index_exports, {
63
77
  SUBMIT_PENDING_MESSAGE: () => SUBMIT_PENDING_MESSAGE,
64
78
  TITLE_MAX_CHARS: () => TITLE_MAX_CHARS,
65
79
  WidgetController: () => WidgetController,
80
+ appPathFromNav: () => appPathFromNav,
66
81
  buildContext: () => buildContext,
67
82
  buildLassoContext: () => buildLassoContext,
68
83
  buildLassoEnvelope: () => buildLassoEnvelope,
@@ -80,10 +95,15 @@ __export(index_exports, {
80
95
  diagnosticsProviderFor: () => diagnosticsProviderFor,
81
96
  getOrCreateGuestId: () => getOrCreateGuestId,
82
97
  guestUser: () => guestUser,
98
+ hintCharCount: () => hintCharCount,
83
99
  isInternalUser: () => isInternalUser,
100
+ matchElement: () => matchElement,
101
+ matchPath: () => matchPath,
84
102
  normalizePin: () => normalizePin,
85
103
  normalizeUser: () => normalizeUser,
104
+ parseHintCatalog: () => parseHintCatalog,
86
105
  parseSourceAttr: () => parseSourceAttr,
106
+ rankHints: () => rankHints,
87
107
  renderReportBody: () => renderReportBody,
88
108
  resolveConfig: () => resolveConfig,
89
109
  resolveContextUser: () => resolveContextUser,
@@ -314,7 +334,9 @@ async function buildReport(parts, opts) {
314
334
  pin: parts.pin,
315
335
  element: parts.element,
316
336
  context,
317
- createdAt: iso(now())
337
+ createdAt: iso(now()),
338
+ // 생략과 "정말 아무 것도 안 눌렀음"을 굳이 구분하지 않는다 — 둘 다 빈 배열로 싣는다.
339
+ hintIds: parts.hintIds ?? []
318
340
  };
319
341
  }
320
342
 
@@ -1437,6 +1459,7 @@ var COMMENT_TOO_LONG_MESSAGE = "4,000\uC790 \uC774\uD558\uB85C \uC785\uB825\uD57
1437
1459
  var SUBMIT_DONE_MESSAGE = "\uBCF4\uB0C8\uC2B5\uB2C8\uB2E4";
1438
1460
  var SUBMIT_PENDING_MESSAGE = "\uB300\uAE30 \uC911";
1439
1461
  var SUBMIT_FAILED_MESSAGE = "\uBCF4\uB0B4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4";
1462
+ var MODAL_ACTION_HINT_TOGGLE = "hint-toggle";
1440
1463
  var MODAL_FIELD_COMMENT = "comment";
1441
1464
  var MODAL_FIELD_PRIORITY = "priority";
1442
1465
  var MODAL_ACTION_ATTACH = "attach";
@@ -1458,6 +1481,12 @@ var ReportModalController = class {
1458
1481
  this.captureGeneration = 0;
1459
1482
  /** 지금 들고 있는 핀의 기준. 핀이 없으면 의미 없다(기본 `picture`). */
1460
1483
  this.pinAnchor = "picture";
1484
+ /**
1485
+ * 사용자가 [제안 ▾] 를 직접 건드렸는가(방향 무관). 한 번이라도 건드리면 이후
1486
+ * 타이핑에 의한 자동 접힘/펼침이 멈춘다 — "사람이 편 상태는 모달이 닫힐 때까지 유지"를
1487
+ * 이렇게 구현한다. `open()` 에서 매번 리셋된다.
1488
+ */
1489
+ this.userToggledHints = false;
1461
1490
  this.state = {
1462
1491
  open: false,
1463
1492
  element: null,
@@ -1478,7 +1507,11 @@ var ReportModalController = class {
1478
1507
  actions: [],
1479
1508
  focusOrder: [],
1480
1509
  focused: null,
1481
- restoreFocusTo: null
1510
+ restoreFocusTo: null,
1511
+ hints: [],
1512
+ hintsExpanded: false,
1513
+ hintsAvailable: false,
1514
+ usedHintIds: []
1482
1515
  };
1483
1516
  this.queue = opts.queue;
1484
1517
  this.createReport = opts.createReport;
@@ -1538,9 +1571,11 @@ var ReportModalController = class {
1538
1571
  this.touched = false;
1539
1572
  this.submitAttempted = false;
1540
1573
  this.pinAnchor = opts.pin && !pendingReport ? "viewport" : "picture";
1574
+ this.userToggledHints = false;
1575
+ const resolvedComment = pendingReport?.comment ?? "";
1541
1576
  this.patch({
1542
1577
  open: true,
1543
- comment: pendingReport?.comment ?? "",
1578
+ comment: resolvedComment,
1544
1579
  // 요소를 지목해서 연 경우엔 그 요소를 물고 간다. 대기 항목 복원이 우선이다.
1545
1580
  element: pendingReport?.element ?? opts.element ?? null,
1546
1581
  priority: pendingReport?.priority ?? "unset",
@@ -1551,7 +1586,10 @@ var ReportModalController = class {
1551
1586
  submitStatus: wasPending ? "pending" : "idle",
1552
1587
  submitMessage: wasPending ? SUBMIT_PENDING_MESSAGE : null,
1553
1588
  closeConfirmVisible: false,
1554
- restoreFocusTo: null
1589
+ restoreFocusTo: null,
1590
+ // 코멘트가 비어 있을 때만 펼쳐진 채로 연다. 대기 항목을 복원해도 규칙은 같다(계약 원문).
1591
+ hintsExpanded: resolvedComment === "",
1592
+ usedHintIds: []
1555
1593
  });
1556
1594
  this.ring.focus(MODAL_FIELD_COMMENT);
1557
1595
  this.patch({ focused: this.ring.current });
@@ -1610,7 +1648,22 @@ var ReportModalController = class {
1610
1648
  setComment(value) {
1611
1649
  if (this.isDraftLocked()) return;
1612
1650
  this.touched = true;
1613
- this.patch({ comment: value });
1651
+ this.patch({ comment: value, ...this.hintsPatchFor(value) });
1652
+ }
1653
+ /**
1654
+ * 코멘트가 비었는지에 따라 칩 줄 상태를 갱신하는 조각을 만든다. `setComment`·`applyHint`
1655
+ * 양쪽에서 쓴다.
1656
+ *
1657
+ * - 완전히 비워지면(trim 기준) 눌린 칩 기록을 버리고 다시 펼친다 — "지우고 처음부터 다시
1658
+ * 쓴 글"에 이전 제안을 귀속시키지 않기 위해서다. 사용자가 직접 편 상태여도 예외 없다.
1659
+ * - 비어 있지 않게 되면, 사용자가 직접 편 게 아닌 이상 접는다(좁은 화면에서 입력란을
1660
+ * 밀어내지 않으려고). 직접 편 상태(`userToggledHints`)면 손대지 않는다.
1661
+ */
1662
+ hintsPatchFor(nextComment) {
1663
+ if (nextComment.trim().length === 0) {
1664
+ return { hintsExpanded: true, usedHintIds: [] };
1665
+ }
1666
+ return this.userToggledHints ? {} : { hintsExpanded: false };
1614
1667
  }
1615
1668
  setPriority(priority) {
1616
1669
  if (this.isDraftLocked()) return;
@@ -1683,6 +1736,41 @@ var ReportModalController = class {
1683
1736
  ...dropPin
1684
1737
  });
1685
1738
  }
1739
+ // ── 제안 힌트 ─────────────────────────────────────────────────────────────
1740
+ /**
1741
+ * 컨트롤러가 랭킹 결과를 밀어넣는다. **모달이 열려 있는 동안에는 무시한다** — 지목 대상을
1742
+ * 바꿔 새로 열기 직전에 호출되는 흐름이 정상이고, 열려 있는 모달의 칩을 도중에 바꾸면
1743
+ * 누르려던 칩이 손가락 밑에서 바뀐다(캐시 백그라운드 갱신도 이 경로로 들어올 수 있다).
1744
+ */
1745
+ setHints(hints) {
1746
+ if (this.state.open) return;
1747
+ this.patch({ hints });
1748
+ }
1749
+ /**
1750
+ * 칩 누름. 이미 눌린 칩이면 아무 일도 하지 않는다(같은 문구가 두 번 이어붙는 걸 막는다).
1751
+ * 코멘트가 비어 있으면 초안으로 치환, 아니면 줄바꿈 뒤 이어붙인다 — 어느 쪽도 사용자가
1752
+ * 쓴 글을 지우지 않는다.
1753
+ */
1754
+ applyHint(hintId) {
1755
+ if (this.isDraftLocked()) return;
1756
+ if (this.state.usedHintIds.includes(hintId)) return;
1757
+ const hint = this.state.hints.find((h) => h.id === hintId);
1758
+ if (!hint) return;
1759
+ this.touched = true;
1760
+ const comment = this.state.comment.length === 0 ? hint.draft : `${this.state.comment}
1761
+ ${hint.draft}`;
1762
+ this.patch({
1763
+ comment,
1764
+ usedHintIds: [...this.state.usedHintIds, hintId],
1765
+ // 방금 채워 넣었으니 비어 있지 않다 — setComment 와 같은 접힘 규칙을 그대로 쓴다.
1766
+ ...this.hintsPatchFor(comment)
1767
+ });
1768
+ }
1769
+ /** [제안 ▾] 토글. 방향과 무관하게 이후 자동 접힘/펼침을 멈춘다(`userToggledHints`). */
1770
+ toggleHints() {
1771
+ this.userToggledHints = true;
1772
+ this.patch({ hintsExpanded: !this.state.hintsExpanded });
1773
+ }
1686
1774
  // ── 포커스 ────────────────────────────────────────────────────────────────
1687
1775
  tabNext() {
1688
1776
  const next = this.ring.next();
@@ -1720,7 +1808,9 @@ var ReportModalController = class {
1720
1808
  priority: this.state.priority,
1721
1809
  screenshot: this.state.screenshot,
1722
1810
  pin: this.state.pin,
1723
- element: this.state.element
1811
+ element: this.state.element,
1812
+ // 눌린 칩 기록. 문구를 고쳐 보내도 "이 제안을 눌렀다"는 사실은 그대로 남는다.
1813
+ hintIds: this.state.usedHintIds
1724
1814
  };
1725
1815
  let outcome;
1726
1816
  try {
@@ -1786,8 +1876,15 @@ var ReportModalController = class {
1786
1876
  actionsFor() {
1787
1877
  return this.platform === "web" ? [MODAL_ACTION_PICK] : [MODAL_ACTION_PIN];
1788
1878
  }
1789
- focusOrderFor() {
1790
- const order = [MODAL_FIELD_COMMENT, MODAL_FIELD_PRIORITY];
1879
+ focusOrderFor(hintsAvailable) {
1880
+ const order = [];
1881
+ if (hintsAvailable) {
1882
+ if (this.state.hintsExpanded) {
1883
+ for (const hint of this.state.hints) order.push(`hint:${hint.id}`);
1884
+ }
1885
+ order.push(MODAL_ACTION_HINT_TOGGLE);
1886
+ }
1887
+ order.push(MODAL_FIELD_COMMENT, MODAL_FIELD_PRIORITY);
1791
1888
  if (this.state.screenshot) order.push(MODAL_ACTION_REMOVE_SCREENSHOT);
1792
1889
  else order.push(MODAL_ACTION_ATTACH);
1793
1890
  order.push(...this.actionsFor());
@@ -1800,7 +1897,8 @@ var ReportModalController = class {
1800
1897
  const canSubmit = this.isCommentValid() && this.state.screenshotStatus !== "capturing" && (this.state.submitStatus === "idle" || this.state.submitStatus === "failed");
1801
1898
  const canPin = this.platform === "native" && this.state.screenshot !== null && this.state.screenshotStatus !== "capturing" && !this.isDraftLocked() && this.state.open;
1802
1899
  const actions = this.actionsFor();
1803
- const focusOrder = this.state.open ? this.focusOrderFor() : [];
1900
+ const hintsAvailable = this.state.hints.length > 0;
1901
+ const focusOrder = this.state.open ? this.focusOrderFor(hintsAvailable) : [];
1804
1902
  this.ring.setItems(focusOrder);
1805
1903
  this.state = {
1806
1904
  ...this.state,
@@ -1809,6 +1907,7 @@ var ReportModalController = class {
1809
1907
  canPin,
1810
1908
  showRetry: this.state.submitStatus === "pending" || this.state.pending > 0,
1811
1909
  actions,
1910
+ hintsAvailable,
1812
1911
  focusOrder,
1813
1912
  focused: this.state.open ? this.ring.current : null
1814
1913
  };
@@ -2199,6 +2298,13 @@ function absoluteEndpoint(endpoint) {
2199
2298
  if (typeof origin !== "string" || origin.length === 0) return null;
2200
2299
  return endpoint.startsWith("/") ? `${origin}${endpoint}` : `${origin}/${endpoint}`;
2201
2300
  }
2301
+ function hintsCacheKeyFrom(endpoint) {
2302
+ const path = endpoint.split(/[?#]/)[0] ?? "";
2303
+ const segments = path.split("/").filter((s) => s.length > 0);
2304
+ const last = segments.at(-1);
2305
+ if (!last || last === "verify") return void 0;
2306
+ return last;
2307
+ }
2202
2308
  function parseRetryAfterMs(res) {
2203
2309
  const raw = res.headers?.get("Retry-After") ?? null;
2204
2310
  if (raw === null) return null;
@@ -2263,6 +2369,8 @@ function buildLassoContext(report) {
2263
2369
  } : null,
2264
2370
  // 속성이 하나도 없으면 빈 객체를 보내지 않는다 — 화면에 빈 항목이 늘어난다.
2265
2371
  element: el && (el.id !== null || Object.keys(el.attributes).length > 0) ? { id: el.id, attributes: el.attributes } : null,
2372
+ // 생략과 "정말 하나도 안 눌렀음"을 구분하지 않는다 — 둘 다 빈 배열.
2373
+ hintIds: [...report.hintIds ?? []],
2266
2374
  native: c.native ? {
2267
2375
  screenPath: c.native.screenPath,
2268
2376
  navPath: c.native.navPath,
@@ -2322,6 +2430,7 @@ function createLassoAdapter(opts = {}) {
2322
2430
  const token = opts.token ?? null;
2323
2431
  const endpoint = opts.endpoint && opts.endpoint.trim().length > 0 ? opts.endpoint.trim() : LASSO_DEFAULT_ENDPOINT;
2324
2432
  const warn = opts.warn ?? null;
2433
+ const hintsCacheKey = hintsCacheKeyFrom(endpoint);
2325
2434
  const emit = (message) => {
2326
2435
  if (!warn) return;
2327
2436
  try {
@@ -2330,6 +2439,7 @@ function createLassoAdapter(opts = {}) {
2330
2439
  }
2331
2440
  };
2332
2441
  return {
2442
+ hintsCacheKey,
2333
2443
  async submit(report) {
2334
2444
  const url = absoluteEndpoint(endpoint);
2335
2445
  if (url === null) {
@@ -2400,7 +2510,491 @@ function createLassoAdapter(opts = {}) {
2400
2510
  return fail(false);
2401
2511
  }
2402
2512
  return { ok: true, id: idFrom(parsed), retryable: false, retryAfterMs: null };
2513
+ },
2514
+ /**
2515
+ * 힌트 카탈로그 조회. `submit` 과 같은 절대 규칙이 그대로 적용된다 — **던지지 않는다**,
2516
+ * **본문·헤더를 로그에 남기지 않는다.** 다만 여기는 `warn` 을 아예 부르지 않는다.
2517
+ * "실패하면 조용히 포기"·"개발 빌드에서만 1회 경고"의 판단(쿨다운·환경 분기)은
2518
+ * `HintProvider`(hints/ 소유자) 몫이라, 어댑터가 매 실패마다 경고를 찍으면 그 판단과
2519
+ * 어긋난다.
2520
+ */
2521
+ async fetchHints(etag) {
2522
+ const url = absoluteEndpoint(`${endpoint.replace(/\/+$/, "")}/hints`);
2523
+ if (url === null) return { kind: "fail" };
2524
+ const sender = resolveFetch(opts.fetch);
2525
+ if (sender === null) return { kind: "fail" };
2526
+ const headers = {};
2527
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
2528
+ if (etag !== null) headers["If-None-Match"] = etag;
2529
+ let res;
2530
+ try {
2531
+ res = await sender(url, { method: "GET", headers });
2532
+ } catch {
2533
+ return { kind: "fail" };
2534
+ }
2535
+ if (res.status === 304) return { kind: "not-modified" };
2536
+ if (res.status === 401 || res.status === 403) {
2537
+ return { kind: "fail", permanent: true };
2538
+ }
2539
+ if (res.status < 200 || res.status >= 300) return { kind: "fail" };
2540
+ let text;
2541
+ try {
2542
+ text = await res.text();
2543
+ } catch {
2544
+ return { kind: "fail" };
2545
+ }
2546
+ try {
2547
+ const raw = JSON.parse(text);
2548
+ return { kind: "ok", raw, etag: res.headers?.get("ETag") ?? null };
2549
+ } catch {
2550
+ return { kind: "fail" };
2551
+ }
2552
+ }
2553
+ };
2554
+ }
2555
+
2556
+ // src/hints/constants.ts
2557
+ var HINT_LABEL_MAX_CHARS = 12;
2558
+ var HINT_DRAFT_MAX_CHARS = 200;
2559
+ var HINT_DISPLAY_DEFAULT = 4;
2560
+ var HINT_DISPLAY_MIN = 3;
2561
+ var HINT_DISPLAY_MAX = 6;
2562
+ var HINT_MAX_ITEMS = 300;
2563
+ var HINT_TTL_MS = 24 * 60 * 60 * 1e3;
2564
+ var HINT_TIMEOUT_MS = 5e3;
2565
+ var HINT_FAIL_COOLDOWN_MS = 5 * 60 * 1e3;
2566
+ var SCORE_PATH_EXACT = 40;
2567
+ var SCORE_PATH_PATTERN = 25;
2568
+ var SCORE_ELEMENT = 30;
2569
+
2570
+ // src/hints/chars.ts
2571
+ function hintCharCount(value) {
2572
+ return [...value.normalize("NFC")].length;
2573
+ }
2574
+
2575
+ // src/hints/path.ts
2576
+ function appPathFromNav(navPath) {
2577
+ if (navPath === null || navPath === void 0 || navPath.length === 0) return null;
2578
+ return "/" + navPath.join("/");
2579
+ }
2580
+ function matchPath(pattern, path) {
2581
+ const patternSegs = splitSegments(pattern);
2582
+ const pathSegs = splitSegments(path);
2583
+ if (patternSegs === null || pathSegs === null) return null;
2584
+ const hasWildcard = patternSegs.some((seg) => seg === "*" || seg === "**");
2585
+ if (!hasWildcard) {
2586
+ if (patternSegs.length !== pathSegs.length) return null;
2587
+ for (let i = 0; i < patternSegs.length; i++) {
2588
+ if (patternSegs[i] !== pathSegs[i]) return null;
2589
+ }
2590
+ return "exact";
2591
+ }
2592
+ return matchesFrom(patternSegs, 0, pathSegs, 0) ? "pattern" : null;
2593
+ }
2594
+ function splitSegments(value) {
2595
+ if (typeof value !== "string" || value.length === 0) return null;
2596
+ if (!value.startsWith("/")) return null;
2597
+ if (value === "/") return [];
2598
+ const trimmed = value.endsWith("/") ? value.slice(0, -1) : value;
2599
+ const segments = trimmed.split("/").slice(1);
2600
+ if (segments.some((seg) => seg.length === 0)) return null;
2601
+ return segments;
2602
+ }
2603
+ function matchesFrom(pattern, patternIndex, path, pathIndex) {
2604
+ if (patternIndex === pattern.length) return pathIndex === path.length;
2605
+ const seg = pattern[patternIndex];
2606
+ if (seg === "**") {
2607
+ for (let consume = 0; pathIndex + consume <= path.length; consume++) {
2608
+ if (matchesFrom(pattern, patternIndex + 1, path, pathIndex + consume)) return true;
2609
+ }
2610
+ return false;
2611
+ }
2612
+ if (pathIndex >= path.length) return false;
2613
+ if (seg === "*") return matchesFrom(pattern, patternIndex + 1, path, pathIndex + 1);
2614
+ return path[pathIndex] === seg && matchesFrom(pattern, patternIndex + 1, path, pathIndex + 1);
2615
+ }
2616
+
2617
+ // src/hints/element.ts
2618
+ function matchElement(rule, element, components) {
2619
+ if (rule === null) return true;
2620
+ try {
2621
+ const axes = [rule.tags, rule.roles, rule.classIncludes, rule.componentIncludes, rule.testIds];
2622
+ const hasAnyAxis = axes.some((axis) => Array.isArray(axis) && axis.length > 0);
2623
+ if (!hasAnyAxis) return true;
2624
+ if (element === null) return false;
2625
+ return matchesTags(rule.tags, element) || matchesRoles(rule.roles, element) || matchesClassIncludes(rule.classIncludes, element) || matchesComponentIncludes(rule.componentIncludes, components) || matchesTestIds(rule.testIds, element);
2626
+ } catch {
2627
+ return false;
2628
+ }
2629
+ }
2630
+ function matchesTags(tags, element) {
2631
+ if (!Array.isArray(tags)) return false;
2632
+ const tag = safeLower(element.tag);
2633
+ if (tag === null) return false;
2634
+ return tags.some((t) => typeof t === "string" && t.toLowerCase() === tag);
2635
+ }
2636
+ function matchesRoles(roles, element) {
2637
+ if (!Array.isArray(roles)) return false;
2638
+ const role = safeLower(element.attributes?.role ?? null);
2639
+ const type = safeLower(element.attributes?.type ?? null);
2640
+ return roles.some((r) => {
2641
+ if (typeof r !== "string") return false;
2642
+ const lowered = r.toLowerCase();
2643
+ return lowered === role || lowered === type;
2644
+ });
2645
+ }
2646
+ function matchesClassIncludes(classIncludes, element) {
2647
+ if (!Array.isArray(classIncludes)) return false;
2648
+ const className = safeLower(element.className);
2649
+ if (className === null) return false;
2650
+ return classIncludes.some((c) => typeof c === "string" && className.includes(c.toLowerCase()));
2651
+ }
2652
+ function matchesComponentIncludes(componentIncludes, components) {
2653
+ if (!Array.isArray(componentIncludes)) return false;
2654
+ if (components === null) return false;
2655
+ return componentIncludes.some((c) => typeof c === "string" && components.includes(c));
2656
+ }
2657
+ function matchesTestIds(testIds, element) {
2658
+ if (!Array.isArray(testIds)) return false;
2659
+ const testId = element.attributes?.["data-testid"];
2660
+ if (typeof testId !== "string") return false;
2661
+ return testIds.some((t) => t === testId);
2662
+ }
2663
+ function safeLower(value) {
2664
+ return typeof value === "string" ? value.toLowerCase() : null;
2665
+ }
2666
+
2667
+ // src/hints/parse.ts
2668
+ var SCOPES = ["screen", "element", "both"];
2669
+ var PLATFORMS = ["web", "app"];
2670
+ function parseHintCatalog(raw) {
2671
+ if (typeof raw !== "object" || raw === null) return null;
2672
+ const obj = raw;
2673
+ if (obj.ok !== true) return null;
2674
+ if (!Array.isArray(obj.hints)) return null;
2675
+ const hints = [];
2676
+ for (const item of obj.hints) {
2677
+ if (hints.length >= HINT_MAX_ITEMS) break;
2678
+ const parsed = parseHintItem(item);
2679
+ if (parsed !== null) hints.push(parsed);
2680
+ }
2681
+ return {
2682
+ version: typeof obj.version === "number" && Number.isInteger(obj.version) ? obj.version : null,
2683
+ maxDisplay: parseMaxDisplay(obj.maxDisplay),
2684
+ hints
2685
+ };
2686
+ }
2687
+ function parseMaxDisplay(value) {
2688
+ if (typeof value === "number" && Number.isInteger(value) && value >= HINT_DISPLAY_MIN && value <= HINT_DISPLAY_MAX) {
2689
+ return value;
2690
+ }
2691
+ return HINT_DISPLAY_DEFAULT;
2692
+ }
2693
+ function parseHintItem(item) {
2694
+ if (typeof item !== "object" || item === null) return null;
2695
+ const obj = item;
2696
+ const id = nonEmptyString(obj.id);
2697
+ const label = nonEmptyString(obj.label);
2698
+ const draft = nonEmptyString(obj.draft);
2699
+ if (id === null || label === null || draft === null) return null;
2700
+ if (typeof obj.scope !== "string" || !SCOPES.includes(obj.scope)) return null;
2701
+ const scope = obj.scope;
2702
+ const platforms = parsePlatforms(obj.platforms);
2703
+ if (platforms === null) return null;
2704
+ return {
2705
+ id,
2706
+ label,
2707
+ draft,
2708
+ scope,
2709
+ platforms,
2710
+ paths: parsePaths(obj.paths),
2711
+ element: parseElementRule(obj.element),
2712
+ active: parseActive(obj.active)
2713
+ };
2714
+ }
2715
+ function parseActive(value) {
2716
+ return value !== false;
2717
+ }
2718
+ function parsePlatforms(value) {
2719
+ if (!Array.isArray(value) || value.length === 0) return null;
2720
+ const result = [];
2721
+ for (const p of value) {
2722
+ if (typeof p !== "string" || !PLATFORMS.includes(p)) return null;
2723
+ result.push(p);
2724
+ }
2725
+ return result;
2726
+ }
2727
+ function parsePaths(value) {
2728
+ if (!Array.isArray(value)) return [];
2729
+ return value.filter((p) => typeof p === "string");
2730
+ }
2731
+ function parseElementRule(value) {
2732
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2733
+ const obj = value;
2734
+ const rule = {};
2735
+ if (Array.isArray(obj.tags)) rule.tags = obj.tags.filter((v) => typeof v === "string");
2736
+ if (Array.isArray(obj.roles)) rule.roles = obj.roles.filter((v) => typeof v === "string");
2737
+ if (Array.isArray(obj.classIncludes)) {
2738
+ rule.classIncludes = obj.classIncludes.filter((v) => typeof v === "string");
2739
+ }
2740
+ if (Array.isArray(obj.componentIncludes)) {
2741
+ rule.componentIncludes = obj.componentIncludes.filter((v) => typeof v === "string");
2742
+ }
2743
+ if (Array.isArray(obj.testIds)) rule.testIds = obj.testIds.filter((v) => typeof v === "string");
2744
+ return rule;
2745
+ }
2746
+ function nonEmptyString(value) {
2747
+ return typeof value === "string" && value.length > 0 ? value : null;
2748
+ }
2749
+
2750
+ // src/hints/rank.ts
2751
+ function rankHints(hints, input, limit) {
2752
+ const scored = [];
2753
+ for (let index = 0; index < hints.length; index++) {
2754
+ const hint = hints[index];
2755
+ if (!passesHardFilter(hint, input)) continue;
2756
+ scored.push({ hint, score: scoreHint(hint, input), index });
2757
+ }
2758
+ const ordered = orderByScoreThenRotatedGroup(scored, input.path);
2759
+ return ordered.slice(0, Math.max(0, limit)).map((s) => s.hint);
2760
+ }
2761
+ function orderByScoreThenRotatedGroup(scored, path) {
2762
+ const groups = /* @__PURE__ */ new Map();
2763
+ for (const s of scored) {
2764
+ const group = groups.get(s.score);
2765
+ if (group) group.push(s);
2766
+ else groups.set(s.score, [s]);
2767
+ }
2768
+ const scoresDesc = [...groups.keys()].sort((a, b) => b - a);
2769
+ const result = [];
2770
+ for (const score of scoresDesc) {
2771
+ result.push(...rotateGroup(groups.get(score), path));
2772
+ }
2773
+ return result;
2774
+ }
2775
+ function rotateGroup(group, path) {
2776
+ if (group.length <= 1 || path === null) return group;
2777
+ const offset = fnv1a32(path) % group.length;
2778
+ if (offset === 0) return group;
2779
+ return [...group.slice(offset), ...group.slice(0, offset)];
2780
+ }
2781
+ function fnv1a32(value) {
2782
+ let hash = 2166136261;
2783
+ for (let i = 0; i < value.length; i++) {
2784
+ hash ^= value.charCodeAt(i);
2785
+ hash = Math.imul(hash, 16777619);
2786
+ }
2787
+ return hash >>> 0;
2788
+ }
2789
+ function passesHardFilter(hint, input) {
2790
+ if (!hint.platforms.includes(input.platform)) return false;
2791
+ const wantsElementScope = input.element !== null;
2792
+ if (wantsElementScope) {
2793
+ if (hint.scope !== "element" && hint.scope !== "both") return false;
2794
+ } else if (hint.scope !== "screen" && hint.scope !== "both") {
2795
+ return false;
2796
+ }
2797
+ if (hint.paths.length > 0 && !matchesAnyPath(hint.paths, input.path)) return false;
2798
+ if (hint.active === false) return false;
2799
+ if (hintCharCount(hint.label) > HINT_LABEL_MAX_CHARS) return false;
2800
+ if (hintCharCount(hint.draft) > HINT_DRAFT_MAX_CHARS) return false;
2801
+ return true;
2802
+ }
2803
+ function matchesAnyPath(paths, path) {
2804
+ if (path === null) return false;
2805
+ return paths.some((pattern) => matchPath(pattern, path) !== null);
2806
+ }
2807
+ function scoreHint(hint, input) {
2808
+ let score = bestPathScore(hint.paths, input.path);
2809
+ if (input.element !== null && matchElement(hint.element, input.element, input.components)) {
2810
+ score += SCORE_ELEMENT;
2811
+ }
2812
+ return score;
2813
+ }
2814
+ function bestPathScore(paths, path) {
2815
+ if (paths.length === 0 || path === null) return 0;
2816
+ let best = 0;
2817
+ for (const pattern of paths) {
2818
+ const result = matchPath(pattern, path);
2819
+ if (result === "exact") return SCORE_PATH_EXACT;
2820
+ if (result === "pattern") best = SCORE_PATH_PATTERN;
2821
+ }
2822
+ return best;
2823
+ }
2824
+
2825
+ // src/hints/provider.ts
2826
+ var CACHE_KEY_PREFIX = "feedback-kit:hints:";
2827
+ var HintProvider = class {
2828
+ constructor(opts) {
2829
+ this.entry = null;
2830
+ /** refresh() 동시 호출을 한 번의 요청으로 합친다. */
2831
+ this.inflight = null;
2832
+ /**
2833
+ * 401/403 등 설정 문제로 영구 실패했는가 — 이 인스턴스가 살아 있는 동안 재요청하지
2834
+ * 않는다. **저장소에는 남기지 않는다**(메모리만): 토큰을 고치고 재배포하면 그건 새
2835
+ * 페이지 로드 = 새 인스턴스이므로 자연히 풀린다. 캐시에 박아두면 사용자가 캐시를
2836
+ * 지우기 전까지 죽은 채로 남는다.
2837
+ */
2838
+ this.permanentlyFailed = false;
2839
+ /**
2840
+ * 실패(네트워크·타임아웃·5xx·401/403 전부 포함) 경고를 이미 냈는가 — 인스턴스당 1회로
2841
+ * 제한한다. 5분 쿨다운마다 매번 경고를 찍으면 콘솔이 "실패했다"만 반복하는 노이즈가
2842
+ * 된다("개발 빌드에서만 경고를 1회 남긴다" — 계약 원문).
2843
+ */
2844
+ this.warnedFailure = false;
2845
+ this.source = opts.source;
2846
+ this.storage = opts.storage ?? null;
2847
+ this.storageKey = CACHE_KEY_PREFIX + opts.cacheKey;
2848
+ this.now = opts.now ?? (() => Date.now());
2849
+ this.warn = opts.warn ?? (() => void 0);
2850
+ this.staticCatalog = opts.staticHints ? { version: null, maxDisplay: HINT_DISPLAY_DEFAULT, hints: opts.staticHints } : null;
2851
+ }
2852
+ /** 캐시 복원. 마운트 직후 1회. 던지지 않는다. */
2853
+ async load() {
2854
+ if (this.staticCatalog !== null) return;
2855
+ if (this.storage === null) return;
2856
+ try {
2857
+ const raw = await this.storage.get(this.storageKey);
2858
+ if (raw === null) return;
2859
+ const parsed = parseCacheEntry(JSON.parse(raw));
2860
+ if (parsed !== null) this.entry = parsed;
2861
+ } catch (err) {
2862
+ this.warn(`feedback-kit: \uD78C\uD2B8 \uCE90\uC2DC\uB97C \uC77D\uC9C0 \uBABB\uD588\uB2E4(${String(err)})`);
2403
2863
  }
2864
+ }
2865
+ /** 필요하면 네트워크. TTL 안이거나 쿨다운 중이면 아무것도 안 한다. 던지지 않는다. */
2866
+ refresh() {
2867
+ if (this.staticCatalog !== null) return Promise.resolve();
2868
+ if (this.source === null) return Promise.resolve();
2869
+ if (this.inflight !== null) return this.inflight;
2870
+ const p = this.doRefresh().finally(() => {
2871
+ this.inflight = null;
2872
+ });
2873
+ this.inflight = p;
2874
+ return p;
2875
+ }
2876
+ /** 지금 쓸 수 있는 카탈로그. 없으면 null. 동기 — 요청을 기다리지 않는다. */
2877
+ catalog() {
2878
+ if (this.staticCatalog !== null) return this.staticCatalog;
2879
+ if (this.entry === null || this.entry.hints.length === 0) return null;
2880
+ return { version: this.entry.version, maxDisplay: this.entry.maxDisplay, hints: this.entry.hints };
2881
+ }
2882
+ async doRefresh() {
2883
+ if (this.permanentlyFailed) return;
2884
+ const now = this.now();
2885
+ if (this.entry !== null) {
2886
+ if (now - this.entry.fetchedAt < HINT_TTL_MS) return;
2887
+ if (this.entry.failedAt !== null && now - this.entry.failedAt < HINT_FAIL_COOLDOWN_MS) return;
2888
+ }
2889
+ const etag = this.entry?.etag ?? null;
2890
+ const result = await this.fetchWithTimeout(etag);
2891
+ if (result.kind === "ok") {
2892
+ const parsed = parseHintCatalog(result.raw);
2893
+ if (parsed === null) {
2894
+ this.warn("feedback-kit: \uD78C\uD2B8 \uC751\uB2F5\uC774 \uC2A4\uD0A4\uB9C8\uB97C \uBC97\uC5B4\uB098 \uAE30\uC874 \uCE90\uC2DC\uB97C \uC720\uC9C0\uD55C\uB2E4");
2895
+ return;
2896
+ }
2897
+ await this.setEntry({
2898
+ version: parsed.version,
2899
+ etag: result.etag,
2900
+ fetchedAt: now,
2901
+ maxDisplay: parsed.maxDisplay,
2902
+ hints: parsed.hints,
2903
+ failedAt: null
2904
+ });
2905
+ return;
2906
+ }
2907
+ if (result.kind === "not-modified") {
2908
+ if (this.entry !== null) {
2909
+ await this.setEntry({ ...this.entry, fetchedAt: now, failedAt: null });
2910
+ }
2911
+ return;
2912
+ }
2913
+ if (result.permanent === true) {
2914
+ this.permanentlyFailed = true;
2915
+ }
2916
+ if (!this.warnedFailure && detectDevBuild()) {
2917
+ this.warnedFailure = true;
2918
+ this.warn(
2919
+ result.permanent === true ? "feedback-kit: \uD78C\uD2B8 \uC694\uCCAD\uC774 \uAC70\uBD80\uB410\uB2E4(401/403) \u2014 \uC774 \uC138\uC158\uC5D0\uC11C\uB294 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC9C0 \uC54A\uB294\uB2E4" : "feedback-kit: \uD78C\uD2B8 \uC694\uCCAD\uC774 \uC2E4\uD328\uD588\uB2E4 \u2014 \uC7A0\uC2DC \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD55C\uB2E4"
2920
+ );
2921
+ }
2922
+ const base = this.entry ?? {
2923
+ version: null,
2924
+ etag: null,
2925
+ fetchedAt: 0,
2926
+ maxDisplay: HINT_DISPLAY_DEFAULT,
2927
+ hints: [],
2928
+ failedAt: null
2929
+ };
2930
+ await this.setEntry({ ...base, failedAt: now });
2931
+ }
2932
+ async fetchWithTimeout(etag) {
2933
+ if (this.source === null) return { kind: "fail" };
2934
+ const g = globalThis;
2935
+ const controller = typeof g.AbortController === "function" ? new g.AbortController() : null;
2936
+ try {
2937
+ return await withTimeout(
2938
+ this.source.fetchHints(etag, controller?.signal),
2939
+ HINT_TIMEOUT_MS,
2940
+ () => controller?.abort()
2941
+ );
2942
+ } catch {
2943
+ return { kind: "fail" };
2944
+ }
2945
+ }
2946
+ async setEntry(entry) {
2947
+ this.entry = entry;
2948
+ if (this.storage === null) return;
2949
+ try {
2950
+ await this.storage.set(this.storageKey, JSON.stringify(entry));
2951
+ } catch (err) {
2952
+ this.warn(`feedback-kit: \uD78C\uD2B8 \uCE90\uC2DC\uB97C \uC800\uC7A5\uD558\uC9C0 \uBABB\uD588\uB2E4(${String(err)})`);
2953
+ }
2954
+ }
2955
+ };
2956
+ function withTimeout(promise, ms, onTimeout) {
2957
+ return new Promise((resolve, reject) => {
2958
+ const g = globalThis;
2959
+ let settled = false;
2960
+ const timer = typeof g.setTimeout === "function" ? g.setTimeout(() => {
2961
+ if (settled) return;
2962
+ settled = true;
2963
+ onTimeout?.();
2964
+ reject(new Error("feedback-kit: hint fetch timed out"));
2965
+ }, ms) : null;
2966
+ const clear = () => {
2967
+ if (timer !== null && typeof g.clearTimeout === "function") g.clearTimeout(timer);
2968
+ };
2969
+ promise.then(
2970
+ (value) => {
2971
+ if (settled) return;
2972
+ settled = true;
2973
+ clear();
2974
+ resolve(value);
2975
+ },
2976
+ (err) => {
2977
+ if (settled) return;
2978
+ settled = true;
2979
+ clear();
2980
+ reject(err);
2981
+ }
2982
+ );
2983
+ });
2984
+ }
2985
+ function parseCacheEntry(value) {
2986
+ if (typeof value !== "object" || value === null) return null;
2987
+ const obj = value;
2988
+ if (!Array.isArray(obj.hints)) return null;
2989
+ if (typeof obj.fetchedAt !== "number") return null;
2990
+ if (typeof obj.maxDisplay !== "number") return null;
2991
+ return {
2992
+ version: typeof obj.version === "number" ? obj.version : null,
2993
+ etag: typeof obj.etag === "string" ? obj.etag : null,
2994
+ fetchedAt: obj.fetchedAt,
2995
+ maxDisplay: obj.maxDisplay,
2996
+ hints: obj.hints,
2997
+ failedAt: typeof obj.failedAt === "number" ? obj.failedAt : null
2404
2998
  };
2405
2999
  }
2406
3000
  // Annotate the CommonJS export names for ESM import in node:
@@ -2418,6 +3012,16 @@ function createLassoAdapter(opts = {}) {
2418
3012
  FLOATING_BUTTON_ID,
2419
3013
  FeedbackQueue,
2420
3014
  FocusRing,
3015
+ HINT_DISPLAY_DEFAULT,
3016
+ HINT_DISPLAY_MAX,
3017
+ HINT_DISPLAY_MIN,
3018
+ HINT_DRAFT_MAX_CHARS,
3019
+ HINT_FAIL_COOLDOWN_MS,
3020
+ HINT_LABEL_MAX_CHARS,
3021
+ HINT_MAX_ITEMS,
3022
+ HINT_TIMEOUT_MS,
3023
+ HINT_TTL_MS,
3024
+ HintProvider,
2421
3025
  KEEPALIVE_BODY_LIMIT_BYTES,
2422
3026
  LASSO_DEFAULT_ENDPOINT,
2423
3027
  LOG_BUFFER_LIMIT,
@@ -2426,6 +3030,7 @@ function createLassoAdapter(opts = {}) {
2426
3030
  MAX_QUEUE_ITEMS,
2427
3031
  MODAL_ACTION_ATTACH,
2428
3032
  MODAL_ACTION_CANCEL,
3033
+ MODAL_ACTION_HINT_TOGGLE,
2429
3034
  MODAL_ACTION_PICK,
2430
3035
  MODAL_ACTION_PIN,
2431
3036
  MODAL_ACTION_REMOVE_SCREENSHOT,
@@ -2439,6 +3044,9 @@ function createLassoAdapter(opts = {}) {
2439
3044
  REDACTED,
2440
3045
  ReportModalController,
2441
3046
  RingBuffer,
3047
+ SCORE_ELEMENT,
3048
+ SCORE_PATH_EXACT,
3049
+ SCORE_PATH_PATTERN,
2442
3050
  SCREENSHOT_FAILED_MESSAGE,
2443
3051
  SCREENSHOT_MAX_BASE64_BYTES,
2444
3052
  SCREENSHOT_QUALITY_STEPS,
@@ -2448,6 +3056,7 @@ function createLassoAdapter(opts = {}) {
2448
3056
  SUBMIT_PENDING_MESSAGE,
2449
3057
  TITLE_MAX_CHARS,
2450
3058
  WidgetController,
3059
+ appPathFromNav,
2451
3060
  buildContext,
2452
3061
  buildLassoContext,
2453
3062
  buildLassoEnvelope,
@@ -2465,10 +3074,15 @@ function createLassoAdapter(opts = {}) {
2465
3074
  diagnosticsProviderFor,
2466
3075
  getOrCreateGuestId,
2467
3076
  guestUser,
3077
+ hintCharCount,
2468
3078
  isInternalUser,
3079
+ matchElement,
3080
+ matchPath,
2469
3081
  normalizePin,
2470
3082
  normalizeUser,
3083
+ parseHintCatalog,
2471
3084
  parseSourceAttr,
3085
+ rankHints,
2472
3086
  renderReportBody,
2473
3087
  resolveConfig,
2474
3088
  resolveContextUser,