@things-factory/figure-ui 10.1.175 → 10.1.177

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 (41) hide show
  1. package/client/modeller/editor-styles.ts +4 -6
  2. package/client/modeller/editor.ts +41 -39
  3. package/client/modeller/figure-links.ts +41 -12
  4. package/client/modeller/figure-parts.ts +31 -14
  5. package/client/modeller/figure-properties.ts +2 -2
  6. package/client/pages/figure-modeller-page.ts +34 -11
  7. package/dist-client/modeller/editor-styles.js +4 -6
  8. package/dist-client/modeller/editor-styles.js.map +1 -1
  9. package/dist-client/modeller/editor.d.ts +6 -10
  10. package/dist-client/modeller/editor.js +34 -37
  11. package/dist-client/modeller/editor.js.map +1 -1
  12. package/dist-client/modeller/figure-links.d.ts +7 -2
  13. package/dist-client/modeller/figure-links.js +45 -11
  14. package/dist-client/modeller/figure-links.js.map +1 -1
  15. package/dist-client/modeller/figure-parts.js +31 -14
  16. package/dist-client/modeller/figure-parts.js.map +1 -1
  17. package/dist-client/modeller/figure-properties.js +2 -2
  18. package/dist-client/modeller/figure-properties.js.map +1 -1
  19. package/dist-client/pages/figure-modeller-page.js +34 -11
  20. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  21. package/dist-client/route.d.ts +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +6 -6
  24. package/test/browser/acceptance-page.js +2 -1
  25. package/test/browser/build.mjs +9 -1
  26. package/test/browser/editing-page.js +2 -1
  27. package/test/browser/links.check.mjs +24 -9
  28. package/test/browser/modeller.check.mjs +6 -0
  29. package/test/browser/parts.check.mjs +3 -12
  30. package/test/browser/places-mesh.check.mjs +4 -9
  31. package/test/browser/props.check.mjs +67 -12
  32. package/test/browser/row-preview.check.mjs +4 -0
  33. package/test/browser/screen.check.mjs +49 -19
  34. package/test/browser/serve-acceptance.mjs +2 -1
  35. package/test/place-words.test.ts +31 -0
  36. package/translations/en.json +6 -3
  37. package/translations/es.json +3 -0
  38. package/translations/ja.json +3 -0
  39. package/translations/ko.json +6 -3
  40. package/translations/ms.json +3 -0
  41. package/translations/zh.json +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.175",
3
+ "version": "10.1.177",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -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.170",
45
- "@things-factory/figure-service": "^10.1.172",
46
- "@things-factory/figure-viewer": "^10.1.175",
47
- "@things-factory/setting-base": "^10.1.167",
44
+ "@things-factory/ai-assistant": "^10.1.177",
45
+ "@things-factory/figure-service": "^10.1.177",
46
+ "@things-factory/figure-viewer": "^10.1.176",
47
+ "@things-factory/setting-base": "^10.1.176",
48
48
  "three": "^0.185.1"
49
49
  },
50
- "gitHead": "36a28986fdfb366ac31516b63369cf5733a82c31"
50
+ "gitHead": "91da09c945ec5e77ab960074db8f53b9ea8ac534"
51
51
  }
@@ -214,7 +214,8 @@ async function start() {
214
214
  }
215
215
  const properties = document.createElement('figure-properties')
216
216
  properties.slot = 'figure'
217
- const keep = () => (properties.source = editor.session.source())
217
+ /* As the modeller page gives it: the revision on screen (it gave the source, which the panel no longer reads -- N6). */
218
+ const keep = () => (properties.revision = editor.session.revisionNow())
218
219
  keep()
219
220
  editor.addEventListener('changed', keep)
220
221
  /* The figure panel's commands, as the modeller page runs them: one step of the session's history. */
