@nuniapp/widget 0.1.6 → 0.1.8

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
@@ -165,7 +165,7 @@ function buildCommentPrompt(comment, options = {}) {
165
165
  }
166
166
  //#endregion
167
167
  //#region src/config.ts
168
- const VERSION = "0.1.6";
168
+ const VERSION = "0.1.8";
169
169
  function resolveConfig(options) {
170
170
  return {
171
171
  project: options.project,
@@ -191,8 +191,10 @@ function resolveConfig(options) {
191
191
  }
192
192
  const POSITIONS = [
193
193
  "bottom-right",
194
+ "bottom-center",
194
195
  "bottom-left",
195
196
  "top-right",
197
+ "top-center",
196
198
  "top-left"
197
199
  ];
198
200
  const THEMES = [
@@ -5555,6 +5557,8 @@ const EN = {
5555
5557
  noOpenKey: "No open comments on this page. Press {key} to add one.",
5556
5558
  noResolved: "Nothing resolved yet.",
5557
5559
  notFoundHere: "Couldn't find on this page",
5560
+ searchComments: "Search comments",
5561
+ noMatches: "No comments match “{query}”.",
5558
5562
  otherPages: "Other pages",
5559
5563
  openOnPage: "{count} open",
5560
5564
  replyCount: {
@@ -5567,14 +5571,15 @@ const EN = {
5567
5571
  yourName: "Your name",
5568
5572
  leaveComment: "Leave a comment",
5569
5573
  notYou: "Not you?",
5570
- screenshotAlt: "Screenshot that will be attached",
5571
- screenshotNote: "Screenshot attached. Only the site owner sees it.",
5572
- remove: "Remove",
5573
5574
  cancel: "Cancel",
5574
5575
  post: "Post",
5575
5576
  posting: "Posting…",
5576
5577
  save: "Save",
5577
5578
  send: "{key} + Enter",
5579
+ charsLeft: {
5580
+ one: "{count} character left",
5581
+ other: "{count} characters left"
5582
+ },
5578
5583
  pinLabel: "Comment by {name}: {body}",
5579
5584
  commentBy: "Comment by {name}",
5580
5585
  openScreenshot: "Open the screenshot",
@@ -5687,6 +5692,9 @@ const ICONS = {
5687
5692
  send: s("<path d=\"m22 2-7 20-4-9-9-4z\"/><path d=\"M22 2 11 13\"/>"),
5688
5693
  bot: s("<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2M20 14h2M15 13v2M9 13v2\"/>"),
5689
5694
  smile: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01\"/>"),
5695
+ user: s("<circle cx=\"12\" cy=\"8\" r=\"4\"/><path d=\"M4 21a8 8 0 0 1 16 0\"/>"),
5696
+ arrowUp: s("<path d=\"M12 19V5M5 12l7-7 7 7\"/>"),
5697
+ search: s("<circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m21 21-4.3-4.3\"/>"),
5690
5698
  target: s("<circle cx=\"12\" cy=\"12\" r=\"9\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
5691
5699
  github: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.6 22.4c.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.9 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A11.5 11.5 0 0 0 12 .5z"/></svg>`,
5692
5700
  link: s("<path d=\"M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/>")
@@ -5942,7 +5950,11 @@ function write(key, value) {
5942
5950
  const KEYS = {
5943
5951
  name: "nuni:name",
5944
5952
  secret: "nuni:author-secret",
5945
- session: (project) => `nuni:session:${project}`
5953
+ session: (project) => `nuni:session:${project}`,
5954
+ /** Where this visitor dragged the toolbar. */
5955
+ position: (project) => `nuni:position:${project}`,
5956
+ /** The comment this visitor opened last, marked in the panel. */
5957
+ lastViewed: (project) => `nuni:last-viewed:${project}`
5946
5958
  };
5947
5959
  //#endregion
5948
5960
  //#region src/styles.ts
@@ -6019,7 +6031,18 @@ button { cursor: pointer; }
6019
6031
  border: 1px solid var(--n-border);
6020
6032
  box-shadow: var(--n-shadow);
6021
6033
  pointer-events: auto;
6022
- }
6034
+ cursor: grab;
6035
+ touch-action: none;
6036
+ user-select: none;
6037
+ -webkit-user-select: none;
6038
+ }
6039
+ .toolbar[data-dragging] {
6040
+ cursor: grabbing;
6041
+ box-shadow: 0 2px 4px rgba(0,0,0,0.08), 0 16px 40px rgba(0,0,0,0.2);
6042
+ }
6043
+ /* While dragging, the host covers the page so no move is lost to it. */
6044
+ :host([data-dragging]) { pointer-events: auto; cursor: grabbing; }
6045
+ :host([data-dragging]) .tb-btn { pointer-events: none; }
6023
6046
  .tb-btn {
6024
6047
  display: inline-flex;
6025
6048
  align-items: center;
@@ -6176,7 +6199,6 @@ button { cursor: pointer; }
6176
6199
  gap: 12px;
6177
6200
  }
6178
6201
  .confirm p { margin: 0; }
6179
- .btn-danger-solid { background: var(--n-danger); color: #fff; border-color: transparent; }
6180
6202
 
6181
6203
  /* Pins */
6182
6204
  .pin {
@@ -6226,59 +6248,34 @@ button { cursor: pointer; }
6226
6248
  /* Cards (composer + thread) */
6227
6249
  .card {
6228
6250
  position: fixed;
6229
- width: 320px;
6251
+ width: 340px;
6230
6252
  max-width: calc(100vw - 24px);
6231
6253
  background: var(--n-bg);
6232
6254
  color: var(--n-fg);
6233
6255
  border: 1px solid var(--n-border);
6234
- border-radius: var(--n-radius);
6256
+ border-radius: 14px;
6235
6257
  box-shadow: var(--n-shadow);
6236
6258
  pointer-events: auto;
6237
6259
  overflow: hidden;
6238
6260
  }
6239
- .card-body { padding: 12px; display: grid; gap: 10px; }
6261
+ .card-body { padding: 12px 14px; display: grid; gap: 10px; }
6240
6262
  .card[data-card="thread"] { display: flex; flex-direction: column; max-height: calc(100vh - 24px); }
6241
- .thread { border-top: 1px solid var(--n-border); display: grid; min-height: 0; }
6242
- .replies { display: grid; gap: 12px; padding: 12px; overflow-y: auto; max-height: 45vh; }
6243
- .reply { display: grid; gap: 6px; }
6244
- .avatar-sm { width: 20px; height: 20px; font-size: 9px; }
6245
- .badge-owner { background: var(--n-accent-soft); color: var(--n-accent); }
6246
- .reply-form { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px 12px; flex-wrap: wrap; }
6247
- .reply-form .field { flex: 1 1 100%; }
6248
- /* A fixed size: growing on focus would move the Send button mid-click. */
6249
- .reply-form .field-reply { flex: 1 1 0; min-height: 56px; height: 56px; resize: none; }
6250
- .reactions { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; position: relative; }
6251
- .reaction {
6252
- display: inline-flex;
6253
- align-items: center;
6254
- gap: 4px;
6255
- height: 24px;
6256
- padding: 0 8px;
6257
- border: 1px solid var(--n-border);
6258
- border-radius: 999px;
6259
- background: var(--n-bg);
6263
+ .card .quote {
6264
+ margin: 0;
6265
+ padding: 6px 10px;
6266
+ border-left: 0;
6267
+ border-radius: 8px;
6268
+ background: var(--n-accent-soft);
6260
6269
  color: var(--n-fg);
6261
- font-size: 12px;
6262
- }
6263
- .reaction:hover { background: var(--n-bg-subtle); }
6264
- .reaction[aria-pressed="true"] { border-color: var(--n-accent); background: var(--n-accent-soft); }
6265
- .reaction-add { color: var(--n-fg-muted); padding: 0 6px; }
6266
- .reaction-picker {
6267
- display: flex;
6268
- gap: 2px;
6269
- padding: 4px;
6270
- border: 1px solid var(--n-border);
6271
- border-radius: 999px;
6272
- background: var(--n-bg);
6273
- box-shadow: var(--n-shadow);
6270
+ font-style: normal;
6271
+ font-size: 13px;
6272
+ box-shadow: inset 2px 0 0 var(--n-accent);
6274
6273
  }
6275
- .reaction-picker .reaction { border-color: transparent; font-size: 14px; }
6276
- .btn-xs { width: 24px; height: 24px; }
6277
- .card-head { display: flex; align-items: center; gap: 8px; }
6274
+ .card-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
6278
6275
  .card-head .spacer { flex: 1; }
6279
6276
  .avatar {
6280
- width: 24px;
6281
- height: 24px;
6277
+ width: 26px;
6278
+ height: 26px;
6282
6279
  border-radius: 999px;
6283
6280
  color: #fff;
6284
6281
  font-size: 10px;
@@ -6288,8 +6285,8 @@ button { cursor: pointer; }
6288
6285
  justify-content: center;
6289
6286
  flex: none;
6290
6287
  }
6291
- .author { font-weight: 600; }
6292
- .meta { color: var(--n-fg-muted); font-size: 12px; }
6288
+ .author { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
6289
+ .meta { color: var(--n-fg-muted); font-size: 12px; white-space: nowrap; }
6293
6290
  .badge {
6294
6291
  display: inline-flex;
6295
6292
  align-items: center;
@@ -6304,28 +6301,147 @@ button { cursor: pointer; }
6304
6301
  text-overflow: ellipsis;
6305
6302
  white-space: nowrap;
6306
6303
  }
6304
+ .badge .icon { width: 12px; height: 12px; }
6307
6305
  .badge-ok { background: rgba(31,157,85,0.14); color: var(--n-ok); }
6308
- .comment-body { white-space: pre-wrap; word-break: break-word; }
6306
+ .badge-owner { background: var(--n-accent-soft); color: var(--n-accent); }
6307
+ .comment-body { white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
6309
6308
  .shot {
6310
6309
  display: block;
6311
6310
  border: 1px solid var(--n-border);
6312
- border-radius: 8px;
6311
+ border-radius: 10px;
6313
6312
  overflow: hidden;
6314
6313
  background: var(--n-bg-subtle);
6315
6314
  }
6316
6315
  .shot img { display: block; width: 100%; max-height: 160px; object-fit: cover; object-position: top; }
6317
- .shot-preview { display: grid; gap: 4px; }
6318
- .shot-preview img {
6319
- display: block;
6320
- width: 100%;
6321
- max-height: 200px;
6322
- /* The whole image, so the commenter sees everything that is attached. */
6323
- object-fit: contain;
6324
- border: 1px solid var(--n-border);
6316
+ .icon-btn {
6317
+ display: inline-grid;
6318
+ place-items: center;
6319
+ width: 28px;
6320
+ height: 28px;
6321
+ padding: 0;
6322
+ border: 0;
6325
6323
  border-radius: 8px;
6326
- background: var(--n-bg-subtle);
6324
+ background: none;
6325
+ color: var(--n-fg-muted);
6326
+ flex: none;
6327
+ transition: background 120ms ease, color 120ms ease;
6328
+ }
6329
+ .icon-btn:hover { background: var(--n-bg-subtle); color: var(--n-fg); }
6330
+ .icon-btn:disabled { opacity: 0.4; cursor: default; background: none; }
6331
+ .icon-btn-danger:hover { background: color-mix(in srgb, var(--n-danger) 12%, transparent); color: var(--n-danger); }
6332
+ .icon-btn.is-resolved, .icon-btn.is-resolved:hover { background: rgba(31,157,85,0.14); color: var(--n-ok); }
6333
+
6334
+ /* A message (the comment or a reply): text lines up with the name. */
6335
+ .msg { gap: 2px; padding: 12px 8px 8px 14px; }
6336
+ .msg-main { display: grid; gap: 8px; padding: 0 6px 0 34px; min-width: 0; }
6337
+ .msg .card-head { min-height: 28px; }
6338
+ .badges { display: flex; flex-wrap: wrap; gap: 4px; }
6339
+ .msg-foot { display: flex; align-items: center; gap: 2px; margin-right: -6px; }
6340
+ .msg-foot .spacer { flex: 1; }
6341
+ /* A reply's tools float over its header on hover, so names never truncate. */
6342
+ .msg-tools:empty { display: none; }
6343
+ .msg-tools {
6344
+ position: absolute;
6345
+ top: 4px;
6346
+ right: 8px;
6347
+ display: flex;
6348
+ gap: 2px;
6349
+ padding: 2px;
6350
+ border: 1px solid var(--n-border);
6351
+ border-radius: 10px;
6352
+ background: var(--n-bg);
6353
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.08);
6354
+ opacity: 0;
6355
+ pointer-events: none;
6356
+ transition: opacity 120ms ease;
6357
+ }
6358
+ .reply:hover .msg-tools, .reply:focus-within .msg-tools { opacity: 1; pointer-events: auto; }
6359
+ @media (hover: none) {
6360
+ .msg-tools { position: static; opacity: 1; pointer-events: auto; padding: 0; border: 0; box-shadow: none; background: none; }
6361
+ }
6362
+
6363
+ /* Replies */
6364
+ .thread { border-top: 1px solid var(--n-border); display: grid; min-height: 0; }
6365
+ .replies { display: grid; padding: 4px 0; overflow-y: auto; max-height: 45vh; }
6366
+ .reply.msg { position: relative; display: grid; padding: 8px 8px 8px 14px; }
6367
+ .reply-form {
6368
+ display: grid;
6369
+ margin: 4px 12px 12px;
6370
+ border: 1px solid var(--n-border);
6371
+ border-radius: 12px;
6372
+ background: var(--n-bg);
6373
+ transition: box-shadow 140ms ease, border-color 140ms ease;
6374
+ }
6375
+ .reply-form:focus-within {
6376
+ border-color: color-mix(in srgb, var(--n-accent) 45%, var(--n-border));
6377
+ box-shadow: 0 0 0 3px var(--n-accent-soft);
6378
+ }
6379
+ .reply-name {
6380
+ padding: 8px 12px 6px;
6381
+ border: 0;
6382
+ border-bottom: 1px solid var(--n-border);
6383
+ background: none;
6384
+ outline: none;
6385
+ font-weight: 600;
6386
+ }
6387
+ .reply-row { display: flex; align-items: flex-end; gap: 4px; padding: 4px; }
6388
+ .reply-text {
6389
+ flex: 1;
6390
+ min-width: 0;
6391
+ min-height: 32px;
6392
+ max-height: 160px;
6393
+ padding: 6px 8px;
6394
+ border: 0;
6395
+ background: none;
6396
+ outline: none;
6397
+ resize: none;
6398
+ line-height: 1.4;
6399
+ }
6400
+ .reply-name::placeholder, .reply-text::placeholder { color: var(--n-fg-muted); opacity: 1; font-weight: 400; }
6401
+ .reply-send {
6402
+ display: inline-grid;
6403
+ place-items: center;
6404
+ width: 32px;
6405
+ height: 32px;
6406
+ border: 0;
6407
+ border-radius: 999px;
6408
+ background: var(--n-accent);
6409
+ color: var(--n-accent-fg);
6410
+ flex: none;
6327
6411
  }
6328
- .shot-note { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--n-fg-muted); }
6412
+ .reply-send:hover { filter: brightness(1.08); }
6413
+ .reply-send:disabled { opacity: 0.4; cursor: default; filter: none; }
6414
+
6415
+ /* Reactions */
6416
+ .reactions { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; position: relative; }
6417
+ .reaction {
6418
+ display: inline-flex;
6419
+ align-items: center;
6420
+ gap: 4px;
6421
+ height: 26px;
6422
+ padding: 0 8px;
6423
+ border: 1px solid var(--n-border);
6424
+ border-radius: 999px;
6425
+ background: var(--n-bg);
6426
+ color: var(--n-fg);
6427
+ font-size: 12px;
6428
+ }
6429
+ .reaction:hover { background: var(--n-bg-subtle); }
6430
+ .reaction[aria-pressed="true"] { border-color: color-mix(in srgb, var(--n-accent) 50%, transparent); background: var(--n-accent-soft); }
6431
+ .reaction-add { color: var(--n-fg-muted); padding: 0 6px; border-color: transparent; }
6432
+ .reaction-add:hover, .reaction-add[aria-expanded="true"] { color: var(--n-fg); }
6433
+ .reaction-picker {
6434
+ display: flex;
6435
+ gap: 2px;
6436
+ padding: 4px;
6437
+ border: 1px solid var(--n-border);
6438
+ border-radius: 999px;
6439
+ background: var(--n-bg);
6440
+ box-shadow: var(--n-shadow);
6441
+ }
6442
+ .reaction-picker .reaction { border-color: transparent; font-size: 14px; }
6443
+
6444
+ /* Shared controls */
6329
6445
  .field {
6330
6446
  width: 100%;
6331
6447
  padding: 8px 10px;
@@ -6357,11 +6473,94 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6357
6473
  .btn-ghost { border-color: transparent; background: transparent; }
6358
6474
  .btn-icon { width: 32px; padding: 0; justify-content: center; }
6359
6475
  .btn-danger { color: var(--n-danger); }
6360
- .kbd { color: var(--n-fg-muted); font-size: 11px; }
6476
+ /* After .btn, which would otherwise paint over the red with the card's background. */
6477
+ .btn-danger-solid,
6478
+ .btn-danger-solid:hover { background: var(--n-danger); border-color: var(--n-danger); color: #fff; }
6479
+ .btn-danger-solid:hover { filter: brightness(1.08); }
6361
6480
  .error { color: var(--n-danger); font-size: 12px; }
6362
6481
  .divider { height: 1px; background: var(--n-border); }
6363
- .actions { display: flex; gap: 4px; padding: 8px 12px; border-top: 1px solid var(--n-border); background: var(--n-bg-subtle); }
6364
- .actions .spacer { flex: 1; }
6482
+
6483
+ /* Composer: one surface, the card itself is the input. */
6484
+ .composer {
6485
+ display: grid;
6486
+ transition: box-shadow 140ms ease, border-color 140ms ease;
6487
+ }
6488
+ .composer:focus-within {
6489
+ border-color: color-mix(in srgb, var(--n-accent) 45%, var(--n-border));
6490
+ box-shadow: 0 0 0 4px var(--n-accent-soft), var(--n-shadow);
6491
+ }
6492
+ .composer.sheet:focus-within { border-color: var(--n-border); box-shadow: var(--n-shadow); }
6493
+ .composer .spacer { flex: 1; }
6494
+ .cmp-head { display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 10px 44px 4px 14px; }
6495
+ .avatar-empty { background: var(--n-bg-subtle); color: var(--n-fg-muted); }
6496
+ .avatar-empty .icon { width: 14px; height: 14px; }
6497
+ .cmp-name {
6498
+ flex: 1;
6499
+ min-width: 0;
6500
+ padding: 0;
6501
+ border: 0;
6502
+ background: none;
6503
+ outline: none;
6504
+ font-weight: 600;
6505
+ }
6506
+ .cmp-link {
6507
+ border: 0;
6508
+ background: none;
6509
+ padding: 2px 4px;
6510
+ border-radius: 6px;
6511
+ color: var(--n-fg-muted);
6512
+ font-size: 12px;
6513
+ white-space: nowrap;
6514
+ }
6515
+ .cmp-link:hover { color: var(--n-fg); }
6516
+ .composer .quote { margin: 6px 14px 0; }
6517
+ .cmp-text {
6518
+ display: block;
6519
+ width: 100%;
6520
+ min-height: 76px;
6521
+ max-height: 240px;
6522
+ padding: 8px 14px;
6523
+ border: 0;
6524
+ background: none;
6525
+ outline: none;
6526
+ resize: none;
6527
+ line-height: 1.5;
6528
+ }
6529
+ .cmp-name::placeholder, .cmp-text::placeholder { color: var(--n-fg-muted); opacity: 1; }
6530
+ .cmp-name::placeholder { font-weight: 500; }
6531
+ .cmp-error { padding: 0 14px 4px; }
6532
+ .cmp-foot { display: flex; align-items: center; gap: 8px; padding: 6px 8px 8px 14px; }
6533
+ .cmp-keys { display: inline-flex; gap: 3px; }
6534
+ .cmp-keys kbd {
6535
+ display: inline-grid;
6536
+ place-items: center;
6537
+ min-width: 20px;
6538
+ height: 20px;
6539
+ padding: 0 4px;
6540
+ border: 1px solid var(--n-border);
6541
+ border-bottom-width: 2px;
6542
+ border-radius: 5px;
6543
+ color: var(--n-fg-muted);
6544
+ font: 500 11px/1 var(--n-font);
6545
+ }
6546
+ .cmp-count { color: var(--n-fg-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
6547
+ .cmp-post {
6548
+ display: inline-flex;
6549
+ align-items: center;
6550
+ gap: 6px;
6551
+ height: 32px;
6552
+ padding: 0 10px 0 14px;
6553
+ border: 0;
6554
+ border-radius: 999px;
6555
+ background: var(--n-accent);
6556
+ color: var(--n-accent-fg);
6557
+ font-weight: 600;
6558
+ font-size: 13px;
6559
+ }
6560
+ .cmp-post:hover { filter: brightness(1.08); }
6561
+ .cmp-post:disabled { opacity: 0.4; cursor: default; filter: none; }
6562
+ .cmp-close { position: absolute; top: 13px; right: 8px; }
6563
+ @media (hover: none) { .cmp-keys { display: none; } }
6365
6564
 
6366
6565
  /* Panel */
6367
6566
  .panel {
@@ -6383,7 +6582,7 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6383
6582
  }
6384
6583
  .panel-head { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--n-border); }
6385
6584
  .panel-title { font-weight: 650; flex: 1; }
6386
- .tabs { display: flex; gap: 4px; padding: 8px 12px 0; }
6585
+ .tabs { display: flex; gap: 4px; padding: 0 12px; margin-top: 8px; }
6387
6586
  .tab {
6388
6587
  border: 0;
6389
6588
  background: transparent;
@@ -6406,6 +6605,11 @@ textarea.field { resize: vertical; min-height: 72px; max-height: 240px; }
6406
6605
  border-radius: 10px;
6407
6606
  }
6408
6607
  .item:hover { background: var(--n-bg-subtle); }
6608
+ /* The comment opened last, so it's easy to find your place again. */
6609
+ .item[aria-current="true"] { background: var(--n-accent-soft); box-shadow: inset 3px 0 0 var(--n-accent); }
6610
+ .panel-search { position: relative; padding: 0 12px; margin-top: 8px; }
6611
+ .panel-search .icon { position: absolute; left: 22px; top: 50%; transform: translateY(-50%); color: var(--n-fg-muted); pointer-events: none; }
6612
+ .panel-search .field { padding-left: 32px; }
6409
6613
  .item-text { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
6410
6614
  .section-label { padding: 12px 10px 4px; font-size: 11px; font-weight: 650; letter-spacing: 0.04em; text-transform: uppercase; color: var(--n-fg-muted); }
6411
6615
  .empty { padding: 32px 16px; text-align: center; color: var(--n-fg-muted); }
@@ -6449,8 +6653,10 @@ a.item { color: inherit; text-decoration: none; }
6449
6653
  * { transition: none !important; animation: none !important; }
6450
6654
  }
6451
6655
 
6452
- /* The toolbar's corner (the position option) */
6656
+ /* The toolbar's spot (the position option, or where it was dragged) */
6453
6657
  :host([data-position$="left"]) .toolbar { right: auto; left: 16px; }
6658
+ :host([data-position$="center"]) .toolbar { left: 0; right: 0; width: max-content; margin-inline: auto; }
6659
+ :host([data-position="top-center"]) .pick-hint { top: auto; bottom: 12px; }
6454
6660
  :host([data-position^="top"]) .toolbar { bottom: auto; top: 16px; }
6455
6661
  :host([data-position$="left"]) .panel { right: auto; left: 12px; }
6456
6662
  :host([data-position^="top"]) .panel { top: 72px; bottom: 12px; }
@@ -6509,13 +6715,11 @@ function isTypingTarget(target) {
6509
6715
  const el = target;
6510
6716
  return el.isContentEditable || el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
6511
6717
  }
6512
- function dataUrl(blob) {
6513
- return new Promise((resolve, reject) => {
6514
- const reader = new FileReader();
6515
- reader.onload = () => resolve(String(reader.result));
6516
- reader.onerror = () => reject(reader.error);
6517
- reader.readAsDataURL(blob);
6518
- });
6718
+ function avatar(name) {
6719
+ return h("span", {
6720
+ class: "avatar",
6721
+ style: `background:${colorFor(name)}`
6722
+ }, initials(name));
6519
6723
  }
6520
6724
  function describeElement(el) {
6521
6725
  const tag = el.tagName.toLowerCase();
@@ -6564,6 +6768,8 @@ var NuniWidget = class {
6564
6768
  _defineProperty(this, "picking", false);
6565
6769
  _defineProperty(this, "panelOpen", false);
6566
6770
  _defineProperty(this, "tab", "open");
6771
+ _defineProperty(this, "search", "");
6772
+ _defineProperty(this, "lastViewedId", void 0);
6567
6773
  _defineProperty(this, "card", null);
6568
6774
  _defineProperty(this, "activeId", null);
6569
6775
  _defineProperty(this, "hoverEl", null);
@@ -6599,7 +6805,12 @@ var NuniWidget = class {
6599
6805
  _defineProperty(this, "layoutFrame", 0);
6600
6806
  _defineProperty(this, "pinPositions", /* @__PURE__ */ new Map());
6601
6807
  _defineProperty(this, "resolveTimer", 0);
6808
+ _defineProperty(this, "shownPlacements", "");
6602
6809
  _defineProperty(this, "i18n", void 0);
6810
+ _defineProperty(this, "position", void 0);
6811
+ _defineProperty(this, "toolbarDrag", null);
6812
+ _defineProperty(this, "endToolbarDrag", null);
6813
+ _defineProperty(this, "toolbarDropped", false);
6603
6814
  _defineProperty(this, "isIgnored", (el) => el === this.host || this.host.contains(el));
6604
6815
  _defineProperty(this, "onFrameKey", (e) => this.onKeyDown(e));
6605
6816
  _defineProperty(this, "swallow", (e) => {
@@ -6679,6 +6890,8 @@ var NuniWidget = class {
6679
6890
  }
6680
6891
  this.secret = secret;
6681
6892
  this.ownerToken = read(KEYS.session(config.project));
6893
+ this.lastViewedId = read(KEYS.lastViewed(config.project));
6894
+ this.position = oneOf(read(KEYS.position(config.project)) ?? void 0, POSITIONS, config.position);
6682
6895
  this.host = document.createElement("div");
6683
6896
  this.host.id = "nuni-root";
6684
6897
  this.host.setAttribute("data-nuni", "");
@@ -6709,6 +6922,11 @@ var NuniWidget = class {
6709
6922
  });
6710
6923
  this.root.append(style, this.markLayer, this.pinLayer, this.overlayLayer, this.uiLayer, this.selectButton, this.live);
6711
6924
  this.uiLayer.addEventListener("keydown", (e) => this.trapFocus(e));
6925
+ this.root.addEventListener("click", (e) => {
6926
+ if (!this.toolbarDropped) return;
6927
+ e.preventDefault();
6928
+ e.stopPropagation();
6929
+ }, true);
6712
6930
  this.api = new NuniApi(config);
6713
6931
  }
6714
6932
  t(key, vars) {
@@ -6721,8 +6939,8 @@ var NuniWidget = class {
6721
6939
  }
6722
6940
  /** Position, theme, brand color and stacking order from the options. */
6723
6941
  applyAppearance() {
6724
- const { position, theme, accentColor, zIndex } = this.config;
6725
- this.host.dataset.position = position;
6942
+ const { theme, accentColor, zIndex } = this.config;
6943
+ this.host.dataset.position = this.position;
6726
6944
  this.host.dataset.theme = theme;
6727
6945
  if (zIndex !== null) this.host.style.zIndex = String(zIndex);
6728
6946
  const rgb = accentColor ? parseColor(accentColor) : null;
@@ -6769,6 +6987,7 @@ var NuniWidget = class {
6769
6987
  if (e.composedPath().includes(this.host)) return;
6770
6988
  this.pointerDown = true;
6771
6989
  this.scheduleSelection();
6990
+ this.closeCardFromOutside(e);
6772
6991
  };
6773
6992
  const onUp = () => {
6774
6993
  this.pointerDown = false;
@@ -6816,6 +7035,7 @@ var NuniWidget = class {
6816
7035
  }
6817
7036
  destroy() {
6818
7037
  this.setPicking(false);
7038
+ this.endToolbarDrag?.();
6819
7039
  this.pageUnsub?.();
6820
7040
  this.sessionUnsub?.();
6821
7041
  this.ownerDetailUnsub?.();
@@ -6960,10 +7180,18 @@ var NuniWidget = class {
6960
7180
  this.renderPins();
6961
7181
  this.layout();
6962
7182
  if (queue.length) whenIdle(slice);
6963
- else if ((onlyStale || run > 1) && this.panelOpen) this.render();
7183
+ else if ((onlyStale || run > 1) && this.panelOpen && this.placementSummary() !== this.shownPlacements) this.render();
6964
7184
  };
6965
7185
  slice();
6966
7186
  }
7187
+ /** What the panel and cards show from the placements: found, approximate or lost. */
7188
+ placementSummary() {
7189
+ return this.comments.map((c) => {
7190
+ const placement = this.placements.get(c._id);
7191
+ if (!placement?.element) return "-";
7192
+ return placement.confidence === "low" ? "~" : "+";
7193
+ }).join("");
7194
+ }
6967
7195
  /**
6968
7196
  * Pins inside shadow roots and iframes: the page's observers don't see
6969
7197
  * changes or scrolling in there, so watch each of those scopes too.
@@ -7245,12 +7473,12 @@ var NuniWidget = class {
7245
7473
  this.activeId = null;
7246
7474
  this.render();
7247
7475
  this.focusComposer();
7248
- this.takeScreenshot(draft);
7476
+ draft.screenshot = this.takeScreenshot(draft);
7249
7477
  }
7250
7478
  /** Capture while the person types, so posting stays instant. */
7251
7479
  async takeScreenshot(draft) {
7252
7480
  const load = this.runtime.loadScreenshot;
7253
- if (!load || draft.element.ownerDocument !== document) return;
7481
+ if (!load || draft.element.ownerDocument !== document) return null;
7254
7482
  const accent = getComputedStyle(this.host).getPropertyValue("--n-accent").trim() || "#d6246e";
7255
7483
  try {
7256
7484
  await new Promise((resolve) => setTimeout(resolve, 60));
@@ -7265,23 +7493,14 @@ var NuniWidget = class {
7265
7493
  bottom: box.top + box.height
7266
7494
  };
7267
7495
  } : void 0;
7268
- const blob = capture ? await capture(element, {
7496
+ return capture ? await capture(element, {
7269
7497
  exclude: this.host,
7270
7498
  accent,
7271
7499
  focus
7272
7500
  }) : null;
7273
- if (!blob || !this.isComposing(draft) || draft.sending) return;
7274
- const preview = await dataUrl(blob);
7275
- if (!this.isComposing(draft) || draft.sending) return;
7276
- draft.screenshot = {
7277
- blob,
7278
- preview
7279
- };
7280
- this.render();
7281
- } catch {}
7282
- }
7283
- isComposing(draft) {
7284
- return this.card?.kind === "composer" && this.card.draft === draft;
7501
+ } catch {
7502
+ return null;
7503
+ }
7285
7504
  }
7286
7505
  collectContext(draft) {
7287
7506
  const { collectors } = this.runtime;
@@ -7293,9 +7512,10 @@ var NuniWidget = class {
7293
7512
  if (draft.dom) context.dom = draft.dom;
7294
7513
  return Object.keys(context).length ? context : void 0;
7295
7514
  }
7296
- async attachScreenshot(id, image) {
7515
+ async attachScreenshot(id, shot) {
7297
7516
  try {
7298
- await this.api.uploadScreenshot(id, this.secret, image);
7517
+ const image = await shot;
7518
+ if (image) await this.api.uploadScreenshot(id, this.secret, image);
7299
7519
  } catch {}
7300
7520
  }
7301
7521
  copyForAgent(comment) {
@@ -7340,7 +7560,10 @@ var NuniWidget = class {
7340
7560
  if (this.confirming) this.answer(false);
7341
7561
  else if (this.picking) this.setPicking(false);
7342
7562
  else if (this.card) this.closeCard();
7343
- else if (this.panelOpen) this.togglePanel(false);
7563
+ else if (this.panelOpen && this.search) {
7564
+ this.search = "";
7565
+ this.render();
7566
+ } else if (this.panelOpen) this.togglePanel(false);
7344
7567
  else return;
7345
7568
  e.stopPropagation();
7346
7569
  return;
@@ -7595,7 +7818,7 @@ var NuniWidget = class {
7595
7818
  },
7596
7819
  context: this.collectContext(draft)
7597
7820
  });
7598
- if (shot) this.attachScreenshot(id, shot.blob);
7821
+ if (shot) this.attachScreenshot(id, shot);
7599
7822
  this.placements.set(id, {
7600
7823
  element: draft.element,
7601
7824
  confidence: "exact",
@@ -7747,10 +7970,22 @@ var NuniWidget = class {
7747
7970
  this.keepFocus();
7748
7971
  this.focusNext = ".panel [role=\"tab\"][aria-selected=\"true\"]";
7749
7972
  if (window.matchMedia(MOBILE_QUERY).matches) this.card = null;
7750
- }
7973
+ } else this.search = "";
7751
7974
  this.render();
7975
+ if (open) this.uiLayer.querySelector(".panel .item[aria-current=\"true\"]")?.scrollIntoView({ block: "nearest" });
7752
7976
  if (!open && !this.card) this.restoreFocus();
7753
7977
  }
7978
+ /**
7979
+ * A press on the page closes the open card, like Figma, unless the card
7980
+ * holds words that haven't been sent yet.
7981
+ */
7982
+ closeCardFromOutside(e) {
7983
+ if (!this.card || this.picking || this.confirming || !e.isPrimary) return;
7984
+ if (onScrollbar(e)) return;
7985
+ if ([...this.uiLayer.querySelector("[data-card]")?.querySelectorAll("textarea") ?? []].some((t) => t.value.trim())) return;
7986
+ if (!this.panelOpen) this.returnFocus = null;
7987
+ this.closeCard();
7988
+ }
7754
7989
  closeCard() {
7755
7990
  const had = this.card !== null;
7756
7991
  this.card = null;
@@ -7762,11 +7997,13 @@ var NuniWidget = class {
7762
7997
  openThread(id) {
7763
7998
  this.keepFocus();
7764
7999
  this.activeId = id;
8000
+ this.lastViewedId = id;
8001
+ write(KEYS.lastViewed(this.config.project), id);
7765
8002
  this.card = {
7766
8003
  kind: "thread",
7767
8004
  id
7768
8005
  };
7769
- this.focusNext = "[data-card=\"thread\"] .card-head button";
8006
+ this.focusNext = "[data-card=\"thread\"] .card-close";
7770
8007
  }
7771
8008
  render() {
7772
8009
  this.syncOwnerDetail();
@@ -7829,6 +8066,7 @@ var NuniWidget = class {
7829
8066
  }
7830
8067
  }
7831
8068
  renderUi() {
8069
+ this.shownPlacements = this.placementSummary();
7832
8070
  const nodes = [this.renderToolbar()];
7833
8071
  if (this.panelOpen) nodes.push(this.renderPanel());
7834
8072
  const card = this.renderCard();
@@ -7859,10 +8097,14 @@ var NuniWidget = class {
7859
8097
  }
7860
8098
  renderToolbar() {
7861
8099
  const open = this.comments.filter((c) => c.status === "open").length;
8100
+ const drag = this.toolbarDrag?.moving ? this.toolbarDrag : null;
7862
8101
  return h("div", {
7863
8102
  class: "toolbar",
7864
8103
  role: "toolbar",
7865
- "aria-label": this.t("toolbar")
8104
+ "aria-label": this.t("toolbar"),
8105
+ "data-dragging": !!drag,
8106
+ style: drag && `transform: translate(${drag.dx}px, ${drag.dy}px)`,
8107
+ onpointerdown: (e) => this.startToolbarDrag(e)
7866
8108
  }, h("button", {
7867
8109
  class: "tb-btn",
7868
8110
  type: "button",
@@ -7881,16 +8123,94 @@ var NuniWidget = class {
7881
8123
  onclick: () => this.togglePanel()
7882
8124
  }, icon(ICONS.list), h("span", { class: "tb-count" }, String(open))));
7883
8125
  }
8126
+ /**
8127
+ * Drag the toolbar anywhere; on release it glides to the nearest corner or
8128
+ * edge middle, which is remembered for this visitor.
8129
+ */
8130
+ startToolbarDrag(e) {
8131
+ if (e.button !== 0 || !e.isPrimary) return;
8132
+ this.endToolbarDrag?.();
8133
+ const drag = {
8134
+ id: e.pointerId,
8135
+ x: e.clientX,
8136
+ y: e.clientY,
8137
+ dx: 0,
8138
+ dy: 0,
8139
+ moving: false
8140
+ };
8141
+ this.toolbarDrag = drag;
8142
+ const toolbar = () => this.uiLayer.querySelector(".toolbar");
8143
+ const onMove = (ev) => {
8144
+ if (ev.pointerId !== drag.id) return;
8145
+ drag.dx = ev.clientX - drag.x;
8146
+ drag.dy = ev.clientY - drag.y;
8147
+ if (!drag.moving && Math.hypot(drag.dx, drag.dy) < 5) return;
8148
+ if (!drag.moving) {
8149
+ drag.moving = true;
8150
+ this.host.dataset.dragging = "";
8151
+ toolbar()?.setAttribute("data-dragging", "");
8152
+ }
8153
+ ev.preventDefault();
8154
+ toolbar()?.style.setProperty("transform", `translate(${drag.dx}px, ${drag.dy}px)`);
8155
+ };
8156
+ const onUp = (ev) => {
8157
+ if (ev.pointerId !== drag.id) return;
8158
+ this.endToolbarDrag?.();
8159
+ if (drag.moving) this.dropToolbar(toolbar(), true);
8160
+ };
8161
+ const onCancel = (ev) => {
8162
+ if (ev instanceof PointerEvent && ev.pointerId !== drag.id) return;
8163
+ this.endToolbarDrag?.();
8164
+ if (drag.moving) this.dropToolbar(toolbar(), false);
8165
+ };
8166
+ window.addEventListener("pointermove", onMove, true);
8167
+ window.addEventListener("pointerup", onUp, true);
8168
+ window.addEventListener("pointercancel", onCancel, true);
8169
+ window.addEventListener("blur", onCancel);
8170
+ this.endToolbarDrag = () => {
8171
+ window.removeEventListener("pointermove", onMove, true);
8172
+ window.removeEventListener("pointerup", onUp, true);
8173
+ window.removeEventListener("pointercancel", onCancel, true);
8174
+ window.removeEventListener("blur", onCancel);
8175
+ delete this.host.dataset.dragging;
8176
+ this.toolbarDrag = null;
8177
+ this.endToolbarDrag = null;
8178
+ };
8179
+ }
8180
+ /** Glide the toolbar to the spot nearest where it was let go, or back. */
8181
+ dropToolbar(el, snap) {
8182
+ this.toolbarDropped = true;
8183
+ setTimeout(() => this.toolbarDropped = false);
8184
+ if (!el) return;
8185
+ const from = el.getBoundingClientRect();
8186
+ if (snap) {
8187
+ const position = nearestPosition(from.left + from.width / 2, from.top + from.height / 2);
8188
+ this.position = position;
8189
+ this.host.dataset.position = position;
8190
+ write(KEYS.position(this.config.project), position === this.config.position ? null : position);
8191
+ }
8192
+ el.removeAttribute("data-dragging");
8193
+ el.style.removeProperty("transform");
8194
+ const to = el.getBoundingClientRect();
8195
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
8196
+ el.animate([{ transform: `translate(${from.left - to.left}px, ${from.top - to.top}px)` }, { transform: "none" }], {
8197
+ duration: 320,
8198
+ easing: "cubic-bezier(0.2, 0.9, 0.3, 1.15)"
8199
+ });
8200
+ }
7884
8201
  renderPanel() {
7885
8202
  const open = this.comments.filter((c) => c.status === "open");
7886
8203
  const resolved = this.comments.filter((c) => c.status === "resolved");
7887
- const list = this.tab === "open" ? open : resolved;
8204
+ const query = this.search.trim().toLocaleLowerCase();
8205
+ const matches = (...texts) => !query || texts.some((t) => t?.toLocaleLowerCase().includes(query));
8206
+ const list = (this.tab === "open" ? open : resolved).filter((c) => matches(c.body, c.authorName, c.anchor.quote?.exact));
7888
8207
  const placed = list.filter((c) => this.placements.get(c._id)?.element);
7889
8208
  const lost = list.filter((c) => !this.placements.get(c._id)?.element);
7890
- const otherPages = this.pages.filter((p) => p.path !== this.pageKey);
8209
+ const otherPages = this.pages.filter((p) => p.path !== this.pageKey && matches(p.path));
7891
8210
  const item = (c) => h("button", {
7892
8211
  class: "item",
7893
8212
  type: "button",
8213
+ "aria-current": c._id === this.lastViewedId ? "true" : null,
7894
8214
  "data-focus-key": `item-${c._id}`,
7895
8215
  onclick: () => this.focusComment(c._id)
7896
8216
  }, h("div", { class: "row" }, h("span", {
@@ -7902,6 +8222,7 @@ var NuniWidget = class {
7902
8222
  }, new URL(c.page.origin).host) : null), c.anchor.quote ? h("div", { class: "item-quote" }, c.anchor.quote.exact) : null, h("div", { class: "item-text" }, c.body));
7903
8223
  const children = [];
7904
8224
  if (!this.loaded) children.push(h("div", { class: "empty" }, this.t("loading")));
8225
+ else if (!list.length && query) children.push(h("div", { class: "empty" }, this.t("noMatches", { query: this.search.trim() })));
7905
8226
  else if (!list.length) children.push(h("div", { class: "empty" }, this.tab === "open" ? this.withKey("noOpen", "noOpenKey") : this.t("noResolved")));
7906
8227
  else {
7907
8228
  children.push(...placed.map(item));
@@ -7929,7 +8250,19 @@ var NuniWidget = class {
7929
8250
  type: "button",
7930
8251
  "aria-label": this.t("close"),
7931
8252
  onclick: () => this.togglePanel(false)
7932
- }, icon(ICONS.close))), h("div", {
8253
+ }, icon(ICONS.close))), this.comments.length ? h("div", { class: "panel-search" }, icon(ICONS.search), h("input", {
8254
+ class: "field",
8255
+ type: "search",
8256
+ value: this.search,
8257
+ placeholder: this.t("searchComments"),
8258
+ "aria-label": this.t("searchComments"),
8259
+ "aria-controls": "nuni-panel-list",
8260
+ "data-focus-key": "panel-search",
8261
+ oninput: (e) => {
8262
+ this.search = e.target.value;
8263
+ this.render();
8264
+ }
8265
+ })) : null, h("div", {
7933
8266
  class: "tabs",
7934
8267
  role: "tablist",
7935
8268
  "aria-label": this.t("comments"),
@@ -7973,80 +8306,112 @@ var NuniWidget = class {
7973
8306
  onclick: () => this.openClaim()
7974
8307
  }, icon(ICONS.github), this.t("claim"))));
7975
8308
  }
8309
+ renderComposer(draft, cls) {
8310
+ const needsName = !this.name;
8311
+ const avatar = h("span", {
8312
+ class: "avatar cmp-avatar",
8313
+ "aria-hidden": "true"
8314
+ });
8315
+ const paintAvatar = (name) => {
8316
+ const clean = name.trim();
8317
+ avatar.classList.toggle("avatar-empty", !clean);
8318
+ if (clean) {
8319
+ avatar.textContent = initials(clean);
8320
+ avatar.style.background = colorFor(clean);
8321
+ } else {
8322
+ avatar.replaceChildren(icon(ICONS.user));
8323
+ avatar.style.background = "";
8324
+ }
8325
+ };
8326
+ const nameInput = h("input", {
8327
+ class: "cmp-name",
8328
+ placeholder: this.t("yourName"),
8329
+ value: this.name,
8330
+ maxlength: LIMITS.nameMaxLength,
8331
+ autocomplete: "name",
8332
+ "data-focus-key": "name",
8333
+ "aria-label": this.t("yourName")
8334
+ });
8335
+ const textarea = h("textarea", {
8336
+ class: "cmp-text",
8337
+ placeholder: this.t("leaveComment"),
8338
+ maxlength: LIMITS.bodyMaxLength,
8339
+ rows: 3,
8340
+ "data-focus-key": "body",
8341
+ "aria-label": this.t("comment")
8342
+ });
8343
+ const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
8344
+ if (prev) textarea.value = prev.value;
8345
+ const prevName = this.uiLayer.querySelector("[data-focus-key=\"name\"]");
8346
+ if (prevName) nameInput.value = prevName.value;
8347
+ paintAvatar(needsName ? nameInput.value : this.name);
8348
+ nameInput.addEventListener("input", () => paintAvatar(nameInput.value));
8349
+ const counter = h("span", {
8350
+ class: "cmp-count",
8351
+ "aria-live": "polite"
8352
+ });
8353
+ const post = h("button", {
8354
+ class: "cmp-post",
8355
+ type: "submit",
8356
+ disabled: Boolean(draft.sending) || !textarea.value.trim()
8357
+ }, draft.sending ? this.t("posting") : this.t("post"), icon(ICONS.arrowUp));
8358
+ const update = () => {
8359
+ post.disabled = Boolean(draft.sending) || !textarea.value.trim();
8360
+ const left = LIMITS.bodyMaxLength - textarea.value.length;
8361
+ counter.textContent = left <= LIMITS.bodyMaxLength * .1 ? this.t("charsLeft", { count: left }) : "";
8362
+ textarea.style.height = "auto";
8363
+ textarea.style.height = `${textarea.scrollHeight + 2}px`;
8364
+ };
8365
+ textarea.addEventListener("input", () => {
8366
+ update();
8367
+ this.positionCard();
8368
+ });
8369
+ requestAnimationFrame(() => {
8370
+ if (!textarea.isConnected) return;
8371
+ update();
8372
+ this.positionCard();
8373
+ });
8374
+ const submit = () => void this.submitDraft(draft, textarea.value, needsName ? nameInput.value : this.name);
8375
+ textarea.addEventListener("keydown", (e) => {
8376
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
8377
+ e.preventDefault();
8378
+ submit();
8379
+ }
8380
+ });
8381
+ return h("form", {
8382
+ class: `${cls} composer`,
8383
+ "data-card": "composer",
8384
+ onsubmit: (e) => {
8385
+ e.preventDefault();
8386
+ submit();
8387
+ }
8388
+ }, h("div", { class: "cmp-head" }, avatar, needsName ? nameInput : h("span", { class: "author" }, this.name), h("span", { class: "spacer" }), needsName ? null : h("button", {
8389
+ class: "cmp-link",
8390
+ type: "button",
8391
+ onclick: () => {
8392
+ this.name = "";
8393
+ this.render();
8394
+ this.focusComposer();
8395
+ }
8396
+ }, this.t("notYou"))), draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.error ? h("div", {
8397
+ class: "error cmp-error",
8398
+ role: "alert"
8399
+ }, draft.error) : null, h("div", { class: "cmp-foot" }, h("span", {
8400
+ class: "cmp-keys",
8401
+ title: this.t("send", { key: this.i18n.mod })
8402
+ }, h("kbd", {}, this.i18n.mod), h("kbd", {}, "↵")), counter, h("span", { class: "spacer" }), post), h("button", {
8403
+ class: "icon-btn cmp-close",
8404
+ type: "button",
8405
+ "aria-label": this.t("cancel"),
8406
+ title: this.t("cancel"),
8407
+ onclick: () => this.closeCard()
8408
+ }, icon(ICONS.close)));
8409
+ }
7976
8410
  renderCard() {
7977
8411
  const card = this.card;
7978
8412
  if (!card) return null;
7979
8413
  const cls = `card${window.matchMedia(MOBILE_QUERY).matches ? " sheet" : ""}`;
7980
- if (card.kind === "composer") {
7981
- const { draft } = card;
7982
- const needsName = !this.name;
7983
- const nameInput = h("input", {
7984
- class: "field",
7985
- placeholder: this.t("yourName"),
7986
- value: this.name,
7987
- maxlength: LIMITS.nameMaxLength,
7988
- autocomplete: "name",
7989
- "data-focus-key": "name",
7990
- "aria-label": this.t("yourName")
7991
- });
7992
- const textarea = h("textarea", {
7993
- class: "field",
7994
- placeholder: this.t("leaveComment"),
7995
- maxlength: LIMITS.bodyMaxLength,
7996
- "data-focus-key": "body",
7997
- "aria-label": this.t("comment")
7998
- });
7999
- const prev = this.uiLayer.querySelector("[data-focus-key=\"body\"]");
8000
- if (prev) textarea.value = prev.value;
8001
- const prevName = this.uiLayer.querySelector("[data-focus-key=\"name\"]");
8002
- if (prevName) nameInput.value = prevName.value;
8003
- const submit = () => void this.submitDraft(draft, textarea.value, needsName ? nameInput.value : this.name);
8004
- textarea.addEventListener("keydown", (e) => {
8005
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
8006
- e.preventDefault();
8007
- submit();
8008
- }
8009
- });
8010
- return h("form", {
8011
- class: cls,
8012
- "data-card": "composer",
8013
- onsubmit: (e) => {
8014
- e.preventDefault();
8015
- submit();
8016
- }
8017
- }, h("div", { class: "card-body" }, needsName ? nameInput : h("div", { class: "row" }, h("span", {
8018
- class: "avatar",
8019
- style: `background:${colorFor(this.name)}`
8020
- }, initials(this.name)), h("span", { class: "author" }, this.name), h("span", { class: "spacer" }), h("button", {
8021
- class: "link",
8022
- type: "button",
8023
- onclick: () => {
8024
- this.name = "";
8025
- this.render();
8026
- }
8027
- }, this.t("notYou"))), draft.anchor.quote ? h("blockquote", { class: "quote" }, draft.anchor.quote.exact) : null, textarea, draft.screenshot ? h("div", { class: "shot-preview" }, h("img", {
8028
- src: draft.screenshot.preview,
8029
- alt: this.t("screenshotAlt")
8030
- }), h("div", { class: "shot-note" }, h("span", {}, this.t("screenshotNote")), h("span", { class: "spacer" }), h("button", {
8031
- class: "link",
8032
- type: "button",
8033
- onclick: () => {
8034
- draft.screenshot = void 0;
8035
- this.render();
8036
- }
8037
- }, this.t("remove")))) : null, draft.error ? h("div", {
8038
- class: "error",
8039
- role: "alert"
8040
- }, draft.error) : null, h("div", { class: "row" }, h("span", { class: "kbd" }, this.t("send", { key: this.i18n.mod })), h("span", { class: "spacer" }), h("button", {
8041
- class: "btn btn-ghost",
8042
- type: "button",
8043
- onclick: () => this.closeCard()
8044
- }, this.t("cancel")), h("button", {
8045
- class: "btn btn-primary",
8046
- type: "submit",
8047
- disabled: Boolean(draft.sending)
8048
- }, icon(ICONS.send), draft.sending ? this.t("posting") : this.t("post")))));
8049
- }
8414
+ if (card.kind === "composer") return this.renderComposer(card.draft, cls);
8050
8415
  const comment = this.comments.find((c) => c._id === card.id);
8051
8416
  if (!comment) return null;
8052
8417
  const mine = this.isMine(comment);
@@ -8074,61 +8439,55 @@ var NuniWidget = class {
8074
8439
  disabled: Boolean(card.busy),
8075
8440
  onclick: () => this.saveEdit(comment, textarea.value)
8076
8441
  }, this.t("save"))));
8077
- } else body = h("div", { class: "card-body" }, h("div", { class: "card-head" }, h("span", {
8078
- class: "avatar",
8079
- style: `background:${colorFor(comment.authorName)}`
8080
- }, initials(comment.authorName)), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(comment.createdAt) + (comment.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), h("button", {
8081
- class: "btn btn-ghost btn-icon",
8082
- type: "button",
8083
- "aria-label": this.t("close"),
8084
- onclick: () => this.closeCard()
8085
- }, icon(ICONS.close))), comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
8086
- class: "shot",
8087
- href: this.ownerDetail.screenshotUrl,
8088
- target: "_blank",
8089
- rel: "noreferrer",
8090
- title: this.t("openScreenshot")
8091
- }, h("img", {
8092
- src: this.ownerDetail.screenshotUrl,
8093
- alt: this.t("screenshotTaken")
8094
- })) : null, h("div", { class: "row" }, comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t("resolved")) : null, comment.page.origin !== location.origin ? h("span", {
8095
- class: "badge",
8096
- title: this.t("leftOn", { origin: comment.page.origin })
8097
- }, new URL(comment.page.origin).host) : null, placement?.confidence === "low" ? h("span", {
8098
- class: "badge",
8099
- title: this.t("approximateHint")
8100
- }, this.t("approximate")) : null, !placement?.element ? h("span", {
8101
- class: "badge",
8102
- title: this.t("originallyOn", { text: comment.anchor.text || comment.anchor.tag })
8103
- }, this.t("notFound")) : null), this.renderReactions(comment, comment._id), card.error ? h("div", {
8104
- class: "error",
8105
- role: "alert"
8106
- }, card.error) : null);
8107
- const actions = [];
8108
- if (!card.editing) {
8109
- if (owner) actions.push(comment.status === "open" ? h("button", {
8110
- class: "btn",
8442
+ } else {
8443
+ const badges = [
8444
+ comment.status === "resolved" ? h("span", { class: "badge badge-ok" }, icon(ICONS.check), this.t("resolved")) : null,
8445
+ comment.page.origin !== location.origin ? h("span", {
8446
+ class: "badge",
8447
+ title: this.t("leftOn", { origin: comment.page.origin })
8448
+ }, new URL(comment.page.origin).host) : null,
8449
+ placement?.confidence === "low" ? h("span", {
8450
+ class: "badge",
8451
+ title: this.t("approximateHint")
8452
+ }, this.t("approximate")) : null,
8453
+ !placement?.element ? h("span", {
8454
+ class: "badge",
8455
+ title: this.t("originallyOn", { text: comment.anchor.text || comment.anchor.tag })
8456
+ }, this.t("notFound")) : null
8457
+ ].filter((badge) => badge !== null);
8458
+ const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
8459
+ const resolved = comment.status === "resolved";
8460
+ body = h("div", { class: "card-body msg" }, h("div", { class: "card-head" }, avatar(comment.authorName), h("span", { class: "author" }, comment.authorName), h("span", { class: "meta" }, this.i18n.timeAgo(comment.createdAt) + (comment.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), owner ? h("button", {
8461
+ class: `icon-btn${resolved ? " is-resolved" : ""}`,
8111
8462
  type: "button",
8463
+ "aria-label": this.t(resolved ? "reopen" : "resolve"),
8464
+ title: this.t(resolved ? "reopen" : "resolve"),
8112
8465
  disabled: Boolean(card.busy),
8113
- onclick: () => this.resolveComment(comment)
8114
- }, icon(ICONS.check), this.t("resolve")) : h("button", {
8115
- class: "btn",
8466
+ onclick: () => resolved ? this.reopenComment(comment) : this.resolveComment(comment)
8467
+ }, icon(ICONS.check)) : null, h("button", {
8468
+ class: "icon-btn card-close",
8116
8469
  type: "button",
8117
- disabled: Boolean(card.busy),
8118
- onclick: () => this.reopenComment(comment)
8119
- }, icon(ICONS.undo), this.t("reopen")));
8120
- actions.push(h("span", { class: "spacer" }));
8121
- const contextPending = owner && !(this.ownerDetailLoaded && this.ownerDetailId === comment._id);
8122
- actions.push(h("button", {
8123
- class: "btn btn-ghost btn-icon",
8470
+ "aria-label": this.t("close"),
8471
+ title: this.t("close"),
8472
+ onclick: () => this.closeCard()
8473
+ }, icon(ICONS.close))), h("div", { class: "msg-main" }, comment.anchor.quote ? h("blockquote", { class: "quote" }, comment.anchor.quote.exact) : null, h("div", { class: "comment-body" }, comment.body), this.ownerDetail?._id === comment._id && this.ownerDetail.screenshotUrl ? h("a", {
8474
+ class: "shot",
8475
+ href: this.ownerDetail.screenshotUrl,
8476
+ target: "_blank",
8477
+ rel: "noreferrer",
8478
+ title: this.t("openScreenshot")
8479
+ }, h("img", {
8480
+ src: this.ownerDetail.screenshotUrl,
8481
+ alt: this.t("screenshotTaken")
8482
+ })) : null, badges.length ? h("div", { class: "badges" }, ...badges) : null, h("div", { class: "msg-foot" }, this.renderReactions(comment, comment._id), h("span", { class: "spacer" }), h("button", {
8483
+ class: "icon-btn",
8124
8484
  type: "button",
8125
8485
  "aria-label": this.t("copyForAgent"),
8126
8486
  title: contextPending ? this.t("loadingContext") : this.t("copyForAgentHint"),
8127
8487
  disabled: contextPending,
8128
8488
  onclick: () => this.copyForAgent(comment)
8129
- }, icon(ICONS.bot)));
8130
- if (mine) actions.push(h("button", {
8131
- class: "btn btn-ghost btn-icon",
8489
+ }, icon(ICONS.bot)), mine ? h("button", {
8490
+ class: "icon-btn",
8132
8491
  type: "button",
8133
8492
  "aria-label": this.t("edit"),
8134
8493
  title: this.t("edit"),
@@ -8136,30 +8495,36 @@ var NuniWidget = class {
8136
8495
  card.editing = true;
8137
8496
  this.render();
8138
8497
  }
8139
- }, icon(ICONS.edit)));
8140
- if (mine || owner) actions.push(h("button", {
8141
- class: "btn btn-ghost btn-icon btn-danger",
8498
+ }, icon(ICONS.edit)) : null, mine || owner ? h("button", {
8499
+ class: "icon-btn icon-btn-danger",
8142
8500
  type: "button",
8143
8501
  "aria-label": this.t("delete"),
8144
8502
  title: this.t("delete"),
8145
8503
  "data-focus-key": "delete",
8146
8504
  disabled: Boolean(card.busy),
8147
8505
  onclick: () => void this.deleteComment(comment)
8148
- }, icon(ICONS.trash)));
8506
+ }, icon(ICONS.trash)) : null), card.error ? h("div", {
8507
+ class: "error",
8508
+ role: "alert"
8509
+ }, card.error) : null));
8149
8510
  }
8150
8511
  return h("div", {
8151
8512
  class: cls,
8152
8513
  "data-card": "thread",
8153
8514
  role: "dialog",
8154
8515
  "aria-label": this.t("commentBy", { name: comment.authorName })
8155
- }, body, actions.length ? h("div", { class: "actions" }, ...actions) : null, card.editing ? null : this.renderThread(comment));
8516
+ }, body, card.editing ? null : this.renderThread(comment));
8156
8517
  }
8157
- /** Emoji chips for one message, plus a picker to add one. */
8158
- renderReactions(comment, targetId) {
8518
+ /**
8519
+ * Emoji chips for one message and the picker to add one. Replies keep
8520
+ * their add button with their other tools, so `withAdd` is off for them.
8521
+ */
8522
+ renderReactions(comment, targetId, withAdd = true) {
8159
8523
  const card = this.card;
8160
8524
  if (card?.kind !== "thread") return null;
8161
8525
  const summaries = this.threadId === comment._id && this.thread ? this.thread.reactions.filter((r) => r.targetId === targetId) : [];
8162
8526
  const open = card.picker === targetId;
8527
+ if (!withAdd && !summaries.length && !open) return null;
8163
8528
  return h("div", { class: "reactions" }, ...summaries.map((r) => {
8164
8529
  const mine = r.authorKeyHashes.includes(this.secretHash);
8165
8530
  return h("button", {
@@ -8169,17 +8534,7 @@ var NuniWidget = class {
8169
8534
  "aria-label": `${r.emoji} ${r.count}${mine ? ", including you" : ""}`,
8170
8535
  onclick: () => this.toggleReaction(comment, targetId, r.emoji)
8171
8536
  }, `${r.emoji} ${r.count}`);
8172
- }), h("button", {
8173
- class: "reaction reaction-add",
8174
- type: "button",
8175
- "aria-label": this.t("addReaction"),
8176
- "aria-expanded": String(open),
8177
- title: this.t("addReaction"),
8178
- onclick: () => {
8179
- card.picker = open ? null : targetId;
8180
- this.render();
8181
- }
8182
- }, icon(ICONS.smile)), open ? h("div", {
8537
+ }), withAdd ? this.reactionAdd(targetId, "reaction reaction-add") : null, open ? h("div", {
8183
8538
  class: "reaction-picker",
8184
8539
  role: "group",
8185
8540
  "aria-label": this.t("reactions")
@@ -8194,6 +8549,22 @@ var NuniWidget = class {
8194
8549
  }
8195
8550
  }, emoji))) : null);
8196
8551
  }
8552
+ reactionAdd(targetId, cls) {
8553
+ const card = this.card;
8554
+ if (card?.kind !== "thread") return null;
8555
+ const open = card.picker === targetId;
8556
+ return h("button", {
8557
+ class: cls,
8558
+ type: "button",
8559
+ "aria-label": this.t("addReaction"),
8560
+ "aria-expanded": String(open),
8561
+ title: this.t("addReaction"),
8562
+ onclick: () => {
8563
+ card.picker = open ? null : targetId;
8564
+ this.render();
8565
+ }
8566
+ }, icon(ICONS.smile));
8567
+ }
8197
8568
  /** The replies under a comment and the box to add one. */
8198
8569
  renderThread(comment) {
8199
8570
  const card = this.card;
@@ -8226,13 +8597,10 @@ var NuniWidget = class {
8226
8597
  }, this.t("save"))));
8227
8598
  }
8228
8599
  return h("div", {
8229
- class: "reply",
8600
+ class: "reply msg",
8230
8601
  "data-reply": reply._id
8231
- }, h("div", { class: "card-head" }, h("span", {
8232
- class: "avatar avatar-sm",
8233
- style: `background:${colorFor(reply.authorName)}`
8234
- }, initials(reply.authorName)), h("span", { class: "author" }, reply.authorName), reply.isOwner ? h("span", { class: "badge badge-owner" }, this.t("owner")) : null, h("span", { class: "meta" }, this.i18n.timeAgo(reply.createdAt) + (reply.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), mine ? h("button", {
8235
- class: "btn btn-ghost btn-icon btn-xs",
8602
+ }, h("div", { class: "card-head" }, avatar(reply.authorName), h("span", { class: "author" }, reply.authorName), reply.isOwner ? h("span", { class: "badge badge-owner" }, this.t("owner")) : null, h("span", { class: "meta" }, this.i18n.timeAgo(reply.createdAt) + (reply.editedAt ? ` · ${this.t("edited")}` : "")), h("span", { class: "spacer" }), h("span", { class: "msg-tools" }, this.reactionAdd(reply._id, "icon-btn"), mine ? h("button", {
8603
+ class: "icon-btn",
8236
8604
  type: "button",
8237
8605
  "aria-label": this.t("editReply"),
8238
8606
  title: this.t("edit"),
@@ -8241,18 +8609,18 @@ var NuniWidget = class {
8241
8609
  this.render();
8242
8610
  }
8243
8611
  }, icon(ICONS.edit)) : null, mine || owner ? h("button", {
8244
- class: "btn btn-ghost btn-icon btn-xs btn-danger",
8612
+ class: "icon-btn icon-btn-danger",
8245
8613
  type: "button",
8246
8614
  "aria-label": this.t("deleteReply"),
8247
8615
  title: this.t("delete"),
8248
8616
  "data-focus-key": `delete-${reply._id}`,
8249
8617
  disabled: Boolean(card.busy),
8250
8618
  onclick: () => void this.deleteReply(comment, reply)
8251
- }, icon(ICONS.trash)) : null), h("div", { class: "comment-body" }, reply.body), this.renderReactions(comment, reply._id));
8619
+ }, icon(ICONS.trash)) : null)), h("div", { class: "msg-main" }, h("div", { class: "comment-body" }, reply.body), this.renderReactions(comment, reply._id, false)));
8252
8620
  });
8253
8621
  const needsName = !owner && !this.name;
8254
8622
  const nameInput = h("input", {
8255
- class: "field",
8623
+ class: "reply-name",
8256
8624
  placeholder: this.t("yourName"),
8257
8625
  value: this.name,
8258
8626
  maxlength: LIMITS.nameMaxLength,
@@ -8263,14 +8631,31 @@ var NuniWidget = class {
8263
8631
  const prevName = this.uiLayer.querySelector("[data-focus-key=\"reply-name\"]");
8264
8632
  if (prevName) nameInput.value = prevName.value;
8265
8633
  const box = h("textarea", {
8266
- class: "field field-reply",
8634
+ class: "reply-text",
8267
8635
  placeholder: replies.length ? this.t("reply") : this.t("replyFirst"),
8268
8636
  maxlength: LIMITS.bodyMaxLength,
8637
+ rows: 1,
8269
8638
  "data-focus-key": "reply",
8270
8639
  "aria-label": this.t("reply")
8271
8640
  });
8272
8641
  const prev = this.uiLayer.querySelector("[data-focus-key=\"reply\"]");
8273
8642
  if (prev) box.value = prev.value;
8643
+ const sendButton = h("button", {
8644
+ class: "reply-send",
8645
+ type: "submit",
8646
+ "aria-label": this.t("sendReply"),
8647
+ title: this.t("sendReply"),
8648
+ disabled: Boolean(card.sendingReply) || !box.value.trim()
8649
+ }, icon(ICONS.arrowUp));
8650
+ const grow = () => {
8651
+ sendButton.disabled = Boolean(card.sendingReply) || !box.value.trim();
8652
+ box.style.height = "auto";
8653
+ box.style.height = `${box.scrollHeight}px`;
8654
+ };
8655
+ box.addEventListener("input", grow);
8656
+ requestAnimationFrame(() => {
8657
+ if (box.isConnected) grow();
8658
+ });
8274
8659
  const send = () => void this.sendReply(comment, box.value, needsName ? nameInput.value : this.name);
8275
8660
  box.addEventListener("keydown", (e) => {
8276
8661
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
@@ -8291,13 +8676,7 @@ var NuniWidget = class {
8291
8676
  e.preventDefault();
8292
8677
  send();
8293
8678
  }
8294
- }, needsName ? nameInput : null, box, h("button", {
8295
- class: "btn btn-primary btn-icon",
8296
- type: "submit",
8297
- "aria-label": this.t("sendReply"),
8298
- title: this.t("sendReply"),
8299
- disabled: Boolean(card.sendingReply)
8300
- }, icon(ICONS.send))));
8679
+ }, needsName ? nameInput : null, h("div", { class: "reply-row" }, box, sendButton)));
8301
8680
  }
8302
8681
  positionCard() {
8303
8682
  const el = this.uiLayer.querySelector("[data-card]");
@@ -8321,8 +8700,8 @@ var NuniWidget = class {
8321
8700
  top = point.y - 28;
8322
8701
  if (left + width > vw - 12) left = point.x - width - 12;
8323
8702
  } else {
8324
- const { position } = this.config;
8325
- left = position.endsWith("left") ? 16 : vw - width - 16;
8703
+ const { position } = this;
8704
+ left = position.endsWith("left") ? 16 : position.endsWith("center") ? (vw - width) / 2 : vw - width - 16;
8326
8705
  top = position.startsWith("top") ? 72 : vh - height - 72;
8327
8706
  }
8328
8707
  left = Math.max(12, Math.min(left, vw - width - 12));
@@ -8336,6 +8715,22 @@ var NuniWidget = class {
8336
8715
  });
8337
8716
  }
8338
8717
  };
8718
+ /** The toolbar spot nearest a point: thirds across the screen, halves down. */
8719
+ function nearestPosition(x, y) {
8720
+ const width = window.innerWidth;
8721
+ const across = x < width / 3 ? "left" : x > width * 2 / 3 ? "right" : "center";
8722
+ return `${y < window.innerHeight / 2 ? "top" : "bottom"}-${across}`;
8723
+ }
8724
+ /** A press on a scrollbar, which scrolls rather than points at the page. */
8725
+ function onScrollbar(e) {
8726
+ const el = e.composedPath()[0];
8727
+ if (!(el instanceof Element)) return false;
8728
+ const root = document.documentElement;
8729
+ if (el === root) return e.clientX >= root.clientWidth || e.clientY >= root.clientHeight;
8730
+ if (!(el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth)) return false;
8731
+ const box = el.getBoundingClientRect();
8732
+ return e.clientX - box.left - el.clientLeft >= el.clientWidth || e.clientY - box.top - el.clientTop >= el.clientHeight;
8733
+ }
8339
8734
  //#endregion
8340
8735
  //#region src/mount.ts
8341
8736
  /** Nuni's own requests are not the page's problem. */