@things-factory/figure-ui 10.1.169 → 10.1.171

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 (105) hide show
  1. package/client/modeller/affected-parts.ts +11 -0
  2. package/client/modeller/canvas.ts +15 -1
  3. package/client/modeller/editor-styles.ts +98 -6
  4. package/client/modeller/editor.ts +212 -53
  5. package/client/modeller/fasten-geometry.ts +19 -0
  6. package/client/modeller/figure-fasten-drawing.ts +4 -3
  7. package/client/modeller/figure-links.ts +80 -37
  8. package/client/modeller/figure-material-panel.ts +20 -1
  9. package/client/modeller/figure-preview.ts +121 -8
  10. package/client/modeller/figure-properties.ts +1 -2
  11. package/client/modeller/figure-section-panel.ts +5 -0
  12. package/client/modeller/figure-verdict.ts +9 -2
  13. package/client/modeller/gate-findings.ts +12 -4
  14. package/client/modeller/group-menu.ts +57 -0
  15. package/client/modeller/links.ts +22 -1
  16. package/client/modeller/modeller-keys.ts +6 -0
  17. package/client/modeller/preview-size.ts +32 -0
  18. package/client/modeller/stage-model.ts +11 -0
  19. package/client/pages/figure-modeller-page.ts +8 -2
  20. package/dist-client/modeller/affected-parts.d.ts +6 -0
  21. package/dist-client/modeller/affected-parts.js +12 -0
  22. package/dist-client/modeller/affected-parts.js.map +1 -0
  23. package/dist-client/modeller/canvas.d.ts +3 -0
  24. package/dist-client/modeller/canvas.js +12 -1
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +1 -1
  27. package/dist-client/modeller/editor-styles.js +98 -6
  28. package/dist-client/modeller/editor-styles.js.map +1 -1
  29. package/dist-client/modeller/editor.d.ts +42 -1
  30. package/dist-client/modeller/editor.js +220 -51
  31. package/dist-client/modeller/editor.js.map +1 -1
  32. package/dist-client/modeller/fasten-geometry.d.ts +27 -0
  33. package/dist-client/modeller/fasten-geometry.js +14 -0
  34. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  35. package/dist-client/modeller/figure-fasten-drawing.js +4 -3
  36. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  37. package/dist-client/modeller/figure-links.d.ts +2 -0
  38. package/dist-client/modeller/figure-links.js +84 -38
  39. package/dist-client/modeller/figure-links.js.map +1 -1
  40. package/dist-client/modeller/figure-material-panel.d.ts +7 -0
  41. package/dist-client/modeller/figure-material-panel.js +24 -1
  42. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  43. package/dist-client/modeller/figure-preview.d.ts +12 -0
  44. package/dist-client/modeller/figure-preview.js +120 -5
  45. package/dist-client/modeller/figure-preview.js.map +1 -1
  46. package/dist-client/modeller/figure-properties.js +1 -2
  47. package/dist-client/modeller/figure-properties.js.map +1 -1
  48. package/dist-client/modeller/figure-section-panel.js +5 -0
  49. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  50. package/dist-client/modeller/figure-verdict.d.ts +1 -0
  51. package/dist-client/modeller/figure-verdict.js +8 -2
  52. package/dist-client/modeller/figure-verdict.js.map +1 -1
  53. package/dist-client/modeller/gate-findings.d.ts +3 -1
  54. package/dist-client/modeller/gate-findings.js +8 -4
  55. package/dist-client/modeller/gate-findings.js.map +1 -1
  56. package/dist-client/modeller/group-menu.d.ts +18 -0
  57. package/dist-client/modeller/group-menu.js +52 -0
  58. package/dist-client/modeller/group-menu.js.map +1 -0
  59. package/dist-client/modeller/links.d.ts +5 -0
  60. package/dist-client/modeller/links.js +21 -1
  61. package/dist-client/modeller/links.js.map +1 -1
  62. package/dist-client/modeller/modeller-keys.js +7 -0
  63. package/dist-client/modeller/modeller-keys.js.map +1 -1
  64. package/dist-client/modeller/preview-size.d.ts +23 -0
  65. package/dist-client/modeller/preview-size.js +32 -0
  66. package/dist-client/modeller/preview-size.js.map +1 -0
  67. package/dist-client/modeller/stage-model.d.ts +11 -0
  68. package/dist-client/modeller/stage-model.js +11 -0
  69. package/dist-client/modeller/stage-model.js.map +1 -1
  70. package/dist-client/pages/figure-modeller-page.d.ts +3 -0
  71. package/dist-client/pages/figure-modeller-page.js +11 -2
  72. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  73. package/dist-client/route.d.ts +1 -1
  74. package/dist-client/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +8 -8
  76. package/test/browser/editing-page.js +3 -1
  77. package/test/browser/editing.check.mjs +2 -1
  78. package/test/browser/links-page.js +1 -0
  79. package/test/browser/links.check.mjs +20 -5
  80. package/test/browser/modeller.check.mjs +9 -1
  81. package/test/browser/more-open.js +11 -0
  82. package/test/browser/parts.check.mjs +11 -1
  83. package/test/browser/places-mesh.check.mjs +9 -3
  84. package/test/browser/product-page.check.mjs +9 -4
  85. package/test/browser/props.check.mjs +224 -0
  86. package/test/browser/screen-page.js +18 -0
  87. package/test/browser/screen.check.mjs +172 -29
  88. package/test/browser/section-page.js +1 -0
  89. package/test/browser/section.check.mjs +6 -0
  90. package/test/command-preview.test.ts +2 -2
  91. package/test/fasten-geometry.test.ts +15 -0
  92. package/test/fields.test.ts +4 -2
  93. package/test/first-minute.test.ts +15 -2
  94. package/test/layout.test.ts +1 -1
  95. package/test/preview-size.test.ts +33 -0
  96. package/test/refusals.test.ts +1 -1
  97. package/test/resize-with-placement.test.ts +1 -1
  98. package/test/stage-model.test.ts +11 -1
  99. package/test/verdict.test.ts +7 -0
  100. package/translations/en.json +37 -27
  101. package/translations/es.json +18 -8
  102. package/translations/ja.json +19 -9
  103. package/translations/ko.json +50 -40
  104. package/translations/ms.json +19 -9
  105. package/translations/zh.json +19 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.169",
