@things-factory/figure-ui 10.1.272 → 10.1.273

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 (56) hide show
  1. package/client/modeller/data-interface.ts +30 -18
  2. package/client/modeller/editor-styles.ts +17 -6
  3. package/client/modeller/editor.ts +2 -0
  4. package/client/modeller/fasten-form.ts +12 -2
  5. package/client/modeller/fasten-geometry.ts +5 -3
  6. package/client/modeller/figure-data-panel.ts +384 -200
  7. package/client/modeller/figure-fasten-drawing.ts +21 -13
  8. package/client/modeller/figure-links.ts +24 -9
  9. package/client/modeller/figure-parts.ts +7 -11
  10. package/client/modeller/figure-preview.ts +258 -76
  11. package/dist-client/modeller/clips.d.ts +1 -4
  12. package/dist-client/modeller/data-interface.d.ts +11 -8
  13. package/dist-client/modeller/data-interface.js +27 -17
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor-styles.js +17 -6
  16. package/dist-client/modeller/editor-styles.js.map +1 -1
  17. package/dist-client/modeller/editor.js +2 -0
  18. package/dist-client/modeller/editor.js.map +1 -1
  19. package/dist-client/modeller/fasten-form.d.ts +5 -0
  20. package/dist-client/modeller/fasten-form.js +8 -2
  21. package/dist-client/modeller/fasten-form.js.map +1 -1
  22. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  23. package/dist-client/modeller/fasten-geometry.js +5 -3
  24. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  25. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  26. package/dist-client/modeller/figure-data-panel.js +382 -190
  27. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  28. package/dist-client/modeller/figure-fasten-drawing.js +21 -13
  29. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  30. package/dist-client/modeller/figure-links.js +31 -16
  31. package/dist-client/modeller/figure-links.js.map +1 -1
  32. package/dist-client/modeller/figure-parts.js +7 -11
  33. package/dist-client/modeller/figure-parts.js.map +1 -1
  34. package/dist-client/modeller/figure-preview.d.ts +13 -0
  35. package/dist-client/modeller/figure-preview.js +257 -77
  36. package/dist-client/modeller/figure-preview.js.map +1 -1
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +8 -7
  39. package/test/browser/editing-page.js +1 -1
  40. package/test/browser/links.check.mjs +30 -0
  41. package/test/browser/modeller.check.mjs +2 -2
  42. package/test/browser/parts-page.js +4 -0
  43. package/test/browser/parts.check.mjs +16 -10
  44. package/test/browser/screen-page.js +12 -1
  45. package/test/browser/screen.check.mjs +142 -11
  46. package/test/browser/serve-response.mjs +5 -2
  47. package/test/browser/side-panel-sweep.check.mjs +171 -10
  48. package/test/data-motion.test.ts +39 -8
  49. package/test/fasten-form.test.ts +25 -0
  50. package/test/fasten-geometry.test.ts +13 -0
  51. package/translations/en.json +23 -11
  52. package/translations/es.json +22 -10
  53. package/translations/ja.json +22 -10
  54. package/translations/ko.json +24 -12
  55. package/translations/ms.json +22 -10
  56. package/translations/zh.json +22 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.272",
3
+ "version": "10.1.273",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,7 +27,7 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.12.4",
30
+ "@hatiolab/figure-model": "^0.12.6",
31
31
  "@hatiolab/things-scene": "^10.1.111",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.29.69",
@@ -42,11 +42,12 @@
42
42
  "@operato/slot-layout-editor": "^10.29.58",
43
43
  "@operato/styles": "^10.0.0",
44
44
  "@operato/utils": "^10.29.43",
45
- "@things-factory/ai-assistant": "^10.1.272",
46
- "@things-factory/figure-service": "^10.1.272",
47
- "@things-factory/figure-viewer": "^10.1.266",
48
- "@things-factory/setting-base": "^10.1.271",
45
+ "@things-factory/ai-assistant": "^10.1.273",
46
+ "@things-factory/figure-service": "^10.1.273",
47
+ "@things-factory/figure-viewer": "^10.1.273",
48
+ "@things-factory/setting-base": "^10.1.273",
49
+ "json5": "^2.2.0",
49
50
  "three": "^0.185.1"
50
51
  },
