dsh-inner-browser-use 0.18.0 → 0.34.28

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/read.js CHANGED
@@ -43,6 +43,9 @@ const SELECTOR = [
43
43
  '[role="option"]',
44
44
  '[role="textbox"]',
45
45
  '[role="searchbox"]',
46
+ /* A drawing surface has no words in the tree, but it is the whole page in
47
+ some products: number it, and let a picture be taken of it. */
48
+ 'canvas',
46
49
  ].join(',')
47
50
 
48
51
  /** How a role is written in the compact read: short enough to cost little. */
@@ -133,6 +136,10 @@ function snapshot(mode, budget, max) {
133
136
  const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
134
137
  const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
135
138
  const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
139
+ if (el.tagName === 'CANVAS' && label === '' && own === '' && text === '' && value === '') {
140
+ const b = el.getBoundingClientRect()
141
+ return 'drawing ' + Math.round(b.width) + 'x' + Math.round(b.height)
142
+ }
136
143
  return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
137
144
  }
138
145
  const box = (el) => el.getBoundingClientRect()
@@ -240,6 +247,98 @@ function snapshot(mode, budget, max) {
240
247
  }
241
248
  }
242
249
  }
250
+ /* A page with almost no words and one big picture is answering with the
251
+ picture. Say so here, in the same round trip, so a model that cannot open
252
+ files still learns that something came back: and sample it small to tell a
253
+ blank chart from a chart. */
254
+ const picture = (() => {
255
+ if (full >= 40) return null
256
+ let best = null
257
+ const missing = []
258
+ for (const el of document.querySelectorAll('img,canvas')) {
259
+ const b = box(el)
260
+ /* A picture that failed to arrive shrinks to an error icon, so its own
261
+ declared size is what the page meant to show. */
262
+ const askW = Number(el.getAttribute?.('width')) || 0
263
+ const askH = Number(el.getAttribute?.('height')) || 0
264
+ const wide = Math.max(b.width, askW)
265
+ const high = Math.max(b.height, askH)
266
+ if (wide < 64 || high < 64 || wide * high < 8000) continue
267
+ const s = getComputedStyle(el)
268
+ if (s.visibility === 'hidden' || s.display === 'none') continue
269
+ /* A picture slot that never got its picture is the commonest way a
270
+ generated image goes missing, and it is silent by default. */
271
+ if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
272
+ missing.push({ width: Math.round(wide), height: Math.round(high) })
273
+ missing[missing.length - 1].name = String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60)
274
+ continue
275
+ }
276
+ const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
277
+ const h = el.tagName === 'CANVAS' ? el.height : el.naturalHeight || Math.round(b.height)
278
+ if (best === null || w * h > best.w * best.h) best = { el, w, h, kind: String(el.tagName).toLowerCase() }
279
+ }
280
+ if (best === null) return missing.length === 0 ? null : { missing }
281
+ /* What the read hands back about one picture: not a screen of fields. */
282
+ const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) }
283
+ said.kind = best.kind
284
+ said.width = best.w
285
+ said.height = best.h
286
+ said.colors = 0
287
+ said.flat = 0
288
+ try {
289
+ const board = document.createElement('canvas')
290
+ board.width = 32
291
+ board.height = 32
292
+ const pen = board.getContext('2d', { willReadFrequently: true })
293
+ if (pen === null) return said
294
+ pen.drawImage(best.el, 0, 0, 32, 32)
295
+ const dots = pen.getImageData(0, 0, 32, 32).data
296
+ const tally = new Map()
297
+ for (let at = 0; at < dots.length; at += 4) {
298
+ const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
299
+ tally.set(key, (tally.get(key) || 0) + 1)
300
+ }
301
+ let most = 0
302
+ for (const count of tally.values()) most = Math.max(most, count)
303
+ said.colors = tally.size
304
+ said.flat = Math.round((most / 1024) * 100) / 100
305
+ } catch {
306
+ /* A picture from elsewhere cannot be sampled: report its size only. */
307
+ }
308
+ return said
309
+ })()
310
+ /* What no amount of text will tell the reader: the screen shows something
311
+ that has no words in the tree. Counted here so every read can say it. */
312
+ const blind = (() => {
313
+ const out = { pictures: 0, canvases: 0, videos: 0, frames: 0 }
314
+ const speaks = (el) => {
315
+ const said = String(el.getAttribute?.('alt') ?? '').trim() || String(el.getAttribute?.('aria-label') ?? '').trim() || String(el.getAttribute?.('title') ?? '').trim()
316
+ if (said.length > 1) return true
317
+ let near = el.closest?.('figure, picture')
318
+ if (near) {
319
+ const words = String(near.querySelector?.('figcaption')?.textContent ?? '').trim()
320
+ if (words.length > 1) return true
321
+ }
322
+ return false
323
+ }
324
+ const shows = (el) => {
325
+ const b = box(el)
326
+ if (b.width < 48 || b.height < 48) return false
327
+ if (!inView(b)) return false
328
+ const st = getComputedStyle(el)
329
+ return st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) > 0.05
330
+ }
331
+ for (const el of document.querySelectorAll('img')) {
332
+ if (!shows(el) || speaks(el)) continue
333
+ out.pictures += 1
334
+ }
335
+ for (const el of document.querySelectorAll('canvas')) if (shows(el)) out.canvases += 1
336
+ for (const el of document.querySelectorAll('video')) if (shows(el)) out.videos += 1
337
+ /* A tracking pixel of a frame is not something the reader is missing: only
338
+ frames that occupy the screen are worth a sentence. */
339
+ for (const el of document.querySelectorAll('iframe')) if (shows(el)) out.frames += 1
340
+ return out
341
+ })()
243
342
  return JSON.stringify({
244
343
  url: location.href, // ui-copy-ignore
245
344
  title: document.title, // ui-copy-ignore
@@ -252,6 +351,8 @@ function snapshot(mode, budget, max) {
252
351
  return { ref: reg.id(el), kind: role(el), name: name(el) }
253
352
  })(),
254
353
  controls: picked,
354
+ picture,
355
+ blind,
255
356
  })
