@things-factory/figure-ui 10.1.180 → 10.1.183

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 (58) 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-fasten-dialog.ts +442 -0
  6. package/client/modeller/figure-fasten-drawing.ts +32 -11
  7. package/client/modeller/figure-links.ts +56 -300
  8. package/client/modeller/modeller-keys.ts +4 -4
  9. package/client/pages/figure-modeller-page.ts +17 -11
  10. package/dist-client/modeller/editor-styles.js +28 -9
  11. package/dist-client/modeller/editor-styles.js.map +1 -1
  12. package/dist-client/modeller/editor.d.ts +23 -2
  13. package/dist-client/modeller/editor.js +121 -12
  14. package/dist-client/modeller/editor.js.map +1 -1
  15. package/dist-client/modeller/fasten-form.d.ts +69 -0
  16. package/dist-client/modeller/fasten-form.js +131 -0
  17. package/dist-client/modeller/fasten-form.js.map +1 -0
  18. package/dist-client/modeller/fasten-geometry.d.ts +3 -1
  19. package/dist-client/modeller/fasten-geometry.js +6 -1
  20. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  21. package/dist-client/modeller/figure-fasten-dialog.d.ts +64 -0
  22. package/dist-client/modeller/figure-fasten-dialog.js +418 -0
  23. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -0
  24. package/dist-client/modeller/figure-fasten-drawing.d.ts +6 -3
  25. package/dist-client/modeller/figure-fasten-drawing.js +36 -10
  26. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  27. package/dist-client/modeller/figure-links.d.ts +15 -39
  28. package/dist-client/modeller/figure-links.js +58 -301
  29. package/dist-client/modeller/figure-links.js.map +1 -1
  30. package/dist-client/modeller/modeller-keys.js +4 -4
  31. package/dist-client/modeller/modeller-keys.js.map +1 -1
  32. package/dist-client/pages/figure-modeller-page.d.ts +5 -1
  33. package/dist-client/pages/figure-modeller-page.js +14 -12
  34. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  35. package/dist-client/route.d.ts +1 -1
  36. package/dist-client/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +14 -14
  38. package/test/browser/links-page.js +4 -4
  39. package/test/browser/links.check.mjs +184 -46
  40. package/test/browser/modeller-page.js +23 -0
  41. package/test/browser/modeller.check.mjs +19 -20
  42. package/test/browser/namespace-page.js +2 -29
  43. package/test/browser/popup-stand-in.js +48 -0
  44. package/test/browser/product-page.check.mjs +4 -3
  45. package/test/browser/product-page.js +19 -32
  46. package/test/browser/props.check.mjs +28 -7
  47. package/test/browser/screen.check.mjs +28 -23
  48. package/test/browser/section-page.js +13 -41
  49. package/test/browser/section.check.mjs +39 -30
  50. package/test/fasten-form.test.ts +77 -0
  51. package/test/fasten-geometry.test.ts +2 -0
  52. package/test/first-minute.test.ts +7 -4
  53. package/translations/en.json +4 -3
  54. package/translations/es.json +4 -3
  55. package/translations/ja.json +4 -3
  56. package/translations/ko.json +4 -3
  57. package/translations/ms.json +4 -3
  58. package/translations/zh.json +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.180",
3
+ "version": "10.1.183",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -30,23 +30,23 @@
30
30
  "@hatiolab/figure-model": "^0.7.2",
31
31
  "@hatiolab/things-scene": "^10.1.91",
32
32
  "@material/web": "^2.0.0",
33
- "@operato/data-grist": "^10.0.0",
34
- "@operato/graphql": "^10.29.36",
33
+ "@operato/data-grist": "^10.29.39",
34
+ "@operato/graphql": "^10.29.39",
35
35
  "@operato/i18n": "^10.0.0",
36
- "@operato/input": "^10.29.37",
37
- "@operato/layout": "^10.28.0",
36
+ "@operato/input": "^10.29.39",
37
+ "@operato/layout": "^10.29.39",
38
38
  "@operato/markdown": "^10.23.0",
