@usercase/mcp 0.2.2-next.1 → 0.2.2-next.3

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/agents/focus.md CHANGED
@@ -17,6 +17,10 @@ Split how they arrive, about half and half:
17
17
 
18
18
  If the focus is only reachable when signed in, everyone is a member — this replaces "keep some as newcomers". Add newcomers only if the user asks for them (to see the sign-in wall). And cast the members to fit what the account has really done: if the user did not say a demo account already joined the room or saved the item, do not write people who are "already in the room" or "checking their saved list" — write people who join, save or start there on this visit.
19
19
 
20
+ Write the focus into the goal itself, including the step it takes to get in: if reading or writing in it needs joining, saving or signing up first, the goal says the person means to do that if it looks worthwhile ("see what the holders are saying, and join and say hello if it looks lively") — not only "look whether there are posts", which ends at the first Join button. Mention the focused action itself (e.g. the chat), not just the area around it.
21
+
22
+ A second thing on their mind stays inside the focus or leads into it (reading the chat and then the room's notices; a news card that sends them looking for the room) — never another part of the site (the calendar, the price list). Here that would be an easy way out of the focus, and the run would lose the visit.
23
+
20
24
  Do not add people who have nothing to do with the focus. In later rounds, cast new people for the same focus.
21
25
 
22
26
  **Step 4 — unchanged.** The visitors are never told about the focus.
package/mcp/hub.js CHANGED
@@ -399,7 +399,18 @@ class Hub {
399
399
  const look = await v.look();
400
400
  if (look.ended) return "This visit has ended.";
401
401
  if (look.card) return renderCard(look.card);
402
- const lines = [renderScreen(look)];
402
+ const lines = [];
403
+ // 굴리지 않았는데 맨 위로 돌아갔다(창이 닫히며 주소가 바뀐 사이트). 사람은 화면이 튀는 걸 본다(10/21 실험실 q3)
404
+ // 다른 페이지로 간 것은 원래 맨 위에서 시작한다 — 같은 페이지(? 뒤만 바뀐 것 포함)일 때만
405
+ const y = look.view.scrollY;
406
+ const at = look.view.path.split("?")[0];
407
+ // 창이 뜨거나 닫히며 위치 기준(페이지 ↔ 창)이 바뀐 것은 튄 게 아니다(10/21 실험실 r3)
408
+ const cover = !!(look.view.cover || look.view.inPanel);
409
+ if (rec.lastY > 300 && y < 40 && at === rec.lastAt && cover === rec.lastCover && !/^scroll/.test(rec.lastAction || "")) lines.push("(The page jumped back to the top.)", "");
410
+ rec.lastY = y;
411
+ rec.lastAt = at;
412
+ rec.lastCover = cover;
413
+ lines.push(renderScreen(look));
403
414
  // 인내심이 다 됐다는 단정이 아니라 조건이다. "볼 만큼 봤다" 고 하면 방문자가 그 자리에서 나갔다 — 진전이 있으면 이어 간다(10/14)
404
415
  if (v.patienceSpent) lines.push("", "(Your patience is running low. Unless what is in front of you is getting you closer to what you came for, this is about where you would give up.)");
405
416
  return lines.join("\n");
@@ -417,6 +428,7 @@ class Hub {
417
428
  async act(id, decision) {
418
429
  const rec = this.get(id);
419
430
  const v = rec.visit;
431
+ rec.lastAction = decision.action;
420
432
  const r = await v.act(decision);
421
433
  if (r.interrupted) return ["Before you could do that, a survey card appeared.", "", await this.screen(rec)].join("\n");
422
434
  const said = [];
@@ -439,7 +451,10 @@ class Hub {
439
451
  const sum = await this.finish(rec);
440
452
  return [...said, "You left the site. This visit is over.", "", sum].join("\n");
441
453
  }
442
- return [...said, ...(said.length ? [""] : []), await this.screen(rec)].join("\n");
454
+ const shown = await this.screen(rec);
455
+ // 하는 사이에 카드가 떴다. 손가락이 카드에 먼저 닿았을 수 있다 — 말 없이 카드만 주면 클릭이 왜 안 됐는지 모른다(10/21 실험실 t3)
456
+ if (v.pending && !/^(read|leave)$/.test(decision.action)) said.push("A survey card popped up just as you did that — once you have dealt with it, check whether what you did went through.");
457
+ return [...said, ...(said.length ? [""] : []), shown].join("\n");
443
458
  }
444
459
 
445
460
  /** 카드에 답하거나 닫는다 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usercase/mcp",
3
- "version": "0.2.2-next.1",
3
+ "version": "0.2.2-next.3",
4
4
  "description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://usercase.io",
@@ -92,7 +92,14 @@ async function aimAt(page, state, el) {
92
92
  const py = Math.min(Math.max(y, box.y + 2), box.y + box.height - 2);
93
93
  if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
94
94
  }
95
- return (await at(cx, cy)) === "대상" ? { x: cx, y: cy, missed: false } : null;
95
+ if ((await at(cx, cy)) === "대상") return { x: cx, y: cy, missed: false };
96
+ // 가운데만 가렸으면 사람은 보이는 쪽을 누른다 — 화면에 붙은 창의 Join 버튼 아래쪽이 아래 메뉴에 깔려
97
+ // 굴려도 안 비켜서 "Watchlist 가 덮었다" 가 됐다(10/21 실험실 q2). 가운데에서 가까운 점부터 본다
98
+ const pts = [];
99
+ for (const fy of [0.2, 0.35, 0.5, 0.65, 0.8]) for (const fx of [0.5, 0.3, 0.7, 0.15, 0.85]) pts.push([box.x + box.width * fx, box.y + box.height * fy]);
100
+ pts.sort((a, b) => Math.hypot(a[0] - cx, a[1] - cy) - Math.hypot(b[0] - cx, b[1] - cy));
101
+ for (const [px, py] of pts) if ((await at(px, py)) === "대상") return { x: px, y: py, missed: false };
102
+ return null;
96
103
  }
97
104
 
98
105
  /**
@@ -215,6 +222,7 @@ async function swipeIntoView(page, state, el, vw) {
215
222
  * @returns { missed: boolean } missed 면 빈 곳을 눌러 아무 일도 안 일어난 것이다
216
223
  */
217
224
  async function clickLikePerson(page, state, el) {
225
+ const startY = await page.evaluate(() => scrollY).catch(() => null);
218
226
  // 위아래로만 굴린다. 사람은 휴대폰에서 화면을 옆으로 끌어 숨은 버튼을 찾지 않는다 — 옆으로 굴리면 화면 밖 것을 끌어와 눌렀다(10/15)
219
227
  await el
220
228
  .evaluate((n) => {
@@ -226,10 +234,16 @@ async function clickLikePerson(page, state, el) {
226
234
  if (!aim) {
227
235
  // 화면 끝에 걸쳐 고정 막대(위 머리줄·아래 메뉴)에 가린 것은 사람이 조금 더 굴려서 누른다.
228
236
  // 이걸 안 하고 "덮였다" 고 하면 모바일 아래 메뉴 밑 항목이 안 눌린다(10/14 bluetags p28, 0.1.12)
237
+ // 창 안(스크롤되는 칸)의 것이면 그 칸을 굴린다 — 페이지만 굴려서는 모바일 정보 창 맨 아래 Join 이
238
+ // 아래 메뉴 밑에서 안 나왔다(10/21 실험실 y2). scrollIntoView 는 감싼 칸들을 모두 굴린다
229
239
  await el
230
240
  .evaluate((n) => {
231
- const r = n.getBoundingClientRect();
232
- window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2);
241
+ const before = n.getBoundingClientRect().top;
242
+ n.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
243
+ if (Math.abs(n.getBoundingClientRect().top - before) < 1) {
244
+ const r = n.getBoundingClientRect();
245
+ window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2);
246
+ }
233
247
  })
234
248
  .catch(() => {});
235
249
  await sleep(rand(150, 350));
@@ -256,6 +270,8 @@ async function clickLikePerson(page, state, el) {
256
270
  return { missed: false, closedOverlay: gone };
257
271
  }
258
272
  if (cover) {
273
+ // 못 누른 채 화면만 굴러가 있으면 "안 됐다" 는데 화면이 바뀐다(10/21 실험실 q2) — 사람은 굴리지 않고 덮인 걸 본다
274
+ if (startY !== null) await page.evaluate((y) => window.scrollTo({ top: y, behavior: "instant" }), startY).catch(() => {});
259
275
  const e = new Error(cover.say);
260
276
  e.human = cover.say;
261
277
  throw e;
@@ -339,14 +355,78 @@ async function scrollLikePerson(page, state, dir = 1) {
339
355
  const total = (300 + Math.random() * 700) * dir;
340
356
  const chunks = 2 + Math.round(Math.random() * 2);
341
357
  const y0 = await page.evaluate(() => scrollY).catch(() => null);
358
+ // 휴대폰에서는 화면 가운데를 엄지로 민다. 커서가 처음 자리(0,0)에 있으면 휠이 그 자리의 것을 굴린다 —
359
+ // 화면을 덮은 정보 창 대신 그 뒤 페이지가 굴러, 창 맨 아래 Join 이 끝내 아래 메뉴 밑에 있었다(10/21 실험실 r1).
360
+ // 데스크톱도 커서가 아직 한 번도 안 움직였으면 본문 쪽으로 옮겨 굴린다
361
+ const touch = await page.evaluate(() => matchMedia("(pointer: coarse)").matches).catch(() => false);
362
+ const vp0 = page.viewportSize() || { width: 1200, height: 800 };
363
+ // 흐린 막 위에 창이 떠 있으면 사람은 그 창 위에서 굴린다 — 주소로 바로 열린 데스크톱 정보 창은 뒤 페이지가
364
+ // 안 잠겨 휠이 뒤 목록을 굴렸고, 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
365
+ const panel = await page
366
+ .evaluate(() => {
367
+ const VW = document.documentElement.clientWidth || innerWidth;
368
+ const fixedCtx = (n) => {
369
+ for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
370
+ return false;
371
+ };
372
+ // 화면에 붙은 큰 판 — 흐린 막(데스크톱 정보 창 뒤)이든 화면을 덮은 불투명한 창(모바일 정보 창)이든
373
+ let overlay = false;
374
+ for (const n of document.body.querySelectorAll("*")) {
375
+ const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
376
+ const a = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
377
+ if (!a) continue;
378
+ const r = n.getBoundingClientRect();
379
+ if ((a < 0.9 ? r.width >= VW * 0.5 : r.width >= VW * 0.9) && r.height >= innerHeight * 0.6 && fixedCtx(n)) {
380
+ overlay = true;
381
+ break;
382
+ }
383
+ }
384
+ if (!overlay) return null;
385
+ let best = null;
386
+ let area = 0;
387
+ for (const n of document.body.querySelectorAll("*")) {
388
+ if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || !/auto|scroll/.test(getComputedStyle(n).overflowY) || !fixedCtx(n)) continue;
389
+ const r = n.getBoundingClientRect();
390
+ const cx = r.left + r.width / 2;
391
+ const cy = Math.min(r.top + r.height / 2, innerHeight - 5);
392
+ const h = document.elementFromPoint(cx, cy);
393
+ if (!h || !(n === h || n.contains(h))) continue; // 위에 떠 있는 칸만
394
+ const a = r.width * r.height;
395
+ if (a > area) (area = a), (best = { x: cx, y: cy });
396
+ }
397
+ return best;
398
+ })
399
+ .catch(() => null);
400
+ if (panel) await moveTo(page, state, panel.x + rand(-30, 30), panel.y + rand(-40, 40));
401
+ else if (touch || !state.mouse || (state.mouse.x < 5 && state.mouse.y < 5)) {
402
+ await moveTo(page, state, vp0.width * rand(0.4, 0.6), vp0.height * rand(0.4, 0.6));
403
+ }
342
404
  for (let i = 0; i < chunks; i++) {
343
405
  await page.mouse.wheel(0, total / chunks);
344
406
  await sleep(rand(80, 260));
345
407
  // 커서가 메뉴·고정 막대 위에 있으면 휠이 페이지를 안 굴린다. 사람은 커서를 본문 쪽으로 옮기고 다시 굴린다
346
408
  // (10/13: 약관 링크를 누른 뒤 세 번 굴려도 0px)
347
409
  if (i === 0 && y0 !== null && (await page.evaluate(() => scrollY).catch(() => null)) === y0) {
410
+ // 페이지가 잠겼고 화면 한쪽 칸만 굴러가면(데스크톱 정보 창) 사람은 그 칸 위로 커서를 옮긴다(10/21 실험실 r4)
411
+ const pane = await page
412
+ .evaluate(() => {
413
+ // 페이지가 그냥 끝에 닿은 것이면 안쪽 칸을 찾지 않는다
414
+ if (document.documentElement.scrollHeight > innerHeight + 2 && getComputedStyle(document.body).overflowY !== "hidden") return null;
415
+ let best = null;
416
+ let area = 0;
417
+ for (const n of document.body.querySelectorAll("*")) {
418
+ if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100 || n.scrollTop + n.clientHeight >= n.scrollHeight - 2) continue;
419
+ if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
420
+ const r = n.getBoundingClientRect();
421
+ const a = Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
422
+ if (a > area) (area = a), (best = { x: r.left + r.width / 2, y: r.top + r.height / 2 });
423
+ }
424
+ return best;
425
+ })
426
+ .catch(() => null);
348
427
  const vp = page.viewportSize() || { width: 1200, height: 800 };
349
- await moveTo(page, state, vp.width * rand(0.45, 0.6), vp.height * rand(0.45, 0.65));
428
+ if (pane) await moveTo(page, state, pane.x + rand(-30, 30), pane.y + rand(-40, 40));
429
+ else await moveTo(page, state, vp.width * rand(0.45, 0.6), vp.height * rand(0.45, 0.65));
350
430
  await page.mouse.wheel(0, total / chunks);
351
431
  await sleep(rand(80, 260));
352
432
  }
@@ -357,7 +437,9 @@ async function scrollLikePerson(page, state, dir = 1) {
357
437
  const after = await pos();
358
438
  const moved = after && y0 !== null ? after.y - y0 : 0;
359
439
  const atEnd = after && (dir > 0 ? after.y >= after.max - 2 : after.y <= 2);
360
- if (Math.sign(moved) === Math.sign(dir) && Math.abs(moved) > 120 && !atEnd && Math.random() < 0.25) {
440
+ // 창 안을 굴렸으면 되돌리지 않는다 — 뒤 페이지가 움직인 만큼 되돌리다 창 안이 맨 위로 돌아가,
441
+ // 창 맨 아래 Join 이 아래 메뉴 밑에 다시 갇혔다(10/21 실험실 x4)
442
+ if (!panel && Math.sign(moved) === Math.sign(dir) && Math.abs(moved) > 120 && !atEnd && Math.random() < 0.25) {
361
443
  await sleep(rand(150, 500));
362
444
  await page.mouse.wheel(0, -moved * (0.15 + Math.random() * 0.2));
363
445
  }
@@ -58,6 +58,15 @@ async function listControls(page, max = 60) {
58
58
  }
59
59
  return op <= 0.45;
60
60
  };
61
+ /**
62
+ * 화면 끝에 몇 px 만 걸친 것은 사람 눈에 아직 없다 — 창 맨 아래 Join 이 4px 걸쳐 "화면 안" 으로 나오고
63
+ * 화면 글에는 없어 서로 어긋났다(10/21 실험실 r2·s1). 반 넘게(긴 것은 16px 넘게) 보여야 화면 안이다
64
+ */
65
+ const whereOf = (r) => {
66
+ const shown = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0);
67
+ if (shown >= Math.min(r.height / 2, 16) && r.right > 0 && r.left < VW) return "in view";
68
+ return (r.top + r.bottom) / 2 >= innerHeight / 2 ? "below" : "above";
69
+ };
61
70
  const nameOf = (el) =>
62
71
  (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || el.getAttribute("placeholder") || "")
63
72
  .replace(/\s+/g, " ")
@@ -78,8 +87,8 @@ async function listControls(page, max = 60) {
78
87
  const key = tag + "|" + label + "|" + (href || `@${Math.round(r.left)},${Math.round(r.top + scrollY)}`);
79
88
  if (seen.has(key)) continue;
80
89
  seen.add(key);
81
- const inView = r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < VW;
82
- const where = inView ? "in view" : r.top >= innerHeight ? "below" : "above";
90
+ const where = whereOf(r);
91
+ const inView = where === "in view";
83
92
  cand.push({ el, tag, label, href: href ? href.split(/[?#]/)[0] : "", where, rank: inView ? 0 : where === "below" ? 1 : 2, dim: dimmed(el), edge: (r.left + r.right) / 2 > VW - 1 || (r.left + r.right) / 2 < 1 });
84
93
  // 성능용 안전 상한. 이건 사람에게 보여줄 예산이 아니다
85
94
  if (cand.length >= 800) break;
@@ -142,14 +151,17 @@ async function listControls(page, max = 60) {
142
151
  const r = el.getBoundingClientRect();
143
152
  // 작은 크기는 글자 없는 것에만 따진다. 날짜 "1" 은 폭이 5px 이라 빠졌다(10/14 실험실 달력)
144
153
  if (text ? r.width < 2 || r.height < 6 : r.width < 8 || r.height < 8) continue;
154
+ // 글자 없이 화면 반을 덮는 것은 아이콘이 아니라 창 뒤의 막이다 — "(icon 0,0)" 으로 나와 방문자가 뭔지 몰랐다(10/21 실험실 q5).
155
+ // 막 뒤의 것을 누르면 손이 막을 누른다
156
+ if (!text && r.width * r.height > VW * innerHeight * 0.25) continue;
145
157
  const hint = text ? null : iconHint(el);
146
158
  const label = text || (hint ? `(icon: ${hint})` : `(icon ${Math.round(r.left)},${Math.round(r.top)})`);
147
159
  // 같은 말인 칩이 여럿인 UI 가 흔하다(태그 목록). 자리까지 봐야 서로 다른 조작으로 남는다
148
160
  const key = `pointer|${label}|${Math.round(r.left)},${Math.round(r.top + scrollY)}`;
149
161
  if (seen.has(key)) continue;
150
162
  seen.add(key);
151
- const inView = r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < VW;
152
- const where = inView ? "in view" : r.top >= innerHeight ? "below" : "above";
163
+ const where = whereOf(r);
164
+ const inView = where === "in view";
153
165
  cand.push({ el, tag: el.tagName.toLowerCase(), label, href: "", where, rank: inView ? 0 : where === "below" ? 1 : 2, size: `${Math.round(r.width)}x${Math.round(r.height)}`, dim: dimmed(el), edge: (r.left + r.right) / 2 > VW - 1 || (r.left + r.right) / 2 < 1 });
154
166
  }
155
167
 
@@ -160,6 +172,83 @@ async function listControls(page, max = 60) {
160
172
  * 지금 화면에 보이는 것부터 남기고, 같은 순위 안에서는 문서 순서를 지킨다(sort 는 안정 정렬).
161
173
  * 2026-09-21 여기어때에서 드러났다. 블루태그스 Week/Month 토글도 같은 결의 문제였다.
162
174
  */
175
+ /**
176
+ * 화면을 통째로 덮은 층(모바일의 전체 화면 정보 창) 밑에 깔린 것은 사람 눈에 없다. 목록에 넣으면 방문자가
177
+ * 밑의 카드를 누르고 "덮였다" 만 세 번 듣고 나갔다(10/21 bluetags p1937, /community?info= 모바일).
178
+ * 고정 머리줄·아래 메뉴처럼 좁은 막대에 가린 것은 남긴다 — 손이 굴려서 누른다
179
+ */
180
+ const buried = (el) => {
181
+ const r = el.getBoundingClientRect();
182
+ const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
183
+ const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
184
+ // 위에서부터 겹친 것을 다 본다 — 맨 위가 다른 작은 것(사이트의 설문 창)이어도 그 밑에 덮은 층이 있다
185
+ for (const h of document.elementsFromPoint(x, y)) {
186
+ if (h === el || el.contains(h) || h.contains(el)) return false;
187
+ if (h.closest(ownSel)) continue;
188
+ // 맞은 것에서 올라가며 처음 칠이 있는 것. 반투명한 뒤판(데스크톱 정보 창 옆의 흐린 막)은 비쳐 보이고
189
+ // 누르면 창이 닫힌다 — 남긴다. 칠 없는 것은 투명하니 그 밑을 본다
190
+ for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
191
+ const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
192
+ const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
193
+ if (!alpha) continue;
194
+ const lr = n.getBoundingClientRect();
195
+ const big = Math.min(lr.right, VW) - Math.max(lr.left, 0) >= VW * 0.9 && Math.min(lr.bottom, innerHeight) - Math.max(lr.top, 0) >= innerHeight * 0.6;
196
+ // 반투명한 큰 막 밑 = 열린 창 뒤에 흐리게 보이는 것. 사람은 창이 떠 있는 줄 안다 — 방문자도 알아야
197
+ // 뒤의 카드를 눌러 창만 닫히는 걸 이해한다(10/21 실험실 q3)
198
+ if (big && alpha < 0.9) return "dim";
199
+ if (big) {
200
+ covers.add(n);
201
+ return "under";
202
+ }
203
+ break; // 작은 것(사이트의 설문 창, 고정 막대) — 그 밑을 본다
204
+ }
205
+ }
206
+ return false;
207
+ };
208
+ const covers = new Set();
209
+ for (let k = cand.length - 1; k >= 0; k--) {
210
+ if (cand[k].where !== "in view") continue;
211
+ const b = buried(cand[k].el);
212
+ if (b === "under") cand.splice(k, 1);
213
+ else if (b === "dim") cand[k].behind = true;
214
+ }
215
+ // 덮은 층이 화면에 붙어 있으면(fixed) 굴려도 그대로 덮여 있다 — 그 밖의 위·아래 것도 안 보인다.
216
+ // 남기면 "위에 있다" 고 나와 방문자가 올려 보고 누르다 덮였다를 듣는다(10/21 실험실 q2)
217
+ const fixedCovers = [...covers].filter((n) => {
218
+ for (let p = n; p && p !== document.body; p = p.parentElement) if (getComputedStyle(p).position === "fixed") return true;
219
+ return false;
220
+ });
221
+ if (fixedCovers.length) for (let k = cand.length - 1; k >= 0; k--) if (cand[k].where !== "in view" && !fixedCovers.some((n) => n.contains(cand[k].el))) cand.splice(k, 1);
222
+ // 화면을 덮은 창이 있으면 굴리는 건 그 창이다. 뒤 페이지의 위치를 말하면 "아래에 더 있다" 는데 굴려도 화면이 그대로였다(10/21 실험실 r1)
223
+ // 페이지가 잠겨 있으면(창이 뜨며 사이트가 막음) 화면에서 굴러가는 가장 큰 칸 — 데스크톱 정보 창 안쪽(10/21 실험실 r4)
224
+ // 흐린 막 위에 창이 떠 있으면 사람은 그 창을 굴린다 — 주소로 바로 열린 창은 뒤 페이지가 안 잠겨
225
+ // 위치·"아래에 더" 가 뒤 페이지 기준이라 창 맨 아래 Join 을 끝내 못 봤다(10/21 실험실 u4)
226
+ const dimOpen = cand.some((c) => c.behind);
227
+ const pageLocked = dimOpen || document.documentElement.scrollHeight <= innerHeight + 2 || getComputedStyle(document.body).overflowY === "hidden";
228
+ let roll = { y: scrollY, height: document.documentElement.scrollHeight, viewport: innerHeight };
229
+ // 굴리는 칸이 페이지가 아니면 "아래에 더 있다" 도 그 칸 안의 것만 센다 — 잠긴 페이지 뒤 목록을 세면 끝에 닿아도 "더 있다" 였다
230
+ let pane = null;
231
+ if (fixedCovers.length || pageLocked) {
232
+ roll = { y: 0, height: innerHeight, viewport: innerHeight };
233
+ pane = fixedCovers[0] || null;
234
+ let best = 0;
235
+ for (const n of fixedCovers.length ? [fixedCovers[0], ...fixedCovers[0].querySelectorAll("*")] : document.body.querySelectorAll("*")) {
236
+ if (n.scrollHeight <= n.clientHeight + 2 || n.clientHeight < 100) continue;
237
+ if (!/auto|scroll/.test(getComputedStyle(n).overflowY)) continue;
238
+ // 창이 떠 있으면 창 쪽(흐리지 않은 것을 품은 칸)만
239
+ // 뒤 목록까지 품은 바깥 칸(페이지 틀)은 창이 아니다 — 그걸 굴리면 뒤 목록이 굴렀다
240
+ if (dimOpen && (!cand.some((c) => !c.behind && c.where === "in view" && n.contains(c.el)) || cand.some((c) => c.behind && n.contains(c.el)))) continue;
241
+ const r = n.getBoundingClientRect();
242
+ const area = Math.max(0, Math.min(r.right, VW) - Math.max(r.left, 0)) * Math.max(0, Math.min(r.bottom, innerHeight) - Math.max(r.top, 0));
243
+ if (area > best) {
244
+ best = area;
245
+ pane = n;
246
+ roll = { y: n.scrollTop, height: n.scrollHeight, viewport: n.clientHeight };
247
+ }
248
+ }
249
+ }
250
+ // 창이 떠 있으면 창 밖의 위·아래 것은 굴려도 안 온다 — 뒤 목록의 필터가 "위에 있다" 로 남았다(10/21 실험실 u1)
251
+ if (pageLocked || fixedCovers.length) for (let k = cand.length - 1; k >= 0; k--) if (cand[k].where !== "in view" && !(pane && pane.contains(cand[k].el))) cand.splice(k, 1);
163
252
  cand.sort((a, b) => a.rank - b.rank);
164
253
  /**
165
254
  * **화면 아래는 보여주지 않는다.** 사람은 내려 봐야 거기 뭐가 있는지 안다.
@@ -204,17 +293,23 @@ async function listControls(page, max = 60) {
204
293
  const picked = pool.slice(0, max);
205
294
  const groups = {};
206
295
  for (const c of picked) (groups[c.label] = groups[c.label] || []).push(c);
207
- const nearAt = (c, d) => {
296
+ const nearAt = (c, d, g) => {
208
297
  let p = c.el.parentElement;
209
298
  for (let k = 0; k < d && p && p !== document.body; k++) p = p.parentElement;
210
- return p ? (p.innerText || "").replace(/\s+/g, " ").replace(c.label, "").trim().slice(0, 40) : "";
299
+ if (!p) return "";
300
+ // 같은 이름의 다른 항목까지 감싼 덩어리의 글자는 그 항목의 곁이 아니다 — 맨 아래 "Bluetags" 에
301
+ // 페이지 전체의 첫 글(맨 위 카드)이 붙었다(10/21 실험실 q4·s4)
302
+ if (g.some((o) => o !== c && p.contains(o.el))) return "";
303
+ // 제 이름은 낱말째로만 뺀다 — 이름이 "0" 인 항목에서 날짜 "20" 의 0 이 빠져 "Oct 2" 가 됐다(10/21 실험실 w5)
304
+ const own = new RegExp(`(^|\\s)${c.label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=\\s|$)`);
305
+ return (p.innerText || "").replace(/\s+/g, " ").replace(own, "$1").replace(/\s+/g, " ").trim().slice(0, 40);
211
306
  };
212
307
  for (const [label, g] of Object.entries(groups)) {
213
308
  if (g.length < 2) continue;
214
309
  // 묶음 안에서 서로 다르게 읽히는 가장 가까운 층을 고른다. 없으면 순서로 가른다
215
310
  let hints = null;
216
311
  for (let d = 0; d < 6 && !hints; d++) {
217
- const h = g.map((c) => nearAt(c, d));
312
+ const h = g.map((c) => nearAt(c, d, g));
218
313
  if (h.every((x) => x.length >= 2) && new Set(h).size === h.length) hints = h;
219
314
  }
220
315
  g.forEach((c, k) => (c.label = hints ? `${label} (${hints[k]})` : `${label} (${k + 1} of ${g.length})`));
@@ -223,7 +318,7 @@ async function listControls(page, max = 60) {
223
318
  const out = picked.map((c, i) => {
224
319
  els.push(c.el);
225
320
  // 반 넘게 화면 옆으로 나간 것. 누르면 손이 "잘렸다" 고 한다 — 목록도 같은 말을 해야 한다(10/15 실험실 x87: 공유 막대가 화면을 넓혀 아래 메뉴가 밀렸다)
226
- return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}) };
321
+ return { i, tag: c.tag, label: c.label, href: c.href, where: c.where, ...(c.dim ? { dim: true } : {}), ...(c.edge ? { edge: true } : {}), ...(c.behind ? { behind: true } : {}) };
227
322
  });
228
323
  Object.defineProperty(window, "__ebMcpEls", { value: els, configurable: true, enumerable: false, writable: true });
229
324
  return {
@@ -238,12 +333,17 @@ async function listControls(page, max = 60) {
238
333
  /** 그중 이름 없는 작은 아이콘 수 */
239
334
  droppedIcons: iconsLeft,
240
335
  /** 화면 아래에 더 있는 조작 수. 무엇인지는 말하지 않는다 */
241
- below,
336
+ below: pane ? cand.filter((c) => c.where === "below" && pane.contains(c.el)).length : below,
242
337
  path: location.pathname + location.search,
243
338
  title: (document.title || "").slice(0, 120),
244
- scrollY: Math.round(scrollY),
245
- height: document.documentElement.scrollHeight,
246
- viewport: innerHeight,
339
+ /** 화면을 덮은 창이 떠 있다 — 위치는 그 창 기준이다 */
340
+ cover: fixedCovers.length > 0,
341
+ /** 굴리는 것이 페이지가 아니라 떠 있는 창 안쪽이다 */
342
+ // 본문이 안쪽 칸에서 굴러가는 앱형 사이트도 페이지가 잠겨 보인다 — 창이 실제로 떠 있을 때만
343
+ inPanel: !!pane && (fixedCovers.length > 0 || cand.some((c) => c.behind)),
344
+ scrollY: Math.round(roll.y),
345
+ height: roll.height,
346
+ viewport: roll.viewport,
247
347
  };
248
348
  },
249
349
  { ownSel: OWN_SEL, max },
@@ -272,8 +372,38 @@ async function visibleText(page, max = 2500) {
272
372
  }
273
373
  return false;
274
374
  };
375
+ /**
376
+ * listControls 의 buried 와 같다 — 화면을 통째로 덮은 불투명한 층(모바일 전체 화면 정보 창) 밑의 글자는 안 보인다.
377
+ * 남기면 창 글자와 밑의 목록이 한 덩어리로 섞여 새 페이지인지 창인지 모른다(10/21 실험실 q4)
378
+ */
379
+ // false | "under"(안 보임) | "dim"(열린 창 뒤에 흐리게 보임)
380
+ const buriedCache = new Map();
381
+ const buried = (el, r) => {
382
+ if (buriedCache.has(el)) return buriedCache.get(el);
383
+ let res = false;
384
+ const x = Math.min(Math.max((r.left + r.right) / 2, 1), VW - 1);
385
+ const y = Math.min(Math.max((r.top + r.bottom) / 2, 1), innerHeight - 1);
386
+ outer: for (const h of document.elementsFromPoint(x, y)) {
387
+ if (h === el || el.contains(h) || h.contains(el)) break;
388
+ if (h.closest(ownSel)) continue;
389
+ for (let n = h; n && n !== document.body && !n.contains(el); n = n.parentElement) {
390
+ const m = getComputedStyle(n).backgroundColor.match(/[\d.]+/g);
391
+ const alpha = !m ? 0 : m.length > 3 ? parseFloat(m[3]) : 1;
392
+ if (!alpha) continue;
393
+ const lr = n.getBoundingClientRect();
394
+ if (Math.min(lr.right, VW) - Math.max(lr.left, 0) >= VW * 0.9 && Math.min(lr.bottom, innerHeight) - Math.max(lr.top, 0) >= innerHeight * 0.6) {
395
+ res = alpha >= 0.9 ? "under" : "dim";
396
+ break outer;
397
+ }
398
+ break;
399
+ }
400
+ }
401
+ buriedCache.set(el, res);
402
+ return res;
403
+ };
275
404
  const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
276
405
  const range = document.createRange();
406
+ const behind = [];
277
407
  let n = 0;
278
408
  for (let node = walker.nextNode(); node; node = walker.nextNode()) {
279
409
  if (++n > 20000) break;
@@ -288,15 +418,30 @@ async function visibleText(page, max = 2500) {
288
418
  const st = getComputedStyle(el);
289
419
  if (st.visibility === "hidden" || st.opacity === "0") continue;
290
420
  if (clippedX(el, r)) continue;
421
+ const b = buried(el, r);
422
+ if (b === "under") continue;
423
+ // 열린 창 뒤에 흐리게 비치는 글은 따로 모은다 — 섞으면 창이 어디서 끝나는지 모른다(10/21 실험실 q3·r4·s1·t1)
424
+ if (b === "dim") {
425
+ behind.push(txt);
426
+ continue;
427
+ }
291
428
  if (lastTop !== null && Math.abs(r.top - lastTop) > 4) out.push("\n");
292
429
  else if (out.length) out.push(" ");
293
430
  out.push(txt);
294
431
  lastTop = r.top;
295
432
  }
433
+ // 뒤의 흐린 글은 짧게 — 길면 글자 한도를 먹어 창 쪽 글이 단어 중간에 잘렸다(10/21 실험실 u1)
434
+ if (behind.length) {
435
+ const b = behind.join(" · ");
436
+ out.push("\n\n[Behind the open panel, dimmed:]\n", b.length > 500 ? b.slice(0, b.lastIndexOf(" ", 500)) + " …" : b);
437
+ }
296
438
  return out.join("");
297
439
  }, OWN_SEL)
298
440
  .catch(() => "");
299
- return t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim().slice(0, max);
441
+ const s = t.replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").trim();
442
+ if (s.length <= max) return s;
443
+ // 넘치면 낱말 사이에서 자르고 잘렸다고 적는다 — "disap" 처럼 끊기면 화면이 그렇게 생긴 줄 안다(10/21 실험실 u1)
444
+ return s.slice(0, Math.max(s.lastIndexOf(" ", max), s.lastIndexOf("\n", max), max - 40)) + " … (more text on screen not shown)";
300
445
  }
301
446
 
302
447
  /** 번호로 그 요소를 집는다. 화면이 바뀌었으면 null */
package/src/run/visit.js CHANGED
@@ -181,7 +181,9 @@ function renderScreen(look) {
181
181
  "```",
182
182
  "",
183
183
  "## Things you can use (number · what it says · where)",
184
- ...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""} · ${it.where}`),
184
+ ...view.items.map((it) => `[${it.i}] ${it.label || "(no text)"}${it.href ? ` → ${it.href}` : ""}${it.dim ? " · looks greyed out" : ""}${it.edge ? " · cut off at the edge of the screen" : ""}${it.behind ? " · dimmed, behind the open panel" : ""} · ${it.where}`),
185
+ // 떠 있는 창 뒤가 흐리게 비치는 화면. 글로 옮기면 창과 뒤 페이지가 한 덩어리로 섞인다(10/21 실험실 q3)
186
+ ...(view.items.some((it) => it.behind) ? ["(A panel is open on top of the page; the page behind it is dimmed.)"] : []),
185
187
  // 화면 안에 있는데 목록에 못 넣은 것. "아래에 더 있다" 고 하면 내려 봐도 못 찾는다
186
188
  // 화면 안에서 빠진 것과 지나온 위쪽에서 빠진 것을 따로 말한다 — 한 숫자로 말하면 "화면에 너무 많다" 가 거짓이 된다
187
189
  ...(view.droppedInView
@@ -192,11 +194,18 @@ function renderScreen(look) {
192
194
  ? [`(${view.dropped} more things further up the page are not listed — scroll up to use them.)`]
193
195
  : []),
194
196
  // 아래에 더 있다는 것만. 무엇인지는 내려 봐야 안다
195
- ...(view.below
196
- ? [`(The page goes on below. Scroll down to see what is there.)`]
197
+ // 굴릴 데가 없는데 "아래에 더 있다" 고 하면 모순이다 — 창이 뜨며 사이트가 페이지 스크롤을 잠근 때(10/21 실험실 r2·r4)
198
+ ...(view.height - view.viewport <= 2
199
+ ? ["(Nothing on this screen scrolls right now.)"]
200
+ // 끝까지 내려왔으면 "아래에 더" 라고 하지 않는다 — 화면 밖 아래에 걸린 것이 있어도 굴려서 못 온다(10/21 실험실 t3)
201
+ : view.below && view.height - view.viewport - view.scrollY > 2
202
+ ? [view.inPanel ? "(The open panel goes on below. Scroll down to see the rest of it.)" : `(The page goes on below. Scroll down to see what is there.)`]
203
+ : view.height - view.viewport - view.scrollY > Math.max(40, view.viewport * 0.25)
204
+ ? [view.inPanel ? "(There is more of the open panel below.)" : "(There is more of the page below.)"]
205
+ // 조금 남은 것(화면 맨 아래 버튼의 나머지, 여백)을 "더 있다" 고 하면 굴려도 새것이 없었다(10/21 실험실 s2·u2·w2)
197
206
  : view.height - view.viewport - view.scrollY > 40
198
- ? ["(There is more of the page below.)"]
199
- : ["(This is the bottom of the page.)"]),
207
+ ? ["(Nearly the bottom — only a little more below.)"]
208
+ : [view.inPanel ? "(This is the bottom of the open panel.)" : "(This is the bottom of the page.)"]),
200
209
  ].join("\n");
201
210
  }
202
211