@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.js CHANGED
@@ -216,7 +216,9 @@ async function buildReport(parts, opts) {
216
216
  pin: parts.pin,
217
217
  element: parts.element,
218
218
  context,
219
- createdAt: iso(now())
219
+ createdAt: iso(now()),
220
+ // 생략과 "정말 아무 것도 안 눌렀음"을 굳이 구분하지 않는다 — 둘 다 빈 배열로 싣는다.
221
+ hintIds: parts.hintIds ?? []
220
222
  };
221
223
  }
222
224
 
@@ -1339,6 +1341,7 @@ var COMMENT_TOO_LONG_MESSAGE = "4,000\uC790 \uC774\uD558\uB85C \uC785\uB825\uD57
1339
1341
  var SUBMIT_DONE_MESSAGE = "\uBCF4\uB0C8\uC2B5\uB2C8\uB2E4";
1340
1342
  var SUBMIT_PENDING_MESSAGE = "\uB300\uAE30 \uC911";
1341
1343
  var SUBMIT_FAILED_MESSAGE = "\uBCF4\uB0B4\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4";
1344
+ var MODAL_ACTION_HINT_TOGGLE = "hint-toggle";
1342
1345
  var MODAL_FIELD_COMMENT = "comment";
1343
1346
  var MODAL_FIELD_PRIORITY = "priority";
1344
1347
  var MODAL_ACTION_ATTACH = "attach";
