@things-factory/figure-ui 10.1.181 → 10.1.185

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.
Files changed (67) hide show
  1. package/client/modeller/editor-styles.ts +28 -9
  2. package/client/modeller/editor.ts +105 -12
  3. package/client/modeller/fasten-form.ts +207 -0
  4. package/client/modeller/fasten-geometry.ts +5 -1
  5. package/client/modeller/figure-facts.ts +4 -35
  6. package/client/modeller/figure-fasten-dialog.ts +457 -0
  7. package/client/modeller/figure-fasten-drawing.ts +32 -11
  8. package/client/modeller/figure-links.ts +56 -300
  9. package/client/modeller/figure-properties.ts +23 -70
  10. package/client/modeller/modeller-keys.ts +4 -4
  11. package/dist-client/modeller/clips.d.ts +2 -2
  12. package/dist-client/modeller/editor-styles.js +28 -9
  13. package/dist-client/modeller/editor-styles.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +23 -2
  15. package/dist-client/modeller/editor.js +121 -12
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-form.d.ts +69 -0
  18. package/dist-client/modeller/fasten-form.js +131 -0
  19. package/dist-client/modeller/fasten-form.js.map +1 -0
  20. package/dist-client/modeller/fasten-geometry.d.ts +3 -1
  21. package/dist-client/modeller/fasten-geometry.js +6 -1
  22. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  23. package/dist-client/modeller/figure-facts.d.ts +2 -13
  24. package/dist-client/modeller/figure-facts.js +2 -29
  25. package/dist-client/modeller/figure-facts.js.map +1 -1
  26. package/dist-client/modeller/figure-fasten-dialog.d.ts +69 -0
  27. package/dist-client/modeller/figure-fasten-dialog.js +433 -0
  28. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -0
  29. package/dist-client/modeller/figure-fasten-drawing.d.ts +6 -3
  30. package/dist-client/modeller/figure-fasten-drawing.js +36 -10
  31. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  32. package/dist-client/modeller/figure-links.d.ts +14 -38
  33. package/dist-client/modeller/figure-links.js +58 -301
  34. package/dist-client/modeller/figure-links.js.map +1 -1
  35. package/dist-client/modeller/figure-properties.d.ts +12 -12
  36. package/dist-client/modeller/figure-properties.js +23 -72
  37. package/dist-client/modeller/figure-properties.js.map +1 -1
  38. package/dist-client/modeller/modeller-keys.js +4 -4
  39. package/dist-client/modeller/modeller-keys.js.map +1 -1
  40. package/dist-client/route.d.ts +1 -1
  41. package/dist-client/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +8 -8
  43. package/test/browser/links-page.js +4 -4
  44. package/test/browser/links.check.mjs +196 -46
  45. package/test/browser/modeller-page.js +23 -0
  46. package/test/browser/modeller.check.mjs +19 -20
  47. package/test/browser/namespace-page.js +2 -29
  48. package/test/browser/popup-stand-in.js +48 -0
  49. package/test/browser/product-page.check.mjs +60 -9
  50. package/test/browser/product-page.js +30 -33
  51. package/test/browser/property-section.check.mjs +3 -3
  52. package/test/browser/props.check.mjs +28 -7
  53. package/test/browser/screen-page.js +7 -2
  54. package/test/browser/screen.check.mjs +28 -23
  55. package/test/browser/section-page.js +13 -41
  56. package/test/browser/section.check.mjs +39 -30
  57. package/test/fasten-form.test.ts +77 -0
  58. package/test/fasten-geometry.test.ts +2 -0
  59. package/test/first-minute.test.ts +7 -4
  60. package/test/properties.test.ts +11 -19
  61. package/translations/en.json +5 -12
  62. package/translations/es.json +5 -12
  63. package/translations/ja.json +5 -12
  64. package/translations/ko.json +5 -12
  65. package/translations/ms.json +5 -12
  66. package/translations/zh.json +5 -12
  67. package/client/modeller/mounting-part.ts +0 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.181",
3
+ "version": "10.1.185",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -28,12 +28,12 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@hatiolab/figure-model": "^0.7.2",
31
- "@hatiolab/things-scene": "^10.1.91",
31
+ "@hatiolab/things-scene": "^10.1.92",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.29.39",
34
34
  "@operato/graphql": "^10.29.39",
35
35
  "@operato/i18n": "^10.0.0",
36
- "@operato/input": "^10.29.39",
36
+ "@operato/input": "^10.29.41",
37
37
  "@operato/layout": "^10.29.39",
38
38
  "@operato/markdown": "^10.23.0",
39
39
  "@operato/p13n": "^10.29.39",
@@ -42,11 +42,11 @@
42
42
  "@operato/slot-layout-editor": "^10.29.39",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.39",