@@ -40,6 +40,14 @@ for (const signal of ['SIGINT', 'SIGTERM'])
40
40
  * `standIns` maps a module the page would import to a file beside this one: for a module that cannot run here (the
41
41
  * assistant dock needs the whole app's tool bar), a stand-in that records what the product asked of it.
42
42
  */
43
+ /*
44
+ Packages that must stand once on a page: what defines custom elements (lit, the @operato and @material elements) and
45
+ what holds one state (i18next). A sibling checkout linked into node_modules for local work -- the operato
46
+ webcomponents, 2026-10-17 -- brought its own @operato/i18n, the page defined ox-i18n twice and stopped before any
47
+ check could start. Each is taken from this checkout's node_modules, whoever imports it.
48
+ */
49
+ const ONE_COPY = id => /^(lit|lit-html|lit-element|@lit\/|@lit-labs\/|i18next|@operato\/|@material\/)/.test(id)
50
+
43
51
  export async function buildPage(page = 'modeller-page.js', { standIns = {} } = {}) {
44
52
  /*
45
53
  The real path: on macOS the temp dir is a link (/var -> /private/var). A module named both through
@@ -87,7 +95,7 @@ export async function buildPage(page = 'modeller-page.js', { standIns = {} } = {
87
95
  }
88
96
  },
89
97
  replace({ preventAssignment: true, values: { 'process.env.NODE_ENV': JSON.stringify('production') } }),
90
- nodeResolve({ browser: true, modulePaths: [join(PACKAGE, 'node_modules'), join(ROOT, 'node_modules')] }),
98
+ nodeResolve({ browser: true, modulePaths: [join(PACKAGE, 'node_modules'), join(ROOT, 'node_modules')], rootDir: PACKAGE, dedupe: ONE_COPY }),
91
99
  commonjs()
92
100
  ]
93
101
  })
@@ -94,7 +94,8 @@ window.editing = {
94
94
  },
95
95
  /** The labels of the number fields on the properties tab. */
96
96
  propertyInputs() {
97
- const panel = editor().renderRoot.querySelector('div[panel]')
97
+ /* The panel shown: the properties, or the 「반응형」 tab a check pressed. */
98
+ const panel = editor().renderRoot.querySelector('div[panel]:not([off])')
98
99
  return [...panel.querySelectorAll('input')].map(i => i.getAttribute('aria-label'))
99
100
  },
100
101
  /** The page point of the first element a selector finds -- anywhere on the page, or inside the `host` elements -- and what it says. */
@@ -90,11 +90,6 @@ 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
93
  /** A fastening the part has is said in one line; 「바꾸기」 opens its drawing and details. */
99
94
  async function changeFastening() {
100
95
  await until(() => look.all('button[change-fastening]').length === 1, [], 'the fastening said in one line')
@@ -131,6 +126,12 @@ async function press(selector, text, what = text ?? selector) {
131
126
  text
132
127
  )
133
128
  assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
129
+ /*
130
+ In the middle of the panel first, as a hand scrolls to what it presses: brought only just into view, at the panel's
131
+ top, it lay under the panel's sticky head and the press fell there (2026-10-17, the leaf's fasten-start once the
132
+ rows were tightened).
133
+ */
134
+ await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
134
135
  await element.click()
135
136
  await settled()
136
137
  }
@@ -205,6 +206,8 @@ describe('a part made to follow the figure size, and let go of it', () => {
205
206
  a = applyFigureAuthoring(a, { kind: 'declare-instance-size', range: SIZE, at: { x: 400, y: 400, z: 400 } })
206
207
  await mount('sized', applyFigureAuthoring(a, { kind: 'link-dimension', part: 'box', axis: 'x', source: { of: 'instance', axis: 'x' } }))
207
208
  await pick('box')
209
+ /* How a part answers a size is the 「반응형」 tab's (user 2026-10-17). */
210
+ await tab('response')
208
211
  })
209
212
 
210
213
  test('on a figure with a size, follow-size is one step, says nothing of a size declared, and is not offered again', async () => {
@@ -247,6 +250,8 @@ function sizedPair(turning) {
247
250
  describe('the sizes a figure may be placed at, and how it grows', () => {
248
251
  test('measured again, the declared range becomes the measured one in one step, and the offer goes', async () => {
249
252
  await mount('sized-pair', sizedPair(false))
253
+ /* The figure's range is the 「반응형」 tab's. */
254
+ await tab('response')
250
255
  const start = await state()
251
256
  await click(await at('button', 'figure.button.remeasure-range'), 'remeasure')
252
257
  await oneStep(start)
@@ -264,6 +269,7 @@ describe('the sizes a figure may be placed at, and how it grows', () => {
264
269
 
265
270
  test('a figure that turns about a joint is urged to grow by one ratio', async () => {
266
271
  await mount('turning', sizedPair(true))
272
+ await tab('response')
267
273
  assert.equal((await all('p[hint]', 'figure.text.sizing-uniform-proposed')).length, 1)
268
274
  })
269
275
  })
@@ -279,6 +285,8 @@ describe('a row\'s pitch', () => {
279
285
  test('a pitch that follows the frame\'s length shows as the number it comes to, and the row is said as drawn', async () => {
280
286
  await mount('pitch-follows', applyFigureAuthoring(conveyor(), { kind: 'set-repeat', part: 'roller', pitch: { from: { of: 'part', part: 'frame', axis: 'x' }, times: 0.1 } }))
281
287
  await pick('roller')
288
+ /* The row is the 「반응형」 tab's. */
289
+ await tab('response')
282
290
  assert.deepEqual((await all('input[aria-label="roller.repeat.pitch"]')).map(i => i.value), ['200'], 'a tenth of 2000')
283
291
  assert.ok((await all('ul[row-reading] li')).length > 0, 'the row said as it stands')
284
292
  })
@@ -291,6 +299,7 @@ describe('a row\'s pitch', () => {
291
299
  ])
292
300
  await mount('published-pitch', published)
293
301
  await pick('roller')
302
+ await tab('response')
294
303
  const start = await state()
295
304
  await type(await handle('input[aria-label="roller.repeat.pitch"]'), '300')
296
305
  await oneStep(start)
@@ -304,7 +313,8 @@ describe('holding places on a part', () => {
304
313
  test('a place needs kinds it takes; a figure that holds nothing says so, and declaring it holds things is one step', async () => {
305
314
  await mount('box')
306
315
  await pick('box')
307
- await openCapability()
316
+ /* What the figure does is a tab again (user 2026-10-17). */
317
+ await tab('capability')
308
318
  assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, true, 'no kinds: no place')
309
319
  await type(await handle('input[placeholder="BOX, TOTE"]'), 'BOX')
310
320
  assert.equal((await at('button[act]', 'figure.button.add-place'))?.disabled, false, 'a kind: a place may be made')
@@ -327,6 +337,8 @@ describe('a position worked out from the figure size', () => {
327
337
  a.document.model.nodes.find(n => n.id === 'lamp.pose').args[1] = 'lamp.lift.value'
328
338
  await mount('lifted', a)
329
339
  await pick('lamp')
340
+ /* A position that follows the figure size is the 「반응형」 tab's. */
341
+ await tab('response')
330
342
  assert.equal((await all('div[pair] span', 'figure.label.position-worked-out')).length, 1)
331
343
  const start = await state()
332
344
  await click(await at('button[act]', 'figure.button.scale-position'), 'scale position')
@@ -525,13 +537,15 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
525
537
 
526
538
  test('a part fastened to nothing: the row is folded under its head and says why', async () => {
527
539
  await pick('loose')
540
+ await tab('response')
528
541
  assert.deepEqual((await all('details[row-fold] > summary h3')).map(h => h.text), ['figure.label.repeat'])
529
542
  assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [false], 'folded')
530
543
  assert.deepEqual((await all('p[row-blocked]')).map(p => p.text), ['figure.text.row-blocked-unfastened'])
531
544
  })
532
545
 
533
- test('the row worked on is the one seen: its copies drawn and said; back on the fastening, it folds', async () => {
546
+ test('the row worked on is the one seen: its copies drawn and said; on the properties tab they are not', async () => {
534
547
  await pick('lamp')
548
+ await tab('response')
535
549
  assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [false], 'folded')
536
550
  await read(() => look.hear('preview-command'))
537
551
  /* One click opens it, and it stays open while the row is worked. */
@@ -543,8 +557,9 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
543
557
  assert.equal((await heard('preview-command')).filter(Boolean).at(-1).action.kind, 'make-repeat')
544
558
  await until(() => look.drawn().filter(n => n.startsWith('__preview')).length > 1, [], 'the copies drawn')
545
559
  await until(() => look.all('section[row] [preview-words]', 'figure.text.row-count-pitch').length === 1, [], 'the row said')
546
- await click(await at('section[fastening] h3 button'), 'the fastening section')
547
- await until(() => look.all('details[row-fold]').every(d => !d.open), [], 'the row folded again')
560
+ /* The properties tab shown: what its fastening would do is said, the row's copies are not drawn. */
561
+ await tab('properties')
562
+ await until(() => !look.drawn().some(n => n.startsWith('__preview')), [], 'the copies not drawn on the properties tab')
548
563
  })
549
564
  })
550
565
 
@@ -53,6 +53,8 @@ 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
58
 
57
59
  const KEYS = { translate: 'KeyW', rotate: 'KeyE', scale: 'KeyR' }
58
60
 
@@ -383,6 +385,7 @@ describe('the fastening form belongs to the part it is for', () => {
383
385
  describe('a figure made in the modeller is given its size from its parts (user 2026-09-25)', () => {
384
386
  before(async () => {
385
387
  await mount('box')
388
+ await tab('response')
386
389
  })
387
390
 
388
391
  test('with nothing picked, the figure offers to declare its size; clicked, its size fields appear', async () => {
@@ -697,6 +700,7 @@ describe('a part follows the figure size in one step (follow-size, 2026-09-26)',
697
700
  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 () => {
698
701
  await mount('box')
699
702
  await click(await read(() => probe.row('box')))
703
+ await tab('response')
700
704
  const at = await page().evaluate(() => {
701
705
  const deep = (root = document) => [...root.querySelectorAll('button[follow-size]')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
702
706
  const b = deep()[0]
@@ -827,6 +831,7 @@ describe('a row\'s pitch made a board\'s to set (ADR-0096)', () => {
827
831
  test('published in one step with a name and a range; the pitch then sets the input; withdrawn, the pitch stays as it is', async () => {
828
832
  await mount('rollers')
829
833
  await click(await read(() => probe.row('roller')))
834
+ await tab('response')
830
835
  const before = (await read(() => probe.ids())).filter(id => id.includes('"roller.placed"')).length
831
836
  await click(await read(() => probe.labelled('figure.label.board-sets')))
832
837
  await typeInto('figure.label.board-name', '롤러 간격')
@@ -893,6 +898,7 @@ describe('the row section opens on one click', () => {
893
898
  test('a part that can be made a row: one click on the folded heading opens it, a second folds it', async () => {
894
899
  await mount('rollers')
895
900
  await click(await read(() => probe.row('mark')))
901
+ await tab('response')
896
902
  const summary = await page().evaluateHandle(() => {
897
903
  const deep = (root = document) => [...root.querySelectorAll('details[row-fold] > summary')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
898
904
  return deep()[0] ?? null
@@ -320,7 +320,8 @@ describe('a row is one part: selected whole, its pitch one value set by the row
320
320
 
321
321
  test('the pitch is offered once, and typed it is one step that sets the row again', async () => {
322
322
  await pick('roller')
323
- await tab('properties')
323
+ /* The row's pitch is the 「반응형」 tab's (user 2026-10-17). */
324
+ await tab('response')
324
325
  const fields = await read(() => editing.propertyInputs())
325
326
  assert.equal(fields.filter(label => /pitch|repeat/i.test(label ?? '')).length, 1, fields.join())
326
327
  const label = fields.find(label => /pitch|repeat/i.test(label ?? ''))
@@ -344,18 +345,8 @@ describe('the long explanations open from their 「?」 beside the title, closed
344
345
  await mount('box', 'doors')
345
346
  await pick('left')
346
347
  const seen = new Set()
347
- for (const name of ['properties', 'motion']) {
348
+ for (const name of ['properties', 'response', 'motion', 'capability']) {
348
349
  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
- }
359
350
  const helps = await read(() =>
360
351
  [...document.querySelector('figure-editor').renderRoot.querySelectorAll('figure-links, figure-clips')].flatMap((element, host) =>
361
352
  [...element.shadowRoot.querySelectorAll('button[help]')].map((b, i) => ({ host, i, seen: b.getClientRects().length > 0 })).filter(b => b.seen)
@@ -198,15 +198,10 @@ 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
- /* 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')
201
+ /* What the figure does is a tab of its own (user 2026-10-17). */
202
+ const tab = await read(l => probe.tab(l), 'figure.label.tab-short-capability')
203
+ await page().mouse.click(tab.x, tab.y)
204
+ await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === 'figure.label.tab-short-capability', [], 'the capability tab on')
210
205
  await settled()
211
206
  })
212
207
 
@@ -70,8 +70,8 @@ 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, the place and the row the
74
- * links draw, the look, what the figure does, and 「더 보기」.
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.
75
75
  */
76
76
  const groupsInOrder = () =>
77
77
  read(() => {
@@ -80,11 +80,11 @@ const groupsInOrder = () =>
80
80
  const links = panel.querySelector('figure-links')?.shadowRoot
81
81
  const marks = {
82
82
  name: panel.querySelector('input[name-field]'),
83
- size: links?.querySelector('section[size]'),
83
+ size: links?.querySelector('section[size], section[size-response]'),
84
84
  place: links?.querySelector('section[fastening]'),
85
+ position: links?.querySelector('section[position-response]'),
85
86
  row: panel.querySelector('section[slot=row]') ?? links?.querySelector('section[row]'),
86
87
  look: panel.querySelector('figure-material-panel:not([show])'),
87
- capability: panel.querySelector('section[capability]'),
88
88
  more: panel.querySelector('section[more]')
89
89
  }
90
90
  return Object.entries(marks)
@@ -93,12 +93,18 @@ const groupsInOrder = () =>
93
93
  .map(([name]) => name)
94
94
  })
95
95
 
96
- describe('the side: four tabs, the view and the history from the bar', () => {
96
+ /* A side tab pressed, by its name. */
97
+ async function tab(name) {
98
+ await press(`aside button[tab][aria-label="figure.label.tab-short-${name}"]`, `the ${name} tab`)
99
+ await read(() => document.querySelector('figure-editor').updateComplete)
100
+ }
101
+
102
+ describe('the side: six tabs, the view and the history from the bar', () => {
97
103
  before(() => mount('rollers'))
98
104
 
99
- test('properties, motion, data and the verdict -- the four tabs, in that order', async () => {
105
+ test('properties, responsive, motion, capability, data and the verdict -- the six tabs, in that order (user 2026-10-17)', async () => {
100
106
  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}`))
107
+ assert.deepEqual(tabs, ['properties', 'response', 'motion', 'capability', 'data', 'verdict'].map(t => `figure.label.tab-short-${t}`))
102
108
  })
103
109
 
104
110
  test('the view opens over the work from the bar, pressed while open; Esc closes it', async () => {
@@ -126,18 +132,27 @@ describe('the side: four tabs, the view and the history from the bar', () => {
126
132
  describe('a part\'s groups in one order, whatever its state', () => {
127
133
  before(() => mount('rollers'))
128
134
 
129
- test('a part fastened in a row: name, size, place, row, look, what it does, more', async () => {
135
+ test('a part fastened in a row: name, size, place, look, more; on 「반응형」 its size rules and its row', async () => {
130
136
  await pick('roller')
131
- assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'row', 'look', 'capability', 'more'])
137
+ await tab('properties')
138
+ assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'look', 'more'])
139
+ await tab('response')
140
+ assert.deepEqual(await groupsInOrder(), ['size', 'row'])
141
+ await tab('properties')
132
142
  })
133
143
 
134
144
  test('a part on the plane, not in a row: the same order, the row folded where a row would be', async () => {
135
145
  await pick('bed')
136
- assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'row', 'look', 'capability', 'more'])
146
+ assert.deepEqual(await groupsInOrder(), ['name', 'size', 'place', 'look', 'more'])
147
+ /* On the plane, its height is worked out from the figure size: the position group stands between. */
148
+ await tab('response')
149
+ assert.deepEqual(await groupsInOrder(), ['size', 'position', 'row'])
150
+ await tab('properties')
137
151
  })
138
152
 
139
153
  test('a fastened part\'s place is one line and 「바꾸기」: no locked cells, no drawing, until it is changed', async () => {
140
154
  await pick('mark')
155
+ await tab('properties')
141
156
  const place = () =>
142
157
  read(() => {
143
158
  const editor = document.querySelector('figure-editor')
@@ -187,6 +202,8 @@ describe('a group\'s 「⋯」: its rarer actions, by the mouse or the keyboard'
187
202
 
188
203
  test('Esc closes the menu and nothing is done', async () => {
189
204
  await pick('roller')
205
+ /* The row is the 「반응형」 tab's. */
206
+ await tab('response')
190
207
  const start = await revision()
191
208
  await press('section[row] button[group-menu]', 'the row\'s 「⋯」')
192
209
  await until(() => document.activeElement?.localName === 'ox-popup-menu', [], 'the menu open, with the focus')
@@ -232,16 +249,19 @@ describe('the parts panel stands at the side\'s heights (2026-10-16)', () => {
232
249
  before(() => mount('rollers'))
233
250
 
234
251
  test('the detail level is one height with the side\'s fields, and the palette\'s tiles one height with each other', async () => {
252
+ await pick('bed')
235
253
  const heights = await read(() => {
236
254
  const parts = document.querySelector('figure-editor').shadowRoot.querySelector('figure-parts').shadowRoot
237
255
  const h = e => Math.round(e.getBoundingClientRect().height)
238
256
  const tiles = [...parts.querySelectorAll('div[palette] button[shape]:not([import])')].map(h)
239
257
  return { level: h(parts.querySelector('div[level] select')), tiles: [...new Set(tiles)], count: tiles.length, import: h(parts.querySelector('button[shape][import]')) }
240
258
  })
241
- assert.equal(heights.level, 30, 'the detail level select')
259
+ const side = await read(() => Math.round(document.querySelector('figure-editor').shadowRoot.querySelector('input[name-field]')?.getBoundingClientRect().height ?? 0))
260
+ assert.ok(side > 0, 'a side field to measure against')
261
+ assert.equal(heights.level, side, 'the detail level select, one height with the side\'s fields')
242
262
  assert.ok(heights.count >= 9, `the starting shapes: ${heights.count}`)
243
263
  assert.equal(heights.tiles.length, 1, `one tile height: ${heights.tiles}`)
244
- assert.equal(heights.import, 30, 'the file import on one line, its own row')
264
+ assert.equal(heights.import, side, 'the file import on one line, its own row, at the fields\' height')
245
265
  })
246
266
 
247
267
  /* A static guard sees the sizes written down; an icon with none stands at md-icon's own 24 (the recipe catalog's did). */
@@ -260,3 +280,38 @@ describe('the parts panel stands at the side\'s heights (2026-10-16)', () => {
260
280
  assert.deepEqual(sizes.filter(s => !['16', '18', '20'].includes(s.split(' ')[0])), [])
261
281
  })
262
282
  })
283
+
284
+ describe('a label and its field on one line, the field a line not a box (user 2026-10-17)', () => {
285
+ before(() => mount('rollers'))
286
+
287
+ /* Every visible label beside the control after it: the field's row, not the row above. */
288
+ const rows = () =>
289
+ read(() => {
290
+ const all = []
291
+ const walk = root => { for (const e of root.querySelectorAll('*')) { all.push(e); if (e.shadowRoot) walk(e.shadowRoot) } }
292
+ walk(document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off])'))
293
+ const out = []
294
+ for (const field of all.filter(e => e.matches?.('div[field], div[inline-field], div[form]') && e.checkVisibility())) {
295
+ const label = field.querySelector(':scope > label, :scope > span')
296
+ const control = field.querySelector(':scope > input, :scope > select, :scope > div[swatches], :scope > div[row]')
297
+ if (!label || !control || !label.checkVisibility() || !control.checkVisibility()) continue
298
+ const a = label.getBoundingClientRect(), b = control.getBoundingClientRect()
299
+ out.push({ label: label.textContent.trim().slice(0, 20), sameLine: a.top < b.bottom && b.top < a.bottom, leftOf: a.right <= b.left + 1 })
300
+ }
301
+ const boxed = all.filter(e => e.matches?.('input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]), select') && e.checkVisibility() && !e.closest('div[cell]'))
302
+ .map(e => { const s = getComputedStyle(e); return { top: s.borderTopWidth, left: s.borderLeftWidth, bottom: s.borderBottomWidth } })
303
+ return { out, boxed }
304
+ })
305
+
306
+ /* The figure's own group is the modeller page's (figure-properties), not this page's: its rows are checked in screen.check. */
307
+ for (const part of ['bed', 'roller']) {
308
+ test(`${part}: every label beside its field, every field a line`, async () => {
309
+ await pick(part)
310
+ const { out, boxed } = await rows()
311
+ assert.ok(out.length >= 2, `rows read: ${out.length}`)
312
+ assert.deepEqual(out.filter(r => !r.sameLine || !r.leftOf), [], 'labels on their field\'s line, to its left')
313
+ assert.ok(boxed.length > 0, 'fields read')
314
+ assert.deepEqual(boxed.filter(b => b.top !== '0px' || b.left !== '0px' || b.bottom !== '1px'), [], 'only the line under it')
315
+ })
316
+ }
317
+ })
@@ -60,6 +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'))
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')
63
67
  await click('details[row-fold] > summary', {}, 'the row\'s fold')
64
68
  })
65
69
 
@@ -191,13 +191,13 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
191
191
  assert.equal(await read(() => getComputedStyle(screenCheck.editor()).containerType), 'inline-size')
192
192
  })
193
193
 
194
- test('the side\'s four tabs, in order, each saying its name under its icon -- the name it is read by', async () => {
194
+ test('the side\'s six tabs, in order, each saying its name under its icon -- the name it is read by', async () => {
195
195
  const tabs = await read(() =>
196
196
  [...screenCheck.editor().shadowRoot.querySelectorAll('aside button[tab]')].map(b => ({ label: b.getAttribute('aria-label'), shows: b.querySelector('span[tab-label]')?.textContent.trim() }))
197
197
  )
198
198
  assert.deepEqual(
199
199
  tabs.map(t => t.label),
200
- ['properties', 'motion', 'data', 'verdict'].map(TAB)
200
+ ['properties', 'response', 'motion', 'capability', 'data', 'verdict'].map(TAB)
201
201
  )
202
202
  for (const tab of tabs) assert.equal(tab.shows, tab.label, `${tab.label} shows its name`)
203
203
  assert.equal(await read(() => screenCheck.editor().shadowRoot.querySelector('aside').firstElementChild.matches('div[tabs]')), true, 'the tabs are the side\'s first row')
@@ -206,12 +206,12 @@ describe('the frame: parts · canvas · one side of seven tabs, and the bar over
206
206
  test('a tab shows its panel and hides the others without taking them down -- the verdict keeps what it holds', async () => {
207
207
  const panels = () =>
208
208
  read(() => [...screenCheck.editor().shadowRoot.querySelectorAll('aside > div[panel]')].map(p => ({ seen: p.getBoundingClientRect().height > 0, connected: p.isConnected, verdict: !!p.querySelector('slot[name=verdict]') })))
209
- const tabs = ['properties', 'motion', 'data', 'verdict']
209
+ const tabs = ['properties', 'response', 'motion', 'capability', 'data', 'verdict']
210
210
  for (const [at, tab] of tabs.entries()) {
211
211
  await pressTab(TAB(tab))
212
212
  await until(`${tab} on`, name => screenCheck.editor().shadowRoot.querySelector('button[tab][on]')?.getAttribute('aria-label') === name, TAB(tab))
213
213
  const now = await panels()
214
- assert.equal(now.length, 4, 'four panels, all standing')
214
+ assert.equal(now.length, 6, 'six panels, all standing')
215
215
  assert.deepEqual(now.map(p => p.seen), tabs.map((_, i) => i === at), `${tab}: its panel seen, the rest hidden`)
216
216
  assert.ok(now.every(p => p.connected))
217
217
  assert.ok(now[tabs.indexOf('verdict')].verdict, 'the verdict panel holds the verdict slot whichever tab is on')
@@ -329,7 +329,7 @@ describe('the editor is drawn in the shell\'s tokens (2026-09-27)', () => {
329
329
  describe('a part chosen: each tab in its place, the figure-wide tabs give way, the list\'s root goes back', () => {
330
330
  before(() => mount('arm'))
331
331
 
332
- test('properties hold the part\'s size, place, material and, folded, its places; motion its motion and controls -- each under the part\'s head', async () => {
332
+ 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 () => {
333
333
  await pressRow('upper-arm')
334
334
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
335
335
  const holds = () =>
@@ -349,16 +349,17 @@ describe('a part chosen: each tab in its place, the figure-wide tabs give way, t
349
349
  assert.ok(now.subject, 'the part\'s head')
350
350
  assert.ok(now.links.includes('figure.label.group-size') && now.links.includes('figure.label.group-place'), `the shape's links: ${now.links}`)
351
351
  assert.ok(now.material, 'its material')
352
- /* What it does is a folded group of the properties now (chief architect 2026-10-16), its places inside. */
353
- await press('the capability fold', () => screenCheck.shownPanel().querySelector('section[capability] > details > summary'))
354
- const places = await read(() => {
355
- const fold = screenCheck.shownPanel().querySelector('section[capability] > details')
356
- const links = fold.querySelector('figure-links')
357
- return { open: fold.open, heads: links ? [...links.shadowRoot.querySelectorAll('section > h3')].map(h => screenCheck.ownText(h)) : [], capability: !!fold.querySelector('figure-capability-panel') }
358
- })
359
- assert.ok(places.open, 'the fold opened')
360
- assert.ok(places.heads.length === 1 && places.heads[0].startsWith('figure.label.places-on-part'), `the places on the part: ${places.heads}`)
361
- assert.ok(places.capability, 'its capabilities')
352
+ /* How it answers a size has a tab of its own (user 2026-10-17, 「반응형」). */
353
+ await pressTab(TAB('response'))
354
+ now = await holds()
355
+ assert.ok(now.subject, 'the part\'s head')
356
+ assert.ok(now.links.includes('figure.label.group-size'), `the response's links: ${now.links}`)
357
+ /* What it does is a tab again. */
358
+ await pressTab(TAB('capability'))
359
+ now = await holds()
360
+ assert.ok(now.subject, 'the part\'s head')
361
+ assert.ok(now.links.length === 1 && now.links[0].startsWith('figure.label.places-on-part'), `the places on the part: ${now.links}`)
362
+ assert.ok(now.capability, 'its capabilities')
362
363
  await pressTab(TAB('motion'))
363
364
  now = await holds()
364
365
  assert.ok(now.subject, 'the part\'s head')
@@ -812,8 +813,11 @@ describe('a part\'s size row: a rule\'s numbers only where a rule sets every sid
812
813
  await mount('box')
813
814
  await pressRow('box')
814
815
  await until('box chosen', () => screenCheck.editor().selected === 'box')
815
- await press('follow the figure\'s size', () => screenCheck.deep('button[follow-size]')[0])
816
+ /* Following the figure's size is the 「반응형」 tab's; the size row it gives is the properties'. */
817
+ await pressTab(TAB('response'))
818
+ await press('follow the figure\'s size', () => screenCheck.deep('button[follow-size]').find(b => b.checkVisibility()))
816
819
  await until('following', () => ['x', 'y', 'z'].every(axis => !screenCheck.editor().session.read().fields.some(f => f.id === `box.size.${axis}`)))
820
+ await pressTab(TAB('properties'))
817
821
  assert.deepEqual(await sizeRow(), { held: 3 }, 'three sides, each held by the rule')
818
822
  })
819
823
  })
@@ -841,9 +845,12 @@ describe('the side panel is found as it was left, per part', () => {
841
845
  test('scrolled and a fold opened on one part; another part opens at its top; back, the first is as it was left', async () => {
842
846
  await pressRow('upper-arm')
843
847
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
848
+ /* 「더 보기」 starts open on this page: folded first, so the fold opened below is one this part leaves open. */
849
+ if (await read(() => !!screenCheck.shownPanel().querySelector('section[more] > details[open]')))
850
+ await press('the more fold', () => screenCheck.shownPanel().querySelector('section[more] > details > summary'))
844
851
  await press('a fold', () => {
845
852
  const panel = screenCheck.shownPanel()
846
- const closed = [...panel.querySelectorAll('figure-links')].flatMap(l => [...l.shadowRoot.querySelectorAll('details')]).find(d => !d.open)
853
+ const closed = [...panel.querySelectorAll('details'), ...[...panel.querySelectorAll('figure-links')].flatMap(l => [...l.shadowRoot.querySelectorAll('details')])].find(d => !d.open && d.checkVisibility())
847
854
  return closed?.querySelector('summary')
848
855
  })
849
856
  const opened = await folds()
@@ -1008,6 +1015,28 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1008
1015
  }
1009
1016
  })
1010
1017
 
1018
+ /* The figure's own group in the side's one-line rows (user 2026-10-17): the identifier, the name, where it stands. */
1019
+ test('the figure panel\'s labels stand beside their fields, the identifier\'s too, each field a line', async () => {
1020
+ await pressTab(TAB('properties'))
1021
+ const rows = await read(() => {
1022
+ const properties = screenCheck.deep('figure-properties')[0]
1023
+ const out = []
1024
+ const type = properties.querySelector('label[type-field]')
1025
+ const pairs = [[type?.querySelector('span'), type?.querySelector('input, code')]]
1026
+ for (const field of properties.shadowRoot.querySelectorAll('div[field]')) pairs.push([field.querySelector(':scope > label'), field.querySelector(':scope > input, :scope > select, :scope > div[row]')])
1027
+ for (const [label, control] of pairs) {
1028
+ if (!label || !control || !label.checkVisibility() || !control.checkVisibility()) continue
1029
+ const a = label.getBoundingClientRect(), b = control.getBoundingClientRect()
1030
+ const line = control.matches('input, select') ? getComputedStyle(control) : null
1031
+ out.push({ label: label.textContent.trim(), sameLine: a.top < b.bottom && b.top < a.bottom, leftOf: a.right <= b.left + 1, box: line ? line.borderTopWidth !== '0px' || line.borderLeftWidth !== '0px' : false })
1032
+ }
1033
+ return out
1034
+ })
1035
+ assert.ok(rows.length >= 3, `rows read: ${rows.map(r => r.label)}`)
1036
+ assert.deepEqual(rows.filter(r => !r.sameLine || !r.leftOf), [], 'labels on their field\'s line, to its left')
1037
+ assert.deepEqual(rows.filter(r => r.box), [], 'fields drawn as a line, not a box')
1038
+ })
1039
+
1011
1040
  test('the figure\'s type is the first field of the figure panel, under its heading', async () => {
1012
1041
  await pressTab(TAB('properties'))
1013
1042
  const first = await read(() => {
@@ -1143,8 +1172,9 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1143
1172
  await open('fig-p', 'arm-clip')
1144
1173
  await pressRow('upper-arm')
1145
1174
  await until('upper-arm chosen', () => screenCheck.editor().selected === 'upper-arm')
1146
- /* The part's panel fits the side now; its places opened make it long enough to scroll. */
1147
- await press('the capability fold', () => screenCheck.shownPanel().querySelector('section[capability] > details:not([open]) > summary'))
1175
+ /* The part's panel fits the side now: its 「더 보기」 open makes it long enough to scroll. */
1176
+ if (await read(() => !!screenCheck.shownPanel().querySelector('section[more] > details:not([open])')))
1177
+ await press('the more fold', () => screenCheck.shownPanel().querySelector('section[more] > details:not([open]) > summary'))
1148
1178
  const at = await read(() => {
1149
1179
  const r = screenCheck.shownPanel().getBoundingClientRect()
1150
1180
  return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
@@ -24,7 +24,8 @@ import { buildPage } from './build.mjs'
24
24
  const HERE = dirname(fileURLToPath(import.meta.url))
25
25
  const bundle = await buildPage('acceptance-page.js')
26
26
  const dir = dirname(bundle)
27
- const ko = readFileSync(resolve(HERE, '../../translations/ko.json'))
27
+ /* The figure panel's words are figure-viewer's: both packages' Korean, as the app loads every module's. */
28
+ const ko = Buffer.from(JSON.stringify({ ...JSON.parse(readFileSync(resolve(HERE, '../../../figure-viewer/translations/ko.json'), 'utf-8')), ...JSON.parse(readFileSync(resolve(HERE, '../../translations/ko.json'), 'utf-8')) }))
28
29
  const APP = resolve(process.env.OPERATO_APP ?? resolve(HERE, '../../../../../../operato-application/packages/operato-figure'))
29
30
  /* The builder's ThemeOverridePlugin, run from the app root so its module order is the app's. */
30
31
  const themeFiles = JSON.parse(
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Words that send the author somewhere name a place that is there (chief architect's ruling 2026-10-17: six side tabs,
3
+ * 「반응형」 holding size behaviour, the position that follows the size, the row and the range). The modeller once said
4
+ * 「움직임 탭의 치수에서」 and 「「위치와 크기」에서 「여기에 고정」」 after both had moved, and 「성숙도 탭」 for the tab named 검사.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+ import { readFileSync } from 'node:fs'
9
+
10
+ const table = (path: string) => JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Record<string, string>
11
+ const GONE: Record<string, RegExp> = {
12
+ ko: /성숙도 탭|「위치와 크기」|움직임 탭의 (치수|붙이기)/,
13
+ en: /maturity tab|under placement|under size and placement|(sizes|fastening) in the motion tab/
14
+ }
15
+
16
+ test('no message sends the author to a tab or group the side no longer has', () => {
17
+ for (const lang of ['ko', 'en'] as const)
18
+ for (const path of ['../translations/', '../../figure-service/translations/']) {
19
+ const said = Object.entries(table(`${path}${lang}.json`)).filter(([, words]) => GONE[lang]!.test(words))
20
+ assert.deepEqual(said, [], `${path}${lang}.json`)
21
+ }
22
+ })
23
+
24
+ test('the places named are the tabs as they are called', () => {
25
+ const ko = table('../translations/ko.json')
26
+ const service = table('../../figure-service/translations/ko.json')
27
+ assert.ok(ko['figure.text.gizmo-size-follows']!.includes(`${ko['figure.label.tab-short-response']} 탭`))
28
+ assert.ok(ko['figure.text.preview-blocked-by-errors']!.includes(`${ko['figure.label.tab-short-verdict']} 탭`))
29
+ for (const key of ['figure.gate.parts-split-on-resize.how', 'figure.gate.sizing-not-uniform.how'])
30
+ assert.ok(service[key]!.includes(`${ko['figure.label.tab-short-response']} 탭의 「${ko['figure.label.group-position-response']}」`), key)
31
+ })