@@ -1360,6 +1363,12 @@ var ReportModalController = class {
1360
1363
  this.captureGeneration = 0;
1361
1364
  /** 지금 들고 있는 핀의 기준. 핀이 없으면 의미 없다(기본 `picture`). */
1362
1365
  this.pinAnchor = "picture";
1366
+ /**
1367
+ * 사용자가 [제안 ▾] 를 직접 건드렸는가(방향 무관). 한 번이라도 건드리면 이후
1368
+ * 타이핑에 의한 자동 접힘/펼침이 멈춘다 — "사람이 편 상태는 모달이 닫힐 때까지 유지"를
1369
+ * 이렇게 구현한다. `open()` 에서 매번 리셋된다.
1370
+ */
1371
+ this.userToggledHints = false;
1363
1372
  this.state = {
1364
1373
  open: false,
1365
1374
  element: null,
@@ -1380,7 +1389,11 @@ var ReportModalController = class {
1380
1389
  actions: [],
1381
1390
  focusOrder: [],
1382
1391
  focused: null,
1383
- restoreFocusTo: null
1392
+ restoreFocusTo: null,
1393
+ hints: [],
1394
+ hintsExpanded: false,
1395
+ hintsAvailable: false,
1396
+ usedHintIds: []
1384
1397
  };
1385
1398
  this.queue = opts.queue;
1386
1399
  this.createReport = opts.createReport;
@@ -1440,9 +1453,11 @@ var ReportModalController = class {
1440
1453
  this.touched = false;
1441
1454
  this.submitAttempted = false;
1442
1455
  this.pinAnchor = opts.pin && !pendingReport ? "viewport" : "picture";
1456
+ this.userToggledHints = false;
1457
+ const resolvedComment = pendingReport?.comment ?? "";
1443
1458
  this.patch({
1444
1459
  open: true,
1445
- comment: pendingReport?.comment ?? "",
1460
+ comment: resolvedComment,
1446
1461
  // 요소를 지목해서 연 경우엔 그 요소를 물고 간다. 대기 항목 복원이 우선이다.
1447
1462
  element: pendingReport?.element ?? opts.element ?? null,
1448
1463
  priority: pendingReport?.priority ?? "unset",
@@ -1453,7 +1468,10 @@ var ReportModalController = class {
1453
1468
  submitStatus: wasPending ? "pending" : "idle",
1454
1469
  submitMessage: wasPending ? SUBMIT_PENDING_MESSAGE : null,
1455
1470
  closeConfirmVisible: false,
1456
- restoreFocusTo: null
1471
+ restoreFocusTo: null,
1472
+ // 코멘트가 비어 있을 때만 펼쳐진 채로 연다. 대기 항목을 복원해도 규칙은 같다(계약 원문).
1473
+ hintsExpanded: resolvedComment === "",
1474
+ usedHintIds: []
1457
1475
  });
1458
1476
  this.ring.focus(MODAL_FIELD_COMMENT);
1459
1477
  this.patch({ focused: this.ring.current });
@@ -1512,7 +1530,22 @@ var ReportModalController = class {
1512
1530
  setComment(value) {
1513
1531
  if (this.isDraftLocked()) return;
1514
1532
  this.touched = true;
1515
- this.patch({ comment: value });
1533
+ this.patch({ comment: value, ...this.hintsPatchFor(value) });
1534
+ }
1535
+ /**
1536
+ * 코멘트가 비었는지에 따라 칩 줄 상태를 갱신하는 조각을 만든다. `setComment`·`applyHint`
1537
+ * 양쪽에서 쓴다.
1538
+ *
1539
+ * - 완전히 비워지면(trim 기준) 눌린 칩 기록을 버리고 다시 펼친다 — "지우고 처음부터 다시
1540
+ * 쓴 글"에 이전 제안을 귀속시키지 않기 위해서다. 사용자가 직접 편 상태여도 예외 없다.
1541
+ * - 비어 있지 않게 되면, 사용자가 직접 편 게 아닌 이상 접는다(좁은 화면에서 입력란을
1542
+ * 밀어내지 않으려고). 직접 편 상태(`userToggledHints`)면 손대지 않는다.
1543
+ */
1544
+ hintsPatchFor(nextComment) {
1545
+ if (nextComment.trim().length === 0) {
1546
+ return { hintsExpanded: true, usedHintIds: [] };
1547
+ }
1548
+ return this.userToggledHints ? {} : { hintsExpanded: false };
1516
1549
  }
1517
1550
  setPriority(priority) {
1518
1551
  if (this.isDraftLocked()) return;
@@ -1585,6 +1618,41 @@ var ReportModalController = class {
1585
1618
  ...dropPin
1586
1619
  });
1587
1620
  }
1621
+ // ── 제안 힌트 ─────────────────────────────────────────────────────────────
1622
+ /**
1623
+ * 컨트롤러가 랭킹 결과를 밀어넣는다. **모달이 열려 있는 동안에는 무시한다** — 지목 대상을
1624
+ * 바꿔 새로 열기 직전에 호출되는 흐름이 정상이고, 열려 있는 모달의 칩을 도중에 바꾸면
1625
+ * 누르려던 칩이 손가락 밑에서 바뀐다(캐시 백그라운드 갱신도 이 경로로 들어올 수 있다).
1626
+ */
1627
+ setHints(hints) {
1628
+ if (this.state.open) return;
1629
+ this.patch({ hints });
1630
+ }
1631
+ /**
1632
+ * 칩 누름. 이미 눌린 칩이면 아무 일도 하지 않는다(같은 문구가 두 번 이어붙는 걸 막는다).
1633
+ * 코멘트가 비어 있으면 초안으로 치환, 아니면 줄바꿈 뒤 이어붙인다 — 어느 쪽도 사용자가
1634
+ * 쓴 글을 지우지 않는다.
1635
+ */
1636
+ applyHint(hintId) {
1637
+ if (this.isDraftLocked()) return;
1638
+ if (this.state.usedHintIds.includes(hintId)) return;
1639
+ const hint = this.state.hints.find((h) => h.id === hintId);
1640
+ if (!hint) return;
1641
+ this.touched = true;
1642
+ const comment = this.state.comment.length === 0 ? hint.draft : `${this.state.comment}
1643
+ ${hint.draft}`;
1644
+ this.patch({
1645
+ comment,
1646
+ usedHintIds: [...this.state.usedHintIds, hintId],
1647
+ // 방금 채워 넣었으니 비어 있지 않다 — setComment 와 같은 접힘 규칙을 그대로 쓴다.
1648
+ ...this.hintsPatchFor(comment)
1649
+ });
1650
+ }
1651
+ /** [제안 ▾] 토글. 방향과 무관하게 이후 자동 접힘/펼침을 멈춘다(`userToggledHints`). */
1652
+ toggleHints() {
1653
+ this.userToggledHints = true;
1654
+ this.patch({ hintsExpanded: !this.state.hintsExpanded });
1655
+ }
1588
1656
  // ── 포커스 ────────────────────────────────────────────────────────────────
1589
1657
  tabNext() {
1590
1658
  const next = this.ring.next();
@@ -1622,7 +1690,9 @@ var ReportModalController = class {
1622
1690
  priority: this.state.priority,
1623
1691
  screenshot: this.state.screenshot,
1624
1692
  pin: this.state.pin,
1625
- element: this.state.element
1693
+ element: this.state.element,
1694
+ // 눌린 칩 기록. 문구를 고쳐 보내도 "이 제안을 눌렀다"는 사실은 그대로 남는다.
1695
+ hintIds: this.state.usedHintIds
1626
1696
  };
1627
1697
  let outcome;
1628
1698
  try {
@@ -1688,8 +1758,15 @@ var ReportModalController = class {
1688
1758
  actionsFor() {
1689
1759
  return this.platform === "web" ? [MODAL_ACTION_PICK] : [MODAL_ACTION_PIN];
1690
1760
  }
1691
- focusOrderFor() {
1692
- const order = [MODAL_FIELD_COMMENT, MODAL_FIELD_PRIORITY];
1761
+ focusOrderFor(hintsAvailable) {
1762
+ const order = [];
1763
+ if (hintsAvailable) {
1764
+ if (this.state.hintsExpanded) {
1765
+ for (const hint of this.state.hints) order.push(`hint:${hint.id}`);
1766
+ }
1767
+ order.push(MODAL_ACTION_HINT_TOGGLE);
1768
+ }
1769
+ order.push(MODAL_FIELD_COMMENT, MODAL_FIELD_PRIORITY);
1693
1770
  if (this.state.screenshot) order.push(MODAL_ACTION_REMOVE_SCREENSHOT);
1694
1771
  else order.push(MODAL_ACTION_ATTACH);
1695
1772
  order.push(...this.actionsFor());
@@ -1702,7 +1779,8 @@ var ReportModalController = class {
1702
1779
  const canSubmit = this.isCommentValid() && this.state.screenshotStatus !== "capturing" && (this.state.submitStatus === "idle" || this.state.submitStatus === "failed");
1703
1780
  const canPin = this.platform === "native" && this.state.screenshot !== null && this.state.screenshotStatus !== "capturing" && !this.isDraftLocked() && this.state.open;
1704
1781
  const actions = this.actionsFor();
1705
- const focusOrder = this.state.open ? this.focusOrderFor() : [];
1782
+ const hintsAvailable = this.state.hints.length > 0;
1783
+ const focusOrder = this.state.open ? this.focusOrderFor(hintsAvailable) : [];
1706
1784
  this.ring.setItems(focusOrder);
1707
1785
  this.state = {
1708
1786
  ...this.state,
@@ -1711,6 +1789,7 @@ var ReportModalController = class {
1711
1789
  canPin,
1712
1790
  showRetry: this.state.submitStatus === "pending" || this.state.pending > 0,
1713
1791
  actions,
1792
+ hintsAvailable,
1714
1793
  focusOrder,
1715
1794
  focused: this.state.open ? this.ring.current : null
1716
1795
  };
@@ -2101,6 +2180,13 @@ function absoluteEndpoint(endpoint) {
2101
2180
  if (typeof origin !== "string" || origin.length === 0) return null;
2102
2181
  return endpoint.startsWith("/") ? `${origin}${endpoint}` : `${origin}/${endpoint}`;
2103
2182
  }
2183
+ function hintsCacheKeyFrom(endpoint) {
2184
+ const path = endpoint.split(/[?#]/)[0] ?? "";
2185
+ const segments = path.split("/").filter((s) => s.length > 0);
2186
+ const last = segments.at(-1);
2187
+ if (!last || last === "verify") return void 0;
2188
+ return last;
2189
+ }
2104
2190
  function parseRetryAfterMs(res) {
2105
2191
  const raw = res.headers?.get("Retry-After") ?? null;
2106
2192
  if (raw === null) return null;
@@ -2165,6 +2251,8 @@ function buildLassoContext(report) {
2165
2251
  } : null,
2166
2252
  // 속성이 하나도 없으면 빈 객체를 보내지 않는다 — 화면에 빈 항목이 늘어난다.
2167
2253
  element: el && (el.id !== null || Object.keys(el.attributes).length > 0) ? { id: el.id, attributes: el.attributes } : null,
2254
+ // 생략과 "정말 하나도 안 눌렀음"을 구분하지 않는다 — 둘 다 빈 배열.
2255
+ hintIds: [...report.hintIds ?? []],
2168
2256
  native: c.native ? {
2169
2257
  screenPath: c.native.screenPath,
2170
2258
  navPath: c.native.navPath,
@@ -2224,6 +2312,7 @@ function createLassoAdapter(opts = {}) {
2224
2312
  const token = opts.token ?? null;
2225
2313
  const endpoint = opts.endpoint && opts.endpoint.trim().length > 0 ? opts.endpoint.trim() : LASSO_DEFAULT_ENDPOINT;
2226
2314
  const warn = opts.warn ?? null;
2315
+ const hintsCacheKey = hintsCacheKeyFrom(endpoint);
2227
2316
  const emit = (message) => {
2228
2317
  if (!warn) return;
2229
2318
  try {
@@ -2232,6 +2321,7 @@ function createLassoAdapter(opts = {}) {
2232
2321
  }
2233
2322
  };
2234
2323
  return {
2324
+ hintsCacheKey,
2235
2325
  async submit(report) {
2236
2326
  const url = absoluteEndpoint(endpoint);
2237
2327
  if (url === null) {
@@ -2302,7 +2392,491 @@ function createLassoAdapter(opts = {}) {
2302
2392
  return fail(false);
2303
2393
  }
2304
2394
  return { ok: true, id: idFrom(parsed), retryable: false, retryAfterMs: null };
2395
+ },
2396
+ /**
2397
+ * 힌트 카탈로그 조회. `submit` 과 같은 절대 규칙이 그대로 적용된다 — **던지지 않는다**,
2398
+ * **본문·헤더를 로그에 남기지 않는다.** 다만 여기는 `warn` 을 아예 부르지 않는다.
2399
+ * "실패하면 조용히 포기"·"개발 빌드에서만 1회 경고"의 판단(쿨다운·환경 분기)은
2400
+ * `HintProvider`(hints/ 소유자) 몫이라, 어댑터가 매 실패마다 경고를 찍으면 그 판단과
2401
+ * 어긋난다.
2402
+ */
2403
+ async fetchHints(etag) {
2404
+ const url = absoluteEndpoint(`${endpoint.replace(/\/+$/, "")}/hints`);
2405
+ if (url === null) return { kind: "fail" };
2406
+ const sender = resolveFetch(opts.fetch);
2407
+ if (sender === null) return { kind: "fail" };
2408
+ const headers = {};
2409
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
2410
+ if (etag !== null) headers["If-None-Match"] = etag;
2411
+ let res;
2412
+ try {
2413
+ res = await sender(url, { method: "GET", headers });
2414
+ } catch {
2415
+ return { kind: "fail" };
2416
+ }
2417
+ if (res.status === 304) return { kind: "not-modified" };
2418
+ if (res.status === 401 || res.status === 403) {
2419
+ return { kind: "fail", permanent: true };
2420
+ }
2421
+ if (res.status < 200 || res.status >= 300) return { kind: "fail" };
2422
+ let text;
2423
+ try {
2424
+ text = await res.text();
2425
+ } catch {
2426
+ return { kind: "fail" };
2427
+ }
2428
+ try {
2429
+ const raw = JSON.parse(text);
2430
+ return { kind: "ok", raw, etag: res.headers?.get("ETag") ?? null };
2431
+ } catch {
2432
+ return { kind: "fail" };
2433
+ }
2434
+ }
2435
+ };
2436
+ }
2437
+
2438
+ // src/hints/constants.ts
2439
+ var HINT_LABEL_MAX_CHARS = 12;
2440
+ var HINT_DRAFT_MAX_CHARS = 200;
2441
+ var HINT_DISPLAY_DEFAULT = 4;
2442
+ var HINT_DISPLAY_MIN = 3;
2443
+ var HINT_DISPLAY_MAX = 6;
2444
+ var HINT_MAX_ITEMS = 300;
2445
+ var HINT_TTL_MS = 24 * 60 * 60 * 1e3;
2446
+ var HINT_TIMEOUT_MS = 5e3;
2447
+ var HINT_FAIL_COOLDOWN_MS = 5 * 60 * 1e3;
2448
+ var SCORE_PATH_EXACT = 40;
2449
+ var SCORE_PATH_PATTERN = 25;
2450
+ var SCORE_ELEMENT = 30;
2451
+
2452
+ // src/hints/chars.ts
2453
+ function hintCharCount(value) {
2454
+ return [...value.normalize("NFC")].length;
2455
+ }
2456
+
2457
+ // src/hints/path.ts
2458
+ function appPathFromNav(navPath) {
2459
+ if (navPath === null || navPath === void 0 || navPath.length === 0) return null;
2460
+ return "/" + navPath.join("/");
2461
+ }
2462
+ function matchPath(pattern, path) {
2463
+ const patternSegs = splitSegments(pattern);
2464
+ const pathSegs = splitSegments(path);
2465
+ if (patternSegs === null || pathSegs === null) return null;
2466
+ const hasWildcard = patternSegs.some((seg) => seg === "*" || seg === "**");
2467
+ if (!hasWildcard) {
2468
+ if (patternSegs.length !== pathSegs.length) return null;
2469
+ for (let i = 0; i < patternSegs.length; i++) {
2470
+ if (patternSegs[i] !== pathSegs[i]) return null;
2471
+ }
2472
+ return "exact";
2473
+ }
2474
+ return matchesFrom(patternSegs, 0, pathSegs, 0) ? "pattern" : null;
2475
+ }
2476
+ function splitSegments(value) {
2477
+ if (typeof value !== "string" || value.length === 0) return null;
2478
+ if (!value.startsWith("/")) return null;
2479
+ if (value === "/") return [];
2480
+ const trimmed = value.endsWith("/") ? value.slice(0, -1) : value;
2481
+ const segments = trimmed.split("/").slice(1);
2482
+ if (segments.some((seg) => seg.length === 0)) return null;
2483
+ return segments;
2484
+ }
2485
+ function matchesFrom(pattern, patternIndex, path, pathIndex) {
2486
+ if (patternIndex === pattern.length) return pathIndex === path.length;
2487
+ const seg = pattern[patternIndex];
2488
+ if (seg === "**") {
2489
+ for (let consume = 0; pathIndex + consume <= path.length; consume++) {
2490
+ if (matchesFrom(pattern, patternIndex + 1, path, pathIndex + consume)) return true;
2491
+ }
2492
+ return false;
2493
+ }
2494
+ if (pathIndex >= path.length) return false;
2495
+ if (seg === "*") return matchesFrom(pattern, patternIndex + 1, path, pathIndex + 1);
2496
+ return path[pathIndex] === seg && matchesFrom(pattern, patternIndex + 1, path, pathIndex + 1);
2497
+ }
2498
+
2499
+ // src/hints/element.ts
2500
+ function matchElement(rule, element, components) {
2501
+ if (rule === null) return true;
2502
+ try {
2503
+ const axes = [rule.tags, rule.roles, rule.classIncludes, rule.componentIncludes, rule.testIds];
2504
+ const hasAnyAxis = axes.some((axis) => Array.isArray(axis) && axis.length > 0);
2505
+ if (!hasAnyAxis) return true;
2506
+ if (element === null) return false;
2507
+ return matchesTags(rule.tags, element) || matchesRoles(rule.roles, element) || matchesClassIncludes(rule.classIncludes, element) || matchesComponentIncludes(rule.componentIncludes, components) || matchesTestIds(rule.testIds, element);
2508
+ } catch {
2509
+ return false;
2510
+ }
2511
+ }
2512
+ function matchesTags(tags, element) {
2513
+ if (!Array.isArray(tags)) return false;
2514
+ const tag = safeLower(element.tag);
2515
+ if (tag === null) return false;
2516
+ return tags.some((t) => typeof t === "string" && t.toLowerCase() === tag);
2517
+ }
2518
+ function matchesRoles(roles, element) {
2519
+ if (!Array.isArray(roles)) return false;
2520
+ const role = safeLower(element.attributes?.role ?? null);
2521
+ const type = safeLower(element.attributes?.type ?? null);
2522
+ return roles.some((r) => {
2523
+ if (typeof r !== "string") return false;
2524
+ const lowered = r.toLowerCase();
2525
+ return lowered === role || lowered === type;
2526
+ });
2527
+ }
2528
+ function matchesClassIncludes(classIncludes, element) {
2529
+ if (!Array.isArray(classIncludes)) return false;
2530
+ const className = safeLower(element.className);
2531
+ if (className === null) return false;
2532
+ return classIncludes.some((c) => typeof c === "string" && className.includes(c.toLowerCase()));
2533
+ }
2534
+ function matchesComponentIncludes(componentIncludes, components) {
2535
+ if (!Array.isArray(componentIncludes)) return false;
2536
+ if (components === null) return false;
2537
+ return componentIncludes.some((c) => typeof c === "string" && components.includes(c));
2538
+ }
2539
+ function matchesTestIds(testIds, element) {
2540
+ if (!Array.isArray(testIds)) return false;
2541
+ const testId = element.attributes?.["data-testid"];
2542
+ if (typeof testId !== "string") return false;
2543
+ return testIds.some((t) => t === testId);
2544
+ }
2545
+ function safeLower(value) {
2546
+ return typeof value === "string" ? value.toLowerCase() : null;
2547
+ }
2548
+
2549
+ // src/hints/parse.ts
2550
+ var SCOPES = ["screen", "element", "both"];
2551
+ var PLATFORMS = ["web", "app"];
2552
+ function parseHintCatalog(raw) {
2553
+ if (typeof raw !== "object" || raw === null) return null;
2554
+ const obj = raw;
2555
+ if (obj.ok !== true) return null;
2556
+ if (!Array.isArray(obj.hints)) return null;
2557
+ const hints = [];
2558
+ for (const item of obj.hints) {
2559
+ if (hints.length >= HINT_MAX_ITEMS) break;
2560
+ const parsed = parseHintItem(item);
2561
+ if (parsed !== null) hints.push(parsed);
2562
+ }
2563
+ return {
2564
+ version: typeof obj.version === "number" && Number.isInteger(obj.version) ? obj.version : null,
2565
+ maxDisplay: parseMaxDisplay(obj.maxDisplay),
2566
+ hints
2567
+ };
2568
+ }
2569
+ function parseMaxDisplay(value) {
2570
+ if (typeof value === "number" && Number.isInteger(value) && value >= HINT_DISPLAY_MIN && value <= HINT_DISPLAY_MAX) {
2571
+ return value;
2572
+ }
2573
+ return HINT_DISPLAY_DEFAULT;
2574
+ }
2575
+ function parseHintItem(item) {
2576
+ if (typeof item !== "object" || item === null) return null;
2577
+ const obj = item;
2578
+ const id = nonEmptyString(obj.id);
2579
+ const label = nonEmptyString(obj.label);
2580
+ const draft = nonEmptyString(obj.draft);
2581
+ if (id === null || label === null || draft === null) return null;
2582
+ if (typeof obj.scope !== "string" || !SCOPES.includes(obj.scope)) return null;
2583
+ const scope = obj.scope;
2584
+ const platforms = parsePlatforms(obj.platforms);
2585
+ if (platforms === null) return null;
2586
+ return {
2587
+ id,
2588
+ label,
2589
+ draft,
2590
+ scope,
2591
+ platforms,
2592
+ paths: parsePaths(obj.paths),
2593
+ element: parseElementRule(obj.element),
2594
+ active: parseActive(obj.active)
2595
+ };
2596
+ }
2597
+ function parseActive(value) {
2598
+ return value !== false;
2599
+ }
2600
+ function parsePlatforms(value) {
2601
+ if (!Array.isArray(value) || value.length === 0) return null;
2602
+ const result = [];
2603
+ for (const p of value) {
2604
+ if (typeof p !== "string" || !PLATFORMS.includes(p)) return null;
2605
+ result.push(p);
2606
+ }
2607
+ return result;
2608
+ }
2609
+ function parsePaths(value) {
2610
+ if (!Array.isArray(value)) return [];
2611
+ return value.filter((p) => typeof p === "string");
2612
+ }
2613
+ function parseElementRule(value) {
2614
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2615
+ const obj = value;
2616
+ const rule = {};
2617
+ if (Array.isArray(obj.tags)) rule.tags = obj.tags.filter((v) => typeof v === "string");
2618
+ if (Array.isArray(obj.roles)) rule.roles = obj.roles.filter((v) => typeof v === "string");
2619
+ if (Array.isArray(obj.classIncludes)) {
2620
+ rule.classIncludes = obj.classIncludes.filter((v) => typeof v === "string");
2621
+ }
2622
+ if (Array.isArray(obj.componentIncludes)) {
2623
+ rule.componentIncludes = obj.componentIncludes.filter((v) => typeof v === "string");
2624
+ }
2625
+ if (Array.isArray(obj.testIds)) rule.testIds = obj.testIds.filter((v) => typeof v === "string");
2626
+ return rule;
2627
+ }
2628
+ function nonEmptyString(value) {
2629
+ return typeof value === "string" && value.length > 0 ? value : null;
2630
+ }
2631
+
2632
+ // src/hints/rank.ts
2633
+ function rankHints(hints, input, limit) {
2634
+ const scored = [];
2635
+ for (let index = 0; index < hints.length; index++) {
2636
+ const hint = hints[index];
2637
+ if (!passesHardFilter(hint, input)) continue;
2638
+ scored.push({ hint, score: scoreHint(hint, input), index });
2639
+ }
2640
+ const ordered = orderByScoreThenRotatedGroup(scored, input.path);
2641
+ return ordered.slice(0, Math.max(0, limit)).map((s) => s.hint);
2642
+ }
2643
+ function orderByScoreThenRotatedGroup(scored, path) {
2644
+ const groups = /* @__PURE__ */ new Map();
2645
+ for (const s of scored) {
2646
+ const group = groups.get(s.score);
2647
+ if (group) group.push(s);
2648
+ else groups.set(s.score, [s]);
2649
+ }
2650
+ const scoresDesc = [...groups.keys()].sort((a, b) => b - a);
2651
+ const result = [];
2652
+ for (const score of scoresDesc) {
2653
+ result.push(...rotateGroup(groups.get(score), path));
2654
+ }
2655
+ return result;
2656
+ }
2657
+ function rotateGroup(group, path) {
2658
+ if (group.length <= 1 || path === null) return group;
2659
+ const offset = fnv1a32(path) % group.length;
2660
+ if (offset === 0) return group;
2661
+ return [...group.slice(offset), ...group.slice(0, offset)];
2662
+ }
2663
+ function fnv1a32(value) {
2664
+ let hash = 2166136261;
2665
+ for (let i = 0; i < value.length; i++) {
2666
+ hash ^= value.charCodeAt(i);
2667
+ hash = Math.imul(hash, 16777619);
2668
+ }
2669
+ return hash >>> 0;
2670
+ }
2671
+ function passesHardFilter(hint, input) {
2672
+ if (!hint.platforms.includes(input.platform)) return false;
2673
+ const wantsElementScope = input.element !== null;
2674
+ if (wantsElementScope) {
2675
+ if (hint.scope !== "element" && hint.scope !== "both") return false;
2676
+ } else if (hint.scope !== "screen" && hint.scope !== "both") {
2677
+ return false;
2678
+ }
2679
+ if (hint.paths.length > 0 && !matchesAnyPath(hint.paths, input.path)) return false;
2680
+ if (hint.active === false) return false;
2681
+ if (hintCharCount(hint.label) > HINT_LABEL_MAX_CHARS) return false;
2682
+ if (hintCharCount(hint.draft) > HINT_DRAFT_MAX_CHARS) return false;
2683
+ return true;
2684
+ }
2685
+ function matchesAnyPath(paths, path) {
2686
+ if (path === null) return false;
2687
+ return paths.some((pattern) => matchPath(pattern, path) !== null);
2688
+ }
2689
+ function scoreHint(hint, input) {
2690
+ let score = bestPathScore(hint.paths, input.path);
2691
+ if (input.element !== null && matchElement(hint.element, input.element, input.components)) {
2692
+ score += SCORE_ELEMENT;
2693
+ }
2694
+ return score;
2695
+ }
2696
+ function bestPathScore(paths, path) {
2697
+ if (paths.length === 0 || path === null) return 0;
2698
+ let best = 0;
2699
+ for (const pattern of paths) {
2700
+ const result = matchPath(pattern, path);
2701
+ if (result === "exact") return SCORE_PATH_EXACT;
2702
+ if (result === "pattern") best = SCORE_PATH_PATTERN;
2703
+ }
2704
+ return best;
2705
+ }
2706
+
2707
+ // src/hints/provider.ts
2708
+ var CACHE_KEY_PREFIX = "feedback-kit:hints:";
2709
+ var HintProvider = class {
2710
+ constructor(opts) {
2711
+ this.entry = null;
2712
+ /** refresh() 동시 호출을 한 번의 요청으로 합친다. */
2713
+ this.inflight = null;
2714
+ /**
2715
+ * 401/403 등 설정 문제로 영구 실패했는가 — 이 인스턴스가 살아 있는 동안 재요청하지
2716
+ * 않는다. **저장소에는 남기지 않는다**(메모리만): 토큰을 고치고 재배포하면 그건 새
2717
+ * 페이지 로드 = 새 인스턴스이므로 자연히 풀린다. 캐시에 박아두면 사용자가 캐시를
2718
+ * 지우기 전까지 죽은 채로 남는다.
2719
+ */
2720
+ this.permanentlyFailed = false;
2721
+ /**
2722
+ * 실패(네트워크·타임아웃·5xx·401/403 전부 포함) 경고를 이미 냈는가 — 인스턴스당 1회로
2723
+ * 제한한다. 5분 쿨다운마다 매번 경고를 찍으면 콘솔이 "실패했다"만 반복하는 노이즈가
2724
+ * 된다("개발 빌드에서만 경고를 1회 남긴다" — 계약 원문).
2725
+ */
2726
+ this.warnedFailure = false;
2727
+ this.source = opts.source;
2728
+ this.storage = opts.storage ?? null;
2729
+ this.storageKey = CACHE_KEY_PREFIX + opts.cacheKey;
2730
+ this.now = opts.now ?? (() => Date.now());
2731
+ this.warn = opts.warn ?? (() => void 0);
2732
+ this.staticCatalog = opts.staticHints ? { version: null, maxDisplay: HINT_DISPLAY_DEFAULT, hints: opts.staticHints } : null;
2733
+ }
2734
+ /** 캐시 복원. 마운트 직후 1회. 던지지 않는다. */
2735
+ async load() {
2736
+ if (this.staticCatalog !== null) return;
2737
+ if (this.storage === null) return;
2738
+ try {
2739
+ const raw = await this.storage.get(this.storageKey);
2740
+ if (raw === null) return;
2741
+ const parsed = parseCacheEntry(JSON.parse(raw));
2742
+ if (parsed !== null) this.entry = parsed;
2743
+ } catch (err) {
2744
+ this.warn(`feedback-kit: \uD78C\uD2B8 \uCE90\uC2DC\uB97C \uC77D\uC9C0 \uBABB\uD588\uB2E4(${String(err)})`);
2305
2745
  }
2746
+ }
2747
+ /** 필요하면 네트워크. TTL 안이거나 쿨다운 중이면 아무것도 안 한다. 던지지 않는다. */
2748
+ refresh() {
2749
+ if (this.staticCatalog !== null) return Promise.resolve();
2750
+ if (this.source === null) return Promise.resolve();
2751
+ if (this.inflight !== null) return this.inflight;
2752
+ const p = this.doRefresh().finally(() => {
2753
+ this.inflight = null;
2754
+ });
2755
+ this.inflight = p;
2756
+ return p;
2757
+ }
2758
+ /** 지금 쓸 수 있는 카탈로그. 없으면 null. 동기 — 요청을 기다리지 않는다. */
2759
+ catalog() {
2760
+ if (this.staticCatalog !== null) return this.staticCatalog;
2761
+ if (this.entry === null || this.entry.hints.length === 0) return null;
2762
+ return { version: this.entry.version, maxDisplay: this.entry.maxDisplay, hints: this.entry.hints };
2763
+ }
2764
+ async doRefresh() {
2765
+ if (this.permanentlyFailed) return;
2766
+ const now = this.now();
2767
+ if (this.entry !== null) {
2768
+ if (now - this.entry.fetchedAt < HINT_TTL_MS) return;
2769
+ if (this.entry.failedAt !== null && now - this.entry.failedAt < HINT_FAIL_COOLDOWN_MS) return;
2770
+ }
2771
+ const etag = this.entry?.etag ?? null;
2772
+ const result = await this.fetchWithTimeout(etag);
2773
+ if (result.kind === "ok") {
2774
+ const parsed = parseHintCatalog(result.raw);
2775
+ if (parsed === null) {
2776
+ 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");
2777
+ return;
2778
+ }
2779
+ await this.setEntry({
2780
+ version: parsed.version,
2781
+ etag: result.etag,
2782
+ fetchedAt: now,
2783
+ maxDisplay: parsed.maxDisplay,
2784
+ hints: parsed.hints,
2785
+ failedAt: null
2786
+ });
2787
+ return;
2788
+ }
2789
+ if (result.kind === "not-modified") {
2790
+ if (this.entry !== null) {
2791
+ await this.setEntry({ ...this.entry, fetchedAt: now, failedAt: null });
2792
+ }
2793
+ return;
2794
+ }
2795
+ if (result.permanent === true) {
2796
+ this.permanentlyFailed = true;
2797
+ }
2798
+ if (!this.warnedFailure && detectDevBuild()) {
2799
+ this.warnedFailure = true;
2800
+ this.warn(
2801
+ 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"
2802
+ );
2803
+ }
2804
+ const base = this.entry ?? {
2805
+ version: null,
2806
+ etag: null,
2807
+ fetchedAt: 0,
2808
+ maxDisplay: HINT_DISPLAY_DEFAULT,
2809
+ hints: [],
2810
+ failedAt: null
2811
+ };
2812
+ await this.setEntry({ ...base, failedAt: now });
2813
+ }
2814
+ async fetchWithTimeout(etag) {
2815
+ if (this.source === null) return { kind: "fail" };
2816
+ const g = globalThis;
2817
+ const controller = typeof g.AbortController === "function" ? new g.AbortController() : null;
2818
+ try {
2819
+ return await withTimeout(
2820
+ this.source.fetchHints(etag, controller?.signal),
2821
+ HINT_TIMEOUT_MS,
2822
+ () => controller?.abort()
2823
+ );
2824
+ } catch {
2825
+ return { kind: "fail" };
2826
+ }
2827
+ }
2828
+ async setEntry(entry) {
2829
+ this.entry = entry;
2830
+ if (this.storage === null) return;
2831
+ try {
2832
+ await this.storage.set(this.storageKey, JSON.stringify(entry));
2833
+ } catch (err) {
2834
+ this.warn(`feedback-kit: \uD78C\uD2B8 \uCE90\uC2DC\uB97C \uC800\uC7A5\uD558\uC9C0 \uBABB\uD588\uB2E4(${String(err)})`);
2835
+ }
2836
+ }
2837
+ };
2838
+ function withTimeout(promise, ms, onTimeout) {
2839
+ return new Promise((resolve, reject) => {
2840
+ const g = globalThis;
2841
+ let settled = false;
2842
+ const timer = typeof g.setTimeout === "function" ? g.setTimeout(() => {
2843
+ if (settled) return;
2844
+ settled = true;
2845
+ onTimeout?.();
2846
+ reject(new Error("feedback-kit: hint fetch timed out"));
2847
+ }, ms) : null;
2848
+ const clear = () => {
2849
+ if (timer !== null && typeof g.clearTimeout === "function") g.clearTimeout(timer);
2850
+ };
2851
+ promise.then(
2852
+ (value) => {
2853
+ if (settled) return;
2854
+ settled = true;
2855
+ clear();
2856
+ resolve(value);
2857
+ },
2858
+ (err) => {
2859
+ if (settled) return;
2860
+ settled = true;
2861
+ clear();
2862
+ reject(err);
2863
+ }
2864
+ );
2865
+ });
2866
+ }
2867
+ function parseCacheEntry(value) {
2868
+ if (typeof value !== "object" || value === null) return null;
2869
+ const obj = value;
2870
+ if (!Array.isArray(obj.hints)) return null;
2871
+ if (typeof obj.fetchedAt !== "number") return null;
2872
+ if (typeof obj.maxDisplay !== "number") return null;
2873
+ return {
2874
+ version: typeof obj.version === "number" ? obj.version : null,
2875
+ etag: typeof obj.etag === "string" ? obj.etag : null,
2876
+ fetchedAt: obj.fetchedAt,
2877
+ maxDisplay: obj.maxDisplay,
2878
+ hints: obj.hints,
2879
+ failedAt: typeof obj.failedAt === "number" ? obj.failedAt : null
2306
2880
  };
2307
2881
  }
2308
2882
  export {
@@ -2319,6 +2893,16 @@ export {
2319
2893
  FLOATING_BUTTON_ID,
2320
2894
  FeedbackQueue,
2321
2895
  FocusRing,
2896
+ HINT_DISPLAY_DEFAULT,
2897
+ HINT_DISPLAY_MAX,
2898
+ HINT_DISPLAY_MIN,
2899
+ HINT_DRAFT_MAX_CHARS,
2900
+ HINT_FAIL_COOLDOWN_MS,
2901
+ HINT_LABEL_MAX_CHARS,
2902
+ HINT_MAX_ITEMS,
2903
+ HINT_TIMEOUT_MS,
2904
+ HINT_TTL_MS,
2905
+ HintProvider,
2322
2906
  KEEPALIVE_BODY_LIMIT_BYTES,
2323
2907
  LASSO_DEFAULT_ENDPOINT,
2324
2908
  LOG_BUFFER_LIMIT,
@@ -2327,6 +2911,7 @@ export {
2327
2911
  MAX_QUEUE_ITEMS,
2328
2912
  MODAL_ACTION_ATTACH,
2329
2913
  MODAL_ACTION_CANCEL,
2914
+ MODAL_ACTION_HINT_TOGGLE,
2330
2915
  MODAL_ACTION_PICK,
2331
2916
  MODAL_ACTION_PIN,
2332
2917
  MODAL_ACTION_REMOVE_SCREENSHOT,
@@ -2340,6 +2925,9 @@ export {
2340
2925
  REDACTED,
2341
2926
  ReportModalController,
2342
2927
  RingBuffer,
2928
+ SCORE_ELEMENT,
2929
+ SCORE_PATH_EXACT,
2930
+ SCORE_PATH_PATTERN,
2343
2931
  SCREENSHOT_FAILED_MESSAGE,
2344
2932
  SCREENSHOT_MAX_BASE64_BYTES,
2345
2933
  SCREENSHOT_QUALITY_STEPS,
@@ -2349,6 +2937,7 @@ export {
2349
2937
  SUBMIT_PENDING_MESSAGE,
2350
2938
  TITLE_MAX_CHARS,
2351
2939
  WidgetController,
2940
+ appPathFromNav,
2352
2941
  buildContext,
2353
2942
  buildLassoContext,
2354
2943
  buildLassoEnvelope,
@@ -2366,10 +2955,15 @@ export {
2366
2955
  diagnosticsProviderFor,
2367
2956
  getOrCreateGuestId,
2368
2957
  guestUser,
2958
+ hintCharCount,
2369
2959
  isInternalUser,
2960
+ matchElement,
2961
+ matchPath,
2370
2962
  normalizePin,
2371
2963
  normalizeUser,
2964
+ parseHintCatalog,
2372
2965
  parseSourceAttr,
2966
+ rankHints,
2373
2967
  renderReportBody,
2374
2968
  resolveConfig,
2375
2969
  resolveContextUser,