3
+ "version": "10.1.171",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "npm run build:server && npm run build:client",
20
20
  "test": "FIGURE_FREEZE_COMPILED=1 node --import ./test/resolve-ts-siblings.mjs --test test/*.test.ts",
21
- "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs",
21
+ "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs test/browser/props.check.mjs",
22
22
  "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
23
23
  "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
24
24
  "build:server": "npx tsc --p ./server/tsconfig.json",
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.7.1",
31
- "@hatiolab/things-scene": "^10.1.90",
30
+ "@hatiolab/figure-model": "^0.7.2",
31
+ "@hatiolab/things-scene": "^10.1.91",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -41,11 +41,11 @@
41
41
  "@operato/slot-layout-editor": "^10.29.25",
42
42
  "@operato/styles": "^10.0.0",
43
43
  "@operato/utils": "^10.0.0",
44
- "@things-factory/ai-assistant": "^10.1.169",
45
- "@things-factory/figure-service": "^10.1.169",
46
- "@things-factory/figure-viewer": "^10.1.169",
44
+ "@things-factory/ai-assistant": "^10.1.170",
45
+ "@things-factory/figure-service": "^10.1.171",
46
+ "@things-factory/figure-viewer": "^10.1.171",
47
47
  "@things-factory/setting-base": "^10.1.167",
48
48
  "three": "^0.185.1"
49
49
  },
50
- "gitHead": "9d71d02b443f3e0080b25de1768b982c51a64111"
50
+ "gitHead": "e88536db0bc3cbb759fe58f9efeef66a67fef2c3"
51
51
  }
@@ -4,6 +4,7 @@
4
4
  * what a person sees of them: a part as the canvas draws it now, the bar's gizmo tools, the app's notices and prompts.
5
5
  * Only reading: every change the check makes goes through the mouse and the keyboard.
6
6
  */
7
+ import './more-open.js'
7
8
  import './modeller-page.js'
8
9
  import { i18next } from '@operato/i18n'
9
10
  import { Raycaster, Vector2 } from 'three'
@@ -87,8 +88,9 @@ window.editing = {
87
88
  return { at: [left, top, zPos].map(Math.round), size: [width, depth, height].map(Math.round), turn: [rotationX, rotation, rotationY].map(v => Math.round((v ?? 0) * 1000) / 1000), glow: glow ? glow.intensity : 0 }
88
89
  },
89
90
  /** The bar's move, turn and resize tools: pressed, offered, and what their tooltip says. */
91
+ /** The bar's move, turn and resize tools: the gizmo's group, not the bar's other icon buttons (the view, the history). */
90
92
  tools() {
91
- return deep('button[tool]').map(b => ({ label: b.getAttribute('aria-label'), title: b.title, disabled: b.disabled, pressed: b.getAttribute('aria-pressed') === 'true', ...centreOf(b) }))
93
+ return deep('.gizmo-tools button[tool]').map(b => ({ label: b.getAttribute('aria-label'), title: b.title, disabled: b.disabled, pressed: b.getAttribute('aria-pressed') === 'true', ...centreOf(b) }))
92
94
  },
93
95
  /** The labels of the number fields on the properties tab. */