45
- "@things-factory/ai-assistant": "^10.1.181",
46
- "@things-factory/figure-service": "^10.1.181",
47
- "@things-factory/figure-viewer": "^10.1.176",
48
- "@things-factory/setting-base": "^10.1.181",
45
+ "@things-factory/ai-assistant": "^10.1.183",
46
+ "@things-factory/figure-service": "^10.1.183",
47
+ "@things-factory/figure-viewer": "^10.1.185",
48
+ "@things-factory/setting-base": "^10.1.183",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "a4387dbc9c5be49ac7bd0e85a59fbcd650f47138"
51
+ "gitHead": "3cfe5c959e7e82d772f46fd280037900fa89fcaa"
52
52
  }
@@ -68,12 +68,12 @@ window.look = {
68
68
  },
69
69
  /**
70
70
  * From now on, keep what every event of this type carries: anywhere on the page, or -- `section` given -- sent by that
71
- * part panel to the editor, which it still is once the panel has left the page.
71
+ * part panel (or the fastening window: `dialog`) to the editor, which it still is once it has left the page.
72
72
  */
73
73
  hear(type, section) {
74
74
  const key = section ? `${section}:${type}` : type
75
75
  const keep = e => heard[key].push(e.detail === undefined ? null : JSON.parse(JSON.stringify(e.detail)))
76
- if (section) deep('figure-links').find(l => l.section === section).addEventListener(type, keep)
76
+ if (section) (section === 'dialog' ? deep('figure-fasten-dialog')[0] : deep('figure-links').find(l => l.section === section)).addEventListener(type, keep)
77
77
  else if (!heard[key]) document.addEventListener(type, keep, true)
78
78
  heard[key] = []
79
79
  },
@@ -102,9 +102,9 @@ window.look = {
102
102
  const small = label?.querySelector('small[fastened-to]')
103
103
  return small ? words(small) : null
104
104
  },
105
- /** An event a part's panel (`section`: shape, places or motion) sends the editor, as the panel would send it. */
105
+ /** An event a part's panel (`section`: shape, places or motion) or the fastening window (`dialog`) sends the editor, as it would send it. */
106
106
  tell(section, type, detail) {
107
- const panel = deep('figure-links').find(l => l.section === section)
107
+ const panel = section === 'dialog' ? deep('figure-fasten-dialog')[0] : deep('figure-links').find(l => l.section === section)
108
108
  panel.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
109
109
  },
110
110
  /** What the canvas tells the editor when a drag on a part ends. */
@@ -90,11 +90,19 @@ async function pick(part) {
90
90
  await until(id => probe.state().selected === id, [part], `${part} selected`)
91
91
  }
92
92
  const tab = name => read(l => probe.tab(l), `figure.label.tab-short-${name}`).then(point => click(point, `the ${name} tab`))
93
- /** A fastening the part has is said in one line; 「바꾸기」 opens its drawing and details. */
93
+ /** A fastening the part has is said in one line; 「바꾸기」 opens the fastening window on it. */
94
94
  async function changeFastening() {
95
95
  await until(() => look.all('button[change-fastening]').length === 1, [], 'the fastening said in one line')
96
96
  await (await handle('button[change-fastening]')).click()
97
- await until(() => look.all('button[change-fastening]').length === 0, [], 'the fastening opened')
97
+ await until(() => !!probe.fastenTargets(), [], 'the fastening window open')
98
+ }
99
+ /** The window's list: each row's part and words, whether chosen, whether it can be. Null while the window is closed. */
100
+ const targets = () => read(() => probe.fastenTargets())
101
+ const windowOpen = () => read(() => !!probe.fastenTargets())
102
+ /** A row of the window's list pressed, as a hand does, and waited for as the one chosen. */
103
+ async function aimAt(part) {
104
+ await click(await read(p => probe.fastenTarget(p), part), `the row of ${part} in the window`)
105
+ await until(p => probe.fastenTargets()?.some(t => t.target === p && t.selected), [part], `${part} chosen in the window`)
98
106
  }
99
107
  /** An element handle found through every shadow root. */
100
108
  const handle = (selector, test) =>
@@ -348,36 +356,50 @@ describe('a position worked out from the figure size', () => {
348
356
  })
349
357
  })
350
358
 