256
357
  })()`
257
358
  }
@@ -278,6 +379,130 @@ export function resolve(ref) {
278
379
  })()`
279
380
  }
280
381
 
382
+ /**
383
+ * How big the control is and where it sits, so a picture can be of it alone.
384
+ * @param {number} ref - ref from a read.
385
+ * @returns {string} an expression that answers with the rectangle.
386
+ */
387
+ export function bounds(ref) {
388
+ return `(() => {
389
+ const reg = window.${REGISTRY}
390
+ if (!reg) return JSON.stringify({ gone: true })
391
+ const hit = reg.find(${Number(ref)})
392
+ if (!hit) return JSON.stringify({ gone: true })
393
+ const el = hit.el
394
+ if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' })
395
+ const b = el.getBoundingClientRect()
396
+ if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
397
+ return JSON.stringify({ gone: false, x: b.left, y: b.top, width: b.width, height: b.height })
398
+ })()`
399
+ }
400
+
401
+ /**
402
+ * The pictures on the page, as files a person can open.
403
+ * A generated image or a chart is often the whole answer, and there is no
404
+ * wording in the tree that stands for it: take the picture itself.
405
+ * @param {number} [ref] - only pictures at or inside this control.
406
+ * @param {number} [count] - how many, largest first. Default 1.
407
+ * @returns {string} an expression that answers with `{pictures: [...]}`.
408
+ */
409
+ export function pictures(ref, count) {
410
+ const scope = Number(ref)
411
+ const limit = Math.max(1, Math.min(4, Number(count) || 1))
412
+ return `(async () => {
413
+ const reg = window.${REGISTRY}
414
+ const where = ${Number.isFinite(scope) ? `(reg ? (reg.find(${scope}) || {}).el : null)` : 'null'}
415
+ const picked = []
416
+ const seen = new Set()
417
+ const add = (el, kind) => {
418
+ if (el === null || el === undefined || seen.has(el)) return
419
+ seen.add(el)
420
+ const b = el.getBoundingClientRect()
421
+ const w = kind === 'canvas' ? el.width : kind === 'video' ? el.videoWidth || b.width : el.naturalWidth || b.width
422
+ const h = kind === 'canvas' ? el.height : kind === 'video' ? el.videoHeight || b.height : el.naturalHeight || b.height
423
+ if (w < 48 || h < 48) return
424
+ if (b.width < 8 || b.height < 8) return
425
+ const s = getComputedStyle(el)
426
+ if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) < 0.05) return
427
+ picked.push({ el, kind, w, h, area: w * h })
428
+ }
429
+ const roots = where ? [where, ...where.querySelectorAll('*')] : [document, ...document.querySelectorAll('*')]
430
+ for (const node of roots) {
431
+ if (node === document || node === where) continue
432
+ const tag = node.tagName
433
+ if (tag === 'IMG') add(node, 'img')
434
+ else if (tag === 'CANVAS') add(node, 'canvas')
435
+ /* A paused video is the frame the person is looking at; it never arrives as
436
+ an image file, so it has to be drawn out of the element. */
437
+ else if (tag === 'VIDEO' && (node.videoWidth || 0) > 0) add(node, 'video')
438
+ }
439
+ if (where && where.tagName === 'IMG') add(where, 'img')
440
+ if (where && where.tagName === 'CANVAS') add(where, 'canvas')
441
+ if (where && where.tagName === 'VIDEO' && (where.videoWidth || 0) > 0) add(where, 'video')
442
+ picked.sort((a, b) => b.area - a.area)
443
+ /* A picture nobody can look at still has to be describable: how much of it is
444
+ one colour tells a blank chart from a chart. Sampled at 32x32, which is
445
+ enough for that and costs nothing. */
446
+ const look = async (data) => {
447
+ try {
448
+ const img = new Image()
449
+ img.src = data
450
+ await img.decode()
451
+ const board = document.createElement('canvas')
452
+ board.width = 32
453
+ board.height = 32
454
+ const pen = board.getContext('2d', { willReadFrequently: true })
455
+ if (pen === null) return null
456
+ pen.drawImage(img, 0, 0, 32, 32)
457
+ const dots = pen.getImageData(0, 0, 32, 32).data
458
+ const tally = new Map()
459
+ for (let at = 0; at < dots.length; at += 4) {
460
+ const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
461
+ tally.set(key, (tally.get(key) || 0) + 1)
462
+ }
463
+ let most = 0
464
+ for (const count of tally.values()) most = Math.max(most, count)
465
+ return { colors: tally.size, flat: Math.round((most / 1024) * 100) / 100 }
466
+ } catch {
467
+ return null
468
+ }
469
+ }
470
+ const out = []
471
+ for (const item of picked.slice(0, ${limit})) {
472
+ let data = ''
473
+ let type = 'image/png'
474
+ try {
475
+ if (item.kind === 'canvas' || item.kind === 'video') {
476
+ /* Same path for both: a video frame is copied onto a board and read as
477
+ a picture. Nothing here needs the video to be playing. */
478
+ const board = document.createElement('canvas')
479
+ board.width = Math.max(1, Math.round(item.w))
480
+ board.height = Math.max(1, Math.round(item.h))
481
+ const pen = board.getContext('2d')
482
+ pen.drawImage(item.el, 0, 0, board.width, board.height)
483
+ data = board.toDataURL('image/png')
484
+ } else {
485
+ const url = item.el.currentSrc || item.el.src || ''
486
+ const answer = await fetch(url, { credentials: 'include' })
487
+ const blob = await answer.blob()
488
+ type = blob.type || type
489
+ data = await new Promise((done, fail) => {
490
+ const reader = new FileReader()
491
+ reader.onload = () => done(String(reader.result))
492
+ reader.onerror = () => fail(new Error('unreadable'))
493
+ reader.readAsDataURL(blob)
494
+ })
495
+ }
496
+ } catch {
497
+ data = ''
498
+ }
499
+ const feel = data ? await look(data) : null
500
+ out.push({ kind: item.kind, width: item.w, height: item.h, type, data, colors: feel?.colors ?? 0, flat: feel?.flat ?? 0 })
501
+ }
502
+ return JSON.stringify({ pictures: out })
503
+ })()`
504
+ }
505
+
281
506
  /**
282
507
  * What sits on top of a control at click time, when it is not the control.
283
508
  * A cookie banner or a modal over the thing the agent meant to click is the
@@ -438,15 +663,15 @@ function routeOf(page, frame, slot) {
438
663
  * @param {any} page - sidecar page.
439
664
  * @param {string} mode - read mode.
440
665
  * @param {number} budget - characters of text to keep.
441
- * @returns {Promise<{text: string, controls: any[]}>} what the frames add.
666
+ * @returns {Promise<{text: string, controls: any[], frames: number}>} what the frames add.
442
667
  */