51
- "gitHead": "f87fefe0687eedf52a25cda1c54d59efabaf3dc2"
52
+ "gitHead": "0ead3ad069edf9f1ef5076caec85916bdb26728f"
52
53
  }
@@ -124,7 +124,7 @@ window.editing = {
124
124
  },
125
125
  /** The page point of a button by its words (the page loads no translations: the words are keys). */
126
126
  button(words) {
127
- const b = deep('button').find(b => b.textContent.includes(words))
127
+ const b = deep('button').find(b => b.textContent.includes(words) || b.title === words)
128
128
  return b ? { ...centreOf(b), disabled: b.disabled } : null
129
129
  },
130
130
  /** The first range input inside the `host` elements: where its thumb is and how many pixels one unit of it spans. */
@@ -802,6 +802,23 @@ describe('the fastening window: tried freely, written once', () => {
802
802
  assert.equal((await state()).revision, start.revision, 'nothing written while trying')
803
803
  }
804
804
 
805
+ test('the ways to fasten stand on one line, a small view beside each word (user 2026-11-12)', async () => {
806
+ await pick('lamp')
807
+ await changeFastening()
808
+ const ways = await read(() => {
809
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
810
+ return deep('div[modes] > button[mode]').map(b => {
811
+ const r = b.getBoundingClientRect()
812
+ return { top: Math.round(r.top), height: Math.round(r.height) }
813
+ })
814
+ })
815
+ assert.ok(ways.length >= 2, `ways: ${JSON.stringify(ways)}`)
816
+ assert.equal(new Set(ways.map(w => w.top)).size, 1, `one line: ${JSON.stringify(ways)}`)
817
+ assert.ok(ways.every(w => w.height <= 30), `each as tall as a line: ${JSON.stringify(ways)}`)
818
+ await press('[cancel]')
819
+ await until(() => !probe.fastenTargets(), [], 'the window closed')
820
+ })
821
+
805
822
  test('three choices tried, then 취소: nothing written, the window closed, nothing of them drawn', async () => {
806
823
  await pick('lamp')
807
824
  const was = await lamp()
@@ -905,6 +922,19 @@ describe('a motion on the motion tab', () => {
905
922
  await until(() => look.drawn().includes('__preview:min:upper-arm') && look.drawn().includes('__preview:max:upper-arm'), [], 'both ends drawn')
906
923
  assert.equal((await all('p[motion-summary]', 'figure.text.motion-summary')).length, 1)
907
924
  })
925
+
926
+ test('its delete and edit are icons on the motion\'s line, and the size-by-state form is folded until asked for (user 2026-11-12)', async () => {
927
+ const seen = await read(() => {
928
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
929
+ const line = deep('div[motion-axis]')[0]
930
+ const icons = [...line.querySelectorAll('button[act]')].map(b => ({ title: b.title, words: b.textContent.replace(/delete|edit/g, '').trim() }))
931
+ const fold = deep('details[state-size-add]')[0]
932
+ return { icons, folded: fold ? !fold.open : null, heading: deep('h4[state-size]').length }
933
+ })
934
+ assert.deepEqual(seen.icons.map(i => i.words), ['', ''], 'no words beside the icons')
935
+ assert.deepEqual(seen.icons.map(i => i.title), ['figure.button.remove-motion', 'figure.button.change-motion'])
936
+ assert.deepEqual([seen.folded, seen.heading], [true, 0], 'folded, no heading over nothing')
937
+ })
908
938
  })
909
939
 
910
940
  describe('pressed on the canvas while the fastening window is open (ADR-0140 stage 4)', () => {
@@ -603,7 +603,7 @@ describe('a motion is changed where it is (2026-09-25)', () => {
603
603
  const button = async text =>
604
604
  page().evaluate(text => {
605
605
  const deep = (root = document) => [...root.querySelectorAll('button')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
606
- const b = deep().find(x => x.textContent.includes(text))
606
+ const b = deep().find(x => x.textContent.includes(text) || x.title === text)
607
607
  if (!b) return null
608
608
  b.scrollIntoView({ block: 'center' })
609
609
  const r = b.getBoundingClientRect()
@@ -641,7 +641,7 @@ describe('a motion is changed where it is (2026-09-25)', () => {
641
641
  page().evaluate(
642
642
  (text, attribute) => {
643
643
  const deep = (root = document) => [...root.querySelectorAll('button')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
644
- const b = deep().find(x => x.textContent.includes(text) && (!attribute || x.hasAttribute(attribute)))
644
+ const b = deep().find(x => (x.textContent.includes(text) || x.title === text) && (!attribute || x.hasAttribute(attribute)))
645
645
  if (!b) return null
646
646
  b.scrollIntoView({ block: 'center' })
647
647
  const r = b.getBoundingClientRect()
@@ -119,6 +119,10 @@ window.parts = {
119
119
  }
120
120
  return { row: box(li), name: box(li.querySelector('span[label]')), ops: box(li.querySelector('div[ops]')) }
121
121
  },
122
+ /** Each row's height, the chosen one among them. */
123
+ rowHeights() {
124
+ return [...panel().shadowRoot.querySelectorAll('li')].map(li => li.getBoundingClientRect().height)
125
+ },
122
126
  /** The list made this wide (the start pane narrowed by the author). */
123
127
  narrow(px) {
124
128
  panel().style.width = px ? `${px}px` : ''
@@ -305,17 +305,23 @@ describe('the list narrowed: the selected row keeps its name and its buttons in
305
305
  before(() => mount('box', 'three'))
306
306
  after(() => read(() => parts.narrow(0)))
307
307
 
308
- test('wide, the buttons stand beside the name; under 200 px they go to a line of their own, inside the row', async () => {
308
+ /*
309
+ Chosen, a row keeps its height, wide or narrow: its buttons beside the name, the name cut short. Going to a line of
310
+ their own under 200 px, they pushed every row under it down as a part was chosen, and the next press landed on
311
+ another part (user 2026-11-12).
312
+ */
313
+ test('the buttons stand beside the name, wide or narrow; the chosen row is as tall as the others, inside the list', async () => {
309
314
  await pick('b')
310
- await read(() => parts.narrow(320))
311
- await sleep(200)
312
- let at = await read(() => parts.selectedRowBoxes())
313
- assert.ok(at.ops.top < at.name.bottom, `beside the name: ${JSON.stringify(at)}`)
314
- await read(() => parts.narrow(180))
315
- await sleep(200)
316
- at = await read(() => parts.selectedRowBoxes())
317
- assert.ok(at.ops.top >= at.name.bottom - 1, `under the name: ${JSON.stringify(at)}`)
318
- assert.ok(at.ops.right <= at.row.right + 1 && at.name.right <= at.row.right + 1, `inside the row: ${JSON.stringify(at)}`)
315
+ for (const width of [320, 180]) {
316
+ await read(width => parts.narrow(width), width)
317
+ await sleep(200)
318
+ const at = await read(() => parts.selectedRowBoxes())
319
+ assert.ok(at.ops.top < at.name.bottom, `${width}: beside the name: ${JSON.stringify(at)}`)
320
+ assert.ok(at.ops.right <= at.row.right + 1 && at.name.right <= at.row.right + 1, `${width}: inside the row: ${JSON.stringify(at)}`)
321
+ assert.ok(at.name.right > at.name.left && at.name.right <= at.ops.left + 1, `${width}: the name shows, clear of the buttons: ${JSON.stringify(at)}`)
322
+ const heights = await read(() => parts.rowHeights())
323
+ assert.equal(new Set(heights.map(Math.round)).size, 1, `${width}: every row as tall: ${heights}`)
324
+ }
319
325
  })
320
326
  })
321
327
 
@@ -16,6 +16,7 @@ import {
16
16
  compileFigureAsset,
17
17
  createFigureAsset,
18
18
  editFigurePart,
19
+ figurePartsExtentOf,
19
20
  robotArmFigure,
20
21
  serializeFigureAsset
21
22
  } from '@hatiolab/figure-model'
@@ -51,6 +52,15 @@ const FIGURES = {
51
52
  /* A figure hung from the ceiling, its box under it. */
52
53
  ceiling: () => applyFigureAuthoring(addFigureBox(named('Hung'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: -200, z: 0 } }), { kind: 'set-placement', placement: 'ceiling' }),
53
54
  /* A drum: a palette part whose sizes are its own fields. */
55
+ /* A box whose every size is held at one value: the board cannot change its size. */
56
+ fixed: () => {
57
+ const asset = addFigureBox(named('Fixed'), cube('box', 0))
58
+ for (const input of asset.document.model.inputs) {
59
+ const at = asset.designInputs[input.id]
60
+ if (/^size\.[xyz]$/.test(input.id) && typeof at === 'number') Object.assign(input, { min: at, max: at })
61
+ }
62
+ return asset
63
+ },
54
64
  drum: () => addFigurePrimitive(named('Drum'), { id: 'drum', kind: 'cylinder', dimensions: { radius: 200, length: 500 }, position: { x: 0, y: 250, z: 0 } }),
55
65
  /* A lighting tower that follows its height only: the wider variants ask a width it does not declare. */
56
66
  tower: () => {
@@ -77,7 +87,8 @@ const FIGURES = {
77
87
  return applyFigureAuthoring(a, { kind: 'make-repeat', part: 'roller', axis: 'x', pitch: 200 })
78
88
  },
79
89
  /* The robot arm with a clip that turns its base: controls and a clip in the preview. */
80
- 'arm-clip': () => editFigurePart(robotArmFigure(), { kind: 'set-driver', state: 'j1-base', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
90
+ /* Declares its size, so the preview offers the size help over it (shown only where the size can change). */
91
+ 'arm-clip': () => editFigurePart(applyFigureAuthoring(robotArmFigure(), { kind: 'declare-instance-size', at: figurePartsExtentOf(robotArmFigure()) }), { kind: 'set-driver', state: 'j1-base', clip: 'cycle', duration: 4, keys: [{ seconds: 0, value: 0 }, { seconds: 4, value: 60 }] })
81
92
  }
82
93
 
83
94
  /** A figure by name, as saved text. */
@@ -604,6 +604,15 @@ describe('the preview: registered as a board registers it, posed and played, the
604
604
  await mode('preview')
605
605
  await until('the preview stands', () => !!screenCheck.deep('figure-preview')[0]?.shadowRoot?.querySelector('div[setup] select'))
606
606
  assert.equal(await read(() => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[setup] select').value), 'forearm', 'the help speaks of the part chosen')
607
+ /* One line over the figure, its words in its title: three lines of help covered the figure (user 2026-11-12). */
608
+ const setup = await read(() => {
609
+ const box = screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[setup]')
610
+ const top = s => { const r = box.querySelector(s).getBoundingClientRect(); return Math.round(r.top + r.height / 2) }
611
+ return { height: box.getBoundingClientRect().height, select: top('select'), button: top('button'), title: box.title }
612
+ })
613
+ assert.ok(Math.abs(setup.select - setup.button) <= 2, `one line: ${JSON.stringify(setup)}`)
614
+ assert.ok(setup.height <= 44, `no taller than a line: ${JSON.stringify(setup)}`)
615
+ assert.ok(setup.title.length > 0, 'its words in its title')
607
616
  await mode('edit')
608
617
  await until('the edit canvas in front', () => screenCheck.deep('figure-preview')[0].hidden)
609
618
  assert.equal(await selected(), 'forearm', 'still chosen')
@@ -622,6 +631,21 @@ describe('the preview: registered as a board registers it, posed and played, the
622
631
  assert.ok(dials.filter(d => d.unit !== 'deg').every(d => !d.dial), 'no dial for what does not turn')
623
632
 
624
633
  const before = { revision: await revision(), pose: await read(() => JSON.parse(screenCheck.editor().session.source()).stateDefaults['j2-shoulder'] ?? 0) }
634
+ /*
635
+ The three buttons stand on one line from the start, unable until there is something to keep or clear: they do not
636
+ come and go and move the rows under them (user 2026-11-12).
637
+ */
638
+ const row = () => ['pose', 'clear-tried', 'fold-controls'].map(name => {
639
+ const b = screenCheck.deep('figure-preview')[0].shadowRoot.querySelector(`div[clips] button[${name}]`)
640
+ return b ? { top: Math.round(b.getBoundingClientRect().top + b.getBoundingClientRect().height / 2), height: Math.round(b.getBoundingClientRect().height), disabled: b.disabled } : null
641
+ })
642
+ const untried = await read(row)
643
+ assert.ok(untried.every(Boolean), 'all three there')
644
+ assert.equal(new Set(untried.map(b => b.top)).size, 1, 'on one line')
645
+ /* The fold an icon now; keep and clear each one line high, not broken inside a word (user 2026-11-12). */
646
+ assert.equal(untried[0].height, untried[1].height, `each label on one line, not broken inside a word: ${JSON.stringify(untried)}`)
647
+ assert.ok(untried[0].height <= 24, `one line high: ${JSON.stringify(untried)}`)
648
+ assert.equal(untried[2].disabled, false, 'folding is always there to press')
625
649
  const slider = await element('the shoulder', () => screenCheck.previewControl('j2-shoulder').querySelector('input[type=range]'))
626
650
  await slider.focus()
627
651
  for (let i = 0; i < 10; i++) await page().keyboard.press('ArrowRight')
@@ -633,19 +657,45 @@ describe('the preview: registered as a board registers it, posed and played, the
633
657
  assert.equal(await read(() => JSON.parse(screenCheck.editor().session.source()).stateDefaults['j2-shoulder']), tried, 'the pose kept is the one tried')
634
658
  })
635
659
 
660
+ test('a row\'s name dragged sideways scrubs its value, as the property panel\'s do: a degree a pixel, Shift ten times finer, held in its range (user 2026-11-12)', async () => {
661
+ const at = () => read(() => {
662
+ const l = screenCheck.previewControl('j1-base')
663
+ const r = l.querySelector('span[scrub]').getBoundingClientRect()
664
+ return { x: r.left + 10, y: r.top + r.height / 2, value: Number(l.querySelector('input[typed]').value), max: Number(l.querySelector('input[type=range]').max), cursor: getComputedStyle(l.querySelector('span[scrub]')).cursor }
665
+ })
666
+ const drag = async (dx, shift = false) => {
667
+ const from = await at()
668
+ if (shift) await page().keyboard.down('Shift')
669
+ await page().mouse.move(from.x, from.y)
670
+ await page().mouse.down()
671
+ await page().mouse.move(from.x + dx / 2, from.y)
672
+ await page().mouse.move(from.x + dx, from.y)
673
+ await page().mouse.up()
674
+ if (shift) await page().keyboard.up('Shift')
675
+ return (await at()).value - from.value
676
+ }
677
+ assert.equal((await at()).cursor, 'ew-resize', 'the name says it can be dragged')
678
+ assert.equal(await drag(20), 20, 'a degree a pixel')
679
+ assert.equal(Math.round((await drag(-20, true)) * 10) / 10, -2, 'Shift: a tenth of a degree a pixel')
680
+ const top = (await at()).max
681
+ await drag(5000)
682
+ assert.equal((await at()).value, top, 'held at the range\'s end')
683
+ })
684
+
636
685
  test('a clip stands still until it is played: its speed reads 0, and the speed played is what every instance is given', async () => {
637
686
  const clip = () =>
638
687
  read(() => {
639
688
  const l = screenCheck.previewControl('cycle')
640
- return { value: Number(l.querySelector('input[type=range]').value), shown: l.querySelector('output').textContent.trim() }
689
+ return { value: Number(l.querySelector('input[type=range]').value), shown: l.querySelector('input[typed]').value }
641
690
  })
642
- assert.deepEqual(await clip(), { value: 0, shown: '0.00' }, 'not played yet')
691
+ assert.deepEqual(await clip(), { value: 0, shown: '0' }, 'not played yet')
643
692
  const slider = await element('the clip\'s speed', () => screenCheck.previewControl('cycle').querySelector('input[type=range]'))
644
693
  await slider.focus()
645
694
  await page().keyboard.press('ArrowRight')
646
- await until('played', () => screenCheck.previewControl('cycle').querySelector('output').textContent.trim() !== '0.00')
695
+ await until('played', () => screenCheck.previewControl('cycle').querySelector('input[typed]').value !== '0')
647
696
  const now = await clip()
648
- assert.equal(now.shown, now.value.toFixed(2))
697
+ /* The speed in the same box every slider's value has (user 2026-11-12). */
698
+ assert.equal(now.shown, String(Math.round(now.value * 100) / 100))
649
699
  const given = await read(() => screenCheck.deep('figure-preview')[0].scene.root.components.map(c => c.state.clips?.cycle))
650
700
  assert.equal(given.length, 1, 'the one figure the preview stands')
651
701
  assert.ok(given.every(v => v === now.value), `each given the speed shown: ${given}`)
@@ -675,7 +725,8 @@ describe('the preview: registered as a board registers it, posed and played, the
675
725
  assert.ok((await given()).every(p => p['j2-shoulder'] === 37), 'a control the clip does not drive keeps its value')
676
726
  await press('back to the default pose', () => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('button[clear-tried]'))
677
727
  await until('all let go', () => screenCheck.deep('figure-preview')[0].scene.root.components.every(c => Object.keys(c.state.parameters ?? {}).length === 0))
678
- assert.equal(await read(() => !!screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('button[clear-tried]')), false, 'nothing left to clear')
728
+ /* Nothing left to clear: the button stays where it is, unable (user 2026-11-12: 「보이다 안보이다 하지마라」). */
729
+ assert.equal(await read(() => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('button[clear-tried]')?.disabled), true, 'nothing left to clear')
679
730
  })
680
731
 
681
732
  test('the preview looks from the front, the side or above, and its controls fold out of the way (robot dog, 2026-11-03)', async () => {
@@ -690,10 +741,56 @@ describe('the preview: registered as a board registers it, posed and played, the
690
741
  await press('fold the controls', () => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('button[fold-controls]'))
691
742
  await until('folded', () => !screenCheck.previewControl('j1-base'))
692
743
  assert.ok(await read(() => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[clips] button[fold-controls]')), 'the one button stays')
744
+ /* Folded, it says what it opens, and stands on the stage's bottom, not at its top (user 2026-11-12). */
745
+ const folded = await read(() => {
746
+ const host = screenCheck.deep('figure-preview')[0]
747
+ const clips = host.shadowRoot.querySelector('div[clips]')
748
+ return { words: clips.querySelector('button[fold-controls]').textContent.replace('unfold_more', '').trim(), gap: Math.round(host.getBoundingClientRect().bottom - clips.getBoundingClientRect().bottom) }
749
+ })
750
+ assert.deepEqual(folded, { words: 'figure.button.controls-unfold', gap: 8 })
693
751
  await press('unfold the controls', () => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('button[fold-controls]'))
694
752
  await until('back', () => !!screenCheck.previewControl('j1-base'))
695
753
  })
696
754
 
755
+ test('short (a window 300 high), the controls stay under the legend and scroll, their keep line and size help in sight (user 2026-11-12)', async () => {
756
+ const viewport = page().viewport()
757
+ await mode('preview')
758
+ await page().setViewport({ width: viewport.width, height: 300 })
759
+ try {
760
+ await until('the controls held short', () => {
761
+ const root = screenCheck.deep('figure-preview')[0].shadowRoot
762
+ const clips = root.querySelector('div[clips]')
763
+ return clips && clips.scrollHeight > clips.clientHeight + 10
764
+ })
765
+ const at = () => read(() => {
766
+ const host = screenCheck.deep('figure-preview')[0]
767
+ const root = host.shadowRoot
768
+ const r = el => el.getBoundingClientRect()
769
+ const clips = root.querySelector('div[clips]')
770
+ return {
771
+ legend: r(root.querySelector('div[legend]')).bottom,
772
+ stage: r(host).bottom,
773
+ clips: { top: r(clips).top, bottom: r(clips).bottom },
774
+ actions: r(root.querySelector('div[clips] div[actions]')).top,
775
+ setup: r(root.querySelector('div[clips] div[setup]')).bottom
776
+ }
777
+ })
778
+ const top = await at()
779
+ assert.ok(top.clips.top >= top.legend, `under the legend: ${JSON.stringify(top)}`)
780
+ assert.ok(top.clips.bottom <= top.stage, `inside the stage: ${JSON.stringify(top)}`)
781
+ await read(() => { const c = screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[clips]'); c.scrollTop = c.scrollHeight })
782
+ await new Promise(resolve => setTimeout(resolve, 100))
783
+ const end = await at()
784
+ assert.ok(Math.abs(end.actions - end.clips.top) <= 1, `the keep line stays at the top as the rows scroll: ${JSON.stringify(end)}`)
785
+ assert.ok(Math.abs(end.setup - end.clips.bottom) <= 1, `the size help stays at the bottom: ${JSON.stringify(end)}`)
786
+ await read(() => { screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[clips]').scrollTop = 0 })
787
+ const start = await at()
788
+ assert.ok(Math.abs(start.setup - start.clips.bottom) <= 1, `scrolled back, the size help still at the bottom: ${JSON.stringify(start)}`)
789
+ } finally {
790
+ await page().setViewport(viewport)
791
+ }
792
+ })
793
+
697
794
  test('narrow (1024 wide and less, both sides open), the size help and the controls share the bottom row without covering each other', async () => {
698
795
  const viewport = page().viewport()
699
796
  await page().setViewport({ width: 1024, height: 768 })
@@ -713,13 +810,25 @@ describe('the preview: registered as a board registers it, posed and played, the
713
810
  const r = el.getBoundingClientRect()
714
811
  return { left: r.left, right: r.right, top: r.top, bottom: r.bottom }
715
812
  }
716
- return { host: rect(screenCheck.deep('figure-preview')[0]), foot: rect(root.querySelector('div[foot]')), setup: rect(root.querySelector('div[foot] > div[setup]')), clips: rect(root.querySelector('div[foot] > div[clips]')) }
813
+ const clips = root.querySelector('div[foot] > div[clips]')
814
+ return {
815
+ host: rect(screenCheck.deep('figure-preview')[0]),
816
+ foot: rect(root.querySelector('div[foot]')),
817
+ setup: rect(clips.querySelector(':scope > div[setup] > button')),
818
+ last: clips.lastElementChild.hasAttribute('setup'),
819
+ clips: rect(clips),
820
+ typed: [...clips.querySelectorAll('input[typed]')].map(rect),
821
+ padding: parseFloat(getComputedStyle(clips).paddingRight)
822
+ }
717
823
  })
718
824
  const inside = (a, b) => a.left >= b.left - 0.5 && a.right <= b.right + 0.5 && a.top >= b.top - 0.5 && a.bottom <= b.bottom + 0.5
719
- const apart = (a, b) => a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top
720
- assert.ok(inside(boxes.setup, boxes.foot) && inside(boxes.clips, boxes.foot), `${width}: both in the bottom row: ${JSON.stringify(boxes)}`)
721
- assert.ok(apart(boxes.setup, boxes.clips), `${width}: neither covers the other: ${JSON.stringify(boxes)}`)
722
- assert.ok(boxes.setup.bottom >= boxes.clips.bottom - 0.5, `${width}: the help on the bottom line, the controls above it when they wrap`)
825
+ /* The help is the controls' last row, as wide as they are: a box of its own left a gap at its right (user 2026-11-12). */
826
+ assert.ok(inside(boxes.clips, boxes.foot), `${width}: in the bottom row: ${JSON.stringify(boxes)}`)
827
+ assert.ok(boxes.last, `${width}: the help is the controls' last row`)
828
+ assert.ok(Math.abs(boxes.setup.right - (boxes.clips.right - boxes.padding)) <= 1, `${width}: the help reaches the controls' right edge: ${JSON.stringify(boxes)}`)
829
+ /* Every slider's value in one column of boxes. */
830
+ assert.equal(new Set(boxes.typed.map(b => `${Math.round(b.left)}-${Math.round(b.right)}`)).size, 1, `${width}: one column of value boxes`)
831
+ assert.ok(Math.abs(boxes.typed[0].right - boxes.setup.right) <= 1, `${width}: the value boxes end where the help does`)
723
832
  assert.ok(Math.abs(boxes.host.bottom - 8 - boxes.foot.bottom) <= 1, `${width}: the row stands on the stage's bottom edge: ${JSON.stringify(boxes)}`)
724
833
  }
725
834
  } finally {
@@ -759,6 +868,28 @@ describe('the preview: registered as a board registers it, posed and played, the
759
868
  })
760
869
  })
761
870
 
871
+ describe('the preview of a figure whose size cannot change', () => {
872
+ test('has no size help over it (user 2026-11-12)', async () => {
873
+ await mount('fixed')
874
+ await press('the preview mode', () => screenCheck.deep('button[mode]').find(b => b.title === 'figure.text.mode-preview-explained'))
875
+ await until('the preview stands', () => !!screenCheck.deep('figure-preview')[0]?.shadowRoot?.querySelector('div[foot]'))
876
+ assert.equal(await read(() => !!screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[setup]')), false, 'no size help on a figure of one size')
877
+ /* Its size said at the legend's end, on the legend's one line; no box of sliders under it (user 2026-11-12). */
878
+ const viewport = page().viewport()
879
+ await page().setViewport({ ...viewport, width: 2400 })
880
+ await new Promise(resolve => setTimeout(resolve, 300))
881
+ const legend = await read(() => {
882
+ const root = screenCheck.deep('figure-preview')[0].shadowRoot
883
+ const middle = el => { const r = el.getBoundingClientRect(); return Math.round(r.top + r.height / 2) }
884
+ return { said: root.querySelector('div[legend] span[said]')?.textContent.trim(), sizes: !!root.querySelector('div[sizes]'), lines: new Set([...root.querySelectorAll('div[legend] button, div[legend] span[said]')].map(middle)).size }
885
+ })
886
+ await page().setViewport(viewport)
887
+ /* This page reads keys, not words, long enough to wrap the legend in a narrower window. */
888
+ assert.deepEqual(legend, { said: '400 × 400 × 400 mm', sizes: false, lines: 1 })
889
+ await boardsUp()
890
+ })
891
+ })
892
+
762
893
  describe('the preview of a figure that does not declare every size', () => {
763
894
  test('a variant drawn at another size than it asks says so in the legend', async () => {
764
895
  await mount('tower')
@@ -818,7 +949,7 @@ describe('the preview stands one figure the author stretches (first 15 minutes,
818
949
  })
819
950
  /* The box is the figure's body at 6 m: the frame, as wide as asked (ADR-0145). */
820
951
  await until('the figure 6 m wide, its body', () => Math.round(screenCheck.deep('figure-preview')[0].scene.root.components[0].model.width) === 6000)
821
- const said = await read(() => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[sizes] p[said]').textContent.trim())
952
+ const said = await read(() => screenCheck.deep('figure-preview')[0].shadowRoot.querySelector('div[legend] span[said]').textContent.trim())
822
953
  assert.ok(said.startsWith('6,000 × '), said)
823
954
  const box = await read(() => { const m = screenCheck.deep('figure-preview')[0].scene.root.components[0].model; return m.left + m.width / 2 })
824
955
  assert.equal(Math.round(box), await read(() => screenCheck.deep('figure-preview')[0].board.width / 2), 'grown about the board\'s centre')
@@ -1,14 +1,17 @@
1
1
  /* Manual browser verification. Uses buildPage's FIGURE_MODEL / THINGS_SCENE overrides when provided. */
2
2
  import { buildPage } from './build.mjs'
3
3
  import { createServer } from 'node:http'
4
- import { readFileSync } from 'node:fs'
4
+ import { readFileSync, writeFileSync } from 'node:fs'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { createRequire } from 'node:module'
7
7
  const require = createRequire(import.meta.url)
8
8
  const bundle = await buildPage(process.argv[2] ?? 'response-page.js')
9
+ /* The words the modeller reads come from figure-ui and figure-viewer, as the app merges them: one alone showed keys. */
10
+ const koWords = join(process.env.TMPDIR ?? '/tmp', 'figure-ui-harness-ko.json')
11
+ writeFileSync(koWords, JSON.stringify({ ...JSON.parse(readFileSync(require.resolve('@things-factory/figure-viewer/translations/ko.json'), 'utf8')), ...JSON.parse(readFileSync(new URL('../../translations/ko.json', import.meta.url), 'utf8')) }))
9
12
  const routes = new Map([
10
13
  ['/page.js', [bundle, 'application/javascript']],
11
- ['/ko.json', [new URL('../../translations/ko.json', import.meta.url), 'application/json']],
14
+ ['/ko.json', [koWords, 'application/json']],
12
15
  ['/icons.woff2', [require.resolve('material-symbols/material-symbols-outlined.woff2'), 'font/woff2']],
13
16
  ['/workers/markdown-webworker.js', [join(dirname(bundle), 'workers/markdown-webworker.js'), 'application/javascript']]
14
17
  ])