@things-factory/figure-ui 10.1.208 → 10.1.210

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 (89) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +74 -57
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +26 -120
  5. package/client/modeller/figure-size-editor.ts +196 -0
  6. package/client/modeller/joint-drag.ts +25 -21
  7. package/client/modeller/joint-fan.ts +4 -3
  8. package/client/modeller/links.ts +36 -24
  9. package/client/modeller/motion-ends.ts +10 -6
  10. package/client/modeller/size-response.ts +95 -0
  11. package/dist-client/modeller/data-interface.d.ts +1 -1
  12. package/dist-client/modeller/data-interface.js +5 -5
  13. package/dist-client/modeller/data-interface.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +9 -1
  15. package/dist-client/modeller/editor.js +80 -58
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-geometry.js +1 -1
  18. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -4
  20. package/dist-client/modeller/figure-links.js +26 -107
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/figure-size-editor.d.ts +27 -0
  23. package/dist-client/modeller/figure-size-editor.js +221 -0
  24. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  25. package/dist-client/modeller/joint-drag.d.ts +1 -1
  26. package/dist-client/modeller/joint-drag.js +27 -22
  27. package/dist-client/modeller/joint-drag.js.map +1 -1
  28. package/dist-client/modeller/joint-fan.js +4 -3
  29. package/dist-client/modeller/joint-fan.js.map +1 -1
  30. package/dist-client/modeller/links.d.ts +9 -6
  31. package/dist-client/modeller/links.js +33 -21
  32. package/dist-client/modeller/links.js.map +1 -1
  33. package/dist-client/modeller/motion-ends.d.ts +4 -1
  34. package/dist-client/modeller/motion-ends.js +10 -6
  35. package/dist-client/modeller/motion-ends.js.map +1 -1
  36. package/dist-client/modeller/size-response.d.ts +29 -0
  37. package/dist-client/modeller/size-response.js +99 -0
  38. package/dist-client/modeller/size-response.js.map +1 -0
  39. package/dist-client/route.d.ts +1 -1
  40. package/dist-client/tsconfig.tsbuildinfo +1 -1
  41. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  42. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  43. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  44. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  45. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  46. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  47. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  48. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  49. package/docs/multipart-response-review-2026-10-24.md +77 -0
  50. package/docs/response-authoring-2026-10-24.md +46 -0
  51. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  52. package/package.json +7 -7
  53. package/test/attachment-size-contract.test.ts +47 -0
  54. package/test/browser/acceptance-page.js +1 -1
  55. package/test/browser/detail-page.js +1 -1
  56. package/test/browser/detail-tabs-page.js +1 -1
  57. package/test/browser/editing-page.js +1 -1
  58. package/test/browser/editing.check.mjs +28 -14
  59. package/test/browser/links.check.mjs +41 -22
  60. package/test/browser/modeller-page.js +1 -1
  61. package/test/browser/modeller-revision.check.mjs +21 -7
  62. package/test/browser/modeller.check.mjs +51 -39
  63. package/test/browser/multipart-response-page.js +8 -0
  64. package/test/browser/parts.check.mjs +2 -1
  65. package/test/browser/product-page.js +1 -1
  66. package/test/browser/props.check.mjs +17 -14
  67. package/test/browser/response-page.js +12 -0
  68. package/test/browser/row-preview.check.mjs +3 -3
  69. package/test/browser/screen.check.mjs +57 -13
  70. package/test/browser/serve-response.mjs +24 -0
  71. package/test/clip-ai-proposal.test.ts +1 -1
  72. package/test/clip-consent.test.ts +2 -2
  73. package/test/command-preview.test.ts +7 -7
  74. package/test/joint-drag.test.ts +6 -6
  75. package/test/joint-frames.test.ts +33 -32
  76. package/test/joints.ts +1 -6
  77. package/test/links.test.ts +21 -20
  78. package/test/multipart-response-fixture.js +17 -0
  79. package/test/multipart-response.test.ts +37 -0
  80. package/test/refusals.test.ts +2 -2
  81. package/test/resize-with-placement.test.ts +23 -1
  82. package/test/size-response.test.ts +49 -0
  83. package/test/size-source-pairing.test.ts +66 -0
  84. package/translations/en.json +44 -10
  85. package/translations/es.json +44 -10
  86. package/translations/ja.json +44 -10
  87. package/translations/ko.json +49 -15
  88. package/translations/ms.json +44 -10
  89. package/translations/zh.json +44 -10