443
668
  async function readFrames(page, mode, budget) {
444
- if (typeof page.frameList !== 'function') return { text: '', controls: [] }
669
+ if (typeof page.frameList !== 'function') return { text: '', controls: [], frames: 0 }
445
670
  let frames = []
446
671
  try {
447
672
  frames = await page.frameList()
448
673
  } catch (error) {
449
- return { text: '', controls: [] }
674
+ return { text: '', controls: [], frames: 0 }
450
675
  }
451
676
  const children = frames.slice(1)
452
677
  const controls = []
@@ -468,7 +693,7 @@ async function readFrames(page, mode, budget) {
468
693
  continue
469
694
  }
470
695
  }
471
- return { text, controls }
696
+ return { text, controls, frames: children.length }
472
697
  }
473
698
 
474
699
  /** Decode a ref into the frame it lives in and the number that frame knows. */
@@ -521,18 +746,55 @@ export async function readPage(page, options = {}) {
521
746
  title: String(read?.title ?? page.title),
522
747
  text: String(read?.text ?? ''),
523
748
  textChars: Number(read?.textChars ?? 0),
749
+ picture: read?.picture ?? null,
524
750
  scroll: read?.scroll,
525
751
  focused: read?.focused ?? null,
526
752
  controls: Array.isArray(read?.controls) ? read.controls : [],
753
+ blind: blindSpots(read?.blind, framed.frames),
527
754
  }
