@things-factory/figure-ui 10.1.174 → 10.1.176

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.174",
3
+ "version": "10.1.176",
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.174",
47
- "@things-factory/setting-base": "^10.1.167",
44
+ "@things-factory/ai-assistant": "^10.1.176",
45
+ "@things-factory/figure-service": "^10.1.176",
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": "501a2ab7dfcda5cf78e2408bb4e914cac902349d"
50
+ "gitHead": "24928f127d73de0e9946423bd4c3295f54cce03d"
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
  })
@@ -131,6 +131,12 @@ async function press(selector, text, what = text ?? selector) {
131
131
  text
132
132
  )
133
133
  assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
134
+ /*
135
+ In the middle of the panel first, as a hand scrolls to what it presses: brought only just into view, at the panel's
136
+ top, it lay under the panel's sticky head and the press fell there (2026-10-17, the leaf's fasten-start once the
137
+ rows were tightened).
138
+ */
139
+ await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
134
140
  await element.click()
135
141
  await settled()
136
142
  }
@@ -36,8 +36,8 @@ async function until(fn, args, what, ms = 8000) {
36
36
  await sleep(50)
37
37
  }
38
38
  }
39
- async function mount(kind) {
40
- await read(kind => harness.mount(kind, null), kind)
39
+ async function mount(kind, templates = []) {
40
+ await read((kind, templates) => harness.mount(kind, null, templates), kind, templates)
41
41
  await until(
42
42
  () => {
43
43
  try {
@@ -172,6 +172,11 @@ describe('a group\'s 「⋯」: its rarer actions, by the mouse or the keyboard'
172
172
  await until(() => document.activeElement?.localName === 'ox-popup-menu', [], 'the menu open, with the focus')
173
173
  const items = await read(() => [...document.querySelectorAll('ox-popup-menu[active] ox-popup-menuitem')].map(i => i.getAttribute('label')))
174
174
  assert.deepEqual(items, ['figure.button.detach'])
175
+ /*
176
+ An item can take the focus once it has drawn (ox-popup-menuitem sets its tabindex in firstUpdated); an arrow before
177
+ that leaves the focus on the menu. A hand is never that quick: one in five runs here was (2026-10-17).
178
+ */
179
+ await until(() => !!document.querySelector('ox-popup-menu[active] ox-popup-menuitem[tabindex]'), [], 'the item ready')
175
180
  await page().keyboard.press('ArrowDown')
176
181
  /* The item takes the focus on the menu's next update; Enter goes to it from then. */
177
182
  await until(() => document.activeElement?.localName === 'ox-popup-menuitem', [], 'the item chosen')
@@ -227,15 +232,69 @@ describe('the parts panel stands at the side\'s heights (2026-10-16)', () => {
227
232
  before(() => mount('rollers'))
228
233
 
229
234
  test('the detail level is one height with the side\'s fields, and the palette\'s tiles one height with each other', async () => {
235
+ await pick('bed')
230
236
  const heights = await read(() => {
231
237
  const parts = document.querySelector('figure-editor').shadowRoot.querySelector('figure-parts').shadowRoot
232
238
  const h = e => Math.round(e.getBoundingClientRect().height)
233
239
  const tiles = [...parts.querySelectorAll('div[palette] button[shape]:not([import])')].map(h)
234
240
  return { level: h(parts.querySelector('div[level] select')), tiles: [...new Set(tiles)], count: tiles.length, import: h(parts.querySelector('button[shape][import]')) }
235
241
  })
236
- assert.equal(heights.level, 30, 'the detail level select')
242
+ const side = await read(() => Math.round(document.querySelector('figure-editor').shadowRoot.querySelector('input[name-field]')?.getBoundingClientRect().height ?? 0))
243
+ assert.ok(side > 0, 'a side field to measure against')
244
+ assert.equal(heights.level, side, 'the detail level select, one height with the side\'s fields')
237
245
  assert.ok(heights.count >= 9, `the starting shapes: ${heights.count}`)
238
246
  assert.equal(heights.tiles.length, 1, `one tile height: ${heights.tiles}`)
239
- assert.equal(heights.import, 30, 'the file import on one line, its own row')
247
+ assert.equal(heights.import, side, 'the file import on one line, its own row, at the fields\' height')
248
+ })
249
+
250
+ /* A static guard sees the sizes written down; an icon with none stands at md-icon's own 24 (the recipe catalog's did). */
251
+ test('every icon the modeller shows stands in one of the three sizes, the ones with no size of their own too', async () => {
252
+ /* With a template the parts panel offers the recipe catalog -- the button whose icon had no size. */
253
+ await mount('rollers', [{ name: 'probe-template', target: 'part' }])
254
+ assert.ok(await read(() => !!document.querySelector('figure-editor').shadowRoot.querySelector('figure-parts').shadowRoot.querySelector('button[recipe-catalog]')), 'the recipe catalog offered')
255
+ const sizes = await read(() => {
256
+ const all = []
257
+ const walk = root => { for (const e of root.querySelectorAll('*')) { all.push(e); if (e.shadowRoot) walk(e.shadowRoot) } }
258
+ walk(document.querySelector('figure-editor').shadowRoot)
259
+ const icons = all.filter(e => e.localName === 'md-icon' && e.checkVisibility() && e.getBoundingClientRect().width > 0)
260
+ return icons.map(e => `${Math.round(e.getBoundingClientRect().width)} ${e.textContent.trim()}`)
261
+ })
262
+ assert.ok(sizes.length > 20, `icons seen: ${sizes.length}`)
263
+ assert.deepEqual(sizes.filter(s => !['16', '18', '20'].includes(s.split(' ')[0])), [])
240
264
  })
241
265
  })
266
+
267
+ describe('a label and its field on one line, the field a line not a box (user 2026-10-17)', () => {
268
+ before(() => mount('rollers'))
269
+
270
+ /* Every visible label beside the control after it: the field's row, not the row above. */
271
+ const rows = () =>
272
+ read(() => {
273
+ const all = []
274
+ const walk = root => { for (const e of root.querySelectorAll('*')) { all.push(e); if (e.shadowRoot) walk(e.shadowRoot) } }
275
+ walk(document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off])'))
276
+ const out = []
277
+ for (const field of all.filter(e => e.matches?.('div[field], div[inline-field], div[form]') && e.checkVisibility())) {
278
+ const label = field.querySelector(':scope > label, :scope > span')
279
+ const control = field.querySelector(':scope > input, :scope > select, :scope > div[swatches], :scope > div[row]')
280
+ if (!label || !control || !label.checkVisibility() || !control.checkVisibility()) continue
281
+ const a = label.getBoundingClientRect(), b = control.getBoundingClientRect()
282
+ out.push({ label: label.textContent.trim().slice(0, 20), sameLine: a.top < b.bottom && b.top < a.bottom, leftOf: a.right <= b.left + 1 })
283
+ }
284
+ 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]'))
285
+ .map(e => { const s = getComputedStyle(e); return { top: s.borderTopWidth, left: s.borderLeftWidth, bottom: s.borderBottomWidth } })
286
+ return { out, boxed }
287
+ })
288
+
289
+ /* The figure's own group is the modeller page's (figure-properties), not this page's: its rows are checked in screen.check. */
290
+ for (const part of ['bed', 'roller']) {
291
+ test(`${part}: every label beside its field, every field a line`, async () => {
292
+ await pick(part)
293
+ const { out, boxed } = await rows()
294
+ assert.ok(out.length >= 2, `rows read: ${out.length}`)
295
+ assert.deepEqual(out.filter(r => !r.sameLine || !r.leftOf), [], 'labels on their field\'s line, to its left')
296
+ assert.ok(boxed.length > 0, 'fields read')
297
+ assert.deepEqual(boxed.filter(b => b.top !== '0px' || b.left !== '0px' || b.bottom !== '1px'), [], 'only the line under it')
298
+ })
299
+ }
300
+ })
@@ -1008,6 +1008,28 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1008
1008
  }
1009
1009
  })
1010
1010
 
1011
+ /* The figure's own group in the side's one-line rows (user 2026-10-17): the identifier, the name, where it stands. */
1012
+ test('the figure panel\'s labels stand beside their fields, the identifier\'s too, each field a line', async () => {
1013
+ await pressTab(TAB('properties'))
1014
+ const rows = await read(() => {
1015
+ const properties = screenCheck.deep('figure-properties')[0]
1016
+ const out = []
1017
+ const type = properties.querySelector('label[type-field]')
1018
+ const pairs = [[type?.querySelector('span'), type?.querySelector('input, code')]]
1019
+ for (const field of properties.shadowRoot.querySelectorAll('div[field]')) pairs.push([field.querySelector(':scope > label'), field.querySelector(':scope > input, :scope > select, :scope > div[row]')])
1020
+ for (const [label, control] of pairs) {
1021
+ if (!label || !control || !label.checkVisibility() || !control.checkVisibility()) continue
1022
+ const a = label.getBoundingClientRect(), b = control.getBoundingClientRect()
1023
+ const line = control.matches('input, select') ? getComputedStyle(control) : null
1024
+ 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 })
1025
+ }
1026
+ return out
1027
+ })
1028
+ assert.ok(rows.length >= 3, `rows read: ${rows.map(r => r.label)}`)
1029
+ assert.deepEqual(rows.filter(r => !r.sameLine || !r.leftOf), [], 'labels on their field\'s line, to its left')
1030
+ assert.deepEqual(rows.filter(r => r.box), [], 'fields drawn as a line, not a box')
1031
+ })
1032
+
1011
1033
  test('the figure\'s type is the first field of the figure panel, under its heading', async () => {
1012
1034
  await pressTab(TAB('properties'))
1013
1035
  const first = await read(() => {
@@ -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(