page-harness 0.1.3 → 0.1.4

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/cli.js CHANGED
@@ -2239,7 +2239,97 @@ function techOf(item) {
2239
2239
  function style(item) {
2240
2240
  return item.patch?.style ?? {};
2241
2241
  }
2242
- function targetWords(item) {
2242
+ function imageCopyEn(item) {
2243
+ const s = style(item);
2244
+ const blob = Object.values(s).join(" ");
2245
+ const white = /#fff\b|#ffffff|white|rgb\(\s*255\s*,\s*255\s*,\s*255/i.test(blob);
2246
+ const border = !!(s.border || s["border-width"] || s["border-style"] || s["border-color"]);
2247
+ const rot = /rotate\(\s*(-?[\d.]+)\s*deg/i.exec(s.transform || blob);
2248
+ const deg = rot ? Number(rot[1]) : 0;
2249
+ const shadow = !!s["box-shadow"];
2250
+ const bits = [];
2251
+ if (border) bits.push(white ? "a white border" : "a border");
2252
+ if (rot && deg < -0.2) bits.push("a slight tilt left");
2253
+ else if (rot && deg > 0.2) bits.push("a slight tilt right");
2254
+ if (shadow) bits.push("a shadow");
2255
+ const polaroid = white && border && !!rot && shadow;
2256
+ const say = bits.length ? `This photo now has ${bits.join(", ")}${polaroid ? ", like a polaroid on a desk" : ""}.` : "This photo looks different. Before and after are below.";
2257
+ let title = "Photo appearance changed";
2258
+ if (polaroid) title = "Photo tilted like a polaroid";
2259
+ else if (white && border && rot) title = "Photo has a white border and a tilt";
2260
+ else if (bits[0]) title = `Photo has ${bits[0]}`;
2261
+ return { title, say };
2262
+ }
2263
+ function textCopyEn(item) {
2264
+ const before = item.anchor?.text?.trim();
2265
+ const after = item.patch?.text?.trim();
2266
+ const s = style(item);
2267
+ const italic = s["font-style"] === "italic";
2268
+ const bangGone = !!before?.endsWith("!") && !!after && !after.endsWith("!");
2269
+ const what = isHeading(item) ? "The heading" : "This text";
2270
+ const parts = [];
2271
+ if (before && after && before !== after) parts.push(`changed from \u201C${clip2(before, 80)}\u201D to \u201C${clip2(after, 80)}\u201D`);
2272
+ else if (after) parts.push(`changed to \u201C${clip2(after, 80)}\u201D`);
2273
+ if (italic) parts.push(parts.length ? "and is now italic" : "is now italic");
2274
+ if (bangGone) parts.push("and the exclamation mark is gone");
2275
+ if (!parts.length) return genericCopyEn(item);
2276
+ const title = italic && bangGone ? `${what} is italic, without the exclamation mark` : italic ? `${what} is italic` : bangGone ? `${what} lost its exclamation mark` : what === "The heading" ? "Heading text changed" : "Text changed";
2277
+ return { title, say: `${what} ${parts.join(", ")}.` };
2278
+ }
2279
+ function rampCopyEn(members) {
2280
+ const books = bookish(members);
2281
+ const noun = books ? "bibliography" : "list";
2282
+ const rows = members.map((i) => {
2283
+ const n = Number(/nth-(?:child|of-type)\((\d+)\)/i.exec(i.selector)?.[1] ?? NaN);
2284
+ const s = style(i);
2285
+ return { n, px: px(s["font-size"]), border: !!(s["border-bottom"] || s["border-bottom-width"]), bold: Number(s["font-weight"]) >= 600 || /bold/i.test(s["font-weight"] || "") };
2286
+ }).filter((r) => !Number.isNaN(r.n) && !Number.isNaN(r.px)).sort((a, b) => a.n - b.n);
2287
+ let decreasing = rows.length ? 1 : 0;
2288
+ for (let i = 1; i < rows.length; i++) {
2289
+ if (rows[i].px < rows[i - 1].px - 0.5) decreasing = i + 1;
2290
+ else break;
2291
+ }
2292
+ const lined = rows.find((r) => r.border);
2293
+ const cut = lined ? rows.indexOf(lined) + 1 : decreasing;
2294
+ const restBold = rows.slice(cut).some((r) => r.bold);
2295
+ const ul = members.find((m) => listRootSelector(m.selector) === norm(m.selector));
2296
+ const bullets = /none/i.test(style(ul ?? members[0])["list-style"] || style(ul ?? members[0])["list-style-type"] || "");
2297
+ const bits = [`The ${noun} ${bullets ? "is no longer a same-size bulleted list" : "uses a new type scale"}`];
2298
+ if (cut >= 2) bits.push(`the first ${cut} items step down in size`);
2299
+ if (lined) bits.push(`item ${lined.n} has a line under it`);
2300
+ if (restBold) bits.push("the later items are the same bold size");
2301
+ const title = cut >= 2 ? `${noun[0].toUpperCase()}${noun.slice(1)} steps down in size` : `${noun[0].toUpperCase()}${noun.slice(1)} appearance changed`;
2302
+ return { title, say: `${bits.join(", ")}.` };
2303
+ }
2304
+ function genericCopyEn(item) {
2305
+ const s = style(item);
2306
+ const bits = [];
2307
+ if (s["font-style"] === "italic") bits.push("is italic");
2308
+ if (Number(s["font-weight"]) >= 600 || /bold/i.test(s["font-weight"] || "")) bits.push("is bold");
2309
+ if (s["font-size"]) bits.push(`size is ${s["font-size"]}`);
2310
+ if (s["font-family"]) bits.push("uses a different font");
2311
+ if (s.color) bits.push("text color changed");
2312
+ if (s["background-color"] || s.background) bits.push("background changed");
2313
+ if (s["border-radius"]) bits.push("corners changed");
2314
+ if (s.border || s["border-color"]) bits.push("border changed");
2315
+ if (s["text-decoration"] || s["text-decoration-line"]) bits.push("text decoration changed");
2316
+ if (s.opacity) bits.push("opacity changed");
2317
+ const where = targetWordsEn(item);
2318
+ if (!bits.length) return { title: "Appearance changed", say: `${where} looks different. Before and after are below.` };
2319
+ return { title: "Appearance changed", say: `${where} ${bits.join(", ")}.` };
2320
+ }
2321
+ function targetWordsEn(item) {
2322
+ const text = item.anchor?.text?.trim();
2323
+ if (text) return `\u201C${clip2(text, 42)}\u201D`;
2324
+ const tag = (item.anchor?.tag || "").toLowerCase();
2325
+ if (tag === "img" || /(?:^|[\s>+~])img(?![a-z-])/i.test(item.selector)) return "this photo";
2326
+ if (/^h[1-6]$/.test(tag) || /(?:^|[\s>+~])h[1-6](?![a-z-])/i.test(item.selector)) return "this heading";
2327
+ const label = item.label?.replace(/^[a-z0-9-]+\s*/i, "").replace(/^"|"$/g, "").trim();
2328
+ if (label) return `\u201C${clip2(label, 42)}\u201D`;
2329
+ return "this part of the page";
2330
+ }
2331
+ function targetWords(item, lang = "zh-CN") {
2332
+ if (lang === "en") return targetWordsEn(item);
2243
2333
  const text = item.anchor?.text?.trim();
2244
2334
  if (text) return `\u300C${clip2(text, 42)}\u300D\u8FD9\u4E00\u5757`;
2245
2335
  const tag = (item.anchor?.tag || "").toLowerCase();
@@ -2259,7 +2349,8 @@ function bookish(items) {
2259
2349
  const blob = items.map((i) => `${i.anchor?.text ?? ""} ${i.label ?? ""} ${i.note ?? ""}`).join(" ");
2260
2350
  return /书目|书单|bibliography|\bC\+\+|Tour of|Programming:/i.test(blob);
2261
2351
  }
2262
- function imageCopy(item) {
2352
+ function imageCopy(item, lang = "zh-CN") {
2353
+ if (lang === "en") return imageCopyEn(item);
2263
2354
  const s = style(item);
2264
2355
  const blob = Object.values(s).join(" ");
2265
2356
  const white = /#fff\b|#ffffff|white|rgb\(\s*255\s*,\s*255\s*,\s*255/i.test(blob);
@@ -2280,7 +2371,8 @@ function imageCopy(item) {
2280
2371
  else if (bits[0]) title = `\u7167\u7247${bits[0]}`;
2281
2372
  return { title, say };
2282
2373
  }
2283
- function textCopy(item) {
2374
+ function textCopy(item, lang = "zh-CN") {
2375
+ if (lang === "en") return textCopyEn(item);
2284
2376
  const before = item.anchor?.text?.trim();
2285
2377
  const after = item.patch?.text?.trim();
2286
2378
  const s = style(item);
@@ -2303,7 +2395,8 @@ function px(value) {
2303
2395
  const n = parseFloat(value);
2304
2396
  return Number.isFinite(n) ? n : NaN;
2305
2397
  }
2306
- function rampCopy(members) {
2398
+ function rampCopy(members, lang = "zh-CN") {
2399
+ if (lang === "en") return rampCopyEn(members);
2307
2400
  const books = bookish(members);
2308
2401
  const noun = books ? "\u4E66\u76EE" : "\u5217\u8868";
2309
2402
  const rows = members.map((i) => {
@@ -2328,7 +2421,8 @@ function rampCopy(members) {
2328
2421
  const title = cut >= 2 ? `${noun}\u6539\u6210\u7531\u5927\u5230\u5C0F\u7684\u5B57\u53F7\u9636\u68AF` : `${noun}\u7684\u5916\u89C2\u6539\u4E86`;
2329
2422
  return { title, say: `${bits.join("\uFF0C")}\u3002` };
2330
2423
  }
2331
- function genericCopy(item) {
2424
+ function genericCopy(item, lang = "zh-CN") {
2425
+ if (lang === "en") return genericCopyEn(item);
2332
2426
  const s = style(item);
2333
2427
  const bits = [];
2334
2428
  if (s["font-style"] === "italic") bits.push("\u6539\u6210\u4E86\u659C\u4F53");
@@ -2341,31 +2435,31 @@ function genericCopy(item) {
2341
2435
  if (s.border || s["border-color"]) bits.push("\u8FB9\u6846\u6539\u4E86");
2342
2436
  if (s["text-decoration"] || s["text-decoration-line"]) bits.push("\u6587\u5B57\u88C5\u9970\u6539\u4E86");
2343
2437
  if (s.opacity) bits.push("\u900F\u660E\u5EA6\u6539\u4E86");
2344
- const where = targetWords(item);
2438
+ const where = targetWords(item, "zh-CN");
2345
2439
  if (!bits.length) return { title: "\u8FD9\u4E00\u5757\u7684\u5916\u89C2\u6539\u4E86", say: `${where}\u7684\u5916\u89C2\u6539\u4E86\uFF0C\u5DEE\u522B\u770B\u4E0B\u9762\u7684\u6539\u524D\u548C\u6539\u540E\u3002` };
2346
2440
  return { title: "\u8FD9\u4E00\u5757\u7684\u5916\u89C2\u6539\u4E86", say: `${where}${bits.join("\uFF0C")}\u3002` };
2347
2441
  }
2348
- function noteCopy(item) {
2442
+ function noteCopy(item, lang = "zh-CN") {
2349
2443
  const note = item.note.trim();
2350
- const line = note.split(/[。.!!?\n]/)[0]?.trim() || "\u6587\u5B57\u610F\u89C1";
2444
+ const line = note.split(/[。.!!?\n]/)[0]?.trim() || (lang === "en" ? "Note" : "\u6587\u5B57\u610F\u89C1");
2351
2445
  return {
2352
2446
  title: clip2(line, 36),
2353
2447
  say: note,
2354
- where: `\u5173\u4E8E${targetWords(item)}`
2448
+ where: lang === "en" ? `About ${targetWords(item, "en")}` : `\u5173\u4E8E${targetWords(item, "zh-CN")}`
2355
2449
  };
2356
2450
  }
2357
- function copyFor(members, ramp) {
2358
- if (ramp) return rampCopy(members);
2451
+ function copyFor(members, ramp, lang = "zh-CN") {
2452
+ if (ramp) return rampCopy(members, lang);
2359
2453
  const item = members[0];
2360
- if (!item || item.kind === "note") return noteCopy(item);
2361
- if (item.patch?.text !== void 0) return textCopy(item);
2362
- if (isImage(item)) return imageCopy(item);
2363
- return genericCopy(item);
2454
+ if (!item || item.kind === "note") return noteCopy(item, lang);
2455
+ if (item.patch?.text !== void 0) return textCopy(item, lang);
2456
+ if (isImage(item)) return imageCopy(item, lang);
2457
+ return genericCopy(item, lang);
2364
2458
  }
2365
- function entryFrom(members, extras, ramp) {
2459
+ function entryFrom(members, extras, ramp, lang = "zh-CN") {
2366
2460
  const root = ramp ? listRootSelector(members[0].selector) : null;
2367
2461
  const primary = ramp ? members.find((m) => norm(m.selector) === root) ?? [...members].sort((a, b) => a.selector.length - b.selector.length || a.n - b.n)[0] : members[0];
2368
- const copy = copyFor(members, ramp);
2462
+ const copy = copyFor(members, ramp, lang);
2369
2463
  const ordered = [...members].sort((a, b) => a.n - b.n);
2370
2464
  const lines = [...ordered, ...extras].map(techOf);
2371
2465
  return {
@@ -2394,7 +2488,7 @@ function rampGroups(patches) {
2394
2488
  }
2395
2489
  return [...buckets.values()].filter((g) => g.length >= 2);
2396
2490
  }
2397
- function readerEntries(items) {
2491
+ function readerEntries(items, lang = "zh-CN") {
2398
2492
  const paths = [...new Set(items.map((i) => i.path))];
2399
2493
  const out = [];
2400
2494
  for (const path18 of paths) {
@@ -2408,9 +2502,9 @@ function readerEntries(items) {
2408
2502
  const extras = rest.filter((i) => folded.has(i.id));
2409
2503
  const singles = rest.filter((i) => !folded.has(i.id));
2410
2504
  const built = [
2411
- ...notes.map((i) => entryFrom([i], [], false)),
2412
- ...ramps.map((g) => entryFrom(g, extras.filter((e) => g.some((m) => isAncestorSelector(e.selector, m.selector))), true)),
2413
- ...singles.map((i) => entryFrom([i], extras.filter((e) => isAncestorSelector(e.selector, i.selector)), false))
2505
+ ...notes.map((i) => entryFrom([i], [], false, lang)),
2506
+ ...ramps.map((g) => entryFrom(g, extras.filter((e) => g.some((m) => isAncestorSelector(e.selector, m.selector))), true, lang)),
2507
+ ...singles.map((i) => entryFrom([i], extras.filter((e) => isAncestorSelector(e.selector, i.selector)), false, lang))
2414
2508
  ];
2415
2509
  out.push(...built);
2416
2510
  }
@@ -2431,9 +2525,13 @@ function mdImage(label, src) {
2431
2525
  if (!src) return [];
2432
2526
  return [`**${label}**`, "", `![${label}](${src})`, ""];
2433
2527
  }
2434
- function reviewMarkdown(target, items, approvals) {
2435
- const entries = readerEntries(items);
2528
+ function reviewMarkdown(target, items, approvals, lang = "zh-CN") {
2529
+ const entries = readerEntries(items, lang);
2530
+ const before = lang === "en" ? "Before" : "\u6539\u524D";
2531
+ const after = lang === "en" ? "After" : "\u6539\u540E";
2532
+ const details = lang === "en" ? "Selector and style" : "\u9009\u62E9\u5668\u548C\u6837\u5F0F";
2436
2533
  const open = entries.filter((e) => e.status === "open").length;
2534
+ const who = (name) => name === "\u533F\u540D" || name === "Anonymous" ? lang === "en" ? "Anonymous" : "\u533F\u540D" : name;
2437
2535
  const lines = [`# Page review`, "", `Site: ${target}`, `Items: ${entries.length} (${open} open)`, ""];
2438
2536
  const paths = [.../* @__PURE__ */ new Set([...entries.map((e) => e.path), ...approvals.map((a) => a.path)])];
2439
2537
  for (const p of paths) {
@@ -2447,15 +2545,15 @@ function reviewMarkdown(target, items, approvals) {
2447
2545
  for (const note of authorNotes(e)) lines.push(note, "");
2448
2546
  const look = e.primary.look;
2449
2547
  if (e.kind === "patch") {
2450
- lines.push(...mdImage("\u6539\u524D", look?.beforePage ?? look?.beforeImage), ...mdImage("\u6539\u540E", look?.afterPage ?? look?.afterImage));
2548
+ lines.push(...mdImage(before, look?.beforePage ?? look?.beforeImage), ...mdImage(after, look?.afterPage ?? look?.afterImage));
2451
2549
  if (!look?.beforeImage && !look?.afterImage && !look?.beforePage && !look?.afterPage) {
2452
2550
  const text = e.lines.find((l) => l.text);
2453
- if (text?.text) lines.push(`\u6539\u524D \u2192 \u6539\u540E\uFF1A${text.text}`, "");
2551
+ if (text?.text) lines.push(lang === "en" ? `${before} \u2192 ${after}: ${text.text}` : `${before} \u2192 ${after}\uFF1A${text.text}`, "");
2454
2552
  }
2455
2553
  }
2456
- lines.push(`By ${e.authorName}`, "");
2554
+ lines.push(`By ${who(e.authorName)}`, "");
2457
2555
  if (e.lines.length) {
2458
- lines.push("<details>", "<summary>\u9009\u62E9\u5668\u548C\u6837\u5F0F</summary>", "");
2556
+ lines.push("<details>", `<summary>${details}</summary>`, "");
2459
2557
  for (const line of e.lines) {
2460
2558
  lines.push(`- \`${line.selector}\``);
2461
2559
  if (line.css) lines.push(` - CSS: \`${line.css}\``);
@@ -2465,7 +2563,7 @@ function reviewMarkdown(target, items, approvals) {
2465
2563
  lines.push("", "</details>", "");
2466
2564
  }
2467
2565
  const replies = e.members.flatMap((m) => m.replies);
2468
- for (const r of replies) lines.push(`- ${r.author.name}: ${r.text.replace(/\n/g, " ")}`);
2566
+ for (const r of replies) lines.push(`- ${who(r.author.name)}: ${r.text.replace(/\n/g, " ")}`);
2469
2567
  if (replies.length) lines.push("");
2470
2568
  }
2471
2569
  }
@@ -2997,10 +3095,76 @@ function packageVersion() {
2997
3095
  }
2998
3096
  return "0.0.0";
2999
3097
  }
3098
+ var BM = "\u2726 Page-Harness";
3099
+ var CMD = "page-harness <url> --no-agent";
3100
+ var HOME_COPY = {
3101
+ en: {
3102
+ title: "Drag to your bookmarks bar",
3103
+ sub: "Click it on any page to start marking.",
3104
+ mine: "My notes ({n})",
3105
+ back: "\u2190 Home",
3106
+ bar: "Bookmarks",
3107
+ docs: "Docs",
3108
+ gh: "GitHub",
3109
+ trouble: "Having trouble?",
3110
+ helpTitle: "Bookmark did nothing?",
3111
+ help: [
3112
+ [
3113
+ { k: "t", v: "HTTPS pages can block requests to 127.0.0.1 on this machine. Use " },
3114
+ { k: "c", v: CMD },
3115
+ { k: "t", v: " and the proxy will carry them." }
3116
+ ],
3117
+ [{ k: "t", v: "Some sites set a CSP, which stops the page from reaching this machine." }],
3118
+ [{ k: "t", v: "The bookmark only works for this run. Drag it again after a restart." }]
3119
+ ],
3120
+ md: "Export Markdown",
3121
+ json: "Export JSON",
3122
+ copy: "Copy prompt",
3123
+ copied: "Copied",
3124
+ empty: "No notes for this site yet.",
3125
+ none: "No notes yet.",
3126
+ ungrouped: "(ungrouped)"
3127
+ },
3128
+ "zh-CN": {
3129
+ title: "\u62D6\u5230\u4E66\u7B7E\u680F\uFF0C\u5C31\u80FD\u7528",
3130
+ sub: "\u5728\u4EFB\u4F55\u7F51\u9875\u4E0A\u70B9\u4E00\u4E0B\uFF0C\u5C31\u80FD\u76F4\u63A5\u6807\u8BB0\u3002",
3131
+ mine: "\u6211\u7684\u6807\u8BB0 ({n})",
3132
+ back: "\u2190 \u9996\u9875",
3133
+ bar: "\u4E66\u7B7E\u680F",
3134
+ docs: "\u6587\u6863",
3135
+ gh: "GitHub",
3136
+ trouble: "\u9047\u5230\u95EE\u9898\uFF1F",
3137
+ helpTitle: "\u70B9\u4E86\u4E66\u7B7E\u6CA1\u53CD\u5E94\uFF1F",
3138
+ help: [
3139
+ [
3140
+ { k: "t", v: "HTTPS \u7F51\u9875\u53EF\u80FD\u4F1A\u62E6\u4E0B\u53D1\u5F80\u672C\u673A 127.0.0.1 \u7684\u8BF7\u6C42\uFF0C\u6362\u7528 " },
3141
+ { k: "c", v: CMD },
3142
+ { k: "t", v: " \u8D70\u4EE3\u7406\u5C31\u884C\u3002" }
3143
+ ],
3144
+ [{ k: "t", v: "\u6709\u7684\u7F51\u7AD9\u8BBE\u4E86 CSP\uFF0C\u4F1A\u7981\u6B62\u9875\u9762\u8FDE\u672C\u673A\u3002" }],
3145
+ [{ k: "t", v: "\u4E66\u7B7E\u53EA\u5728\u8FD9\u6B21\u542F\u52A8\u671F\u95F4\u6709\u6548\uFF0C\u91CD\u65B0\u542F\u52A8\u540E\u8981\u518D\u62D6\u4E00\u6B21\u3002" }]
3146
+ ],
3147
+ md: "\u5BFC\u51FA Markdown",
3148
+ json: "\u5BFC\u51FA JSON",
3149
+ copy: "\u590D\u5236 Prompt",
3150
+ copied: "\u5DF2\u590D\u5236",
3151
+ empty: "\u8FD9\u4E2A\u7F51\u7AD9\u8FD8\u6CA1\u6709\u6807\u8BB0\u3002",
3152
+ none: "\u8FD8\u6CA1\u6709\u6807\u8BB0\u3002",
3153
+ ungrouped: "\uFF08\u672A\u5206\u7EC4\uFF09"
3154
+ }
3155
+ };
3156
+ function esc(s) {
3157
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3158
+ }
3159
+ function helpInner(lines) {
3160
+ return lines.map((line) => `<p>\xB7 ${line.map((bit) => bit.k === "c" ? `<code>${esc(bit.v)}</code>` : esc(bit.v)).join("")}</p>`).join("");
3161
+ }
3000
3162
  function homeHtml(boot) {
3001
3163
  const json2 = JSON.stringify(boot).replace(/</g, "\\u003c");
3164
+ const t = HOME_COPY[boot.lang ?? "en"];
3165
+ const htmlLang = boot.lang ?? "en";
3002
3166
  return `<!doctype html>
3003
- <html lang="en">
3167
+ <html lang="${htmlLang}">
3004
3168
  <head>
3005
3169
  <meta charset="utf-8">
3006
3170
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -3008,35 +3172,42 @@ function homeHtml(boot) {
3008
3172
  <link rel="stylesheet" href="/__ph/home.css">
3009
3173
  </head>
3010
3174
  <body>
3011
- <div class="app">
3012
- <aside>
3013
- <div class="brand"><i></i><span>page-harness</span><span class="tag" id="tag">Review only \xB7 no agent</span></div>
3014
- <section class="card">
3015
- <b id="step">Drag the button to the bookmarks bar</b>
3016
- <p class="lead" id="lead">Open any site and click the bookmark. The dock is Pick and Notes only.</p>
3017
- <p class="drag"><a class="bm" id="bm" href="#" draggable="true">PH Review</a></p>
3018
- <div class="cdn"><span id="src-label">Source</span>
3019
- <select id="src" data-testid="bookmarklet-source">
3020
- <option value="local">local</option>
3021
- <option value="cdn">jsDelivr CDN</option>
3022
- </select>
3175
+ <div class="top">
3176
+ <div class="brand"><i></i><span>page-harness</span></div>
3177
+ <a class="navl" id="tonotes" href="#notes">${esc(t.mine.replace("{n}", "0"))}</a>
3178
+ </div>
3179
+ <div class="view on" id="home">
3180
+ <div class="hero">
3181
+ <div class="bar" id="bar"><span id="bar-label">${esc(t.bar)}</span><span class="chip" id="chip-docs">${esc(t.docs)}</span><span class="chip" id="chip-gh">${esc(t.gh)}</span></div>
3182
+ <div class="copy">
3183
+ <h1 id="hero-title">${esc(t.title)}</h1>
3184
+ <p class="sub" id="hero-sub">${esc(t.sub)}</p>
3185
+ </div>
3186
+ <a class="bm" id="bm" href="#" draggable="true">${BM}</a>
3187
+ </div>
3188
+ <button type="button" class="help" id="help">${esc(t.trouble)}</button>
3189
+ </div>
3190
+ <div class="view" id="notes">
3191
+ <div class="notes">
3192
+ <div class="side" id="doms"></div>
3193
+ <section class="main">
3194
+ <div class="mh">
3195
+ <h2 id="host-title"></h2>
3196
+ <span class="actions">
3197
+ <button type="button" class="sb" id="exmd" data-testid="export-md">${esc(t.md)}</button>
3198
+ <button type="button" class="sb" id="exjson" data-testid="export-json">${esc(t.json)}</button>
3199
+ <button type="button" class="sb p" id="cp" data-testid="copy-prompt">${esc(t.copy)}</button>
3200
+ </span>
3023
3201
  </div>
3024
- <p class="limit" id="limit"></p>
3202
+ <div id="list"></div>
3025
3203
  </section>
3026
- <div class="label" id="by">By site</div>
3027
- <div id="doms"></div>
3028
- </aside>
3029
- <section class="main">
3030
- <header>
3031
- <h1 id="title">Pick a site</h1>
3032
- <span class="actions">
3033
- <button type="button" class="btn sm" id="exmd" data-testid="export-md">Export Markdown</button>
3034
- <button type="button" class="btn sm" id="exjson" data-testid="export-json">Export JSON</button>
3035
- <button type="button" class="btn sm copy" id="cp" data-testid="copy-prompt">Copy prompt</button>
3036
- </span>
3037
- </header>
3038
- <div id="list"></div>
3039
- </section>
3204
+ </div>
3205
+ </div>
3206
+ <div class="modal" id="modal">
3207
+ <div class="mc" id="modal-card" role="dialog" aria-modal="true" aria-labelledby="help-title">
3208
+ <b id="help-title">${esc(t.helpTitle)}</b>
3209
+ <div id="help-body">${helpInner(t.help)}</div>
3210
+ </div>
3040
3211
  </div>
3041
3212
  <script type="application/json" id="ph-boot">${json2}</script>
3042
3213
  <script src="/__ph/home.js"></script>
@@ -3055,145 +3226,158 @@ ${HOME_FONTS_CSS}
3055
3226
  * { box-sizing: border-box; }
3056
3227
  html, body { height: 100%; margin: 0; }
3057
3228
  body { background: var(--base); color: var(--fg); font: var(--fs)/1.5 var(--font); -webkit-font-smoothing: antialiased; }
3058
- button, select { font: inherit; color: inherit; }
3059
- button { cursor: pointer; }
3229
+ button { font: inherit; color: inherit; cursor: pointer; }
3060
3230
  button:disabled { cursor: not-allowed; opacity: .45; }
3061
- button:focus-visible, select:focus-visible, a:focus-visible { outline: none; box-shadow: var(--focus); }
3062
- [hidden] { display: none !important; }
3063
- .app { height: 100%; display: grid; grid-template-columns: 320px 1fr; }
3064
- aside { background: var(--bg2); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; }
3065
- .brand { display: flex; align-items: center; gap: 8px; padding: 14px 14px 8px; font-weight: 700; font-size: var(--fs-lg); }
3231
+ button:focus-visible, a:focus-visible { outline: none; box-shadow: var(--focus); }
3232
+ .top { position: fixed; z-index: 2; top: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: center; padding: 18px 28px; }
3233
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; }
3066
3234
  .brand i { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); transform: rotate(45deg); flex: none; }
3067
- .tag { font-weight: 600; font-size: var(--fs-xs); color: var(--accent); background: var(--accent-soft); padding: 1px 8px; border-radius: 999px; }
3068
- .card { margin: 4px 12px 8px; padding: 12px; border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--bg); box-shadow: var(--shadow-sm); }
3069
- .card b { font-size: var(--fs); }
3070
- .lead { margin: 6px 0 12px; color: var(--fg2); font-size: var(--fs-sm); }
3071
- .drag { margin: 0 0 10px; }
3072
- .bm { display: inline-flex; align-items: center; height: var(--h-lg); padding: 0 14px; border-radius: var(--r); background: var(--accent); color: var(--accent-fg); font-weight: 600; text-decoration: none; box-shadow: var(--shadow-sm); cursor: grab; }
3073
- .bm:hover { background: var(--accent-hover); }
3235
+ .navl { color: var(--fg2); text-decoration: none; font-size: 14px; padding: 6px 12px; border-radius: 8px; }
3236
+ .navl:hover { background: var(--bg2); color: var(--fg); }
3237
+ .navl.back { background: var(--bg); border: 1px solid var(--line); color: var(--fg2); box-shadow: var(--shadow-sm); }
3238
+ .view { display: none; }
3239
+ .view.on { display: block; }
3240
+ .hero { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 22px; padding: 80px 24px; text-align: center; background: radial-gradient(600px 300px at 50% 42%, var(--accent-soft), transparent 70%); }
3241
+ .bar { width: min(520px, 100%); height: 40px; border-radius: 10px; background: var(--bg); border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line-strong)); display: flex; align-items: center; padding: 0 12px; gap: 8px; color: var(--fg3); font-size: 13px; }
3242
+ .bar.hot { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
3243
+ .chip { background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 4px 10px; color: var(--fg); font-size: 13px; line-height: 1.2; }
3244
+ .copy { display: flex; flex-direction: column; align-items: center; gap: 8px; }
3245
+ .hero h1 { margin: 0; font-size: 30px; letter-spacing: -0.02em; font-weight: 700; line-height: 1.25; }
3246
+ .sub { margin: 0; color: var(--fg2); font-size: 15px; }
3247
+ .bm { display: inline-flex; align-items: center; gap: 8px; padding: 14px 26px; border-radius: 12px; background: var(--accent); color: var(--accent-fg); font-weight: 600; font-size: 16px; text-decoration: none; box-shadow: 0 10px 30px color-mix(in srgb, var(--accent) 25%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.25); cursor: grab; transition: transform .15s var(--ease); }
3248
+ .bm:hover { transform: translateY(-2px); }
3074
3249
  .bm:active { cursor: grabbing; }
3075
- .cdn { display: flex; gap: 8px; align-items: center; color: var(--fg2); font-size: var(--fs-sm); }
3076
- select { height: var(--h); padding: 0 8px; border: 1px solid var(--line); border-radius: var(--r); background: var(--bg); color: var(--fg); }
3077
- .limit { margin: 10px 0 0; color: var(--fg3); font-size: var(--fs-xs); line-height: 1.5; }
3078
- .label { padding: 8px 14px 4px; font-size: var(--fs-xs); font-weight: 600; color: var(--fg3); }
3079
- #doms { flex: 1; overflow: auto; padding: 4px 8px; display: flex; flex-direction: column; gap: 2px; }
3080
- .dom { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px; border-radius: var(--r); cursor: pointer; }
3081
- .dom:hover { background: var(--bg3); }
3082
- .dom.on { background: var(--accent-soft); }
3083
- .dom .n { flex: none; font-size: var(--fs-xs); color: var(--fg3); }
3084
- .main { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); }
3085
- header { display: flex; align-items: center; gap: 8px; padding: 12px 20px; border-bottom: 1px solid var(--line); }
3086
- h1 { flex: 1; min-width: 0; margin: 0; font-size: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3087
- .actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
3088
- .btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: var(--h); padding: 0 10px; border: 1px solid var(--line); border-radius: var(--r); background: var(--bg); white-space: nowrap; transition: background .12s, border-color .12s, color .12s; }
3089
- .btn:hover:not(:disabled) { background: var(--bg2); }
3090
- .btn.sm { height: var(--h-sm); padding: 0 8px; font-size: var(--fs-sm); border-radius: var(--r-sm); }
3091
- .btn.copy { background: color-mix(in srgb, var(--accent) 12%, transparent); border-color: transparent; color: var(--accent); font-weight: 600; }
3092
- .btn.copy:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 20%, transparent); }
3093
- .btn.copy.on, .btn.copy.on:hover:not(:disabled) { background: var(--ok); border-color: var(--ok); color: #fff; }
3094
- #list { flex: 1; overflow: auto; padding: 8px 12px 24px; }
3095
- .note { display: flex; gap: 8px; align-items: flex-start; margin: 2px 0; padding: 8px; border-radius: var(--r); border-left: 3px solid var(--accent); }
3096
- .note:hover { background: var(--bg2); }
3097
- .note.issue { border-color: var(--err); }
3098
- .note.info { border-color: var(--fg3); }
3099
- .note.patch { border-color: var(--ok); }
3100
- .note.done { opacity: .55; }
3101
- .num { flex: none; width: 20px; height: 20px; border-radius: 50%; background: var(--accent); color: var(--accent-fg); font-size: var(--fs-xs); line-height: 20px; text-align: center; font-weight: 600; }
3102
- .note.issue .num { background: var(--err); color: #fff; }
3103
- .note.info .num { background: var(--fg2); color: var(--bg); }
3104
- .note.patch .num { background: var(--ok); color: #fff; border-radius: 4px; }
3105
- .say { word-break: break-word; }
3106
- .sel { margin-top: 2px; font: var(--fs-xs)/1.4 var(--mono); color: var(--fg3); word-break: break-word; }
3250
+ .help { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); border: 0; background: none; font-size: 13px; color: var(--fg3); padding: 4px 8px; }
3251
+ .help:hover { color: var(--fg2); }
3252
+ .modal { position: fixed; z-index: 20; inset: 0; background: var(--mask); display: none; align-items: center; justify-content: center; padding: 24px; }
3253
+ .modal.on { display: flex; }
3254
+ .mc { background: var(--bg); color: var(--fg2); border-radius: 14px; padding: 22px 24px; width: min(440px, 100%); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.13); font-size: 14px; line-height: 1.6; }
3255
+ .mc b { display: block; color: var(--fg); margin: 0 0 8px; font-size: 15px; }
3256
+ .mc p { margin: 0 0 6px; }
3257
+ .mc code { font-family: var(--mono); font-size: 12px; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
3258
+ .notes { display: grid; grid-template-columns: 240px 1fr; height: 100vh; padding-top: 64px; }
3259
+ .side { border-right: 1px solid var(--line); padding: 12px; overflow: auto; }
3260
+ .site { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 9px 12px; border: 0; border-radius: 8px; background: transparent; text-align: left; font-size: 14px; }
3261
+ .site:hover { background: var(--bg2); }
3262
+ .site.on { background: var(--accent-soft); color: var(--accent-hover); }
3263
+ .site .n { flex: none; color: var(--fg3); font-size: 12px; }
3264
+ .site.on .n { color: var(--accent); }
3265
+ .main { display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 8px 28px 24px; }
3266
+ .mh { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 0 4px; }
3267
+ .mh h2 { margin: 0; min-width: 0; font-size: 18px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3268
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; flex: none; }
3269
+ .sb { padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); font-size: 13px; white-space: nowrap; }
3270
+ .sb:hover:not(:disabled) { background: var(--bg2); }
3271
+ .sb.p { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); font-weight: 600; }
3272
+ .sb.p:hover:not(:disabled) { background: var(--accent-hover); }
3273
+ .sb.p.on, .sb.p.on:hover:not(:disabled) { background: var(--ok); border-color: var(--ok); color: #fff; }
3274
+ #list { flex: 1; overflow: auto; min-height: 0; }
3275
+ .note { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; border-bottom: 1px solid var(--line2); }
3276
+ .pin { flex: none; min-width: 24px; height: 24px; padding: 0 6px; box-sizing: border-box; border-radius: 12px; background: var(--accent); color: var(--accent-fg); font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; }
3277
+ .say { font-size: 14px; word-break: break-word; }
3278
+ .q { margin-top: 3px; color: var(--fg3); font-size: 12px; line-height: 1.4; word-break: break-word; }
3107
3279
  .empty { color: var(--fg2); font-size: var(--fs-sm); padding: 12px 6px; }
3108
3280
  @media (max-width: 800px) {
3109
- .app { grid-template-columns: 1fr; }
3110
- aside { max-height: 46vh; border-right: 0; border-bottom: 1px solid var(--line); }
3111
- header { padding: 12px; align-items: flex-start; flex-wrap: wrap; }
3281
+ .top { padding: 14px 16px; }
3282
+ .hero { padding: 88px 16px 80px; }
3283
+ .hero h1 { font-size: 26px; }
3284
+ .notes { grid-template-columns: 1fr; height: auto; min-height: 100vh; }
3285
+ .side { max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--line); }
3286
+ .main { padding: 8px 16px 24px; }
3287
+ .mh { align-items: flex-start; flex-wrap: wrap; }
3288
+ }
3289
+ @media (prefers-reduced-motion: reduce) {
3290
+ * { transition: none !important; }
3291
+ .bm:hover { transform: none; }
3112
3292
  }
3113
- @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
3114
3293
  `;
3115
3294
  var HOME_JS = `
3116
3295
  const boot = JSON.parse(document.getElementById('ph-boot').textContent)
3117
- const zh = (navigator.language || '').toLowerCase().startsWith('zh')
3118
- const t = zh ? {
3119
- tag: '\u4EC5\u8BC4\u5BA1 \xB7 \u65E0 Agent',
3120
- step: '\u628A\u6309\u94AE\u62D6\u5230\u6536\u85CF\u5939\u680F',
3121
- lead: '\u5728\u4EFB\u610F\u7F51\u7AD9\u70B9\u8FD9\u4E2A\u4E66\u7B7E\u3002\u5E95\u680F\u53EA\u6709\u53D6\u70B9\u548C\u610F\u89C1\u3002',
3122
- src: '\u6765\u6E90',
3123
- local: '\u672C\u673A ' + location.host,
3124
- cdn: 'jsDelivr CDN',
3125
- by: '\u6309\u7F51\u7AD9',
3126
- pick: '\u9009\u62E9\u4E00\u4E2A\u7F51\u7AD9',
3127
- notes: '\u6761',
3128
- empty: '\u8FD9\u4E2A\u7F51\u7AD9\u8FD8\u6CA1\u6709\u610F\u89C1\u3002',
3129
- none: '\u8FD8\u6CA1\u6709\u610F\u89C1\u3002\u5728\u7F51\u9875\u4E0A\u70B9\u4E66\u7B7E\uFF0C\u53D6\u70B9\u540E\u5199\u4E00\u6761\u3002',
3130
- md: '\u5BFC\u51FA Markdown',
3131
- json: '\u5BFC\u51FA JSON',
3132
- copy: '\u590D\u5236 Prompt',
3133
- copied: '\u5DF2\u590D\u5236',
3134
- bm: 'PH \u8BC4\u5BA1',
3135
- limit: '\u672C\u673A\u811A\u672C\u6765\u81EA ' + location.origin + '\uFF0C\u9002\u5408 http \u9875\u9762\u3002https \u9875\u9762\u7ECF\u5E38\u4F1A\u62E6\u622A http://127.0.0.1 \u7684\u811A\u672C\uFF0C\u8FD9\u65F6\u6539\u7528 jsDelivr\uFF08\u811A\u672C\u8D70 https\uFF09\u3002\u610F\u89C1\u4ECD\u8981\u5199\u56DE\u672C\u673A\u7684 http \u63A7\u5236\u7AEF\u53E3\uFF1AChrome \u901A\u5E38\u5141\u8BB8\u8BBF\u95EE 127.0.0.1\uFF0C\u5E76\u53EF\u80FD\u5F39\u51FA\u672C\u5730\u7F51\u7EDC\u6743\u9650\uFF08\u670D\u52A1\u4F1A\u56DE\u5E94 Access-Control-Allow-Private-Network\uFF09\uFF1BFirefox \u548C Safari \u591A\u534A\u4F1A\u5F53\u6210\u6DF7\u5408\u5185\u5BB9\u62E6\u6389\u3002\u88AB\u62E6\u4F4F\u65F6\uFF0C\u7528 page-harness <\u7F51\u5740> --no-agent \u8D70\u4EE3\u7406\u3002\u4E66\u7B7E\u91CC\u6709\u672C\u6B21\u8FD0\u884C\u7684\u4EE4\u724C\uFF0C\u53EA\u5728\u4F60\u70B9\u8FC7\u4E66\u7B7E\u7684\u9875\u9762\u91CC\u6709\u6548\u3002\u9875\u9762\u7684 CSP \u5982\u679C\u7981\u6B62\u8FDE\u63A5 127.0.0.1\uFF0C\u4FDD\u5B58\u4E5F\u4F1A\u5931\u8D25\u3002',
3136
- ungrouped: '\uFF08\u672A\u5206\u7EC4\uFF09',
3137
- } : {
3138
- tag: 'Review only \xB7 no agent',
3139
- step: 'Drag the button to the bookmarks bar',
3140
- lead: 'Click it on any site. The dock is Pick and Notes only.',
3141
- src: 'Source',
3142
- local: 'This server ' + location.host,
3143
- cdn: 'jsDelivr CDN',
3144
- by: 'By site',
3145
- pick: 'Pick a site',
3146
- notes: 'notes',
3147
- empty: 'No notes for this site yet.',
3148
- none: 'No notes yet. Open a page, click the bookmark, pick an element and comment.',
3149
- md: 'Export Markdown',
3150
- json: 'Export JSON',
3151
- copy: 'Copy prompt',
3152
- copied: 'Copied',
3153
- bm: 'PH Review',
3154
- limit: 'The local script is served from ' + location.origin + ' and works on http pages. HTTPS pages often block a script from http://127.0.0.1 (mixed content); switch the source to jsDelivr so the script itself loads over HTTPS. Saving a note still calls this http control port. Chrome usually allows 127.0.0.1 and may ask for local-network permission (the server answers Access-Control-Allow-Private-Network). Firefox and Safari often block it as mixed content. When that happens, run page-harness <url> --no-agent and use the proxy. The bookmarklet carries this run\\'s token and only works on pages where you click it. A host page CSP that forbids connections to 127.0.0.1 will also block saving.',
3155
- ungrouped: '(ungrouped)',
3156
- }
3157
- document.documentElement.lang = zh ? 'zh-CN' : 'en'
3296
+ const copy = ${JSON.stringify(HOME_COPY)}
3297
+ const lang = boot.lang === 'zh-CN' || boot.lang === 'en' ? boot.lang : ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh-CN' : 'en')
3298
+ const t = copy[lang]
3299
+ const BM = '\u2726 Page-Harness'
3300
+ document.documentElement.lang = lang
3158
3301
  document.title = 'page-harness'
3159
- tag.textContent = t.tag
3160
- step.textContent = t.step
3161
- lead.textContent = t.lead
3162
- document.getElementById('src-label').textContent = t.src
3163
- src.options[0].textContent = t.local
3164
- src.options[1].textContent = t.cdn
3165
- by.textContent = t.by
3166
- exmd.textContent = t.md
3167
- exjson.textContent = t.json
3168
- cp.textContent = t.copy
3169
- bm.textContent = t.bm
3170
- limit.textContent = t.limit
3302
+ const bm = document.getElementById('bm')
3303
+ const bar = document.getElementById('bar')
3304
+ const home = document.getElementById('home')
3305
+ const notes = document.getElementById('notes')
3306
+ const tonotes = document.getElementById('tonotes')
3307
+ const doms = document.getElementById('doms')
3308
+ const list = document.getElementById('list')
3309
+ const hostTitle = document.getElementById('host-title')
3310
+ const exmd = document.getElementById('exmd')
3311
+ const exjson = document.getElementById('exjson')
3312
+ const cp = document.getElementById('cp')
3313
+ const modal = document.getElementById('modal')
3314
+ const helpTitle = document.getElementById('help-title')
3315
+ const helpBody = document.getElementById('help-body')
3171
3316
 
3172
- function bookmarklet(scriptUrl) {
3173
- const cfg = JSON.stringify({ controlOrigin: location.origin, bookmarklet: true, noAgent: true, token: boot.token }).replace(/</g, '\\\\u003c')
3174
- const srcUrl = JSON.stringify(scriptUrl).replace(/</g, '\\\\u003c')
3317
+ function fillHelp() {
3318
+ helpTitle.textContent = t.helpTitle
3319
+ helpBody.replaceChildren()
3320
+ for (const line of t.help) {
3321
+ const p = document.createElement('p')
3322
+ p.append('\xB7 ')
3323
+ for (const bit of line) {
3324
+ if (bit.k === 'c') {
3325
+ const code = document.createElement('code')
3326
+ code.textContent = bit.v
3327
+ p.append(code)
3328
+ } else p.append(bit.v)
3329
+ }
3330
+ helpBody.append(p)
3331
+ }
3332
+ }
3333
+ function applyCopy() {
3334
+ document.getElementById('hero-title').textContent = t.title
3335
+ document.getElementById('hero-sub').textContent = t.sub
3336
+ document.getElementById('bar-label').textContent = t.bar
3337
+ document.getElementById('chip-docs').textContent = t.docs
3338
+ document.getElementById('chip-gh').textContent = t.gh
3339
+ document.getElementById('help').textContent = t.trouble
3340
+ bm.textContent = BM
3341
+ exmd.textContent = t.md
3342
+ exjson.textContent = t.json
3343
+ cp.textContent = t.copy
3344
+ fillHelp()
3345
+ }
3346
+ function bookmarklet() {
3347
+ const cfgObj = { controlOrigin: location.origin, bookmarklet: true, noAgent: true, token: boot.token }
3348
+ if (boot.lang) cfgObj.lang = boot.lang
3349
+ const cfg = JSON.stringify(cfgObj).replace(/</g, '\\\\u003c')
3350
+ const srcUrl = JSON.stringify(location.origin + '/__ph/overlay.js').replace(/</g, '\\\\u003c')
3175
3351
  return 'javascript:(function(){if(window.__phOverlay)return;window.__PH_CONFIG=' + cfg + ';var s=document.createElement(\\'script\\');s.src=' + srcUrl + ';document.documentElement.appendChild(s);})();'
3176
3352
  }
3177
- function scriptUrl() {
3178
- if (src.value === 'cdn') return 'https://cdn.jsdelivr.net/npm/page-harness@' + boot.version + '/dist/client/page/overlay.js'
3179
- return location.origin + '/__ph/overlay.js'
3180
- }
3181
- function paintBm() { bm.href = bookmarklet(scriptUrl()) }
3182
- src.addEventListener('change', paintBm)
3183
- paintBm()
3353
+ bm.href = bookmarklet()
3354
+ applyCopy()
3184
3355
 
3185
3356
  let sites = []
3186
3357
  let cur = ''
3187
- const doms = document.getElementById('doms')
3188
- const list = document.getElementById('list')
3358
+ let total = 0
3359
+ let ready = false
3189
3360
  function label(host) { return host || t.ungrouped }
3361
+ function notesOpen() { return location.hash === '#notes' }
3362
+ function applyView() {
3363
+ const on = notesOpen()
3364
+ home.classList.toggle('on', !on)
3365
+ notes.classList.toggle('on', on)
3366
+ tonotes.textContent = on ? t.back : t.mine.replace('{n}', String(total))
3367
+ tonotes.href = on ? '#' : '#notes'
3368
+ tonotes.classList.toggle('back', on)
3369
+ }
3370
+ function setExport(on) { exmd.disabled = exjson.disabled = cp.disabled = !on }
3190
3371
  async function loadSites() {
3191
3372
  const r = await fetch('/__ph/api/review/sites', { headers: { Accept: 'application/json' } })
3192
3373
  const data = await r.json()
3193
3374
  sites = data.sites || []
3194
- if (!sites.some((s) => s.host === cur)) cur = (location.hash.slice(1) && sites.some((s) => s.host === decodeURIComponent(location.hash.slice(1))) ? decodeURIComponent(location.hash.slice(1)) : sites[0]?.host) || ''
3375
+ total = sites.reduce((n, s) => n + (Number(s.total) || 0), 0)
3376
+ if (!sites.some((s) => s.host === cur)) cur = sites[0] ? sites[0].host : ''
3377
+ ready = true
3378
+ applyView()
3195
3379
  renderSites()
3196
- await renderNotes()
3380
+ if (notesOpen()) await renderNotes()
3197
3381
  }
3198
3382
  function renderSites() {
3199
3383
  doms.replaceChildren()
@@ -3205,56 +3389,69 @@ function renderSites() {
3205
3389
  return
3206
3390
  }
3207
3391
  for (const s of sites) {
3208
- const e = document.createElement('div')
3209
- e.className = 'dom' + (s.host === cur ? ' on' : '')
3392
+ const e = document.createElement('button')
3393
+ e.type = 'button'
3394
+ e.className = 'site' + (s.host === cur ? ' on' : '')
3210
3395
  e.dataset.testid = 'domain'
3211
3396
  e.dataset.host = s.host
3212
3397
  const name = document.createElement('span')
3213
3398
  name.textContent = label(s.host)
3214
3399
  const n = document.createElement('span')
3215
3400
  n.className = 'n'
3216
- n.textContent = s.total + ' ' + t.notes
3401
+ n.textContent = String(s.total)
3217
3402
  e.append(name, n)
3218
- e.addEventListener('click', () => { cur = s.host; history.replaceState(null, '', '#' + encodeURIComponent(s.host)); renderSites(); void renderNotes() })
3403
+ e.addEventListener('click', () => { cur = s.host; renderSites(); void renderNotes() })
3219
3404
  doms.append(e)
3220
3405
  }
3221
3406
  }
3222
3407
  async function renderNotes() {
3223
- title.textContent = cur ? label(cur) : t.pick
3224
- const on = !!cur || sites.some((s) => s.host === '')
3225
- exmd.disabled = exjson.disabled = cp.disabled = !cur && cur !== ''
3226
- if (!sites.length) { list.replaceChildren(); const e = document.createElement('div'); e.className = 'empty'; e.textContent = t.none; list.append(e); exmd.disabled = exjson.disabled = cp.disabled = true; return }
3408
+ const has = sites.some((s) => s.host === cur)
3409
+ hostTitle.textContent = has ? label(cur) : ''
3410
+ setExport(has)
3411
+ list.replaceChildren()
3412
+ if (!has) {
3413
+ const e = document.createElement('div')
3414
+ e.className = 'empty'
3415
+ e.textContent = t.none
3416
+ list.append(e)
3417
+ return
3418
+ }
3227
3419
  const r = await fetch('/__ph/api/review/host?host=' + encodeURIComponent(cur), { headers: { Accept: 'application/json' } })
3228
3420
  const data = await r.json()
3229
3421
  list.replaceChildren()
3230
3422
  const items = data.items || []
3231
- if (!items.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = t.empty; list.append(e); return }
3423
+ if (!items.length) {
3424
+ const e = document.createElement('div')
3425
+ e.className = 'empty'
3426
+ e.textContent = t.empty
3427
+ list.append(e)
3428
+ return
3429
+ }
3232
3430
  for (const item of items) {
3233
3431
  const e = document.createElement('div')
3234
- const kind = item.kind === 'patch' ? 'patch' : (item.severity || 'suggestion')
3235
- e.className = 'note ' + kind + (item.status === 'resolved' ? ' done' : '')
3432
+ e.className = 'note'
3236
3433
  e.dataset.testid = 'note'
3237
- const num = document.createElement('span')
3238
- num.className = 'num'
3239
- num.textContent = String(item.n || '')
3434
+ const pin = document.createElement('span')
3435
+ pin.className = 'pin'
3436
+ pin.textContent = String(item.n || '')
3240
3437
  const col = document.createElement('div')
3241
3438
  const body = document.createElement('div')
3242
3439
  body.className = 'say'
3243
- body.textContent = item.note || item.say || ''
3440
+ body.textContent = item.note || ''
3244
3441
  const meta = document.createElement('div')
3245
- meta.className = 'sel'
3246
- const bits = [item.selector, item.text ? '\u201C' + item.text + '\u201D' : '', item.path, item.status].filter(Boolean)
3247
- meta.textContent = bits.join(' \xB7 ')
3442
+ meta.className = 'q'
3443
+ const quote = item.text ? '\u201C' + item.text + '\u201D' : (item.selector || '')
3444
+ meta.textContent = [quote, item.path].filter(Boolean).join(' \xB7 ')
3445
+ if (item.selector) meta.title = item.selector
3248
3446
  col.append(body, meta)
3249
- e.append(num, col)
3447
+ e.append(pin, col)
3250
3448
  list.append(e)
3251
3449
  }
3252
- void on
3253
3450
  }
3254
3451
  function download(kind) {
3255
- if (!cur && cur !== '') return
3452
+ if (!sites.some((s) => s.host === cur)) return
3256
3453
  const a = document.createElement('a')
3257
- a.href = '/__ph/api/review/export.' + kind + '?host=' + encodeURIComponent(cur)
3454
+ a.href = '/__ph/api/review/export.' + kind + '?host=' + encodeURIComponent(cur) + '&lang=' + encodeURIComponent(lang)
3258
3455
  a.download = ''
3259
3456
  a.click()
3260
3457
  }
@@ -3278,14 +3475,21 @@ async function writeClipboard(text) {
3278
3475
  }
3279
3476
  cp.addEventListener('click', async () => {
3280
3477
  if (cp.disabled) return
3281
- const lang = zh ? 'zh-CN' : 'en'
3282
- const r = await fetch('/__ph/api/review/prompt?host=' + encodeURIComponent(cur) + '&lang=' + lang, { headers: { Accept: 'text/plain' } })
3478
+ const r = await fetch('/__ph/api/review/prompt?host=' + encodeURIComponent(cur) + '&lang=' + encodeURIComponent(lang), { headers: { Accept: 'text/plain' } })
3283
3479
  const text = await r.text()
3284
3480
  if (!(await writeClipboard(text))) return
3285
3481
  cp.textContent = t.copied
3286
3482
  cp.classList.add('on')
3287
3483
  setTimeout(() => { cp.textContent = t.copy; cp.classList.remove('on') }, 1500)
3288
3484
  })
3485
+ bm.addEventListener('dragstart', () => bar.classList.add('hot'))
3486
+ bm.addEventListener('dragend', () => bar.classList.remove('hot'))
3487
+ document.getElementById('help').addEventListener('click', () => modal.classList.add('on'))
3488
+ modal.addEventListener('click', () => modal.classList.remove('on'))
3489
+ document.getElementById('modal-card').addEventListener('click', (e) => e.stopPropagation())
3490
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') modal.classList.remove('on') })
3491
+ window.addEventListener('hashchange', () => { applyView(); if (notesOpen() && ready) void renderNotes() })
3492
+ applyView()
3289
3493
  void loadSites()
3290
3494
  `;
3291
3495
 
@@ -4704,7 +4908,7 @@ async function startServers(o) {
4704
4908
  if (!existsSync9(p)) return send(res, 503, `/* page-harness: ${file} missing, run npm run build */`, "text/javascript");
4705
4909
  let body = readFileSync9(p, "utf8");
4706
4910
  if (file === "overlay.js") {
4707
- body = `window.__PH_CONFIG=${JSON.stringify({ controlOrigin: controlOrigin(req), appPort: o.appPort, target: o.target.host, relay: proxy.site, ...o.noAgent ? { noAgent: true } : {} })};
4911
+ body = `window.__PH_CONFIG=${JSON.stringify({ controlOrigin: controlOrigin(req), appPort: o.appPort, target: o.target.host, relay: proxy.site, ...o.noAgent ? { noAgent: true } : {}, ...o.lang ? { lang: o.lang } : {} })};
4708
4912
  ` + body;
4709
4913
  }
4710
4914
  return send(res, 200, body, "text/javascript; charset=utf-8", { "Cross-Origin-Resource-Policy": "same-origin" });
@@ -4765,7 +4969,7 @@ async function startServers(o) {
4765
4969
  }
4766
4970
  if (o.bookmarklet && (p === "/" || p === "/__ph" || p === "/__ph/" || p === "/__ph/home") && req.method === "GET") {
4767
4971
  o.auth.issuePageSession(req, res);
4768
- return send(res, 200, homeHtml({ token: o.bookmarkletToken ?? "", version: packageVersion() }), "text/html; charset=utf-8", {
4972
+ return send(res, 200, homeHtml({ token: o.bookmarkletToken ?? "", version: packageVersion(), lang: o.lang }), "text/html; charset=utf-8", {
4769
4973
  "Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
4770
4974
  "Referrer-Policy": "no-referrer"
4771
4975
  });
@@ -5010,8 +5214,9 @@ async function serveReviewExtras(o, req, res, url) {
5010
5214
  const slice = o.gateway.reviewSlice(host);
5011
5215
  if (p === "/__ph/api/review/host") return json(res, { host, items: slice.items.map(slimItem) }), true;
5012
5216
  const fileHost = (host || "ungrouped").replace(/[^\w.-]+/g, "_");
5217
+ const lang = url.searchParams.get("lang") === "en" ? "en" : "zh-CN";
5013
5218
  if (p === "/__ph/api/review/export.md") {
5014
- const md = reviewMarkdown(siteUrl(host), slice.items, slice.approvals);
5219
+ const md = reviewMarkdown(siteUrl(host), slice.items, slice.approvals, lang);
5015
5220
  return send(res, 200, md, "text/markdown; charset=utf-8", { "Content-Disposition": `attachment; filename="review-${fileHost}.md"` }), true;
5016
5221
  }
5017
5222
  if (p === "/__ph/api/review/export.json") {
@@ -5019,7 +5224,6 @@ async function serveReviewExtras(o, req, res, url) {
5019
5224
  "Content-Disposition": `attachment; filename="review-${fileHost}.json"`
5020
5225
  }), true;
5021
5226
  }
5022
- const lang = url.searchParams.get("lang") === "en" ? "en" : "zh-CN";
5023
5227
  const page = siteUrl(host);
5024
5228
  const text = reviewPrompt(slice.items, {
5025
5229
  site: page,
@@ -5337,18 +5541,18 @@ function turnText(turn) {
5337
5541
 
5338
5542
  // src/server/prompt.ts
5339
5543
  import path14 from "path";
5340
- var esc = (s) => s.replace(/</g, "&lt;");
5544
+ var esc2 = (s) => s.replace(/</g, "&lt;");
5341
5545
  function buildPrompt(msg, opts) {
5342
5546
  const parts = [];
5343
5547
  const images = [];
5344
- if (msg.pageUrl) parts.push(`<page url="${esc(msg.pageUrl)}" />`);
5548
+ if (msg.pageUrl) parts.push(`<page url="${esc2(msg.pageUrl)}" />`);
5345
5549
  if (msg.picks.length) {
5346
5550
  const lines = msg.picks.map((p, i) => {
5347
5551
  const html = truncate(p.outerHTML.replace(/\s+/g, " "), 1200);
5348
5552
  return `${i + 1}. <${p.tagName.toLowerCase()}> selector: ${p.selector}${p.text ? `
5349
5553
  text: ${JSON.stringify(truncate(p.text, 200))}` : ""}${p.url && p.url !== msg.pageUrl ? `
5350
5554
  page: ${p.url}` : ""}
5351
- html: ${esc(html)}`;
5555
+ html: ${esc2(html)}`;
5352
5556
  });
5353
5557
  parts.push(`<picked-elements>
5354
5558
  ${lines.join("\n")}
@@ -6426,7 +6630,8 @@ async function startHarness(o) {
6426
6630
  keepHost: o.keepHost,
6427
6631
  noAgent,
6428
6632
  bookmarklet,
6429
- bookmarkletToken
6633
+ bookmarkletToken,
6634
+ lang: o.lang
6430
6635
  });
6431
6636
  let closed = false;
6432
6637
  return {
@@ -6463,6 +6668,15 @@ function lanAddress() {
6463
6668
  return void 0;
6464
6669
  }
6465
6670
 
6671
+ // src/shared/lang.ts
6672
+ function parseLang(raw) {
6673
+ if (raw === void 0 || raw.trim() === "") return void 0;
6674
+ const s = raw.trim().toLowerCase();
6675
+ if (s === "en" || s.startsWith("en-")) return "en";
6676
+ if (s === "zh" || s.startsWith("zh-")) return "zh-CN";
6677
+ throw new Error("--lang must be zh or en");
6678
+ }
6679
+
6466
6680
  // src/cli/index.ts
6467
6681
  var HELP = `page-harness \u2014 Pick, Chat and Artifacts on top of any running frontend, driven by your local coding agent.
6468
6682
  Without a URL it starts review-only: a home page with a bookmarklet, and notes grouped by site. No agent.
@@ -6495,6 +6709,7 @@ Options
6495
6709
  --no-agent Review only. Do not detect or start an agent CLI. The dock is Pick and Notes
6496
6710
  (comment, edit, reply, resolve, approve). No Chat, Artifacts, Live or page control.
6497
6711
  Implied when no frontend URL is given.
6712
+ --lang <zh|en> Home page and dock language. Omit it and each browser keeps its own.
6498
6713
  --share Listen on the network and print an invite link: teammates join as reviewers
6499
6714
  (comment, reply, resolve, approve, edit text/style in review mode; never the agent)
6500
6715
  --verbose / --quiet
@@ -6579,6 +6794,7 @@ async function main(argv = process.argv.slice(2)) {
6579
6794
  review: { type: "boolean" },
6580
6795
  source: { type: "boolean" },
6581
6796
  "no-agent": { type: "boolean" },
6797
+ lang: { type: "string" },
6582
6798
  share: { type: "boolean" },
6583
6799
  verbose: { type: "boolean" },
6584
6800
  quiet: { type: "boolean" },
@@ -6596,6 +6812,7 @@ async function main(argv = process.argv.slice(2)) {
6596
6812
  }
6597
6813
  if (values.verbose) log.setVerbose(true);
6598
6814
  if (values.quiet) log.setQuiet(true);
6815
+ const lang = parseLang(values.lang);
6599
6816
  const rawTarget = positionals[0];
6600
6817
  if (!rawTarget) {
6601
6818
  if (values.source) throw new Error("--source needs a frontend URL");
@@ -6612,7 +6829,8 @@ async function main(argv = process.argv.slice(2)) {
6612
6829
  controlPort: controlPort2,
6613
6830
  allowHosts: values["allow-host"],
6614
6831
  publicScheme: schemeOf(values["public-scheme"]),
6615
- noAgent: true
6832
+ noAgent: true,
6833
+ lang
6616
6834
  });
6617
6835
  log.info(`v${version()}`);
6618
6836
  process.stderr.write(
@@ -6657,7 +6875,8 @@ async function main(argv = process.argv.slice(2)) {
6657
6875
  liveGuardHook: values["live-guard-hook"],
6658
6876
  mode: values.review ? "review" : values.source ? "source" : void 0,
6659
6877
  share: values.share,
6660
- noAgent
6878
+ noAgent,
6879
+ lang
6661
6880
  });
6662
6881
  const agentLine = h.agents.map((a) => a.available ? `${a.id === h.defaultAgent ? log.bold(a.id) : a.id}${a.version ? ` ${log.dim(a.version)}` : ""}` : log.dim(`${a.id} (not found)`)).join(" ");
6663
6882
  log.info(`v${version()}`);