528
755
  }
529
756
 
757
+ /**
758
+ * Merge what the page counted with what the frame reader managed to see.
759
+ * @param {any} said - counts from the page.
760
+ * @param {number} [read] - frames that answered.
761
+ * @returns {{pictures: number, canvases: number, videos: number, frames: number}} what text cannot show.
762
+ */
763
+ export function blindSpots(said, read) {
764
+ const n = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : 0)
765
+ /* The page knows which frames take up the screen; the frame reader knows how
766
+ many exist, tracking pixels included. The screen is what the reader is
767
+ missing, so its count wins whenever the page could count at all. */
768
+ const frames = said !== undefined && said !== null && said.frames !== undefined ? n(said.frames) : n(read)
769
+ return { pictures: n(said?.pictures), canvases: n(said?.canvases), videos: n(said?.videos), frames }
770
+ }
771
+
530
772
  /**
531
773
  * Resolve a ref into a point, at the moment of the action.
532
774
  * @param {any} page - sidecar page.
533
775
  * @param {number} ref - ref from a read.
534
776
  * @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
535
777
  */
778
+ /**
779
+ * Whether a number still means what it meant when it was read.
780
+ *
781
+ * A page can keep the same element and change its words: "Send" becomes
782
+ * "Stop", a row is reused for another order. Pressing the old number then
783
+ * does something the person never asked for.
784
+ * @param {any} known - the control the read handed out.
785
+ * @param {any} spot - what the number resolves to now.
786
+ * @returns {string} what changed, or '' when it is the same control.
787
+ */
788
+ export function differs(known, spot) {
789
+ if (known === undefined || known === null || spot === undefined || spot === null) return ''
790
+ const before = String(known.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
791
+ const now = String(spot.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
792
+ if (before === '' || now === '' || before === now) return ''
793
+ const head = Math.min(12, before.length, now.length)
794
+ if (head >= 4 && (before.slice(0, head) === now.slice(0, head) || before.includes(now.slice(0, head)) || now.includes(before.slice(0, head)))) return ''
795
+ return `that number now points at "${now}" instead of "${before}" — read the page again and use the new number.`
796
+ }
797
+
536
798
  export async function locate(page, ref) {
537
799
  const split = splitFrameRef(ref)
538
800
  let route = null
@@ -572,6 +834,10 @@ export function renderPage(read, options = {}) {
572
834
  const mode = String(options.mode ?? 'text')
573
835
  const maxChars = Number(options.maxChars ?? 6000)
574
836
  const lines = [read.url]
837
+ /* A browser error page is not a site with nothing on it: it is a site that
838
+ never arrived. Saying so is the only way the reader stops looking for the
839
+ content that would have been there. */
840
+ if (String(read.url).startsWith('chrome-error://')) lines.push('the page did not load — sidecar_nav to open the address again, or sidecar_open if the browser is gone')
575
841
  if (read.title !== '') lines.push(`# ${read.title}`)
576
842
  const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
577
843
  const hidden = read.controls.length - shown.length
@@ -591,6 +857,28 @@ export function renderPage(read, options = {}) {
591
857
  lines.push(body)
592
858
  if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
593
859
  }
860
+ if (read.blind !== undefined && read.blind !== null) {
861
+ const bits = []
862
+ if (read.blind.pictures > 0) bits.push(`${read.blind.pictures} picture(s) with no words`)
863
+ if (read.blind.canvases > 0) bits.push(`${read.blind.canvases} drawing surface(s)`)
864
+ if (read.blind.videos > 0) bits.push(`${read.blind.videos} video(s)`)
865
+ if (read.blind.frames > 0) bits.push(`${read.blind.frames} embedded frame(s)`)
866
+ /* Only when something is actually out of reach: a line saying "nothing is
867
+ hidden" on every read is a line the reader learns to skip. */
868
+ if (bits.length > 0) lines.push(`text cannot show: ${bits.join(', ')} — sidecar_grab takes the picture, sidecar_shot shows the screen`)
869
+ }
870
+ if (read.picture && Array.isArray(read.picture.missing)) {
871
+ /* Something was meant to be here and is not: say that instead of nothing. */
872
+ for (const gone of read.picture.missing.slice(0, 2)) {
873
+ const size = `${Math.round(Number(gone.width) || 0)}x${Math.round(Number(gone.height) || 0)}`
874
+ lines.push(`picture ${size}${gone.name === '' ? '' : ` "${gone.name}"`} did not load — the page may need a reload, or the picture is gone`)
875
+ }
876
+ } else if (read.picture) {
877
+ const pic = read.picture
878
+ const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
879
+ const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
880
+ lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — sidecar_grab saves it as a file`)
881
+ }
594
882
  if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
595
883
  lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
596
884
  }
@@ -634,23 +922,8 @@ export function sameThing(want, control) {
634
922
  return true
635
923
  }
636
924
 
637
- /**
638
- * The field a label belongs to, when nothing formally connects the two.
639
- *
640
- * `{field: "Email"}` used to mean "click the control that says Email". On a real
641
- * form the words live in a div above a bare input, so the click landed on the
642
- * text and the typing went wherever focus already was — the classic silent wrong
643
- * field. This finds the *editable* element the words point at: a real label, an
644
- * aria name, a placeholder, a name attribute, the text right before it, or the
645
- * text of the small wrapper it sits in.
646
- * @param {string} words - what the field is called.
647
- * @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
648
- */
649
- export function labelSpot(words) {
650
- const wanted = JSON.stringify(String(words ?? '').toLowerCase())
651
- return `(() => {
652
- const want = ${wanted}
653
- if (want === '') return JSON.stringify({})
925
+ /** The in-page half shared by `labelSpot` and `selectSpot`: shadow roots, what says what. */
926
+ const NAME_PRELUDE = `
654
927
  const roots = [document]
655
928
  for (const el of document.querySelectorAll('*')) {
656
929
  if (roots.length > 400) break
@@ -685,34 +958,125 @@ export function labelSpot(words) {
685
958
  add(el.getAttribute('aria-label'))
686
959
  add(el.getAttribute('placeholder'))
687
960
  add(el.getAttribute('title'))
688
- add(el.getAttribute('name'))
689
961
  if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
690
962
  const prev = el.previousElementSibling
691
963
  if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
692
964
  const parent = el.parentElement
693
965
  if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
966
+ /* Wrapped a couple of levels deep (a styled box hiding a real input), the
967
+ words sit before the wrapper, not before the input. */
968
+ let up = parent
969
+ for (let depth = 0; depth < 3 && up; depth += 1) {
970
+ const before = up.previousElementSibling
971
+ if (before && editable(before) !== true) add(before.innerText ?? before.textContent)
972
+ up = up.parentElement
973
+ }
974
+ /* The attribute is the last resort for a name on screen: it is often the
975
+ developer's word for the field, not the one the user reads. */
976
+ add(el.getAttribute('name'))
694
977
  return out
695
978
  }
696
- let best = null
697
- for (const root of roots) {
698
- for (const el of root.querySelectorAll('input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]')) {
699
- if (editable(el) !== true || shown(el) !== true) continue
700
- let score = 0
701
- let name = ''
702
- for (const nm of names(el)) {
703
- const low = nm.toLowerCase()
704
- if (low === want) score = Math.max(score, 3)
705
- else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
706
- else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
707
- if (score > 0 && name === '') name = nm
979
+ const squeeze = (s) => String(s).toLowerCase().replace(/\s+/g, '')
980
+ const scored = (el, want) => {
981
+ let score = 0
982
+ let name = ''
983
+ const flat = squeeze(want)
984
+ for (const nm of names(el)) {
985
+ const low = nm.toLowerCase()
986
+ if (low === want || squeeze(low) === flat) score = Math.max(score, 3)
987
+ else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
988
+ else if (flat.length >= 2 && squeeze(low).includes(flat)) score = Math.max(score, 2)
989
+ else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
990
+ if (score > 0 && name === '') name = nm
991
+ }
992
+ return { score, name }
993
+ }
994
+ const boxOf = (el) => {
995
+ for (let node = el; node; node = node.parentElement) {
996
+ const b = node.getBoundingClientRect()
997
+ if (b.width < 8 || b.height < 8) continue
998
+ const s = getComputedStyle(node)
999
+ if (s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.05) return b
1000
+ }
1001
+ return null
1002
+ }
1003
+ const bestOf = (selector, want, hiddenOk) => {
1004
+ let best = null
1005
+ for (const root of roots) {
1006
+ for (const el of root.querySelectorAll(selector)) {
1007
+ if (hiddenOk !== true && shown(el) !== true) continue
1008
+ const hit = scored(el, want)
1009
+ if (hit.score === 0) continue
1010
+ if (best === null || hit.score > best.score) best = { el, score: hit.score, name: hit.name }
708
1011
  }
709
- if (score === 0) continue
710
- if (best === null || score > best.score) best = { el, score, name }
711
1012
  }
1013
+ return best
712
1014
  }
713
- if (best === null) return JSON.stringify({})
1015
+ `
1016
+
1017
+ /**
1018
+ * The field a label belongs to, when nothing formally connects the two.
1019
+ * ...
1020
+ */
1021
+ /**
1022
+ * The field a label belongs to, when nothing formally connects the two.
1023
+ *
1024
+ * `{field: "Email"}` used to mean "click the control that says Email". On a real
1025
+ * form the words live in a div above a bare input, so the click landed on the
1026
+ * text and the typing went wherever focus already was — the classic silent wrong
1027
+ * field. This finds the *editable* element the words point at: a real label, an
1028
+ * aria name, a placeholder, a name attribute, the text right before it, or the
1029
+ * text of the small wrapper it sits in.
1030
+ * @param {string} words - what the field is called.
1031
+ * @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
1032
+ */
1033
+ export function labelSpot(words) {
1034
+ const wanted = JSON.stringify(String(words ?? '').toLowerCase())
1035
+ return `(() => {
1036
+ const want = ${wanted}
1037
+ if (want === '') return JSON.stringify({})
1038
+ ${NAME_PRELUDE}
1039
+ const selector = 'input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]'
1040
+ let best = bestOf(selector, want)
1041
+ let box = best === null ? null : boxOf(best.el)
1042
+ let behind = false
1043
+ if (best === null || box === null) {
1044
+ /* The field a date picker shows is often a transparent box over a styled
1045
+ div: the input stays out of sight, but the words still name it. */
1046
+ const under = bestOf(selector, want, true)
1047
+ const place = under === null ? null : boxOf(under.el)
1048
+ if (under !== null && place !== null) { best = under; box = place; behind = true }
1049
+ }
1050
+ if (best === null || box === null) return JSON.stringify({})
1051
+ if (editable(best.el) !== true) return JSON.stringify({})
714
1052
  best.el.scrollIntoView({ block: 'center', inline: 'nearest' })
715
- const b = best.el.getBoundingClientRect()
716
- return JSON.stringify({ x: Math.round(b.left + b.width / 2), y: Math.round(b.top + b.height / 2), name: best.name, score: best.score })
1053
+ const fresh = boxOf(best.el) ?? box
1054
+ return JSON.stringify({ x: Math.round(fresh.left + fresh.width / 2), y: Math.round(fresh.top + fresh.height / 2), name: best.name, score: best.score, behind })
1055
+ })()`
1056
+ }
1057
+
1058
+ /**
1059
+ * The dropdown the words belong to, with the wanted option already chosen.
1060
+ * @param {string} words - what the dropdown is called on screen.
1061
+ * @param {string} value - option label or value.
1062
+ * @returns {string} an expression that picks the option and says what it did.
1063
+ */
1064
+ export function selectSpot(words, value) {
1065
+ const wanted = JSON.stringify(String(words ?? '').toLowerCase())
1066
+ const wanted2 = JSON.stringify(String(value ?? ''))
1067
+ return `(() => {
1068
+ const want = ${wanted}
1069
+ const wanted = ${wanted2}
1070
+ if (want === '') return 'no dropdown was named'
1071
+ ${NAME_PRELUDE}
1072
+ const best = bestOf('select', want)
1073
+ if (best === null) return 'no dropdown says ' + JSON.stringify(want)
1074
+ const select = best.el
1075
+ const option = Array.from(select.options).find((item) => item.value === wanted || item.text.trim() === wanted || item.text.trim().toLowerCase() === wanted.toLowerCase())
1076
+ if (!option) return 'no option ' + wanted + ' in ' + best.name + '; options are ' + Array.from(select.options).slice(0, 12).map((item) => item.text.trim()).join(' | ')
1077
+ select.value = option.value
1078
+ select.dispatchEvent(new Event('input', { bubbles: true }))
1079
+ select.dispatchEvent(new Event('change', { bubbles: true }))
1080
+ return 'chose ' + option.text.trim() + ' in ' + best.name
717
1081
  })()`
718
1082
  }