351
- describe('the fastening form on the robot arm', () => {
359
+ describe('the fastening window on the robot arm', () => {
352
360
  test('the upper arm opens on what it is fastened to; a part hanging from it is listed, not choosable, with the reason', async () => {
353
361
  await mount('arm')
354
362
  await pick('upper-arm')
355
363
  await changeFastening()
356
364
  const own = (await source()).joints.find(j => j.body1.part === 'upper-arm').body0.part
357
- const target = await read(() => look.select('figure.label.target-part'))
358
- assert.equal(target.value, own, 'its own fastening')
359
- const forearm = target.options.find(o => o.value === 'forearm')
360
- assert.deepEqual(forearm, { value: 'forearm', text: 'figure.text.target-would-loop', disabled: true })
365
+ const list = await targets()
366
+ assert.deepEqual(list.filter(t => t.selected).map(t => t.target), [own], 'its own fastening')
367
+ const forearm = list.find(t => t.target === 'forearm')
368
+ assert.deepEqual(forearm, { target: 'forearm', text: 'figure.text.target-would-loop', selected: false, disabled: true })
369
+ })
370
+
371
+ test('the parts hanging from it are said in one line, and the chips show the part names as they are written', async () => {
372
+ const said = await read(() => {
373
+ const root = document.querySelector('figure-fasten-dialog').shadowRoot
374
+ const chip = root.querySelector('ox-input-select-buttons').shadowRoot.querySelector('md-filter-chip')
375
+ const label = chip.shadowRoot.querySelector('.label-text') ?? chip.shadowRoot.querySelector('.label')
376
+ return { lines: root.querySelectorAll('p[loops]').length, transform: getComputedStyle(label).textTransform }
377
+ })
378
+ const hanging = (await targets()).filter(t => t.disabled).map(t => t.target)
379
+ assert.ok(hanging.length > 1, `several parts hang from the upper arm: ${hanging}`)
380
+ assert.deepEqual(said, { lines: 1, transform: 'none' })
361
381
  })
362
382
  })
363
383
 
364
- describe('a new fastening for a door leaf: seen before it is done', () => {
384
+ describe('a new fastening for a door leaf: chosen in the window, seen before it is done', () => {
365
385
  before(async () => {
366
386
  await mount('door')
367
387
  await read(() => look.hear('preview-command'))
368
388
  })
369
389
  const ghosts = () => read(() => look.drawn().filter(n => n.startsWith('__preview')))
370
390
 
371
- test('started, it asks what to fasten to, with nothing chosen', async () => {
391
+ test('started, the window opens in the standard popup, asking what to fasten to, nothing chosen', async () => {
372
392
  await pick('leaf')
373
- await press('button[act]', 'figure.button.fasten-start')
374
- /* The form is the panel's, drawn after the editor's update. */
375
- const target = await until(() => look.select('figure.label.target-part'), [], 'the fastening form')
376
- assert.deepEqual([target.value, target.options[0].text], ['', 'figure.label.target-part-placeholder'])
393
+ await press('button[fasten-start]')
394
+ const list = await until(() => probe.fastenTargets(), [], 'the fastening window')
395
+ assert.deepEqual(list.filter(t => t.selected), [], 'nothing chosen')
396
+ assert.ok(list.some(t => t.target === 'frame' && !t.disabled), 'the frame offered')
397
+ assert.deepEqual((await all('[apply]')).map(b => b.disabled), [true], 'nothing to apply yet')
398
+ assert.deepEqual(await read(() => probe.fastenPopup()), { title: 'figure.label.fastening', movable: true, backdrop: true, role: 'dialog' })
377
399
  })
378
400
 
379
401
  test('the frame chosen: the editor is told the command, and the canvas draws where the leaf would go and the face it meets', async () => {
380
- await choose('figure.label.target-part', 'frame')
402
+ await aimAt('frame')
381
403
  await until(() => look.drawn().includes('__preview:leaf') && look.drawn().includes('__target-face'), [], 'the leaf and the face drawn')
382
404
  const told = (await heard('preview-command')).filter(Boolean).at(-1)
383
405
  assert.equal(told?.action.kind, 'attach')
@@ -393,51 +415,49 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
393
415
  assert.deepEqual((await all('span[fasten-summary]')).map(s => s.text), ['figure.text.fasten-summary-gap'])
394
416
  })
395
417
 
396
- test('on another tab it is not drawn; back on the properties it is', async () => {
397
- await tab('motion')
398
- await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing drawn on the motion tab')
399
- await tab('properties')
400
- await until(() => look.drawn().includes('__preview:leaf'), [], 'drawn again')
401
- })
402
-
403
- test('a refused command clears what was drawn and says why', async () => {
404
- await read(() => look.tell('shape', 'preview-command', { action: { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'nowhere' } } }))
418
+ test('a refused command clears what was drawn and says why, in the window', async () => {
419
+ await read(() => look.tell('dialog', 'preview-command', { action: { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'nowhere' } } }))
405
420
  await until(() => look.all('[preview-words]', 'figure.text.preview-refused').length > 0, [], 'the refusal said')
406
421
  assert.deepEqual(await ghosts(), [], 'nothing of the earlier result drawn')
407
422
  })
408
423
 
409
- test('the part let go of, the panel leaving tells the editor to clear it', async () => {
410
- await read(() => look.hear('preview-command', 'shape'))
411
- await click(await read(id => probe.row(id), 'leaf'), 'the leaf row')
412
- await page().keyboard.press('Escape')
413
- await until(() => probe.state().selected === '', [], 'nothing selected')
414
- assert.deepEqual(await heard('preview-command', 'shape'), [null], 'told once: nothing to show')
424
+ test('a press outside the window closes it with nothing written, and nothing it tried is drawn', async () => {
425
+ const start = await state()
426
+ /* The motion tab, under the popup's backdrop: the press closes the popup, it does not reach the tab. */
427
+ await click(await read(l => probe.tab(l), 'figure.label.tab-short-motion'), 'outside the window')
428
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
429
+ await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing it tried drawn')
430
+ assert.equal(await read(() => look.tab()), 'figure.label.tab-short-properties', 'the press did not reach the tab')
431
+ assert.equal((await state()).revision, start.revision, 'nothing written')
415
432
  })
416
433
 
417
- test('"fasten it first" from the motion tab opens this part\'s fastening, as it is now, in view, on the properties tab', async () => {
418
- await pick('leaf')
419
- await press('button[act]', 'figure.button.fasten-start')
420
- await choose('figure.label.target-part', 'frame')
421
- /* The panel scrolled back to its head, the fastening below the fold. */
422
- const panel = await at('section[fastening]')
423
- await page().mouse.move(panel.x, panel.y)
424
- await page().mouse.wheel({ deltaY: -5000 })
425
- await until(() => !look.inView('section[fastening]'), [], 'the fastening section scrolled out of view')
434
+ test('"fasten it first" from the motion tab opens this part\'s window, on what it has, on the properties tab', async () => {
426
435
  await tab('motion')
427
436
  await click(await at('button[go-fasten]'), 'go-fasten')
428
- await until(() => look.all('section[fastening]').some(s => s.spotlit), [], 'the fastening section marked')
437
+ const list = await until(() => probe.fastenTargets(), [], 'the window open')
429
438
  assert.equal(await read(() => look.tab()), 'figure.label.tab-short-properties', 'on the properties tab, not another')
430
- assert.equal((await read(() => look.select('figure.label.target-part'))).value, '', 'the form is the fastening it has: none')
431
- await until(() => look.inView('section[fastening]'), [], 'the section in view')
439
+ assert.deepEqual(list.filter(t => t.selected), [], 'the fastening it has: none')
440
+ })
441
+
442
+ test('Esc closes the window and tells the editor to clear what it tried; the leaf stays selected', async () => {
443
+ await aimAt('frame')
444
+ await until(() => look.drawn().includes('__preview:leaf'), [], 'the leaf drawn where it would go')
445
+ await read(() => look.hear('preview-command', 'dialog'))
446
+ await page().keyboard.press('Escape')
447
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
448
+ assert.equal((await state()).selected, 'leaf', 'Esc was the window\'s, not the modeller\'s')
449
+ assert.deepEqual(await heard('preview-command', 'dialog'), [null], 'told once: nothing to show')
450
+ assert.deepEqual(await ghosts(), [])
432
451
  })
433
452
 
434
- test('the frame\'s front chosen in the drawing, fastened: the leaf keeps its place across that face', async () => {
453
+ test('the frame\'s front chosen in the drawing, 붙이기: one step, the leaf keeps its place across that face', async () => {
454
+ await pick('leaf')
455
+ await press('button[fasten-start]')
435
456
  const was = (await read(() => probe.body('leaf'))).t.map(Math.round)
436
- await choose('figure.label.target-part', 'frame')
457
+ await aimAt('frame')
437
458
  /*
438
459
  The frame's faces are offered once the drawing has the frame: a press before that is not a choice. And the press is
439
- aimed once the form has drawn what the choice brings -- the leaf where it would go, and the words above the drawing
440
- that move it down -- or it lands where the face was.
460
+ aimed once the window has drawn what the choice brings -- the leaf where it would go -- or it lands where the face was.
441
461
  */
442
462
  await until(() => look.face('front')?.pickable && look.drawn().includes('__preview:leaf'), [], 'the front face offered, the leaf drawn where it would go')
443
463
  await settled()
@@ -445,9 +465,10 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
445
465
  await click(await at('path[face="front"]'), 'the front face in the drawing')
446
466
  await until(() => look.face('front').chosen, [], 'the front face drawn as the one chosen')
447
467
  const start = await state()
448
- await click(await at('button[act]', 'figure.button.fasten'), 'fasten')
468
+ await press('[apply]')
449
469
  assert.equal((await state()).error, '', 'not refused')
450
470
  await oneStep(start)
471
+ assert.equal(await windowOpen(), false, 'the window closed')
451
472
  assert.equal((await source()).joints.find(j => j.body1.part === 'leaf').body0.face, 'front')
452
473
  const now = (await read(() => probe.body('leaf'))).t.map(Math.round)
453
474
  assert.deepEqual([now[0], now[1]], [was[0], was[1]], `left-right and height kept: was ${was}, now ${now}`)
@@ -526,6 +547,14 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
526
547
  await until(() => look.all('section[fastening] [detach-words]', 'figure.text.detach-keeps-children').length === 1, [], 'said under the fastening')
527
548
  })
528
549
 
550
+ test('the part let go of, the panel leaving tells the editor to clear the face it lit', async () => {
551
+ await read(() => look.hear('preview-command', 'shape'))
552
+ await page().keyboard.press('Escape')
553
+ await until(() => probe.state().selected === '', [], 'nothing selected')
554
+ assert.deepEqual(await heard('preview-command', 'shape'), [null], 'told once: nothing to show')
555
+ await until(() => !look.drawn().includes('__target-face'), [], 'the face no longer lit')
556
+ })
557
+
529
558
  test('a part that would stay where it stands: nothing is said under its fastening', async () => {
530
559
  await pick('lamp')
531
560
  /* Taking it off is the place group's 「⋯」 (chief architect 2026-10-16). */
@@ -639,6 +668,127 @@ async function poll(ask, ms = 5000) {
639
668
  }
640
669
  }
641
670
 
671
+ /*
672
+ * The fastening window writes once (user 2026-10-17): whatever is tried in it is a preview, 「적용」 is one step of the
673
+ * history with all of it, and 「취소」 or Esc leave the history as it was.
674
+ */
675
+ describe('the fastening window: tried freely, written once', () => {
676
+ before(async () => {
677
+ let a = fasten(box(createFigureAsset('Bench'), 'leg', [60, 700, 60], [-400, 350, 0]), 'leg', 'plane')
678
+ a = fasten(box(a, 'top', [1200, 40, 600], [0, 900, 0]), 'top', 'leg')
679
+ a = fasten(box(a, 'lamp', [80, 80, 80], [200, 960, 100]), 'lamp', 'top')
680
+ await mount('bench-window', box(a, 'loose', [100, 100, 100], [800, 50, 0]))
681
+ })
682
+ const lamp = async () => (await source()).joints.find(j => j.body1.part === 'lamp')
683
+ const lampTold = () => read(() => look.heard('preview-command').filter(Boolean).at(-1)?.action ?? null)
684
+ /* Flush, a gap of 40 and the top's front: three choices, each drawn on the canvas, none written. */
685
+ async function tryThree(start) {
686
+ await read(() => look.hear('preview-command'))
687
+ await press('button[mode="flush"]')
688
+ await click(await at('details[fasten-detail] > summary'), 'the details')
689
+ await type(await handle('details[fasten-detail] input', 'figure.label.gap'), '40')
690
+ await until(() => look.face('front')?.pickable, [], 'the front face offered')
691
+ await click(await at('path[face="front"]'), 'the front face in the drawing')
692
+ await until(() => look.heard('preview-command').filter(Boolean).at(-1)?.action.to?.face === 'front', [], 'the three told')
693
+ const told = await lampTold()
694
+ assert.deepEqual([told.facing, told.gap, told.to], ['flush', 40, { part: 'top', face: 'front' }], 'all three in the command tried')
695
+ assert.equal((await state()).revision, start.revision, 'nothing written while trying')
696
+ }
697
+
698
+ test('three choices tried, then 취소: nothing written, the window closed, nothing of them drawn', async () => {
699
+ await pick('lamp')
700
+ const was = await lamp()
701
+ await changeFastening()
702
+ const start = await state()
703
+ await tryThree(start)
704
+ await press('[cancel]')
705
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
706
+ await settled()
707
+ await sleep(300)
708
+ assert.equal((await state()).revision, start.revision, 'the history as it was')
709
+ assert.deepEqual(await lamp(), was, 'the fastening as it was')
710
+ await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing tried drawn')
711
+ })
712
+
713
+ test('Esc closes it the same way, and leaves the part selected', async () => {
714
+ await pick('lamp')
715
+ const was = await lamp()
716
+ await changeFastening()
717
+ const start = await state()
718
+ await press('button[mode="flush"]')
719
+ await page().keyboard.press('Escape')
720
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
721
+ await sleep(300)
722
+ const now = await state()
723
+ assert.deepEqual([now.revision, now.selected], [start.revision, 'lamp'], 'nothing written, the lamp still selected')
724
+ assert.deepEqual(await lamp(), was)
725
+ })
726
+
727
+ test('three choices and one 적용: one step holding all three; one undo takes all three back', async () => {
728
+ await pick('lamp')
729
+ const was = await lamp()
730
+ await changeFastening()
731
+ const start = await state()
732
+ await tryThree(start)
733
+ await press('[apply]')
734
+ await oneStep(start)
735
+ assert.equal(await windowOpen(), false, 'the window closed')
736
+ const now = await lamp()
737
+ assert.deepEqual([now.body0.face, now.origin?.facing, now.origin?.gap], ['front', 'flush', 40], JSON.stringify(now))
738
+ await page().keyboard.down('Control')
739
+ await page().keyboard.press('z')
740
+ await page().keyboard.up('Control')
741
+ await until(w => JSON.stringify(JSON.parse(document.querySelector('figure-editor').session.source()).joints.find(j => j.body1.part === 'lamp')) === w, [JSON.stringify(was)], 'one undo: the fastening as it was')
742
+ })
743
+
744
+ test('moved by the popup\'s head out of the way of the canvas: nothing written, still open', async () => {
745
+ await pick('lamp')
746
+ await changeFastening()
747
+ const rect = () => read(() => {
748
+ const r = document.querySelector('figure-fasten-dialog')?.getBoundingClientRect()
749
+ return r ? { x: Math.round(r.left), y: Math.round(r.top) } : null
750
+ })
751
+ const was = await rect()
752
+ const head = await read(() => {
753
+ const h = document.querySelector('figure-fasten-dialog').closest('ox-floating-overlay').shadowRoot.querySelector('[header] h1').getBoundingClientRect()
754
+ return { x: h.left + h.width / 2, y: h.top + h.height / 2 }
755
+ })
756
+ const start = await state()
757
+ await page().mouse.move(head.x, head.y)
758
+ await page().mouse.down()
759
+ for (let i = 1; i <= 6; i++) await page().mouse.move(head.x - 30 * i, head.y + 20 * i)
760
+ await page().mouse.up()
761
+ const moved = await rect()
762
+ assert.deepEqual([moved.x - was.x, moved.y - was.y], [-180, 120], `moved with the pointer: ${JSON.stringify([was, moved])}`)
763
+ assert.equal(await windowOpen(), true, 'still open')
764
+ assert.equal((await state()).revision, start.revision, 'nothing written')
765
+ await press('[cancel]')
766
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
767
+ })
768
+
769
+ test('the keys: the arrows move among the chips and light that part on the canvas, the pointer lights another, Space chooses, Enter applies', async () => {
770
+ await pick('lamp')
771
+ await changeFastening()
772
+ const litAt = y => Math.abs((document.querySelector('figure-editor').shadowRoot.querySelector('figure-canvas').highlight?.pose.t[1] ?? 0) - y) < 2
773
+ /* The list has the focus, on the top it is fastened to. */
774
+ await until(() => document.querySelector('figure-fasten-dialog')?.shadowRoot.querySelector('ox-input-select-buttons')?.shadowRoot.activeElement?.getAttribute('data-value') === 'top', [], 'the focus on the chosen chip')
775
+ await page().keyboard.press('ArrowLeft')
776
+ /* The leg's top, 700 high (the lit plate a little over it); moving the focus lights, it does not choose. */
777
+ await until(litAt, [702], 'the leg\'s top lit')
778
+ assert.equal((await targets()).find(t => t.selected)?.target, 'top', 'still the top chosen')
779
+ const loose = await read(() => probe.fastenTarget('loose'))
780
+ await page().mouse.move(loose.x, loose.y)
781
+ await until(litAt, [102], 'the loose box\'s top lit under the pointer')
782
+ await page().keyboard.press('Space')
783
+ await until(() => probe.fastenTargets().find(t => t.selected)?.target === 'leg', [], 'the leg chosen by Space')
784
+ const start = await state()
785
+ await page().keyboard.press('Enter')
786
+ await oneStep(start)
787
+ assert.equal((await lamp()).body0.part, 'leg', 'fastened to what was chosen')
788
+ assert.equal(await windowOpen(), false)
789
+ })
790
+ })
791
+
642
792
  describe('a motion on the motion tab', () => {
643
793
  test('named by its parts, and drawn at both ends of its range', async () => {
644
794
  await mount('arm')
@@ -4,6 +4,7 @@
4
4
  * `client/` by the runner.
5
5
  */
6
6
  import 'figure-ui-client/modeller/editor.js'
7
+ import './popup-stand-in.js'
7
8
  import { budgetOf, figureReportOf } from 'figure-ui-client/modeller/maturity.js'
8
9
  import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
9
10
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, figureWork, robotArmFigure, serializeFigureAsset } from '@hatiolab/figure-model'
@@ -200,6 +201,28 @@ window.probe = {
200
201
  fastenMode() {
201
202
  return deep('figure-fasten-drawing')[0]?.mode ?? null
202
203
  },
204
+ /** The fastening window's list of what to fasten to, each row's part and words, whether chosen, whether it can be; null while closed. */
205
+ fastenTargets() {
206
+ const dialog = deep('figure-fasten-dialog')[0]
207
+ if (!dialog) return null
208
+ const root = dialog.shadowRoot
209
+ const chips = [...(root.querySelector('ox-input-select-buttons')?.shadowRoot?.querySelectorAll('md-filter-chip') ?? [])]
210
+ return [
211
+ ...chips.map(c => ({ target: c.getAttribute('data-value'), text: c.getAttribute('label') ?? '', selected: !!c.selected, disabled: false })),
212
+ /* A part hanging from this one: not a chip, said why under them. */
213
+ ...[...root.querySelectorAll('p[loops]')].flatMap(p => p.getAttribute('targets').split(',').map(target => ({ target, text: p.textContent.replace(/\s+/g, ' ').trim(), selected: false, disabled: true })))
214
+ ]
215
+ },
216
+ /** The page point of a chip of the fastening window's list. */
217
+ fastenTarget(part) {
218
+ const chip = deep('figure-fasten-dialog')[0]?.shadowRoot.querySelector('ox-input-select-buttons')?.shadowRoot?.querySelector(`md-filter-chip[data-value="${CSS.escape(part)}"]`)
219
+ return chip ? centreOf(chip) : null
220
+ },
221
+ /** The fastening window's popup, as the standard overlay holds it: its title, whether its head moves it, its backdrop. */
222
+ fastenPopup() {
223
+ const overlay = deep('figure-fasten-dialog')[0]?.closest('ox-floating-overlay')
224
+ return overlay ? { title: overlay.title, movable: overlay.movable, backdrop: !!overlay.backdrop, role: overlay.querySelector('figure-fasten-dialog').getAttribute('role') } : null
225
+ },
203
226
  /** The page point of an input by its aria-label, or of the part name field. */
204
227
  nameField() {
205
228
  const input = deep('input[name-field]')[0]
@@ -324,17 +324,13 @@ describe('the robot arm: a drag on a part edits its joint (ADR-0093)', () => {
324
324
  })
325
325
  })
326
326
 
327
- describe('the fastening form belongs to the part it is for', () => {
327
+ describe('the fastening window belongs to the part it is for', () => {
328
328
  before(async () => {
329
329
  await mount('arm')
330
330
  })
331
331
 
332
- /* The select whose label (the i18n key: the check loads no translations) is `label`, in the parts' links form. */
333
- const select = label =>
334
- page().evaluateHandle(label => {
335
- const deep = (root = document) => [...root.querySelectorAll('select')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
336
- return deep().find(s => s.previousElementSibling?.textContent?.trim() === label) ?? null
337
- }, label)
332
+ /* What the fastening window's list has chosen: the part, or `__plane`. */
333
+ const targetChosen = () => read(() => probe.fastenTargets()?.find(t => t.selected)?.target ?? null)
338
334
  /* Where the part sits across the target face, as the face drawing holds it (the drawing sets it since 2026-10-02). */
339
335
  const placing = () =>
340
336
  read(() => {
@@ -345,13 +341,13 @@ describe('the fastening form belongs to the part it is for', () => {
345
341
 
346
342
  test('a choice made for one part is not carried to the next part picked; the next opens on its own fastening (셔터 커튼 2026-09-25, walk 1 2026-09-26)', async () => {
347
343
  await click(await read(() => probe.row('forearm')))
348
- /* A fastening the part has is said in one line; 「바꾸기」 opens it (chief architect 2026-10-16). */
344
+ /* A fastening the part has is said in one line; 「바꾸기」 opens the window on it (user 2026-10-17). */
349
345
  await click(await read(() => probe.button('change-fastening')))
350
- assert.equal(await (await select('figure.label.target-part')).evaluate(s => s?.value), 'elbow', 'the forearm opens on what it is fastened to')
346
+ assert.equal(await targetChosen(), 'elbow', 'the forearm opens on what it is fastened to')
351
347
  /* Placed off centre on the elbow's face, as a hand does: the face drawing focused, the arrows. */
352
348
  const face = await read(() => {
353
349
  const deep = (root = document) => [...root.querySelectorAll('figure-fasten-drawing')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
354
- const rect = deep()[0]?.shadowRoot.querySelector('div[where] svg > rect')
350
+ const rect = deep().find(d => d.show === 'face')?.shadowRoot.querySelector('div[where] svg > rect')
355
351
  if (!rect) return null
356
352
  rect.scrollIntoView({ block: 'center' })
357
353
  const r = rect.getBoundingClientRect()
@@ -365,9 +361,13 @@ describe('the fastening form belongs to the part it is for', () => {
365
361
  const chosen = await placing()
366
362
  assert.equal(chosen?.x, 'mm', `placed off centre for the forearm: ${JSON.stringify(chosen)}`)
367
363
  assert.ok(chosen.mm > 0)
364
+ /* The window is a popup over the work: closed (nothing written) before another part is picked. */
365
+ await page().keyboard.press('Escape')
366
+ await sleep(600)
367
+ assert.equal(await read(() => !!probe.fastenTargets()), false, 'closed by Esc')
368
368
 
369
369
  await click(await read(() => probe.row('elbow')))
370
- /* Another part opens on the line again, the form closed: 「바꾸기」 opens the elbow's own. */
370
+ /* Another part opens on the line again: 「바꾸기」 opens the elbow's own. */
371
371
  assert.ok(await read(() => probe.button('change-fastening')), 'the elbow\'s fastening said in one line')
372
372
  await click(await read(() => probe.button('change-fastening')))
373
373
  /* The elbow opens on its own fastening (walk 1, 2026-09-26), not on the forearm's choices. */
@@ -375,7 +375,7 @@ describe('the fastening form belongs to the part it is for', () => {
375
375
  const joint = JSON.parse(document.querySelector('figure-editor').session.source()).joints.find(j => j.body1.part === 'elbow')
376
376
  return { target: joint.body0.part, x: Object.keys(joint.origin?.align?.x ?? {})[0] ?? 'centre' }
377
377
  })
378
- assert.equal(await (await select('figure.label.target-part')).evaluate(s => s?.value), own.target, 'the elbow opens on what it is fastened to')
378
+ assert.equal(await targetChosen(), own.target, 'the elbow opens on what it is fastened to')
379
379
  const next = await placing()
380
380
  assert.equal(next?.target, own.target)
381
381
  assert.equal(next.x, own.x, 'and on its own placing, not the forearm\'s')
@@ -635,16 +635,13 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
635
635
  await mount('door')
636
636
  await click(await read(() => probe.row('leaf')))
637
637
  const was = await body('leaf')
638
- /* The floor is chosen as what the leaf is fastened to, in the fastening form; a new fastening waits for 붙이기. */
638
+ /* The floor is chosen as what the leaf is fastened to, in the fastening window; a new fastening waits for 붙이기. */
639
639
  await click(await centre(await find('button', 'figure.button.fasten-start')))
640
- const target = await page().evaluateHandle(() => {
641
- const deep = (root = document) => [...root.querySelectorAll('select')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
642
- return deep().find(s => s.previousElementSibling?.textContent?.trim() === 'figure.label.target-part') ?? null
643
- })
644
- assert.ok(await target.evaluate(s => !!s), 'the form asks what the leaf is fastened to')
645
- await target.select('__plane')
640
+ await sleep(300)
641
+ assert.ok(await read(() => !!probe.fastenTargets()), 'the window asks what the leaf is fastened to')
642
+ await click(await read(() => probe.fastenTarget('__plane')))
646
643
  await sleep(500)
647
- const fasten = await find('button[act]', 'figure.button.fasten')
644
+ const fasten = await find('[apply]', 'figure.button.fasten')
648
645
  const start = await state()
649
646
  await click(await centre(fasten))
650
647
  const after = await state()
@@ -880,6 +877,8 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
880
877
  /* Walk 1 (2026-09-26): the fastening form opens on the roller's own fastening -- by its centre, the drawing's way (2026-10-01). */
881
878
  await click(await read(() => probe.button('change-fastening')))
882
879
  assert.equal(await read(() => probe.fastenMode()), 'centre', 'the way it is fastened reads centre')
880
+ await page().keyboard.press('Escape')
881
+ await sleep(600)
883
882
  /* And the row's part keeps a name of its own to change. */
884
883
  const name = await read(() => probe.nameField())
885
884
  assert.equal(name.disabled, false, 'the roller can be renamed')
@@ -4,36 +4,9 @@
4
4
  */
5
5
  import 'figure-ui-client/setting-let/figure-namespace-setting-let.js'
6
6
  import 'figure-ui-client/modeller/figure-first-save.js'
7
- import { html, render } from 'lit'
7
+ import { html } from 'lit'
8
8
  import { openPopup } from '@operato/layout'
9
- import { setOpenPopupImplementation } from '@operato/popup'
10
- import '@operato/popup/ox-floating-overlay.js'
11
-
12
- /* Popups, drawn as the layout draws them (as product-page.js does): an overlay in the body, the result given when it leaves. */
13
- let popups = 0
14
- setOpenPopupImplementation((template, options = {}) => {
15
- const name = `$popup-${popups++}`
16
- const fragment = document.createDocumentFragment()
17
- render(html`<ox-floating-overlay id=${name} name=${name} .backdrop=${!!options.backdrop} .title=${options.title || ''} .size=${options.size || 'small'} .historical=${false}>${template}</ox-floating-overlay>`, fragment)
18
- document.body.appendChild(fragment)
19
- const handle = {
20
- name,
21
- close: () => {
22
- const popup = document.getElementById(name)
23
- if (!popup) return false
24
- popup.close()
25
- return true
26
- },
27
- closed: false
28
- }
29
- document.addEventListener('overlay-closed', function listener(e) {
30
- if (e.detail !== name) return
31
- document.removeEventListener('overlay-closed', listener)
32
- handle.closed = true
33
- handle.onclosed && handle.onclosed()
34
- })
35
- return handle
36
- })
9
+ import './popup-stand-in.js'
37
10
 
38
11
  let rows = [
39
12
  { id: '1', name: 'acme.example', state: 'active', isDefault: true },
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Popups drawn as the layout draws them -- an overlay of their own in the body, the result given when it leaves -- for
3
+ * the pages that have no layout to append them to. The popup's own options are passed as the layout passes them: its
4
+ * title, size, where it hovers, its backdrop, whether it closes by X or Esc and whether its head moves it.
5
+ */
6
+ import { html, render } from 'lit'
7
+ import { setOpenPopupImplementation } from '@operato/popup'
8
+ import '@operato/popup/ox-floating-overlay.js'
9
+
10
+ let popups = 0
11
+ setOpenPopupImplementation((template, options = {}) => {
12
+ const name = `$popup-${popups++}`
13
+ const fragment = document.createDocumentFragment()
14
+ render(
15
+ html`<ox-floating-overlay
16
+ id=${name}
17
+ name=${name}
18
+ .backdrop=${!!options.backdrop}
19
+ .hovering=${options.hovering}
20
+ .title=${options.title || ''}
21
+ .size=${options.size || 'small'}
22
+ .closable=${!!options.closable}
23
+ .escapable=${!!options.escapable}
24
+ .movable=${!!options.movable}
25
+ .historical=${false}
26
+ >${template}</ox-floating-overlay
27
+ >`,
28
+ fragment
29
+ )
30
+ document.body.appendChild(fragment)
31
+ const handle = {
32
+ name,
33
+ close: () => {
34
+ const popup = document.getElementById(name)
35
+ if (!popup) return false
36
+ popup.close()
37
+ return true
38
+ },
39
+ closed: false
40
+ }
41
+ document.addEventListener('overlay-closed', function listener(e) {
42
+ if (e.detail !== name) return
43
+ document.removeEventListener('overlay-closed', listener)
44
+ handle.closed = true
45
+ handle.onclosed && handle.onclosed()
46
+ })
47
+ return handle
48
+ })