94
96
  propertyInputs() {
@@ -293,7 +293,8 @@ describe('the material: colours by purpose, one step each, a glow drawn while dr
293
293
  test('a drum offers the four common segment counts as outlines, the rest folded away', async () => {
294
294
  await pick('drum')
295
295
  const offered = await read(() => {
296
- const panel = [...document.querySelector('figure-editor').renderRoot.querySelectorAll('figure-material-panel')][0].shadowRoot
296
+ /* Segments are in the material's rarer half, in 「더 보기」. */
297
+ const panel = document.querySelector('figure-editor').renderRoot.querySelector('figure-material-panel[show="more"]').shadowRoot
297
298
  const free = panel.querySelector('details[free]')
298
299
  return {
299
300
  common: [...panel.querySelectorAll('div[segments] > button[seg]')].map(b => !!b.querySelector('svg polygon')),
@@ -3,6 +3,7 @@
3
3
  * an element found through every shadow root by what it says, what the canvas draws, what the panels tell the editor.
4
4
  * Only reading, apart from `tell`: the event a panel sends the editor, for a command no control on the page offers.
5
5
  */
6
+ import './more-open.js'
6
7
  import './modeller-page.js'
7
8
 
8
9
  function deep(selector, root = document) {
@@ -90,6 +90,17 @@ 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
+ /** What the figure does is a folded group of the properties (chief architect 2026-10-16): its fold opened, as a person opens it. */
94
+ async function openCapability() {
95
+ const summary = await handle('section[capability] > details > summary')
96
+ if (!(await summary.evaluate(s => s.parentElement.open))) await summary.click()
97
+ }
98
+ /** A fastening the part has is said in one line; 「바꾸기」 opens its drawing and details. */
99
+ async function changeFastening() {
100
+ await until(() => look.all('button[change-fastening]').length === 1, [], 'the fastening said in one line')
101
+ await (await handle('button[change-fastening]')).click()
102
+ await until(() => look.all('button[change-fastening]').length === 0, [], 'the fastening opened')
103
+ }
93
104
  /** An element handle found through every shadow root. */
94
105
  const handle = (selector, test) =>
95
106
  page().evaluateHandle(
@@ -202,7 +213,9 @@ describe('a part made to follow the figure size, and let go of it', () => {
202
213
  await click(await at('button[follow-size]'), 'follow-size')
203
214
  await oneStep(start)
204
215
  await until(() => !look.at('button[follow-size]'), [], 'the button gone once every side follows')
205
- assert.equal((await all('p[hint]', 'figure.text.resize-all-following')).length, 1, 'said that every side follows')
216
+ /* Each side's row says it follows; no sentence under them says it again (user 2026-10-16). */
217
+ assert.equal((await all('p[hint]', 'figure.text.resize-all-following')).length, 0, 'no sentence repeats the rows')
218
+ assert.equal((await all('div[resize-now] span', 'figure.label.resize-following')).length, 3, 'every side said to follow, once')
206
219
  const told = (await heard('notify')).filter(n => n?.level === 'info' && String(n.message).includes('figure.text.size-declared-to-follow'))
207
220
  assert.deepEqual(told, [], 'the figure had its size: no line says it was declared')
208
221
  })
@@ -291,7 +304,7 @@ describe('holding places on a part', () => {
291
304
  test('a place needs kinds it takes; a figure that holds nothing says so, and declaring it holds things is one step', async () => {
292
305
  await mount('box')
293
306
  await pick('box')
294
- await tab('capability')
307
+ await openCapability()
295
308
  assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, true, 'no kinds: no place')
296
309
  await type(await handle('input[placeholder="BOX, TOTE"]'), 'BOX')
297
310
  assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, false, 'a kind: a place may be made')
@@ -327,6 +340,7 @@ describe('the fastening form on the robot arm', () => {
327
340
  test('the upper arm opens on what it is fastened to; a part hanging from it is listed, not choosable, with the reason', async () => {
328
341
  await mount('arm')
329
342
  await pick('upper-arm')
343
+ await changeFastening()
330
344
  const own = (await source()).joints.find(j => j.body1.part === 'upper-arm').body0.part
331
345
  const target = await read(() => look.select('figure.label.target-part'))
332
346
  assert.equal(target.value, own, 'its own fastening')
@@ -497,12 +511,13 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
497
511
  test('a fastened part picked: the face it is fastened to is lit, and taking it off is said to keep what hangs from it', async () => {
498
512
  await pick('top')
499
513
  await until(() => look.drawn().includes('__target-face'), [], 'the leg\'s top lit')
500
- await until(() => look.all('div[fastened] [detach-words]', 'figure.text.detach-keeps-children').length === 1, [], 'said beside the detach button')
514
+ await until(() => look.all('section[fastening] [detach-words]', 'figure.text.detach-keeps-children').length === 1, [], 'said under the fastening')
501
515
  })
502
516
 
503
- test('a part that would stay where it stands: nothing is said beside the detach button', async () => {
517
+ test('a part that would stay where it stands: nothing is said under its fastening', async () => {
504
518
  await pick('lamp')
505
- await until(() => look.all('section[fastening] button[act]', 'figure.button.detach').length === 1, [], 'the lamp\'s detach button')
519
+ /* Taking it off is the place group's 「⋯」 (chief architect 2026-10-16). */
520
+ await until(() => look.all('section[fastening] button[group-menu]').length === 1, [], 'the lamp\'s place menu')
506
521
  /* What detaching would do is worked out once the figure rests (150 ms after the pick). */
507
522
  await sleep(500)
508
523
  assert.deepEqual(await all('[detach-words]'), [])
@@ -343,6 +343,8 @@ describe('the fastening form belongs to the part it is for', () => {
343
343
 
344
344
  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 () => {
345
345
  await click(await read(() => probe.row('forearm')))
346
+ /* A fastening the part has is said in one line; 「바꾸기」 opens it (chief architect 2026-10-16). */
347
+ await click(await read(() => probe.button('change-fastening')))
346
348
  assert.equal(await (await select('figure.label.target-part')).evaluate(s => s?.value), 'elbow', 'the forearm opens on what it is fastened to')
347
349
  /* Placed off centre on the elbow's face, as a hand does: the face drawing focused, the arrows. */
348
350
  const face = await read(() => {
@@ -363,6 +365,9 @@ describe('the fastening form belongs to the part it is for', () => {
363
365
  assert.ok(chosen.mm > 0)
364
366
 
365
367
  await click(await read(() => probe.row('elbow')))
368
+ /* Another part opens on the line again, the form closed: 「바꾸기」 opens the elbow's own. */
369
+ assert.ok(await read(() => probe.button('change-fastening')), 'the elbow\'s fastening said in one line')
370
+ await click(await read(() => probe.button('change-fastening')))
366
371
  /* The elbow opens on its own fastening (walk 1, 2026-09-26), not on the forearm's choices. */
367
372
  const own = await read(() => {
368
373
  const joint = JSON.parse(document.querySelector('figure-editor').session.source()).joints.find(j => j.body1.part === 'elbow')
@@ -712,7 +717,9 @@ describe('a part follows the figure size in one step (follow-size, 2026-09-26)',
712
717
  assert.equal(after.revision, start.revision + 1, 'one step')
713
718
  assert.deepEqual(await read(() => probe.fields('size.')), { 'size.x': 400, 'size.y': 400, 'size.z': 400 }, 'the size is the box as it stands')
714
719
  const notice = await read(() => (window.__told ?? []).filter(n => n.level === 'info').at(-1)?.message ?? null)
715
- assert.ok(notice?.includes('figure.text.size-declared-to-follow'), `a line says so: ${notice}`)
720
+ /* The box had no space declared either: the same step declared it, and the one line says both (ruling 2026-10-16, Q1 A). */
721
+ assert.ok(notice?.includes('figure.text.size-and-space-declared-to-follow'), `a line says so: ${notice}`)
722
+ assert.equal(await read(() => !!document.querySelector('figure-editor').session.revisionNow().asset.occupancy), true, 'the space declared by the same step')
716
723
  const grown = await read(() => {
717
724
  const editor = document.querySelector('figure-editor')
718
725
  editor.session.apply([{ kind: 'set-design', input: 'size.x', unit: 'mm', value: 800 }])
@@ -866,6 +873,7 @@ describe('a row that carries a part (ADR-0093, 2026-09-26)', () => {
866
873
  assert.equal((await state()).selected, 'roller')
867
874
  assert.ok(await read(() => probe.hasInput('roller.repeat.pitch')), 'the roller offers its row')
868
875
  /* 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). */
876
+ await click(await read(() => probe.button('change-fastening')))
869
877
  assert.equal(await read(() => probe.fastenMode()), 'centre', 'the way it is fastened reads centre')
870
878
  /* And the row's part keeps a name of its own to change. */
871
879
  const name = await read(() => probe.nameField())
@@ -0,0 +1,11 @@
1
+ /*
2
+ * The properties' 「더 보기」 open, as a person who opened it once finds it: the checks that drive what is inside it (the
3
+ * section, its inside, glow, segments) reach it the way that person does. Imported first by those checks' pages, before
4
+ * the editor is made; the acceptance page a person looks at does not, and opens as a new person's would. The fold's own
5
+ * default and its memory are props.check.mjs's.
6
+ */
7
+ try {
8
+ localStorage.setItem('figure-properties-more-open', '1')
9
+ } catch {
10
+ /* A browser that keeps nothing opens the editor with the fold closed; the checks inside it then say so. */
11
+ }
@@ -344,8 +344,18 @@ describe('the long explanations open from their 「?」 beside the title, closed
344
344
  await mount('box', 'doors')
345
345
  await pick('left')
346
346
  const seen = new Set()
347
- for (const name of ['properties', 'capability', 'motion']) {
347
+ for (const name of ['properties', 'motion']) {
348
348
  await tab(name)
349
+ /* What the figure does is a folded group of the properties now (chief architect 2026-10-16): opened as a hand opens it. */
350
+ if (name === 'properties') {
351
+ const fold = await read(() => {
352
+ const summary = document.querySelector('figure-editor').renderRoot.querySelector('div[panel]:not([off]) section[capability] > details > summary')
353
+ summary.scrollIntoView({ block: 'center' })
354
+ const r = summary.getBoundingClientRect()
355
+ return { x: r.left + 20, y: r.top + r.height / 2 }
356
+ })
357
+ await click(fold, 'the capability fold')
358
+ }
349
359
  const helps = await read(() =>
350
360
  [...document.querySelector('figure-editor').renderRoot.querySelectorAll('figure-links, figure-clips')].flatMap((element, host) =>
351
361
  [...element.shadowRoot.querySelectorAll('button[help]')].map((b, i) => ({ host, i, seen: b.getClientRects().length > 0 })).filter(b => b.seen)
@@ -198,9 +198,15 @@ describe('places made by a rule: the shared slot layout editor on a part\'s face
198
198
  before(async () => {
199
199
  await mount(addFigureBox(figure('Stacker'), { id: 'fork', size: { x: 1200, y: 50, z: 900 }, position: { x: 0, y: 25, z: 0 } }))
200
200
  await pick('fork')
201
- const tab = await read(l => probe.tab(l), 'figure.label.tab-short-capability')
202
- await page().mouse.click(tab.x, tab.y)
203
- await until(() => look.tab() === 'figure.label.tab-short-capability', [], 'the capability tab on')
201
+ /* What the figure does is a folded group of the properties (chief architect 2026-10-16): opened as a hand opens it. */
202
+ const fold = await read(() => {
203
+ const summary = document.querySelector('figure-editor').renderRoot.querySelector('div[panel]:not([off]) section[capability] > details > summary')
204
+ summary.scrollIntoView({ block: 'center' })
205
+ const r = summary.getBoundingClientRect()
206
+ return { x: r.left + 20, y: r.top + r.height / 2 }
207
+ })
208
+ await page().mouse.click(fold.x, fold.y)
209
+ await until(() => !!document.querySelector('figure-editor').renderRoot.querySelector('div[panel]:not([off]) section[capability] > details[open]'), [], 'the capability fold open')
204
210
  await settled()
205
211
  })
206
212
 
@@ -265,6 +265,11 @@ 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]'
270
+ async function openHistory() {
271
+ if ((await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY)) !== 'true') await press(HISTORY, 'the history button')
272
+ }
268
273
  const VERSION_1 = { version: 1, comment: 'first', state: 'released', updatedAt: null, updater: null, score: null, triangles: null, groups: null }
269
274
  /** The revert choice popup's buttons: keep editing, let the edits go, save them first. */
270
275
  const CHOICE = { cancel: 'figure-revert-choice >>> md-text-button[slot=cancel]', discard: 'figure-revert-choice >>> md-text-button[slot=start]', save: 'figure-revert-choice >>> md-filled-button[slot=confirm]' }
@@ -286,12 +291,12 @@ describe('the history panel on the page', () => {
286
291
  await open('h')
287
292
  })
288
293
 
289
- test('the release beside the save leads to the release section of the history tab', async () => {
294
+ test('the release beside the save leads to the release section of the history, opened from the bar', async () => {
290
295
  await press('button[publish]', 'release')
291
296
  await until(p => product.deep(p)[0]?.shadowRoot.activeElement?.closest('section[release]'), 'the release section focused', PANEL)
292
297
  const panel = await read(p => { const panel = product.deep(p)[0]; return { slot: panel.assignedSlot?.name, visible: panel.checkVisibility() } }, PANEL)
293
298
  assert.deepEqual(panel, { slot: 'history', visible: true })
294
- assert.equal(await read(() => product.deep('figure-editor >>> button[tab][on]')[0]?.getAttribute('aria-label')), 'figure.label.tab-short-history')
299
+ assert.equal(await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY), 'true', 'the bar\'s history pressed')
295
300
  })
296
301
 
297
302
  test('a released version downloads as a package by its full reference', async () => {
@@ -307,7 +312,7 @@ describe('the history panel on the page', () => {
307
312
  await until(r => product.view().revision === r + 1, 'one step', before.revision)
308
313
  await press(TAB('properties'), 'the properties tab')
309
314
  await until(() => product.deep('figure-properties >>> div[row] > span')[0]?.textContent === 'figure.text.declared-space-declared', 'the room declared')
310
- await press(TAB('history'), 'the history tab')
315
+ await openHistory()
311
316
  })
312
317
 
313
318
  test('with edits not saved, a revert asks first; kept editing, nothing is sent', async () => {
@@ -774,7 +779,7 @@ describe('the page at a narrow width', () => {
774
779
  after(() => page().setViewport({ width: 1400, height: 900 }))
775
780
 
776
781
  test('the page fits the width it is given: the history panel does not run off its right edge', async () => {
777
- await press(TAB('history'), 'the history tab')
782
+ await openHistory()
778
783
  await until(p => product.deep(p)[0]?.checkVisibility(), 'the history panel', PANEL)
779
784
  const edges = await read(p => ({ panel: product.deep(p)[0].getBoundingClientRect().right, page: product.page().getBoundingClientRect().right, window: innerWidth }), PANEL)
780
785
  assert.ok(edges.panel <= edges.page && edges.page <= edges.window, JSON.stringify(edges))
@@ -0,0 +1,224 @@
1
+ /*
2
+ * The properties panel's order and its quieter parts in a real browser (chief architect 2026-10-16, after the user's
3
+ * 「너무 이해하기 어렵고 산만하고, 버튼 박스도 과하게」): four side tabs; a part's groups in one order whatever its state;
4
+ * a fastening said in one line until it is changed; a group's rarer actions in its 「⋯」, reached by the keyboard too;
5
+ * 「더 보기」 folded by default and left as the person left it; the view and the history opened from the bar.
6
+ *
7
+ * Run: node --test test/browser/props.check.mjs
8
+ */
9
+ import { test, describe, before, after } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+
12
+ import { buildPage } from './build.mjs'
13
+ import { openPage } from './serve.mjs'
14
+
15
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
16
+
17
+ let opened
18
+ before(async () => {
19
+ opened = await openPage(await buildPage('links-page.js'))
20
+ })
21
+ after(async () => {
22
+ await opened?.close()
23
+ })
24
+
25
+ const page = () => opened.page
26
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
27
+ const revision = () => read(() => probe.state().revision)
28
+ const MORE = 'figure-properties-more-open'
29
+
30
+ async function until(fn, args, what, ms = 8000) {
31
+ const deadline = Date.now() + ms
32
+ for (;;) {
33
+ const got = await read(fn, ...args)
34
+ if (got) return got
35
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
36
+ await sleep(50)
37
+ }
38
+ }
39
+ async function mount(kind) {
40
+ await read(kind => harness.mount(kind, null), kind)
41
+ await until(
42
+ () => {
43
+ try {
44
+ return probe.ids().some(id => !!probe.partOnPage(id))
45
+ } catch {
46
+ return false
47
+ }
48
+ },
49
+ [],
50
+ 'the figure drawn in 3D',
51
+ 15000
52
+ )
53
+ }
54
+ async function pick(part) {
55
+ const point = await read(id => probe.row(id), part)
56
+ assert.ok(point, `the row of ${part}`)
57
+ await page().mouse.click(point.x, point.y)
58
+ await until(id => probe.state().selected === id, [part], `${part} selected`)
59
+ await read(() => document.querySelector('figure-editor').updateComplete)
60
+ }
61
+ /** An element found through every shadow root; clicked where a person clicks it. */
62
+ async function press(selector, what) {
63
+ const handle = await page().evaluateHandle(s => {
64
+ const deep = (root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
65
+ return deep()[0] ?? null
66
+ }, selector)
67
+ const element = handle.asElement()
68
+ assert.ok(element, `${what ?? selector} is on the page`)
69
+ await element.click()
70
+ }
71
+
72
+ /**
73
+ * The groups of the panel shown, top to bottom, by what each is: the part's name, the size, the place and the row the
74
+ * links draw, the look, what the figure does, and 「더 보기」.
75
+ */
76
+ const groupsInOrder = () =>
77
+ read(() => {
78
+ const editor = document.querySelector('figure-editor')
79
+ const panel = editor.shadowRoot.querySelector('div[panel]:not([off])')
80
+ const links = panel.querySelector('figure-links')?.shadowRoot
81
+ const marks = {
82
+ name: panel.querySelector('input[name-field]'),
83
+ size: links?.querySelector('section[size]'),
84
+ place: links?.querySelector('section[fastening]'),
85
+ row: panel.querySelector('section[slot=row]') ?? links?.querySelector('section[row]'),
86
+ look: panel.querySelector('figure-material-panel:not([show])'),
87
+ capability: panel.querySelector('section[capability]'),
88
+ more: panel.querySelector('section[more]')
89
+ }
90
+ return Object.entries(marks)
91
+ .filter(([, e]) => e && e.getBoundingClientRect().height > 0)
92
+ .sort((a, b) => a[1].getBoundingClientRect().top - b[1].getBoundingClientRect().top)
93
+ .map(([name]) => name)
94
+ })
95
+
96
+ describe('the side: four tabs, the view and the history from the bar', () => {
97
+ before(() => mount('rollers'))
98
+
99
+ test('properties, motion, data and the verdict -- the four tabs, in that order', async () => {
100
+ const tabs = await read(() => [...document.querySelector('figure-editor').shadowRoot.querySelectorAll('aside button[tab]')].map(b => b.getAttribute('aria-label')))
101
+ assert.deepEqual(tabs, ['properties', 'motion', 'data', 'verdict'].map(t => `figure.label.tab-short-${t}`))
102
+ })
103
+
104
+ test('the view opens over the work from the bar, pressed while open; Esc closes it', async () => {
105
+ const shown = which => read(w => document.querySelector('figure-editor').shadowRoot.querySelector(`div[bar-overlay-panel=${w}]`).getBoundingClientRect().height > 0, which)
106
+ assert.equal(await shown('view'), false, 'closed at first')
107
+ await press('ox-canvas-bar button[bar-overlay=view]', 'the view button')
108
+ await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=view]').getBoundingClientRect().height > 0, [], 'the view open')
109
+ assert.equal(await read(() => document.querySelector('figure-editor').shadowRoot.querySelector('button[bar-overlay=view]').getAttribute('aria-pressed')), 'true')
110
+ assert.equal(await read(() => !!document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=view] figure-settings')), true, 'the view settings in it')
111
+ await read(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=view] button[quiet]').focus())
112
+ await page().keyboard.press('Escape')
113
+ await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=view]').getBoundingClientRect().height === 0, [], 'closed by Esc')
114
+ })
115
+
116
+ test('the history opens from the bar beside save, and the view closes for it', async () => {
117
+ await press('ox-canvas-bar button[bar-overlay=view]', 'the view button')
118
+ await press('ox-canvas-bar button[bar-overlay=history]', 'the history button')
119
+ const open = await read(() => ['view', 'history'].map(w => document.querySelector('figure-editor').shadowRoot.querySelector(`div[bar-overlay-panel=${w}]`).getBoundingClientRect().height > 0))
120
+ assert.deepEqual(open, [false, true], 'one at a time')
121
+ assert.equal(await read(() => !!document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=history] slot[name=history]')), true, 'the page\'s history goes in it')
122
+ await press('ox-canvas-bar button[bar-overlay=history]', 'the history button')
123
+ })
124
+ })
125
+
126
+ describe('a part\'s groups in one order, whatever its state', () => {
127
+ before(() => mount('rollers'))
128
+
129
+ test('a part fastened in a row: name, size, place, row, look, what it does, more', async () => {
130
+ await pick('roller')
131
+ assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'row', 'look', 'capability', 'more'])
132
+ })
133
+
134
+ test('a part on the plane, not in a row: the same order, the row folded where a row would be', async () => {
135
+ await pick('bed')
136
+ assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'row', 'look', 'capability', 'more'])
137
+ })
138
+
139
+ test('a fastened part\'s place is one line and 「바꾸기」: no locked cells, no drawing, until it is changed', async () => {
140
+ await pick('mark')
141
+ const place = () =>
142
+ read(() => {
143
+ const editor = document.querySelector('figure-editor')
144
+ const links = editor.shadowRoot.querySelector('div[panel]:not([off]) figure-links').shadowRoot
145
+ return {
146
+ sentence: !!links.querySelector('div[fastened-summary] p[fasten-sentence]'),
147
+ change: !!links.querySelector('button[change-fastening]'),
148
+ drawing: !!links.querySelector('figure-fasten-drawing'),
149
+ cells: !!editor.shadowRoot.querySelector('div[panel]:not([off]) div[slot=place]')
150
+ }
151
+ })
152
+ assert.deepEqual(await place(), { sentence: true, change: true, drawing: false, cells: false })
153
+ await press('button[change-fastening]', '「바꾸기」')
154
+ await until(() => !!document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off]) figure-links').shadowRoot.querySelector('figure-fasten-drawing'), [], 'the drawing')
155
+ /* Another part chosen and back: one line again. */
156
+ await pick('bed')
157
+ await pick('mark')
158
+ assert.equal((await place()).change, true, 'said in one line again')
159
+ })
160
+ })
161
+
162
+ describe('a group\'s 「⋯」: its rarer actions, by the mouse or the keyboard', () => {
163
+ before(() => mount('rollers'))
164
+
165
+ test('the place group\'s 「⋯」 takes the fastening off, opened and chosen by the keyboard, in one step', async () => {
166
+ /* The bed stands on the plane; nothing it carries is in the way of taking it off. */
167
+ await pick('bed')
168
+ const start = await revision()
169
+ await read(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off]) figure-links').shadowRoot.querySelector('section[fastening] button[group-menu]').focus())
170
+ await page().keyboard.press('Enter')
171
+ /* The menu takes the focus on its next frame (ox-popup); the keys go to it from then. */
172
+ await until(() => document.activeElement?.localName === 'ox-popup-menu', [], 'the menu open, with the focus')
173
+ const items = await read(() => [...document.querySelectorAll('ox-popup-menu[active] ox-popup-menuitem')].map(i => i.getAttribute('label')))
174
+ assert.deepEqual(items, ['figure.button.detach'])
175
+ await page().keyboard.press('ArrowDown')
176
+ /* The item takes the focus on the menu's next update; Enter goes to it from then. */
177
+ await until(() => document.activeElement?.localName === 'ox-popup-menuitem', [], 'the item chosen')
178
+ await page().keyboard.press('Enter')
179
+ await until(r => probe.state().revision === r + 1, [start], 'one step')
180
+ assert.equal(await read(() => JSON.parse(document.querySelector('figure-editor').session.source()).joints.some(j => j.body1.part === 'bed')), false, 'taken off')
181
+ })
182
+
183
+ test('Esc closes the menu and nothing is done', async () => {
184
+ await pick('roller')
185
+ const start = await revision()
186
+ await press('section[row] button[group-menu]', 'the row\'s 「⋯」')
187
+ await until(() => document.activeElement?.localName === 'ox-popup-menu', [], 'the menu open, with the focus')
188
+ assert.deepEqual(await read(() => [...document.querySelectorAll('ox-popup-menu[active] ox-popup-menuitem')].map(i => i.getAttribute('label'))), ['figure.button.remove-repeat'])
189
+ await page().keyboard.press('Escape')
190
+ await until(() => !document.querySelector('ox-popup-menu[active]'), [], 'the menu closed')
191
+ await sleep(200)
192
+ assert.equal(await revision(), start, 'nothing done')
193
+ })
194
+ })
195
+
196
+ describe('「더 보기」: folded by default, left as the person left it', () => {
197
+ const fold = () => read(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off]) section[more] > details')?.open ?? null)
198
+ before(() => read(key => localStorage.removeItem(key), MORE))
199
+ after(() => read(key => localStorage.setItem(key, '1'), MORE))
200
+
201
+ test('a person who never opened it finds it folded, the rare eight inside', async () => {
202
+ await mount('box')
203
+ await pick('box')
204
+ assert.equal(await fold(), false)
205
+ const inside = await read(() => {
206
+ const more = document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off]) section[more]')
207
+ return { section: !!more.querySelector('figure-section-panel[embedded]'), material: !!more.querySelector('figure-material-panel[show=more]'), tools: !!more.querySelector('div[tools]') }
208
+ })
209
+ assert.deepEqual(inside, { section: true, material: true, tools: true })
210
+ })
211
+
212
+ test('opened, it stays open for the next part, and for the next figure opened in this browser', async () => {
213
+ await press('section[more] > details > summary', 'the 「더 보기」 fold')
214
+ await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('section[more] > details').open, [], 'open')
215
+ /* The fold's toggle event comes a task after it opens. */
216
+ await until(key => localStorage.getItem(key) === '1', [MORE], 'kept in this browser')
217
+ await mount('box')
218
+ await pick('box')
219
+ assert.equal(await fold(), true, 'open again')
220
+ await press('section[more] > details > summary', 'the 「더 보기」 fold')
221
+ await until(() => !document.querySelector('figure-editor').shadowRoot.querySelector('section[more] > details').open, [], 'folded')
222
+ await until(key => localStorage.getItem(key) === '0', [MORE], 'folded, kept so')
223
+ })
224
+ })
@@ -3,6 +3,7 @@
3
3
  * `window.editorHarness` -- the modeller page's harness takes `window.harness` after it), the figures the check opens,
4
4
  * and what the app shows that is not in the editor: its notices, the card's picture, the scene's registered figures.
5
5
  */
6
+ import './more-open.js'
6
7
  import './modeller-page.js'
7
8
  import { captureFigureThumbnail } from 'figure-ui-client/modeller/thumbnail.js'
8
9
  import { drawableFigureParts } from 'figure-ui-client/modeller/renderer.js'
@@ -16,6 +17,7 @@ import {
16
17
  compileFigureAsset,
17
18
  createFigureAsset,
18
19
  editFigurePart,
20
+ proposeFigureOccupancy,
19
21
  robotArmFigure,
20
22
  serializeFigureAsset
21
23
  } from '@hatiolab/figure-model'
@@ -58,6 +60,22 @@ const FIGURES = {
58
60
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'mast', face: 'bottom', to: { plane: 'mounting-plane' } })
59
61
  return applyFigureAuthoring(a, { kind: 'declare-instance-size', range: { x: { min: 200, max: 200 }, y: { min: 1000, max: 3000 }, z: { min: 300, max: 300 } } })
60
62
  },
63
+ /* A block fastened on a base: a part with a fastening to take back, under a figure with no size of its own yet. */
64
+ stack: () => {
65
+ let a = addFigureBox(named('Stack'), { id: 'base', size: { x: 1000, y: 200, z: 600 }, position: { x: 0, y: 100, z: 0 } })
66
+ a = addFigureBox(a, { id: 'top', size: { x: 200, y: 200, z: 200 }, position: { x: 0, y: 300, z: 0 } })
67
+ return applyFigureAuthoring(a, { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'base', face: 'top' } })
68
+ },
69
+ /* A conveyor that follows its width, a roller every 200 mm, and the space it takes declared: the one figure stretched. */
70
+ stretch: () => {
71
+ let a = addFigureBox(named('Stretch'), { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 50, z: 0 } })
72
+ a = addFigurePrimitive(a, { id: 'roller', kind: 'cylinder', dimensions: { radius: 30, length: 560 }, position: { x: 0, y: 0, z: 0 }, rotation: { x: 90, y: 0, z: 0 } })
73
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
74
+ a = applyFigureAuthoring(a, { kind: 'follow-size', part: 'frame', axes: ['x'] })
75
+ a = applyFigureAuthoring(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
76
+ /* A space wider than the parts, as a spin swept or a padding gives: the box is the space, not the size (2026-10-16). */
77
+ return applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a, { over: 'range' }), padding: { x: 100, y: 0, z: 0 } })
78
+ },
61
79
  /* The robot arm with a clip that turns its base: controls and a clip in the preview. */
62
80
  'arm-clip': () => editFigurePart(robotArmFigure(), { kind: 'set-driver', state: 'j1-base', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
63
81
  }