39
- "@operato/p13n": "^10.0.0",
40
- "@operato/popup": "^10.23.0",
41
- "@operato/shell": "^10.0.0",
42
- "@operato/slot-layout-editor": "^10.29.25",
39
+ "@operato/p13n": "^10.29.39",
40
+ "@operato/popup": "^10.29.39",
41
+ "@operato/shell": "^10.29.39",
42
+ "@operato/slot-layout-editor": "^10.29.39",
43
43
  "@operato/styles": "^10.0.0",
44
- "@operato/utils": "^10.0.0",
45
- "@things-factory/ai-assistant": "^10.1.179",
46
- "@things-factory/figure-service": "^10.1.180",
44
+ "@operato/utils": "^10.29.39",
45
+ "@things-factory/ai-assistant": "^10.1.183",
46
+ "@things-factory/figure-service": "^10.1.183",
47
47
  "@things-factory/figure-viewer": "^10.1.176",
48
- "@things-factory/setting-base": "^10.1.179",
48
+ "@things-factory/setting-base": "^10.1.183",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "2549e50c0790c05a75781d133f5d975cff3b02f9"
51
+ "gitHead": "c36684e0db8353f5f4523ed55111df3154bae19e"
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,38 @@ 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 })
361
369
  })
362
370
  })
363
371
 