@@ -72,13 +72,17 @@ async function mode(which) {
72
72
  describe('work counted on the robot arm', () => {
73
73
  before(() => mount('arm'))
74
74
 
75
- /* The motion tab is drawn out of sight with the rest: the selected part's motion is evaluated at its two ends. */
76
- test('picking a part on the canvas compiles nothing; it evaluates only the two ends of the part\'s motion', async () => {
75
+ /*
76
+ The tabs are drawn out of sight with the rest: the selected part's motion is evaluated at its two ends, and the size
77
+ editor on 「배치」 asks once which of the parent's axes each of the part's own lies along, the figure at rest
78
+ (figureAxisPairsOf, ADR-0159 3) -- one evaluation for all three rows.
79
+ */
80
+ test('picking a part on the canvas compiles nothing; it evaluates the two ends of the part\'s motion and its axes at rest', async () => {
77
81
  const part = 'upper-arm'
78
82
  const point = await read(id => probe.partOnPage(id), part)
79
83
  const { before, spent } = await counted(() => click(point))
80
84
  assert.equal((await read(() => probe.state())).selected, part)
81
- assert.deepEqual(spent, { compiles: 0, evaluations: 2 })
85
+ assert.deepEqual(spent, { compiles: 0, evaluations: 3 })
82
86
  /*
83
87
  Once the screen rests, two commands are seen on copies (ADR-0097 §5.5): taking the part off (its step without the
84
88
  joint, and its result) and the fastening the form opens on (its result). Each compiles only what it makes; the
@@ -89,11 +93,18 @@ describe('work counted on the robot arm', () => {
89
93
  assert.ok(rested.compiles <= 3, JSON.stringify(rested))
90
94
  })
91
95
 
92
- /* The edit and its read are the kernel's two compiles (its version's asset is the edit's own); then the part's motion ends. */
96
+ /*
97
+ The edit and its read are the kernel's two compiles (its version's asset is the edit's own); then the part's motion
98
+ ends, and its axes paired at rest for the size editor once more on the new version.
99
+ */
93
100
  test('a value typed into its field: the edit and its read, and nothing else while it is drawn', async () => {
101
+ /* The part's measures are the 「배치」 tab's size editor (2026-10-24). */
102
+ await click(await read(() => probe.tab('figure.label.tab-arrangement')))
103
+ await settled()
94
104
  const field = await read(() => {
95
105
  const deep = (root = document) => [...root.querySelectorAll('input[type=number]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
96
- const input = deep().find(i => !i.disabled && /\.size\.|\.dimension\./.test(i.getAttribute('aria-label') ?? ''))
106
+ /* A measure of the part in the size editor. */
107
+ const input = deep().find(i => !i.disabled && / figure\.label\.size-measure$/.test(i.getAttribute('aria-label') ?? ''))
97
108
  if (!input) return null
98
109
  input.scrollIntoView({ block: 'center' })
99
110
  const r = input.getBoundingClientRect()
@@ -105,7 +116,7 @@ describe('work counted on the robot arm', () => {
105
116
  await page().keyboard.type(String(field.value + 10))
106
117
  const { spent } = await counted(() => page().keyboard.press('Enter'))
107
118
  assert.equal((await read(() => probe.state())).revision, revision + 1, `${field.label} is one step`)
108
- assert.ok(spent.compiles <= 2 && spent.evaluations <= 5, JSON.stringify(spent))
119
+ assert.ok(spent.compiles <= 2 && spent.evaluations <= 6, JSON.stringify(spent))
109
120
  })
110
121
  })
111
122
 
@@ -178,7 +189,10 @@ describe('the edit canvas and the preview stay standing', () => {
178
189
  test('a value changed beside the preview is drawn inside the scene that stands', async () => {
179
190
  await click(await read(() => probe.row('box')))
180
191
  await sleep(300)
181
- const field = await read(() => probe.labelled('box.size.y'))
192
+ /* The box's size is the 「배치」 tab's (2026-10-24). */
193
+ await click(await read(() => probe.tab('figure.label.tab-arrangement')))
194
+ await sleep(300)
195
+ const field = await read(() => probe.labelled('box Y figure.label.size-measure'))
182
196
  assert.ok(field, 'the height field')
183
197
  await page().mouse.click(field.x, field.y, { clickCount: 3 })
184
198
  await page().keyboard.type('600')
@@ -53,8 +53,11 @@ async function click(point) {
53
53
  await page().mouse.click(point.x, point.y)
54
54
  await sleep(400)
55
55
  }
56
- /* A side tab pressed, by its short name; how a part answers a size is the 「반응형」 tab's (user 2026-10-17). */
57
- const tab = async name => click(await read(l => probe.tab(l), `figure.label.tab-short-${name}`))
56
+ /*
57
+ A side tab pressed, by its short name; how a part answers a size is the 「반응형」 tab's (user 2026-10-17), named
58
+ 「배치」 (figure.label.tab-arrangement) while a part is chosen.
59
+ */
60
+ const tab = async name => click(await read(n => probe.tab(`figure.label.tab-short-${n}`) ?? (n === 'response' ? probe.tab('figure.label.tab-arrangement') : null), name))
58
61
 
59
62
  const KEYS = { translate: 'KeyW', rotate: 'KeyE', scale: 'KeyR' }
60
63
 
@@ -495,37 +498,33 @@ describe('a figure made in the modeller is given its size from its parts (user 2
495
498
  assert.deepEqual(await (await field()).evaluate(i => [i.value, i.placeholder]), ['', '∞'], 'empty, and nothing limits it')
496
499
  })
497
500
 
501
+ /*
502
+ The box's size editor on 「반응형」 (ADR-0159 3): its width made to follow -- on nothing, the figure's own width --
503
+ then kept a difference from it, and the width typed.
504
+ */
498
505
  test('a part follows the figure\'s width less a difference: chosen and typed, the width is the size less it', async () => {
499
506
  await click(await read(() => probe.partOnPage('box')))
500
507
  assert.equal((await state()).selected, 'box')
501
- const handle = (what, label) =>
502
- page().evaluateHandle(
503
- (what, label) => {
504
- const deep = (root = document) => [...root.querySelectorAll(what)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
505
- if (what === 'select') return deep().find(s => s.previousElementSibling?.textContent?.trim() === label && [...s.options].some(o => o.value === 'instance:x')) ?? null
506
- return deep().find(i => i.closest('label[inline-plus]')) ?? null
507
- },
508
- what,
509
- label
510
- )
511
- /* The way to make a side follow is folded under the advanced choice (2026-09-28): opened, as a hand does. */
512
- await click(await read(() => {
513
- const deep = (root = document) => [...root.querySelectorAll('details[resize-choice] > summary')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
514
- const summary = deep()[0]
515
- if (!summary) return null
516
- summary.scrollIntoView({ block: 'center' })
517
- const r = summary.getBoundingClientRect()
518
- return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
519
- }))
520
- const width = await handle('select', 'X')
521
- assert.ok(await width.evaluate(s => !!s), 'the width can follow the figure size')
522
- await width.select('instance:x')
523
- await sleep(600)
524
- const plus = await handle('input')
525
- assert.ok(await plus.evaluate(i => !!i), 'a following width offers its difference')
526
- await plus.evaluate(i => i.scrollIntoView({ block: 'center' }))
527
- await plus.click({ clickCount: 3 })
528
- await page().keyboard.type('-50')
508
+ const handle = selector =>
509
+ page().evaluateHandle(selector => {
510
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
511
+ return deep()[0] ?? null
512
+ }, selector)
513
+ const press = async (selector, what) => {
514
+ const element = await handle(selector)
515
+ assert.ok(await element.evaluate(e => !!e), what)
516
+ await click(await element.evaluate(e => {
517
+ e.scrollIntoView({ block: 'center' })
518
+ const r = e.getBoundingClientRect()
519
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
520
+ }))
521
+ }
522
+ await press('input[aria-label="box X figure.label.size-follows"]', 'the width can follow')
523
+ await press('div.dimension[data-axis=x] div.modes label:nth-of-type(2) input[type=radio]', 'a following width offers its difference')
524
+ const width = await handle('input[aria-label="box X figure.label.size-measure"]')
525
+ await width.evaluate(i => i.scrollIntoView({ block: 'center' }))
526
+ await width.click({ clickCount: 3 })
527
+ await page().keyboard.type('350')
529
528
  await page().keyboard.press('Tab')
530
529
  await sleep(600)
531
530
  /*
@@ -534,6 +533,11 @@ describe('a figure made in the modeller is given its size from its parts (user 2
534
533
  */
535
534
  assert.equal((await state()).error, '', 'taken')
536
535
  assert.equal((await body('box')).dimensions['size.x'], 350, 'the figure\'s 400 less 50')
536
+ const said = await read(() => {
537
+ const deep = (root = document) => [...root.querySelectorAll('div.dimension[data-axis=x] p.result')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
538
+ return deep().map(p => p.textContent.trim())
539
+ })
540
+ assert.deepEqual(said, ['figure.text.size-difference-less'], 'said as the size less a difference')
537
541
  const asked = await read(() => {
538
542
  const deep = (root = document) => [...root.querySelectorAll('ox-prompt')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
539
543
  return deep().length
@@ -622,7 +626,8 @@ describe('a motion is changed where it is (2026-09-25)', () => {
622
626
  assert.equal(after.error, '', 'not refused')
623
627
  assert.equal(after.revision, start.revision + 1, 'one step')
624
628
  const now = await jointOf('upper-arm')
625
- assert.equal(now.upperLimit, 45, 'the new limit')
629
+ /* A joint is a list of axes (ADR-0159): the form changes its first. */
630
+ assert.equal(now.motion[0].upperLimit, 45, 'the new limit')
626
631
  assert.equal(now.id, was.id, 'the same joint')
627
632
  })
628
633
  })
@@ -660,16 +665,18 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
660
665
  assert.equal(await resize.evaluate(b => b.getAttribute('aria-pressed')), 'false')
661
666
  })
662
667
 
668
+ /* The part's size is the 「반응형」 tab's size editor (2026-10-24), and what a size typed keeps is said under it. */
663
669
  test('a height typed into its field keeps the part\'s middle, and the size group says so (ADR-0140 4.1)', async () => {
664
670
  await mount('box')
665
671
  await click(await read(() => probe.partOnPage('box')))
672
+ await tab('response')
666
673
  const was = await body('box')
667
674
  const middleOf = b => b.t[1] + b.dimensions.start + b.dimensions.length / 2
668
675
  assert.ok(await read(() => {
669
676
  const deep = (root = document) => [...root.querySelectorAll('p[size-keeps]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
670
677
  return deep().some(p => p.textContent.trim() === 'figure.text.size-keeps-centre')
671
678
  }), 'said beside the size fields')
672
- const height = await find('input', 'box.size.y')
679
+ const height = await find('input', 'box Y figure.label.size-measure')
673
680
  assert.ok(await height.evaluate(i => !!i), 'the height field')
674
681
  const at = await centre(height)
675
682
  await page().mouse.click(at.x, at.y, { clickCount: 3 })
@@ -744,20 +751,24 @@ describe('the modeller made easier to find and to keep (2026-09-26 baseline)', (
744
751
  })
745
752
  })
746
753
 
754
+ /*
755
+ The one-click follow-size button is gone (2026-10-24): a side is made to follow by its own 'follows' in the size editor.
756
+ On a figure with no size, the first one declares the figure's size from its parts in the same step.
757
+ */
747
758
  describe('a part follows the figure size in one step (follow-size, 2026-09-26)', () => {
748
- test('one click on a figure with no size: its size is declared from the part, said in a line, and the part follows it', async () => {
759
+ test('a side made to follow on a figure with no size: its size is declared from the part, said in a line, and the part follows it', async () => {
749
760
  await mount('box')
750
761
  await click(await read(() => probe.row('box')))
751
762
  await tab('response')
752
763
  const at = await page().evaluate(() => {
753
- const deep = (root = document) => [...root.querySelectorAll('button[follow-size]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
764
+ const deep = (root = document) => [...root.querySelectorAll('input[aria-label="box X figure.label.size-follows"]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
754
765
  const b = deep()[0]
755
- if (!b) return null
766
+ if (!b || b.checked) return null
756
767
  b.scrollIntoView({ block: 'center' })
757
768
  const r = b.getBoundingClientRect()
758
769
  return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
759
770
  })
760
- assert.ok(at, 'the button is offered for a box that follows nothing')
771
+ assert.ok(at, 'the width of a box that follows nothing can be made to follow')
761
772
  await page().evaluate(() => {
762
773
  window.__told = []
763
774
  document.addEventListener('notify', e => window.__told.push(e.detail))
@@ -892,7 +903,7 @@ describe('a row\'s pitch made a board\'s to set (ADR-0096)', () => {
892
903
  assert.deepEqual([input.min, input.max, source.designInputs['roller.pitch']], [100, 400, 200])
893
904
  assert.deepEqual(source.joints.find(j => j.body1.part === 'roller').repeat.pitch, { from: { of: 'input', input: 'roller.pitch' } })
894
905
  assert.equal((await read(() => probe.ids())).filter(id => id.includes('"roller.placed"')).length, before, 'the row as it was')
895
- await typeInto('roller.repeat.pitch', '400')
906
+ await typeInto('figure.label.repeat-gap', '400')
896
907
  source = await read(() => probe.source())
897
908
  assert.equal(source.designInputs['roller.pitch'], 400, 'the pitch field sets the input')
898
909
  assert.ok((await read(() => probe.ids())).filter(id => id.includes('"roller.placed"')).length < before, 'fewer rollers at a wider pitch')
@@ -919,10 +930,11 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
919
930
  assert.ok(copy, 'a mark on the fourth roller is drawn')
920
931
  await click(await read(id => probe.partOnPage(id), copy))
921
932
  assert.equal((await state()).selected, 'mark', 'picked by the part it copies')
922
- assert.equal(await read(() => probe.hasInput('mark.repeat.pitch')), false, 'the mark has no row of its own to set')
933
+ /* The row's pitch field is the selected part's own: named by what it is, the centre spacing. */
934
+ assert.equal(await read(() => probe.hasInput('figure.label.repeat-gap')), false, 'the mark has no row of its own to set')
923
935
  await click(await read(() => probe.row('roller')))
924
936
  assert.equal((await state()).selected, 'roller')
925
- assert.ok(await read(() => probe.hasInput('roller.repeat.pitch')), 'the roller offers its row')
937
+ assert.ok(await read(() => probe.hasInput('figure.label.repeat-gap')), 'the roller offers its row')
926
938
  /* 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). */
927
939
  await click(await read(() => probe.button('change-fastening')))
928
940
  assert.equal(await read(() => probe.fastenMode()), 'centre', 'the way it is fastened reads centre')
@@ -0,0 +1,8 @@
1
+ import './modeller-page.js'
2
+ import { i18next } from '@operato/i18n'
3
+ import { serializeFigureAsset } from '@hatiolab/figure-model'
4
+ import { multipartResponseFixture } from '../multipart-response-fixture.js'
5
+ const ko = await fetch('/ko.json').then(r => r.json())
6
+ i18next.addResourceBundle('ko', 'translations', ko, true, true)
7
+ await i18next.changeLanguage('ko')
8
+ await window.harness.mount('box', serializeFigureAsset(multipartResponseFixture()))
@@ -76,8 +76,9 @@ async function press(...keys) {
76
76
  for (const key of keys.slice(0, -1).reverse()) await page().keyboard.up(key)
77
77
  await sleep(400)
78
78
  }
79
+ /* A side tab by its short name; with a part chosen, 「반응형」 is named 「배치」 (figure.label.tab-arrangement). */
79
80
  async function tab(name) {
80
- await click(await read(name => probe.tab(name), `figure.label.tab-short-${name}`), `the ${name} tab`)
81
+ await click(await read(n => probe.tab(`figure.label.tab-short-${n}`) ?? (n === 'response' ? probe.tab('figure.label.tab-arrangement') : null), name), `the ${name} tab`)
81
82
  }
82
83
 
83
84
  describe('the parts list: each button does what it says, one step of the session each', () => {
@@ -46,7 +46,7 @@ export function doorSource() {
46
46
  a = addFigureBox(a, { id: 'leaf', size: { x: 900, y: 1800, z: 40 }, position: { x: 0, y: 1000, z: 0 } })
47
47
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', to: { part: 'frame', face: 'top' } })
48
48
  const j = a.joints.find(k => k.body1.part === 'leaf')
49
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: 'open' } } })
49
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Y', state: { id: 'open' } }] } })
50
50
  a = editFigurePart(a, { kind: 'set-driver', state: 'open', clip: 'swing', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
51
51
  return serializeFigureAsset(a)
52
52
  }
@@ -70,8 +70,9 @@ async function press(selector, what) {
70
70
  }
71
71
 
72
72
  /**
73
- * The groups of the panel shown, top to bottom, by what each is: the part's name, the size and the place the links
74
- * draw, the look and 「더 보기」 on the properties tab; on 「반응형」 the size rules, a position that follows, the row.
73
+ * The groups of the panel shown, top to bottom, by what each is: the part's name, the place the links draw -- and its
74
+ * shape's own measures, for a shape that has any -- the look and 「더 보기」 on the properties tab; on 「반응형」 its size,
75
+ * a position that follows, the row.
75
76
  */
76
77
  const groupsInOrder = () =>
77
78
  read(() => {
@@ -80,7 +81,7 @@ const groupsInOrder = () =>
80
81
  const links = panel.querySelector('figure-links')?.shadowRoot
81
82
  const marks = {
82
83
  name: panel.querySelector('input[name-field]'),
83
- size: links?.querySelector('section[size], section[size-response]'),
84
+ size: links?.querySelector('section[size], section[size-response]') ?? panel.querySelector('section[slot=size]'),
84
85
  place: links?.querySelector('section[fastening]'),
85
86
  position: links?.querySelector('section[position-response]'),
86
87
  row: panel.querySelector('section[slot=row]') ?? links?.querySelector('section[row]'),
@@ -93,9 +94,10 @@ const groupsInOrder = () =>
93
94
  .map(([name]) => name)
94
95
  })
95
96
 
96
- /* A side tab pressed, by its name. */
97
+ /* A side tab pressed, by its name; with a part chosen, 「반응형」 is named 「배치」 (figure.label.tab-arrangement). */
97
98
  async function tab(name) {
98
- await press(`aside button[tab][aria-label="figure.label.tab-short-${name}"]`, `the ${name} tab`)
99
+ const named = `aside button[tab][aria-label="figure.label.tab-short-${name}"]`
100
+ await press(name === 'response' ? `${named}, aside button[tab][aria-label="figure.label.tab-arrangement"]` : named, `the ${name} tab`)
99
101
  await read(() => document.querySelector('figure-editor').updateComplete)
100
102
  }
101
103
 
@@ -150,10 +152,11 @@ describe('the side: six tabs, the view and the releases from the bar', () => {
150
152
  describe('a part\'s groups in one order, whatever its state', () => {
151
153
  before(() => mount('rollers'))
152
154
 
153
- test('a part fastened in a row: name, size, place, look, more; on 「반응형」 its size rules and its row', async () => {
155
+ /* A box's size is the 「배치」 tab's (2026-10-24): it has no measures of its own shape left on the properties. */
156
+ test('a part fastened in a row: name, place, look, more; on 「반응형」 its size and its row', async () => {
154
157
  await pick('roller')
155
158
  await tab('properties')
156
- assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'look', 'more'])
159
+ assert.deepEqual(await groupsInOrder(), ['name', 'place', 'look', 'more'])
157
160
  await tab('response')
158
161
  assert.deepEqual(await groupsInOrder(), ['size', 'row'])
159
162
  await tab('properties')
@@ -161,10 +164,10 @@ describe('a part\'s groups in one order, whatever its state', () => {
161
164
 
162
165
  test('a part on the plane, not in a row: the same order, the row folded where a row would be', async () => {
163
166
  await pick('bed')
164
- assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'look', 'more'])
165
- /* On the plane, its height is worked out from the figure size: the position group stands between. */
167
+ assert.deepEqual(await groupsInOrder(), ['name', 'place', 'look', 'more'])
168
+ /* On the plane, its place is its fastening's: no position worked out from the figure size is offered (2026-10-24). */
166
169
  await tab('response')
167
- assert.deepEqual(await groupsInOrder(), ['size', 'position', 'row'])
170
+ assert.deepEqual(await groupsInOrder(), ['size', 'row'])
168
171
  await tab('properties')
169
172
  })
170
173
 
@@ -221,14 +224,14 @@ describe('a group\'s 「⋯」: its rarer actions, by the mouse or the keyboard'
221
224
  assert.equal(await read(() => JSON.parse(document.querySelector('figure-editor').session.source()).joints.some(j => j.body1.part === 'bed')), false, 'taken off')
222
225
  })
223
226
 
227
+ /* The row's 「⋯」 is gone: taking a row off is a button of its own (2026-10-24). The roller's place group has one. */
224
228
  test('Esc closes the menu and nothing is done', async () => {
225
229
  await pick('roller')
226
- /* The row is the 「반응형」 tab's. */
227
- await tab('response')
230
+ await tab('properties')
228
231
  const start = await revision()
229
- await press('section[row] button[group-menu]', 'the row\'s 「⋯」')
232
+ await press('section[fastening] button[group-menu]', 'the place group\'s 「⋯」')
230
233
  await until(() => document.activeElement?.localName === 'ox-popup-menu', [], 'the menu open, with the focus')
231
- assert.deepEqual(await read(() => [...document.querySelectorAll('ox-popup-menu[active] ox-popup-menuitem')].map(i => i.getAttribute('label'))), ['figure.button.remove-repeat'])
234
+ assert.deepEqual(await read(() => [...document.querySelectorAll('ox-popup-menu[active] ox-popup-menuitem')].map(i => i.getAttribute('label'))), ['figure.button.detach'])
232
235
  await page().keyboard.press('Escape')
233
236
  await until(() => !document.querySelector('ox-popup-menu[active]'), [], 'the menu closed')
234
237
  await sleep(200)
@@ -0,0 +1,12 @@
1
+ /* Manual CUA check of the real editor, using an in-memory store and a fresh fixed-size part. */
2
+ import './modeller-page.js'
3
+ import { i18next } from '@operato/i18n'
4
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
5
+
6
+ const ko = await fetch('/ko.json').then(r => r.json())
7
+ i18next.addResourceBundle('ko', 'translations', ko, true, true)
8
+ await i18next.changeLanguage('ko')
9
+ const asset = addFigureBox(createFigureAsset('반응형 치수 검증'), {
10
+ id: 'frame', size: { x: 1500, y: 80, z: 600 }, position: { x: 0, y: 50, z: 0 }
11
+ })
12
+ await window.harness.mount('box', serializeFigureAsset(asset))
@@ -60,10 +60,10 @@ describe('a row\'s preview follows its basis', () => {
60
60
  const at = await read(() => probe.row('bin'))
61
61
  await page().mouse.click(at.x, at.y)
62
62
  await until(() => probe.state().selected === 'bin', [], 'bin selected')
63
- /* The row is the 「반응형」 tab's (user 2026-10-17). */
64
- const tab = await read(() => probe.tab('figure.label.tab-short-response'))
63
+ /* The row is the 「반응형」 tab's (user 2026-10-17), named 「배치」 while a part is chosen (figure.label.tab-arrangement). */
64
+ const tab = await read(() => probe.tab('figure.label.tab-arrangement'))
65
65
  await page().mouse.click(tab.x, tab.y)
66
- await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === 'figure.label.tab-short-response', [], 'the 반응형 tab on')
66
+ await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === 'figure.label.tab-arrangement', [], 'the 배치 tab on')
67
67
  await click('details[row-fold] > summary', {}, 'the row\'s fold')
68
68
  })
69
69
 
@@ -82,6 +82,8 @@ const tabOn = () => read(() => screenCheck.editor().shadowRoot.querySelector('bu
82
82
  const pressBar = action => press(action, a => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').shadowRoot.querySelector(`button[data-action=${a}]`), action)
83
83
  const pressTab = name => press(name, name => screenCheck.editor().shadowRoot.querySelector(`button[tab][aria-label="${name}"]`), name)
84
84
  const TAB = name => `figure.label.tab-short-${name}`
85
+ /* 「반응형」 while a part is chosen: named by what it holds for the part, its size and arrangement (2026-10-24). */
86
+ const ARRANGEMENT = 'figure.label.tab-arrangement'
85
87
  /*
86
88
  The view settings or the releases, opened from the bar over the work (they were tabs once); left open if open. The
87
89
  releases open from 「발행」, the bar's one way to them (user 2026-10-18).
@@ -226,7 +228,9 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
226
228
  assert.equal(await saveOn(), false, 'saved: the save button is dimmed')
227
229
 
228
230
  await pressRow('box')
229
- const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box.size.y'))
231
+ /* A part's size is the 「배치」 tab's (2026-10-24). */
232
+ await pressTab(ARRANGEMENT)
233
+ const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box Y figure.label.size-measure'))
230
234
  await height.click({ clickCount: 3 })
231
235
  await page().keyboard.type('600')
232
236
  await page().keyboard.press('Enter')
@@ -240,6 +244,7 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
240
244
  await until('redone', () => probe.value('box.size.y') === 600)
241
245
  await press('save', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]'))
242
246
  await until('saved: dimmed again', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[save]').disabled)
247
+ await pressTab(TAB('properties'))
243
248
 
244
249
  const width = () => read(() => Math.round(screenCheck.deep('figure-canvas')[0].getBoundingClientRect().width))
245
250
  const was = await width()
@@ -316,7 +321,7 @@ describe('the editor is drawn in the shell\'s tokens (2026-09-27)', () => {
316
321
  describe('a part chosen: each tab in its place, the figure-wide tabs give way, the list\'s root goes back', () => {
317
322
  before(() => mount('arm'))
318
323
 
319
- test('properties hold the part\'s size and place; 「반응형」 how it answers a size; capability its places; motion its motion and controls -- each under the part\'s head', async () => {
324
+ test('properties hold the part\'s place; 「반응형」 its size and how it answers one; capability its places; motion its motion and controls -- each under the part\'s head', async () => {
320
325
  await pressRow('upper-arm')
321
326
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
322
327
  const holds = () =>
@@ -334,10 +339,11 @@ describe('a part chosen: each tab in its place, the figure-wide tabs give way, t
334
339
  await pressTab(TAB('properties'))
335
340
  let now = await holds()
336
341
  assert.ok(now.subject, 'the part\'s head')
337
- assert.ok(now.links.includes('figure.label.group-size') && now.links.includes('figure.label.group-place'), `the shape's links: ${now.links}`)
342
+ /* A box's size is the 「배치」 tab's, in its size editor (2026-10-24): the properties keep its place. */
343
+ assert.ok(now.links.includes('figure.label.group-place') && !now.links.includes('figure.label.group-size'), `the shape's links: ${now.links}`)
338
344
  assert.ok(now.material, 'its material')
339
- /* How it answers a size has a tab of its own (user 2026-10-17, 「반응형」). */
340
- await pressTab(TAB('response'))
345
+ /* How it answers a size has a tab of its own (user 2026-10-17, 「반응형」), named 「배치」 while a part is chosen. */
346
+ await pressTab(ARRANGEMENT)
341
347
  now = await holds()
342
348
  assert.ok(now.subject, 'the part\'s head')
343
349
  assert.ok(now.links.includes('figure.label.group-size'), `the response's links: ${now.links}`)
@@ -726,6 +732,8 @@ describe('the camera follows a size typed far from the view (first 15 minutes, 2
726
732
  test('a width typed ten times larger fits the view again; a little wider leaves the camera where it is', async () => {
727
733
  await mount('box')
728
734
  await pressRow('box')
735
+ /* A part's size is the 「배치」 tab's (2026-10-24). */
736
+ await pressTab(ARRANGEMENT)
729
737
  await read(() => {
730
738
  const canvas = screenCheck.deep('figure-canvas')[0]
731
739
  const fit = canvas.fit.bind(canvas)
@@ -733,7 +741,7 @@ describe('the camera follows a size typed far from the view (first 15 minutes, 2
733
741
  canvas.fit = () => (window.fits++, fit())
734
742
  })
735
743
  const typeWidth = async mm => {
736
- const width = await element('the width field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box.size.x'))
744
+ const width = await element('the width field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'box X figure.label.size-measure'))
737
745
  await width.click({ clickCount: 3 })
738
746
  await page().keyboard.type(String(mm))
739
747
  await page().keyboard.press('Enter')
@@ -745,6 +753,7 @@ describe('the camera follows a size typed far from the view (first 15 minutes, 2
745
753
  assert.equal(await read(() => window.fits), 0, 'a little wider: the camera stays')
746
754
  await typeWidth(4000)
747
755
  await until('the view fitted to it', () => window.fits >= 1)
756
+ await pressTab(TAB('properties'))
748
757
  await boardsUp()
749
758
  })
750
759
  })
@@ -796,16 +805,25 @@ describe('a part\'s size row: a rule\'s numbers only where a rule sets every sid
796
805
  assert.equal(await sizeRow(), null)
797
806
  })
798
807
 
808
+ /*
809
+ A box's size is the 「배치」 tab's size editor (2026-10-24), one row a side: made to follow on every side, each row
810
+ shows the size the rule gives it, and the properties draw no size row.
811
+ */
799
812
  test('a box made to follow the figure\'s size on every side shows the size the rule gives it', async () => {
800
813
  await mount('box')
801
814
  await pressRow('box')
802
815
  await until('box chosen', () => screenCheck.editor().selected === 'box')
803
- /* Following the figure's size is the 「반응형」 tab's; the size row it gives is the properties'. */
804
- await pressTab(TAB('response'))
805
- await press('follow the figure\'s size', () => screenCheck.deep('button[follow-size]').find(b => b.checkVisibility()))
816
+ await pressTab(ARRANGEMENT)
817
+ for (const axis of ['X', 'Y', 'Z']) {
818
+ const before = await revision()
819
+ await press(`${axis} made to follow`, axis => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === `box ${axis} figure.label.size-follows`), axis)
820
+ await until(`${axis} following`, before => screenCheck.editor().session.read().revision > before, before)
821
+ }
806
822
  await until('following', () => ['x', 'y', 'z'].every(axis => !screenCheck.editor().session.read().fields.some(f => f.id === `box.size.${axis}`)))
823
+ const measures = await read(() => ['X', 'Y', 'Z'].map(axis => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === `box ${axis} figure.label.size-measure`)?.value))
824
+ assert.deepEqual(measures, ['400', '400', '400'], 'each side the size the rule gives it')
807
825
  await pressTab(TAB('properties'))
808
- assert.deepEqual(await sizeRow(), { held: 3 }, 'three sides, each held by the rule')
826
+ assert.equal(await sizeRow(), null, 'no size row on the properties')
809
827
  })
810
828
  })
811
829
 
@@ -829,7 +847,20 @@ describe('the side panel is found as it was left, per part', () => {
829
847
  }
830
848
  before(() => mount('arm'))
831
849
 
850
+ /*
851
+ The part's properties fit a tall window since its size moved to 「배치」 (2026-10-24): a shorter one gives the panel
852
+ something to scroll.
853
+ */
832
854
  test('scrolled and a fold opened on one part; another part opens at its top; back, the first is as it was left', async () => {
855
+ const viewport = page().viewport()
856
+ await page().setViewport({ ...viewport, height: 600 })
857
+ try {
858
+ await scrolledAndBack()
859
+ } finally {
860
+ await page().setViewport(viewport)
861
+ }
862
+ })
863
+ async function scrolledAndBack() {
833
864
  await pressRow('upper-arm')
834
865
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
835
866
  /* 「더 보기」 starts open on this page: folded first, so the fold opened below is one this part leaves open. */
@@ -854,7 +885,7 @@ describe('the side panel is found as it was left, per part', () => {
854
885
  await pressRow('upper-arm')
855
886
  await until('upper-arm as it was left', top => screenCheck.editor().selected === 'upper-arm' && Math.abs(screenCheck.shownPanel().scrollTop - top) <= 2, top)
856
887
  assert.deepEqual(await folds(), opened, 'its folds as they were')
857
- })
888
+ }
858
889
  })
859
890
 
860
891
  describe('the fastening window says whether it changes the fastening the part has or fastens it anew', () => {
@@ -1075,12 +1106,15 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1075
1106
 
1076
1107
  test('an edit is judged as it stands on screen: the source the server is sent is the one edited', async () => {
1077
1108
  await pressRow('left')
1078
- const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'left.size.y'))
1109
+ /* A part's size is the 「배치」 tab's (2026-10-24). */
1110
+ await pressTab(ARRANGEMENT)
1111
+ const height = await element('the height field', () => screenCheck.deep('input').find(i => i.getAttribute('aria-label') === 'left Y figure.label.size-measure'))
1079
1112
  await height.click({ clickCount: 3 })
1080
1113
  await page().keyboard.type('600')
1081
1114
  await page().keyboard.press('Enter')
1082
1115
  await until('the edit made', () => screenCheck.editor().session.read().fields.find(f => f.id === 'left.size.y')?.value === 600)
1083
1116
  await until('the edited source judged', () => product.calls().filter(c => c.field === 'inspectFigureDraft').at(-1)?.variables.source === screenCheck.editor().session.revisionNow().text)
1117
+ await pressTab(TAB('properties'))
1084
1118
  })
1085
1119
 
1086
1120
  test('a save sends the card\'s picture with the figure', async () => {
@@ -1114,7 +1148,17 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1114
1148
  }
1115
1149
  })
1116
1150
 
1151
+ /* The part's properties fit a tall window since its size moved to 「배치」 (2026-10-24): a shorter one scrolls. */
1117
1152
  test('another figure opened starts its panels clean: a part of the same name opens at its top', async () => {
1153
+ const viewport = page().viewport()
1154
+ await page().setViewport({ ...viewport, height: 600 })
1155
+ try {
1156
+ await anotherFigureClean()
1157
+ } finally {
1158
+ await page().setViewport(viewport)
1159
+ }
1160
+ })
1161
+ async function anotherFigureClean() {
1118
1162
  await open('fig-p', 'arm-clip')
1119
1163
  await pressRow('upper-arm')
1120
1164
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
@@ -1136,7 +1180,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1136
1180
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
1137
1181
  for (let i = 0; i < 3; i++) await page().waitForFunction(() => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(() => done(true)))))
1138
1182
  assert.equal(await read(() => screenCheck.shownPanel().scrollTop), 0, 'not where the other figure\'s part was left')
1139
- })
1183
+ }
1140
1184
 
1141
1185
  test('a new figure\'s AI draft puts a request in the assistant\'s input, and sends nothing', async () => {
1142
1186
  await read(() => assistant.forget())
@@ -0,0 +1,24 @@
1
+ /* Manual browser verification. Uses buildPage's FIGURE_MODEL / THINGS_SCENE overrides when provided. */
2
+ import { buildPage } from './build.mjs'
3
+ import { createServer } from 'node:http'
4
+ import { readFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { createRequire } from 'node:module'
7
+ const require = createRequire(import.meta.url)
8
+ const bundle = await buildPage(process.argv[2] ?? 'response-page.js')
9
+ const routes = new Map([
10
+ ['/page.js', [bundle, 'application/javascript']],
11
+ ['/ko.json', [new URL('../../translations/ko.json', import.meta.url), 'application/json']],
12
+ ['/icons.woff2', [require.resolve('material-symbols/material-symbols-outlined.woff2'), 'font/woff2']],
13
+ ['/workers/markdown-webworker.js', [join(dirname(bundle), 'workers/markdown-webworker.js'), 'application/javascript']]
14
+ ])
15
+ const html = `<!doctype html><html lang="ko"><meta charset="utf-8"><title>반응형 치수 — 실제 편집기 로컬 검증</title><style>
16
+ @font-face{font-family:"Material Symbols Outlined";src:url(/icons.woff2)}
17
+ html,body{margin:0;height:100%;font-family:sans-serif}body{display:flex}figure-editor{flex:1;min-width:0;min-height:0}
18
+ </style><body><script type="module" src="/page.js"></script></body></html>`
19
+ createServer((req, res) => {
20
+ const file = routes.get(req.url)
21
+ if (!file && req.url !== '/') { res.writeHead(404); res.end(); return }
22
+ res.setHeader('Content-Type', file ? file[1] : 'text/html; charset=utf-8')
23
+ res.end(file ? readFileSync(file[0]) : html)
24
+ }).listen(Number(process.env.PORT ?? 4391), '127.0.0.1', function () { console.log(`Ready http://127.0.0.1:${this.address().port}/`) })
@@ -23,7 +23,7 @@ function door(): string {
23
23
  a = addFigureBox(a, { id: 'sign', size: { x: 200, y: 100, z: 10 }, position: { x: 0, y: 2200, z: 200 } })
24
24
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', to: { part: 'frame', face: 'top' } })
25
25
  const j = a.joints!.find(k => k.body1.part === 'leaf')!
26
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: 'open' } } })
26
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Y', state: { id: 'open' } }] } })
27
27
  a = editFigurePart(a, { kind: 'set-driver', state: 'open', clip: 'swing', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
28
28
  return serializeFigureAsset(a)
29
29
  }
@@ -22,7 +22,7 @@ function doors(): string {
22
22
  a = addFigureBox(a, { id, size: { x: 900, y: 1800, z: 40 }, position: { x, y: 1000, z: 0 } })
23
23
  a = applyFigureAuthoring(a, { kind: 'attach', part: id, to: { part: 'frame', face: 'top' } })
24
24
  const j = a.joints!.find(k => k.body1.part === id)!
25
- a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: `${id}-open` } } })
25
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, motion: [{ type: 'revolute', axis: 'Y', state: { id: `${id}-open` } }] } })
26
26
  a = editFigurePart(a, { kind: 'set-driver', state: `${id}-open`, clip: `${id}-swing`, duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
27
27
  }
28
28
  return serializeFigureAsset(a)
@@ -31,7 +31,7 @@ const store = { load: async (id: string) => ({ id, source: doors() }), save: asy
31
31
  const clipsOf = (s: ReturnType<typeof createFigureProductSession>) => (parseFigureAsset(s.source()) as FigureAsset).drivers?.map(d => d.clip) ?? []
32
32
  const fixed = (s: ReturnType<typeof createFigureProductSession>, part: string) => {
33
33
  const j = (parseFigureAsset(s.source()) as FigureAsset).joints!.find(k => k.body1.part === part)!
34
- return { kind: 'set-joint', joint: { id: j.id, type: 'fixed', body0: j.body0, body1: j.body1, origin: j.origin } } as never
34
+ return { kind: 'set-joint', joint: { id: j.id, body0: j.body0, body1: j.body1, origin: j.origin } } as never
35
35
  }
36
36
 
37
37
  test('refused and named first; agreed, exactly those go in one step; undo brings them back; consent does not outlive it', async () => {