364
- describe('a new fastening for a door leaf: seen before it is done', () => {
372
+ describe('a new fastening for a door leaf: chosen in the window, seen before it is done', () => {
365
373
  before(async () => {
366
374
  await mount('door')
367
375
  await read(() => look.hear('preview-command'))
368
376
  })
369
377
  const ghosts = () => read(() => look.drawn().filter(n => n.startsWith('__preview')))
370
378
 
371
- test('started, it asks what to fasten to, with nothing chosen', async () => {
379
+ test('started, the window opens in the standard popup, asking what to fasten to, nothing chosen', async () => {
372
380
  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'])
381
+ await press('button[fasten-start]')
382
+ const list = await until(() => probe.fastenTargets(), [], 'the fastening window')
383
+ assert.deepEqual(list.filter(t => t.selected), [], 'nothing chosen')
384
+ assert.ok(list.some(t => t.target === 'frame' && !t.disabled), 'the frame offered')
385
+ assert.deepEqual((await all('[apply]')).map(b => b.disabled), [true], 'nothing to apply yet')
386
+ assert.deepEqual(await read(() => probe.fastenPopup()), { title: 'figure.label.fastening', movable: true, backdrop: true, role: 'dialog' })
377
387
  })
378
388
 
379
389
  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')
390
+ await aimAt('frame')
381
391
  await until(() => look.drawn().includes('__preview:leaf') && look.drawn().includes('__target-face'), [], 'the leaf and the face drawn')
382
392
  const told = (await heard('preview-command')).filter(Boolean).at(-1)
383
393
  assert.equal(told?.action.kind, 'attach')
@@ -393,51 +403,49 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
393
403
  assert.deepEqual((await all('span[fasten-summary]')).map(s => s.text), ['figure.text.fasten-summary-gap'])
394
404
  })
395
405
 
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' } } }))
406
+ test('a refused command clears what was drawn and says why, in the window', async () => {
407
+ await read(() => look.tell('dialog', 'preview-command', { action: { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'nowhere' } } }))
405
408
  await until(() => look.all('[preview-words]', 'figure.text.preview-refused').length > 0, [], 'the refusal said')
406
409
  assert.deepEqual(await ghosts(), [], 'nothing of the earlier result drawn')
407
410
  })
408
411
 
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')
412
+ test('a press outside the window closes it with nothing written, and nothing it tried is drawn', async () => {
413
+ const start = await state()
414
+ /* The motion tab, under the popup's backdrop: the press closes the popup, it does not reach the tab. */
415
+ await click(await read(l => probe.tab(l), 'figure.label.tab-short-motion'), 'outside the window')
416
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
417
+ await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing it tried drawn')
418
+ assert.equal(await read(() => look.tab()), 'figure.label.tab-short-properties', 'the press did not reach the tab')
419
+ assert.equal((await state()).revision, start.revision, 'nothing written')
415
420
  })
416
421
 
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')
422
+ test('"fasten it first" from the motion tab opens this part\'s window, on what it has, on the properties tab', async () => {
426
423
  await tab('motion')
427
424
  await click(await at('button[go-fasten]'), 'go-fasten')
428
- await until(() => look.all('section[fastening]').some(s => s.spotlit), [], 'the fastening section marked')
425
+ const list = await until(() => probe.fastenTargets(), [], 'the window open')
429
426
  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')
427
+ assert.deepEqual(list.filter(t => t.selected), [], 'the fastening it has: none')
428
+ })
429
+
430
+ test('Esc closes the window and tells the editor to clear what it tried; the leaf stays selected', async () => {
431
+ await aimAt('frame')
432
+ await until(() => look.drawn().includes('__preview:leaf'), [], 'the leaf drawn where it would go')
433
+ await read(() => look.hear('preview-command', 'dialog'))
434
+ await page().keyboard.press('Escape')
435
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
436
+ assert.equal((await state()).selected, 'leaf', 'Esc was the window\'s, not the modeller\'s')
437
+ assert.deepEqual(await heard('preview-command', 'dialog'), [null], 'told once: nothing to show')
438
+ assert.deepEqual(await ghosts(), [])
432
439
  })
433
440
 
434
- test('the frame\'s front chosen in the drawing, fastened: the leaf keeps its place across that face', async () => {
441
+ test('the frame\'s front chosen in the drawing, 붙이기: one step, the leaf keeps its place across that face', async () => {
442
+ await pick('leaf')
443
+ await press('button[fasten-start]')
435
444
  const was = (await read(() => probe.body('leaf'))).t.map(Math.round)
436
- await choose('figure.label.target-part', 'frame')
445
+ await aimAt('frame')
437
446
  /*
438
447
  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.
448
+ aimed once the window has drawn what the choice brings -- the leaf where it would go -- or it lands where the face was.
441
449
  */
442
450
  await until(() => look.face('front')?.pickable && look.drawn().includes('__preview:leaf'), [], 'the front face offered, the leaf drawn where it would go')
443
451
  await settled()
@@ -445,9 +453,10 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
445
453
  await click(await at('path[face="front"]'), 'the front face in the drawing')
446
454
  await until(() => look.face('front').chosen, [], 'the front face drawn as the one chosen')
447
455
  const start = await state()
448
- await click(await at('button[act]', 'figure.button.fasten'), 'fasten')
456
+ await press('[apply]')
449
457
  assert.equal((await state()).error, '', 'not refused')
450
458
  await oneStep(start)
459
+ assert.equal(await windowOpen(), false, 'the window closed')
451
460
  assert.equal((await source()).joints.find(j => j.body1.part === 'leaf').body0.face, 'front')
452
461
  const now = (await read(() => probe.body('leaf'))).t.map(Math.round)
453
462
  assert.deepEqual([now[0], now[1]], [was[0], was[1]], `left-right and height kept: was ${was}, now ${now}`)
@@ -526,6 +535,14 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
526
535
  await until(() => look.all('section[fastening] [detach-words]', 'figure.text.detach-keeps-children').length === 1, [], 'said under the fastening')
527
536
  })
528
537
 
538
+ test('the part let go of, the panel leaving tells the editor to clear the face it lit', async () => {
539
+ await read(() => look.hear('preview-command', 'shape'))
540
+ await page().keyboard.press('Escape')
541
+ await until(() => probe.state().selected === '', [], 'nothing selected')
542
+ assert.deepEqual(await heard('preview-command', 'shape'), [null], 'told once: nothing to show')
543
+ await until(() => !look.drawn().includes('__target-face'), [], 'the face no longer lit')
544
+ })
545
+
529
546
  test('a part that would stay where it stands: nothing is said under its fastening', async () => {
530
547
  await pick('lamp')
531
548
  /* Taking it off is the place group's 「⋯」 (chief architect 2026-10-16). */
@@ -639,6 +656,127 @@ async function poll(ask, ms = 5000) {
639
656
  }
640
657
  }
641
658
 
659
+ /*
660
+ * The fastening window writes once (user 2026-10-17): whatever is tried in it is a preview, 「적용」 is one step of the
661
+ * history with all of it, and 「취소」 or Esc leave the history as it was.
662
+ */
663
+ describe('the fastening window: tried freely, written once', () => {
664
+ before(async () => {
665
+ let a = fasten(box(createFigureAsset('Bench'), 'leg', [60, 700, 60], [-400, 350, 0]), 'leg', 'plane')
666
+ a = fasten(box(a, 'top', [1200, 40, 600], [0, 900, 0]), 'top', 'leg')
667
+ a = fasten(box(a, 'lamp', [80, 80, 80], [200, 960, 100]), 'lamp', 'top')
668
+ await mount('bench-window', box(a, 'loose', [100, 100, 100], [800, 50, 0]))
669
+ })
670
+ const lamp = async () => (await source()).joints.find(j => j.body1.part === 'lamp')
671
+ const lampTold = () => read(() => look.heard('preview-command').filter(Boolean).at(-1)?.action ?? null)
672
+ /* Flush, a gap of 40 and the top's front: three choices, each drawn on the canvas, none written. */
673
+ async function tryThree(start) {
674
+ await read(() => look.hear('preview-command'))
675
+ await press('button[mode="flush"]')
676
+ await click(await at('details[fasten-detail] > summary'), 'the details')
677
+ await type(await handle('details[fasten-detail] input', 'figure.label.gap'), '40')
678
+ await until(() => look.face('front')?.pickable, [], 'the front face offered')
679
+ await click(await at('path[face="front"]'), 'the front face in the drawing')
680
+ await until(() => look.heard('preview-command').filter(Boolean).at(-1)?.action.to?.face === 'front', [], 'the three told')
681
+ const told = await lampTold()
682
+ assert.deepEqual([told.facing, told.gap, told.to], ['flush', 40, { part: 'top', face: 'front' }], 'all three in the command tried')
683
+ assert.equal((await state()).revision, start.revision, 'nothing written while trying')
684
+ }
685
+
686
+ test('three choices tried, then 취소: nothing written, the window closed, nothing of them drawn', async () => {
687
+ await pick('lamp')
688
+ const was = await lamp()
689
+ await changeFastening()
690
+ const start = await state()
691
+ await tryThree(start)
692
+ await press('[cancel]')
693
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
694
+ await settled()
695
+ await sleep(300)
696
+ assert.equal((await state()).revision, start.revision, 'the history as it was')
697
+ assert.deepEqual(await lamp(), was, 'the fastening as it was')
698
+ await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'nothing tried drawn')
699
+ })
700
+
701
+ test('Esc closes it the same way, and leaves the part selected', async () => {
702
+ await pick('lamp')
703
+ const was = await lamp()
704
+ await changeFastening()
705
+ const start = await state()
706
+ await press('button[mode="flush"]')
707
+ await page().keyboard.press('Escape')
708
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
709
+ await sleep(300)
710
+ const now = await state()
711
+ assert.deepEqual([now.revision, now.selected], [start.revision, 'lamp'], 'nothing written, the lamp still selected')
712
+ assert.deepEqual(await lamp(), was)
713
+ })
714
+
715
+ test('three choices and one 적용: one step holding all three; one undo takes all three back', async () => {
716
+ await pick('lamp')
717
+ const was = await lamp()
718
+ await changeFastening()
719
+ const start = await state()
720
+ await tryThree(start)
721
+ await press('[apply]')
722
+ await oneStep(start)
723
+ assert.equal(await windowOpen(), false, 'the window closed')
724
+ const now = await lamp()
725
+ assert.deepEqual([now.body0.face, now.origin?.facing, now.origin?.gap], ['front', 'flush', 40], JSON.stringify(now))
726
+ await page().keyboard.down('Control')
727
+ await page().keyboard.press('z')
728
+ await page().keyboard.up('Control')
729
+ 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')
730
+ })
731
+
732
+ test('moved by the popup\'s head out of the way of the canvas: nothing written, still open', async () => {
733
+ await pick('lamp')
734
+ await changeFastening()
735
+ const rect = () => read(() => {
736
+ const r = document.querySelector('figure-fasten-dialog')?.getBoundingClientRect()
737
+ return r ? { x: Math.round(r.left), y: Math.round(r.top) } : null
738
+ })
739
+ const was = await rect()
740
+ const head = await read(() => {
741
+ const h = document.querySelector('figure-fasten-dialog').closest('ox-floating-overlay').shadowRoot.querySelector('[header] h1').getBoundingClientRect()
742
+ return { x: h.left + h.width / 2, y: h.top + h.height / 2 }
743
+ })
744
+ const start = await state()
745
+ await page().mouse.move(head.x, head.y)
746
+ await page().mouse.down()
747
+ for (let i = 1; i <= 6; i++) await page().mouse.move(head.x - 30 * i, head.y + 20 * i)
748
+ await page().mouse.up()
749
+ const moved = await rect()
750
+ assert.deepEqual([moved.x - was.x, moved.y - was.y], [-180, 120], `moved with the pointer: ${JSON.stringify([was, moved])}`)
751
+ assert.equal(await windowOpen(), true, 'still open')
752
+ assert.equal((await state()).revision, start.revision, 'nothing written')
753
+ await press('[cancel]')
754
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
755
+ })
756
+
757
+ 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 () => {
758
+ await pick('lamp')
759
+ await changeFastening()
760
+ const litAt = y => Math.abs((document.querySelector('figure-editor').shadowRoot.querySelector('figure-canvas').highlight?.pose.t[1] ?? 0) - y) < 2
761
+ /* The list has the focus, on the top it is fastened to. */
762
+ 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')
763
+ await page().keyboard.press('ArrowLeft')
764
+ /* The leg's top, 700 high (the lit plate a little over it); moving the focus lights, it does not choose. */
765
+ await until(litAt, [702], 'the leg\'s top lit')
766
+ assert.equal((await targets()).find(t => t.selected)?.target, 'top', 'still the top chosen')
767
+ const loose = await read(() => probe.fastenTarget('loose'))
768
+ await page().mouse.move(loose.x, loose.y)
769
+ await until(litAt, [102], 'the loose box\'s top lit under the pointer')
770
+ await page().keyboard.press('Space')
771
+ await until(() => probe.fastenTargets().find(t => t.selected)?.target === 'leg', [], 'the leg chosen by Space')
772
+ const start = await state()
773
+ await page().keyboard.press('Enter')
774
+ await oneStep(start)
775
+ assert.equal((await lamp()).body0.part, 'leg', 'fastened to what was chosen')
776
+ assert.equal(await windowOpen(), false)
777
+ })
778
+ })
779
+
642
780
  describe('a motion on the motion tab', () => {
643
781
  test('named by its parts, and drawn at both ends of its range', async () => {
644
782
  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]')].map(p => ({ target: p.getAttribute('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
+ })
@@ -265,8 +265,8 @@ describe('opening another figure', () => {
265
265
 
266
266
  const PANEL = 'figure-modeller-page >>> figure-history-panel'
267
267
  const TAB = name => `figure-editor >>> button[tab][aria-label="figure.label.tab-short-${name}"]`
268
- /** The release history opens from the bar beside save (it was a tab); pressed only when it is not open already. */
269
- const HISTORY = 'figure-editor >>> button[bar-overlay=history]'
268
+ /** The releases open from 「발행」 beside save, the bar's one way to them (user 2026-10-18); pressed only when not open already. */
269
+ const HISTORY = 'figure-editor >>> button[publish]'
270
270
  async function openHistory() {
271
271
  if ((await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY)) !== 'true') await press(HISTORY, 'the history button')
272
272
  }
@@ -296,7 +296,8 @@ describe('the history panel on the page', () => {
296
296
  await until(p => product.deep(p)[0]?.shadowRoot.activeElement?.closest('section[release]'), 'the release section focused', PANEL)
297
297
  const panel = await read(p => { const panel = product.deep(p)[0]; return { slot: panel.assignedSlot?.name, visible: panel.checkVisibility() } }, PANEL)
298
298
  assert.deepEqual(panel, { slot: 'history', visible: true })
299
- assert.equal(await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY), 'true', 'the bar\'s history pressed')
299
+ assert.equal(await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY), 'true', 'the bar\'s 「발행」 pressed')
300
+ assert.equal(await read(() => product.deep('figure-editor >>> button[bar-overlay=history]').length), 0, 'no second way to the same panel')
300
301
  })
301
302
 
302
303
  test('a released version downloads as a package by its full reference', async () => {