@things-factory/figure-ui 10.1.181 → 10.1.185

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 (67) hide show
  1. package/client/modeller/editor-styles.ts +28 -9
  2. package/client/modeller/editor.ts +105 -12
  3. package/client/modeller/fasten-form.ts +207 -0
  4. package/client/modeller/fasten-geometry.ts +5 -1
  5. package/client/modeller/figure-facts.ts +4 -35
  6. package/client/modeller/figure-fasten-dialog.ts +457 -0
  7. package/client/modeller/figure-fasten-drawing.ts +32 -11
  8. package/client/modeller/figure-links.ts +56 -300
  9. package/client/modeller/figure-properties.ts +23 -70
  10. package/client/modeller/modeller-keys.ts +4 -4
  11. package/dist-client/modeller/clips.d.ts +2 -2
  12. package/dist-client/modeller/editor-styles.js +28 -9
  13. package/dist-client/modeller/editor-styles.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +23 -2
  15. package/dist-client/modeller/editor.js +121 -12
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-form.d.ts +69 -0
  18. package/dist-client/modeller/fasten-form.js +131 -0
  19. package/dist-client/modeller/fasten-form.js.map +1 -0
  20. package/dist-client/modeller/fasten-geometry.d.ts +3 -1
  21. package/dist-client/modeller/fasten-geometry.js +6 -1
  22. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  23. package/dist-client/modeller/figure-facts.d.ts +2 -13
  24. package/dist-client/modeller/figure-facts.js +2 -29
  25. package/dist-client/modeller/figure-facts.js.map +1 -1
  26. package/dist-client/modeller/figure-fasten-dialog.d.ts +69 -0
  27. package/dist-client/modeller/figure-fasten-dialog.js +433 -0
  28. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -0
  29. package/dist-client/modeller/figure-fasten-drawing.d.ts +6 -3
  30. package/dist-client/modeller/figure-fasten-drawing.js +36 -10
  31. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  32. package/dist-client/modeller/figure-links.d.ts +14 -38
  33. package/dist-client/modeller/figure-links.js +58 -301
  34. package/dist-client/modeller/figure-links.js.map +1 -1
  35. package/dist-client/modeller/figure-properties.d.ts +12 -12
  36. package/dist-client/modeller/figure-properties.js +23 -72
  37. package/dist-client/modeller/figure-properties.js.map +1 -1
  38. package/dist-client/modeller/modeller-keys.js +4 -4
  39. package/dist-client/modeller/modeller-keys.js.map +1 -1
  40. package/dist-client/route.d.ts +1 -1
  41. package/dist-client/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +8 -8
  43. package/test/browser/links-page.js +4 -4
  44. package/test/browser/links.check.mjs +196 -46
  45. package/test/browser/modeller-page.js +23 -0
  46. package/test/browser/modeller.check.mjs +19 -20
  47. package/test/browser/namespace-page.js +2 -29
  48. package/test/browser/popup-stand-in.js +48 -0
  49. package/test/browser/product-page.check.mjs +60 -9
  50. package/test/browser/product-page.js +30 -33
  51. package/test/browser/property-section.check.mjs +3 -3
  52. package/test/browser/props.check.mjs +28 -7
  53. package/test/browser/screen-page.js +7 -2
  54. package/test/browser/screen.check.mjs +28 -23
  55. package/test/browser/section-page.js +13 -41
  56. package/test/browser/section.check.mjs +39 -30
  57. package/test/fasten-form.test.ts +77 -0
  58. package/test/fasten-geometry.test.ts +2 -0
  59. package/test/first-minute.test.ts +7 -4
  60. package/test/properties.test.ts +11 -19
  61. package/translations/en.json +5 -12
  62. package/translations/es.json +5 -12
  63. package/translations/ja.json +5 -12
  64. package/translations/ko.json +5 -12
  65. package/translations/ms.json +5 -12
  66. package/translations/zh.json +5 -12
  67. package/client/modeller/mounting-part.ts +0 -15
@@ -265,8 +265,8 @@ 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]'
268
+ /** The releases open from 「발행」 beside save, the bar's one way to them (user 2026-10-18); pressed only when not open already. */
269
+ const HISTORY = 'figure-editor >>> button[publish]'
270
270
  async function openHistory() {
271
271
  if ((await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY)) !== 'true') await press(HISTORY, 'the history button')
272
272
  }
@@ -296,7 +296,8 @@ describe('the history panel on the page', () => {
296
296
  await until(p => product.deep(p)[0]?.shadowRoot.activeElement?.closest('section[release]'), 'the release section focused', PANEL)
297
297
  const panel = await read(p => { const panel = product.deep(p)[0]; return { slot: panel.assignedSlot?.name, visible: panel.checkVisibility() } }, PANEL)
298
298
  assert.deepEqual(panel, { slot: 'history', visible: true })
299
- assert.equal(await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY), 'true', 'the bar\'s history pressed')
299
+ assert.equal(await read(s => product.deep(s)[0]?.getAttribute('aria-pressed'), HISTORY), 'true', 'the bar\'s 「발행」 pressed')
300
+ assert.equal(await read(() => product.deep('figure-editor >>> button[bar-overlay=history]').length), 0, 'no second way to the same panel')
300
301
  })
301
302
 
302
303
  test('a released version downloads as a package by its full reference', async () => {
@@ -310,8 +311,7 @@ describe('the history panel on the page', () => {
310
311
  const before = await read(() => product.view())
311
312
  await press(`${PANEL} >>> button[fit-base]`, 'fit')
312
313
  await until(r => product.view().revision === r + 1, 'one step', before.revision)
313
- await press(TAB('properties'), 'the properties tab')
314
- await until(() => product.deep('figure-properties >>> div[row] > span')[0]?.textContent === 'figure.text.declared-space-declared', 'the room declared')
314
+ await until(() => !!JSON.parse(product.view().source).occupancy, 'the room declared')
315
315
  await openHistory()
316
316
  })
317
317
 
@@ -534,11 +534,30 @@ describe('the page around the figure: the shell, the dock, the properties', () =
534
534
  await until(() => product.view().name === 'Door', 'undone')
535
535
  })
536
536
 
537
- test('fitting from the properties declares the room from the parts, as one step', async () => {
537
+ /*
538
+ Nothing to set up before work goes on (user 2026-10-18, after a walk without it): the tab names the figure and
539
+ where it stands. The room is declared where release asks for it; no face is chosen here.
540
+ */
541
+ test('the properties tab neither declares the room nor picks a face; where it stands is one step', async () => {
542
+ const shown = await read(() => {
543
+ const root = product.deep('figure-modeller-page >>> figure-properties')[0].shadowRoot
544
+ return {
545
+ headings: [...root.querySelectorAll('section > h3')].map(h => h.textContent.trim()),
546
+ actions: root.querySelectorAll('button[act]').length,
547
+ mounting: !!root.querySelector('div[mounting-face]'),
548
+ selects: [...root.querySelectorAll('select')].map(s => s.previousElementSibling?.textContent.trim())
549
+ }
550
+ })
551
+ assert.deepEqual(shown, { headings: ['figure.label.group-figure', 'figure.label.group-catalog'], actions: 0, mounting: false, selects: ['figure.label.figure-placement'] })
538
552
  const before = await read(() => product.view())
539
- await press('figure-properties >>> button[act]', 'fit to the parts')
540
- await until(() => product.deep('figure-properties >>> div[row] > span')[0]?.textContent === 'figure.text.declared-space-declared', 'the room declared')
541
- assert.equal((await read(() => product.view())).revision, before.revision + 1)
553
+ await read(() => {
554
+ const select = product.deep('figure-modeller-page >>> figure-properties')[0].shadowRoot.querySelector('select')
555
+ select.value = 'ceiling'
556
+ select.dispatchEvent(new Event('change', { bubbles: true }))
557
+ })
558
+ await until(r => product.view().revision === r + 1, 'one step', before.revision)
559
+ assert.equal(JSON.parse((await read(() => product.view())).source).placement, 'ceiling')
560
+ assert.equal(JSON.parse((await read(() => product.view())).source).occupancy, undefined, 'no room declared on the way')
542
561
  })
543
562
 
544
563
  test('unsaved: closing or reloading the tab is asked about', async () => {
@@ -769,6 +788,38 @@ describe('the catalogue preview', () => {
769
788
  })
770
789
  })
771
790
 
791
+ /* A mounting face named already -- by the assistant, or before 2026-10-18 -- is said, and can be taken off; nothing more. */
792
+ describe('a mounting face named already', () => {
793
+ const line = () =>
794
+ read(() => {
795
+ const root = product.deep('figure-modeller-page >>> figure-properties')[0]?.shadowRoot
796
+ const field = root?.querySelector('div[mounting-face]')
797
+ return field ? { label: field.querySelector('label').textContent.trim(), said: field.querySelector('div[row] > span').textContent.trim(), clear: !!field.querySelector('button[quiet][clear-mounting-face]') } : null
798
+ })
799
+
800
+ test('it is one line with the way to take it off, which is one step', async () => {
801
+ await fresh()
802
+ await read(() => product.seed({ id: 'm', type: 'mounted', source: product.mountedSource('floor') }))
803
+ await open('m')
804
+ await until(() => !!product.deep('figure-modeller-page >>> figure-properties')[0]?.shadowRoot?.querySelector('div[mounting-face]'), 'the mounting face said')
805
+ assert.deepEqual(await line(), { label: 'figure.label.mounting-face', said: 'box · figure.label.face-bottom', clear: true })
806
+ const before = await read(() => product.view())
807
+ await press('figure-properties >>> button[clear-mounting-face]', 'take the mounting face off')
808
+ await until(r => product.view().revision === r + 1, 'one step', before.revision)
809
+ assert.equal(JSON.parse((await read(() => product.view())).source).occupancy.contact, undefined, 'taken off')
810
+ assert.ok(JSON.parse((await read(() => product.view())).source).occupancy.bounds, 'the room stays')
811
+ await until(() => !product.deep('figure-modeller-page >>> figure-properties')[0].shadowRoot.querySelector('div[mounting-face]'), 'the line gone')
812
+ })
813
+
814
+ test('on a centred figure it says the face is not checked there', async () => {
815
+ await fresh()
816
+ await read(() => product.seed({ id: 'mc', type: 'mounted', source: product.mountedSource('center') }))
817
+ await open('mc')
818
+ await until(() => !!product.deep('figure-modeller-page >>> figure-properties')[0]?.shadowRoot?.querySelector('div[mounting-face]'), 'the mounting face said')
819
+ assert.equal((await line()).said, 'box · figure.label.face-bottom · figure.text.mounting-face-unchecked-at-center')
820
+ })
821
+ })
822
+
772
823
  describe('the page at a narrow width', () => {
773
824
  before(async () => {
774
825
  await fresh()
@@ -17,10 +17,10 @@ import { thumbnailHashOf } from 'figure-ui-client/modeller/thumbnail-hash.js'
17
17
  import { client } from '@operato/graphql'
18
18
  import { i18next } from '@operato/i18n'
19
19
  import { store } from '@operato/shell'
20
- import { setOpenPopupImplementation } from '@operato/popup'
21
- import '@operato/popup/ox-floating-overlay.js'
20
+ import { addressController } from '@operato/utils/address.js'
21
+ import './popup-stand-in.js'
22
22
  import { html, render } from 'lit'
23
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, serializeFigureAsset } from '@hatiolab/figure-model'
23
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
24
24
 
25
25
  const PALETTE = { 'palette.dark': '#2b2b2b', 'palette.neutral': '#9a9a9a', 'palette.primary': '#477b9e', 'palette.accent': '#f79c40', 'palette.light': '#dddddd' }
26
26
 
@@ -30,6 +30,15 @@ export function boxSource(name = 'Probe') {
30
30
  return serializeFigureAsset(addFigureBox(asset, { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } }))
31
31
  }
32
32
 
33
+ /* A box whose bottom is named as its mounting face -- the assistant can name one; the properties tab only takes it off. */
34
+ export function mountedSource(placement = 'floor') {
35
+ let a = applyFigureAuthoring(createFigureAsset('Mounted'), { kind: 'set-placement', placement })
36
+ a.palette = { ...a.palette, ...PALETTE }
37
+ a = addFigureBox(a, { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
38
+ a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a) })
39
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-mounting-face', part: 'box', face: 'bottom' }))
40
+ }
41
+
33
42
  /* A door whose leaf swings on a clip: a proposal that takes the leaf away would stop the clip. */
34
43
  export function doorSource() {
35
44
  let a = createFigureAsset('Door')
@@ -129,32 +138,6 @@ function everywhere(selector, root) {
129
138
  return found
130
139
  }
131
140
 
132
- /* Popups, drawn as the layout draws them: an overlay of their own in the body, the result given when it leaves. */
133
- let popups = 0
134
- setOpenPopupImplementation((template, options = {}) => {
135
- const name = `$popup-${popups++}`
136
- const fragment = document.createDocumentFragment()
137
- render(html`<ox-floating-overlay id=${name} name=${name} .backdrop=${!!options.backdrop} .hovering=${options.hovering} .title=${options.title || ''} .size=${options.size || 'small'} .historical=${false}>${template}</ox-floating-overlay>`, fragment)
138
- document.body.appendChild(fragment)
139
- const handle = {
140
- name,
141
- close: () => {
142
- const popup = document.getElementById(name)
143
- if (!popup) return false
144
- popup.close()
145
- return true
146
- },
147
- closed: false
148
- }
149
- document.addEventListener('overlay-closed', function listener(e) {
150
- if (e.detail !== name) return
151
- document.removeEventListener('overlay-closed', listener)
152
- handle.closed = true
153
- handle.onclosed && handle.onclosed()
154
- })
155
- return handle
156
- })
157
-
158
141
  /* Notices the product raised (`notify`), as the snack bar would show them. */
159
142
  const notices = []
160
143
  document.addEventListener('notify', e => notices.push({ level: e.detail.level, message: e.detail.message }))
@@ -162,8 +145,20 @@ document.addEventListener('notify', e => notices.push({ level: e.detail.level, m
162
145
  /*
163
146
  The shell's router, for what the product navigates to: the store's route follows the address, and the modeller page
164
147
  is told the figure the address names. Each navigation is kept, with whether the detail preview was still shown then.
148
+ Like the shell's own router (@operato/shell router.js), it follows the entries the address controller commits;
149
+ `navigate` sends no `popstate`. The addresses the shell itself moves to below (`goTo`, `open`) are not the product's.
165
150
  */
166
151
  const navigations = []
152
+ let shellMoving = false
153
+ /** The shell's own move to an address: routed by the caller, not kept as the product's navigation. */
154
+ function shellMoves(move) {
155
+ shellMoving = true
156
+ try {
157
+ move()
158
+ } finally {
159
+ shellMoving = false
160
+ }
161
+ }
167
162
  const detailShown = () => !!store.getState().layout?.viewparts?.['figure-detail']?.show
168
163
  function route() {
169
164
  const [, page = '', resourceId] = location.pathname.match(/^\/([^/]*)\/?([^/]*)/) ?? []
@@ -171,10 +166,11 @@ function route() {
171
166
  const element = page === 'figure-modeller' ? document.querySelector('figure-modeller-page') : null
172
167
  if (element) element.lifecycle = { ...(element.lifecycle ?? {}), resourceId: resourceId || undefined }
173
168
  }
174
- window.addEventListener('popstate', () => {
169
+ addressController().subscribe(() => {
170
+ if (shellMoving) return
175
171
  navigations.push({ path: location.pathname, entries: history.length, detailShown: detailShown() })
176
172
  route()
177
- })
173
+ }, false)
178
174
 
179
175
  /* The aside bar's part: the detail viewpart drawn while the store shows it. */
180
176
  const aside = document.createElement('div')
@@ -202,6 +198,7 @@ document.head.appendChild(fill)
202
198
  window.product = {
203
199
  boxSource,
204
200
  doorSource,
201
+ mountedSource,
205
202
  page,
206
203
  deep,
207
204
  calls: () => calls.map(c => ({ ...c })),
@@ -227,7 +224,7 @@ window.product = {
227
224
  route: () => ({ page: store.getState().route?.page, resourceId: store.getState().route?.resourceId }),
228
225
  /** The shell moved to a page (an address the product did not navigate to itself). */
229
226
  goTo(path) {
230
- history.pushState({}, '', path)
227
+ shellMoves(() => history.pushState({}, '', path))
231
228
  route()
232
229
  },
233
230
  /** A row on the server, as a figure saved earlier. */
@@ -250,7 +247,7 @@ window.product = {
250
247
  shell.replaceChildren(element)
251
248
  document.body.replaceChildren(shell)
252
249
  }
253
- history.replaceState({}, '', resourceId ? `/figure-modeller/${resourceId}` : '/figure-modeller')
250
+ shellMoves(() => history.replaceState({}, '', resourceId ? `/figure-modeller/${resourceId}` : '/figure-modeller'))
254
251
  store.dispatch({ type: 'UPDATE_PAGE', page: 'figure-modeller', resourceId, params: {} })
255
252
  element.lifecycle = { ...(element.lifecycle ?? {}), resourceId }
256
253
  element.active = true
@@ -26,9 +26,9 @@ const sections = () =>
26
26
  describe('sections an app adds', () => {
27
27
  test('only for the figures they are for, after the panel\'s own', async () => {
28
28
  await page().evaluate(() => harness.show('EQUIPMENT'))
29
- assert.deepEqual(await sections(), ['own', 'own', 'own'])
29
+ assert.deepEqual(await sections(), ['own', 'own'])
30
30
  await page().evaluate(() => harness.show('CARRIER'))
31
- assert.deepEqual(await sections(), ['own', 'own', 'own', 'carrier'])
31
+ assert.deepEqual(await sections(), ['own', 'own', 'carrier'])
32
32
  })
33
33
 
34
34
  test('a section changes the figure by the modeller\'s command', async () => {
@@ -41,7 +41,7 @@ describe('sections an app adds', () => {
41
41
  test('a section that fails says so in its place; the others stand', async () => {
42
42
  await page().evaluate(() => harness.failOn())
43
43
  await page().evaluate(() => harness.show('CARRIER'))
44
- assert.deepEqual(await sections(), ['own', 'own', 'own', 'carrier', 'broken'])
44
+ assert.deepEqual(await sections(), ['own', 'own', 'carrier', 'broken'])
45
45
  assert.match(await page().evaluate(() => document.querySelector('figure-properties').shadowRoot.querySelector('section[data-section="broken"] [role="alert"]').textContent), /figure\.text\.property-section-failed|no table/)
46
46
  await page().evaluate(() => harness.failOff())
47
47
  })
@@ -99,7 +99,7 @@ async function tab(name) {
99
99
  await read(() => document.querySelector('figure-editor').updateComplete)
100
100
  }
101
101
 
102
- describe('the side: six tabs, the view and the history from the bar', () => {
102
+ describe('the side: six tabs, the view and the releases from the bar', () => {
103
103
  before(() => mount('rollers'))
104
104
 
105
105
  test('properties, responsive, motion, capability, data and the verdict -- the six tabs, in that order (user 2026-10-17)', async () => {
@@ -119,13 +119,31 @@ describe('the side: six tabs, the view and the history from the bar', () => {
119
119
  await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=view]').getBoundingClientRect().height === 0, [], 'closed by Esc')
120
120
  })
121
121
 
122
- test('the history opens from the bar beside save, and the view closes for it', async () => {
122
+ test('the releases open from 「발행」 beside save, the one way to them, and the view closes for them', async () => {
123
+ assert.equal(await read(() => document.querySelectorAll('figure-editor')[0].shadowRoot.querySelectorAll('button[bar-overlay=history]').length), 0, 'no history button beside it')
123
124
  await press('ox-canvas-bar button[bar-overlay=view]', 'the view button')
124
- await press('ox-canvas-bar button[bar-overlay=history]', 'the history button')
125
+ await press('ox-canvas-bar button[publish]', 'the release button')
125
126
  const open = await read(() => ['view', 'history'].map(w => document.querySelector('figure-editor').shadowRoot.querySelector(`div[bar-overlay-panel=${w}]`).getBoundingClientRect().height > 0))
126
127
  assert.deepEqual(open, [false, true], 'one at a time')
127
128
  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')
128
- await press('ox-canvas-bar button[bar-overlay=history]', 'the history button')
129
+ await press('ox-canvas-bar button[publish]', 'the release button')
130
+ assert.equal(await read(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=history]').getBoundingClientRect().height > 0), false, 'a second press closes it')
131
+ })
132
+
133
+ /* User 2026-10-18: 「발행버튼도 저장버튼도 라운드 버튼으로 해주고, 버튼 안에 적절한 아이콘을 추가해주면 혼동되지 않을 것」. */
134
+ test('save and release: one round shape, one height, an icon inside each', async () => {
135
+ const look = await read(() => {
136
+ const bar = document.querySelector('figure-editor').shadowRoot
137
+ return ['save', 'publish'].map(k => {
138
+ const b = bar.querySelector(`ox-canvas-bar button[${k}]`)
139
+ const r = b.getBoundingClientRect()
140
+ return { height: Math.round(r.height), round: parseFloat(getComputedStyle(b).borderTopLeftRadius) >= r.height / 2, icon: b.querySelector('md-icon')?.textContent.trim() }
141
+ })
142
+ })
143
+ assert.deepEqual(look, [
144
+ { height: 32, round: true, icon: 'save' },
145
+ { height: 32, round: true, icon: 'publish' }
146
+ ])
129
147
  })
130
148
  })
131
149
 
@@ -150,7 +168,7 @@ describe('a part\'s groups in one order, whatever its state', () => {
150
168
  await tab('properties')
151
169
  })
152
170
 
153
- test('a fastened part\'s place is one line and 「바꾸기」: no locked cells, no drawing, until it is changed', async () => {
171
+ test('a fastened part\'s place is one line and 「바꾸기」: no locked cells, no drawing; 「바꾸기」 opens the window, not the panel', async () => {
154
172
  await pick('mark')
155
173
  await tab('properties')
156
174
  const place = () =>
@@ -166,8 +184,11 @@ describe('a part\'s groups in one order, whatever its state', () => {
166
184
  })
167
185
  assert.deepEqual(await place(), { sentence: true, change: true, drawing: false, cells: false })
168
186
  await press('button[change-fastening]', '「바꾸기」')
169
- await until(() => !!document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off]) figure-links').shadowRoot.querySelector('figure-fasten-drawing'), [], 'the drawing')
170
- /* Another part chosen and back: one line again. */
187
+ await until(() => !!document.querySelector('figure-fasten-dialog'), [], 'the window')
188
+ assert.deepEqual(await place(), { sentence: true, change: true, drawing: false, cells: false }, 'the panel still says the result')
189
+ /* Closed with nothing written; another part chosen and back: one line. */
190
+ await page().keyboard.press('Escape')
191
+ await until(() => !document.querySelector('figure-fasten-dialog'), [], 'the window closed')
171
192
  await pick('bed')
172
193
  await pick('mark')
173
194
  assert.equal((await place()).change, true, 'said in one line again')
@@ -60,11 +60,16 @@ const FIGURES = {
60
60
  a = applyFigureAuthoring(a, { kind: 'attach', part: 'mast', face: 'bottom', to: { plane: 'mounting-plane' } })
61
61
  return applyFigureAuthoring(a, { kind: 'declare-instance-size', range: { x: { min: 200, max: 200 }, y: { min: 1000, max: 3000 }, z: { min: 300, max: 300 } } })
62
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. */
63
+ /*
64
+ A block fastened on a base: a part with a fastening to take back, under a figure with no size of its own yet; its
65
+ base's bottom named as the mounting face, which the figure panel offers to take off.
66
+ */
64
67
  stack: () => {
65
68
  let a = addFigureBox(named('Stack'), { id: 'base', size: { x: 1000, y: 200, z: 600 }, position: { x: 0, y: 100, z: 0 } })
66
69
  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' } })
70
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'base', face: 'top' } })
71
+ a = applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a) })
72
+ return applyFigureAuthoring(a, { kind: 'set-mounting-face', part: 'base', face: 'bottom' })
68
73
  },
69
74
  /* A conveyor that follows its width, a roller every 200 mm, and the space it takes declared: the one figure stretched. */
70
75
  stretch: () => {
@@ -82,10 +82,14 @@ const tabOn = () => read(() => screenCheck.editor().shadowRoot.querySelector('bu
82
82
  const pressBar = action => press(action, a => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').shadowRoot.querySelector(`button[data-action=${a}]`), action)
83
83
  const pressTab = name => press(name, name => screenCheck.editor().shadowRoot.querySelector(`button[tab][aria-label="${name}"]`), name)
84
84
  const TAB = name => `figure.label.tab-short-${name}`
85
- /** The view settings or the release history, opened from the bar over the work (they were tabs once); left open if open. */
85
+ /*
86
+ The view settings or the releases, opened from the bar over the work (they were tabs once); left open if open. The
87
+ releases open from 「발행」, the bar's one way to them (user 2026-10-18).
88
+ */
89
+ const OVERLAY_BUTTON = { view: 'button[bar-overlay=view]', history: 'button[publish]' }
86
90
  async function openOverlay(which) {
87
- if (await read(w => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar button[bar-overlay=${w}]`)?.getAttribute('aria-pressed') === 'true', which)) return
88
- await press(`the ${which} button`, w => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar button[bar-overlay=${w}]`), which)
91
+ if (await read(b => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar ${b}`)?.getAttribute('aria-pressed') === 'true', OVERLAY_BUTTON[which])) return
92
+ await press(`the ${which} button`, b => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar ${b}`), OVERLAY_BUTTON[which])
89
93
  await until(`${which} open`, w => screenCheck.editor().shadowRoot.querySelector(`div[bar-overlay-panel=${w}]`)?.getBoundingClientRect().height > 0, which)
90
94
  }
91
95
  /** A row of the parts list, by the part's name. */
@@ -870,39 +874,40 @@ describe('the side panel is found as it was left, per part', () => {
870
874
  })
871
875
  })
872
876
 
873
- describe('the fastening form says whether it changes the fastening the part has or fastens it anew', () => {
874
- const preview = () => read(() => screenCheck.deep('[preview-words]').length)
875
- const fastenButton = () => read(() => screenCheck.deep('button[act]').filter(b => b.textContent.trim().endsWith('figure.button.fasten')).length)
876
- const target = () => element('the target select', () => screenCheck.deep('select').find(s => s.previousElementSibling?.textContent?.trim() === 'figure.label.target-part'))
877
+ describe('the fastening window says whether it changes the fastening the part has or fastens it anew', () => {
878
+ const applyWords = () => read(() => screenCheck.deep('[apply]').map(b => b.textContent.trim()))
879
+ /* A chip of the window's list, by its part. */
880
+ const pressTarget = part => press(`${part} in the list`, part => screenCheck.deep('md-filter-chip').find(o => o.getAttribute('data-value') === part), part)
877
881
 
878
- test('a part fastened to nothing: 「새로 붙이기」 said, the sentence and what 붙이기 would do shown once a target is chosen, and nothing done until 붙이기', async () => {
882
+ test('a part fastened to nothing: 「다른 부품에 붙이기」 opens the window; the sentence and what 붙이기 would do once a target is chosen; nothing done until 붙이기', async () => {
879
883
  await mount('door')
880
884
  await pressRow('leaf')
881
885
  await until('leaf chosen', () => screenCheck.editor().selected === 'leaf')
882
886
  await press('start fastening', () => screenCheck.deep('button').find(b => b.textContent.trim().endsWith('figure.button.fasten-start')))
883
- await until('the form', () => screenCheck.deep('p[fasten-mode]').length === 1)
887
+ await until('the window', () => screenCheck.deep('figure-fasten-dialog').length === 1)
884
888
  const before = await revision()
885
- await (await target()).select('frame')
886
- await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').length === 1)
889
+ await pressTarget('frame')
890
+ await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').some(p => !p.hasAttribute('hint')))
887
891
  await until('what 붙이기 would do', () => screenCheck.deep('[preview-words]').length === 1)
888
- assert.equal(await fastenButton(), 1, '붙이기 for a new one')
892
+ assert.deepEqual((await applyWords()).map(w => w.endsWith('figure.button.fasten')), [true], '붙이기 for a new one')
889
893
  assert.equal(await revision(), before, 'nothing done until it is pressed')
890
894
  })
891
895
 
892
- test('a part already fastened: said in one line with 「바꾸기」; opened, no 「새로 붙이기」, no 붙이기 -- another target is taken at once, in one step', async () => {
896
+ test('a part already fastened: one line with 「바꾸기」; opened, its button says 적용 -- another target tried, nothing written until 적용, then one step', async () => {
893
897
  await mount('arm')
894
898
  await pressRow('forearm')
895
899
  await until('forearm chosen', () => screenCheck.editor().selected === 'forearm')
896
900
  await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').length === 1)
897
901
  assert.equal(await read(() => screenCheck.deep('figure-fasten-drawing').length + screenCheck.deep('select').filter(s => s.closest('section[fastening]')).length), 0, 'one line: no drawing, no target to choose')
898
902
  await press('change the fastening', () => screenCheck.deep('button[change-fastening]')[0])
899
- await until('the drawing', () => screenCheck.deep('figure-fasten-drawing').length === 1)
900
- assert.equal(await read(() => screenCheck.deep('p[fasten-mode]').length), 0, 'the one it has is not said to be new')
901
- assert.equal(await fastenButton(), 0, 'no 붙이기')
903
+ await until('the window and its drawings', () => screenCheck.deep('figure-fasten-drawing').length === 2)
904
+ assert.deepEqual((await applyWords()).map(w => w.endsWith('figure.button.apply')), [true], '적용, not 붙이기')
902
905
  await until('the fastening seen before it is done: its face lit on the canvas', () => !!screenCheck.deep('figure-canvas')[0].highlight)
903
- assert.equal(await preview(), 0, 'no words for a command no button sends')
904
906
  const before = await revision()
905
- await (await target()).select('upper-arm')
907
+ await pressTarget('upper-arm')
908
+ await until('what 적용 would do', () => screenCheck.deep('[preview-words]').length === 1)
909
+ assert.equal(await revision(), before, 'nothing written while trying')
910
+ await press('apply', () => screenCheck.deep('[apply]')[0])
906
911
  await until('one step', was => screenCheck.editor().session.read().revision === was + 1, before)
907
912
  assert.equal(await read(() => JSON.parse(screenCheck.editor().session.source()).joints.find(j => j.body1.part === 'forearm').body0.part), 'upper-arm')
908
913
  })
@@ -982,11 +987,11 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
982
987
  /* First 15 minutes (2026-10-16): release was refused for a volume never declared, said only at the foot of the verdict tab. */
983
988
  test('what blocks release is said beside 「발행」, with the way out at hand there', async () => {
984
989
  const answer = blocked => read(blocked => product.answer('inspectFigureDraft', () => ({ __typename: 'FigureInspection', blocked, findings: blocked ? [alone.finding('occupancy-undeclared')] : [] })), blocked)
985
- const bar = () => read(() => [...screenCheck.editor().shadowRoot.querySelectorAll('ox-canvas-bar > button[slot=commit]')].map(b => (b.hasAttribute('bar-overlay') ? 'history' : b.hasAttribute('release-blocked') ? 'blocked' : b.hasAttribute('publish') ? 'publish' : 'save')))
990
+ const bar = () => read(() => [...screenCheck.editor().shadowRoot.querySelectorAll('ox-canvas-bar > button[slot=commit]')].map(b => (b.hasAttribute('bar-overlay') ? 'overlay' : b.hasAttribute('release-blocked') ? 'blocked' : b.hasAttribute('publish') ? 'publish' : 'save')))
986
991
  await answer(true)
987
992
  await open('fig-block', 'two', 'probe-block')
988
993
  await until('said beside 발행', () => !!screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[release-blocked]'))
989
- assert.deepEqual(await bar(), ['history', 'blocked', 'publish', 'save'])
994
+ assert.deepEqual(await bar(), ['blocked', 'publish', 'save'])
990
995
  await press('what blocks release', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[release-blocked]'))
991
996
  await until('the list under the bar', () => !!screenCheck.editor().shadowRoot.querySelector('div[release-blockers] li[finding]'))
992
997
  assert.equal(await read(() => !!screenCheck.editor().session.revisionNow().asset.occupancy), false)
@@ -994,7 +999,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
994
999
  await press('fit to the parts', () => screenCheck.editor().shadowRoot.querySelector('div[release-blockers] button[act]'))
995
1000
  await until('the space declared, in one step', () => !!screenCheck.editor().session.revisionNow().asset.occupancy)
996
1001
  await until('nothing blocks: the word is gone', () => !screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[release-blocked]'))
997
- assert.deepEqual(await bar(), ['history', 'publish', 'save'])
1002
+ assert.deepEqual(await bar(), ['publish', 'save'])
998
1003
  await read(() => product.answer('inspectFigureDraft'))
999
1004
  await saved()
1000
1005
  await open('fig-a')
@@ -1055,7 +1060,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1055
1060
  assert.deepEqual(first, { heading: 'figure.label.group-figure', assigned: true, order: [true, true] })
1056
1061
  })
1057
1062
 
1058
- test('the verdict tab shows the page\'s verdict, and the bar\'s history button its release history', async () => {
1063
+ test('the verdict tab shows the page\'s verdict, and the bar\'s 「발행」 the releases', async () => {
1059
1064
  const assigned = within =>
1060
1065
  read(w => (w ? screenCheck.editor().shadowRoot.querySelector(w) : screenCheck.shownPanel()).querySelector('slot')?.assignedElements().map(e => ({ tag: e.localName, height: Math.round(e.getBoundingClientRect().height) })), within)
1061
1066
  await pressTab(TAB('verdict'))
@@ -1069,7 +1074,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1069
1074
  assert.equal(shown[0].tag, 'figure-history-panel')
1070
1075
  assert.ok(shown[0].height > 0, 'figure-history-panel is shown')
1071
1076
  /* Closed again: it lies over the side, and the checks after this press there. */
1072
- await press('the history button', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[bar-overlay=history]'))
1077
+ await press('the release button', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[publish]'))
1073
1078
  })
1074
1079
 
1075
1080
  test('the verdict tab\'s count is the report\'s, measured by the verdict', async () => {
@@ -5,39 +5,8 @@
5
5
  */
6
6
  import './more-open.js'
7
7
  import './modeller-page.js'
8
- import { html, render } from 'lit'
9
- import { setOpenPopupImplementation } from '@operato/popup'
10
8
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
11
9
 
12
- /*
13
- * Popups drawn as the layout draws them -- an overlay of their own in the body, the result given when it leaves -- for
14
- * the section editor's close question: the page has no layout to append it to.
15
- */
16
- let popups = 0
17
- setOpenPopupImplementation((template, options = {}) => {
18
- const name = `$popup-${popups++}`
19
- const fragment = document.createDocumentFragment()
20
- render(html`<ox-floating-overlay id=${name} name=${name} .backdrop=${!!options.backdrop} .title=${options.title || ''} .size=${options.size || 'small'} .historical=${false}>${template}</ox-floating-overlay>`, fragment)
21
- document.body.appendChild(fragment)
22
- const handle = {
23
- name,
24
- close: () => {
25
- const popup = document.getElementById(name)
26
- if (!popup) return false
27
- popup.close()
28
- return true
29
- },
30
- closed: false
31
- }
32
- document.addEventListener('overlay-closed', function listener(e) {
33
- if (e.detail !== name) return
34
- document.removeEventListener('overlay-closed', listener)
35
- handle.closed = true
36
- handle.onclosed && handle.onclosed()
37
- })
38
- return handle
39
- })
40
-
41
10
  const PALETTE = { 'palette.dark': '#2b2b2b', 'palette.neutral': '#9a9a9a', 'palette.primary': '#477b9e', 'palette.accent': '#f79c40', 'palette.light': '#dddddd' }
42
11
 
43
12
  function figure(name) {
@@ -203,21 +172,24 @@ Object.assign(window.probe, {
203
172
  ends: Object.fromEntries([...root.querySelectorAll('g[end]')].map(g => [g.getAttribute('end'), { text: words(g.querySelector('text')), typed: g.querySelector('text').hasAttribute('typed') }]))
204
173
  }
205
174
  },
206
- /** The fastening drawing as a person sees it: the ways offered and the one on, the faces of the target. */
175
+ /** The fastening window's drawings as a person sees them: the ways offered and the one on (none drawn when there is one way). */
207
176
  fastenDrawing() {
208
- const host = deep('figure-fasten-drawing')[0]
209
- if (!host) return null
210
- const root = host.shadowRoot
177
+ const hosts = deep('figure-fasten-drawing')
178
+ if (!hosts.length) return null
179
+ const how = hosts.find(h => h.show === 'how')?.shadowRoot
211
180
  return {
212
- modes: [...root.querySelectorAll('button[mode]')].map(b => b.getAttribute('mode')),
213
- on: root.querySelector('button[mode][on]')?.getAttribute('mode') ?? null
181
+ modes: how ? [...how.querySelectorAll('button[mode]')].map(b => b.getAttribute('mode')) : [],
182
+ on: how?.querySelector('button[mode][on]')?.getAttribute('mode') ?? null
214
183
  }
215
184
  },
216
- /** The labels before the fastening form's lists, and what each holds. */
185
+ /** The labels before the fastening window's lists, and the questions it asks. */
217
186
  fastenForm() {
218
- return deep('select')
219
- .filter(s => s.previousElementSibling)
220
- .map(s => ({ label: words(s.previousElementSibling), value: s.value, options: [...s.options].map(o => ({ value: o.value, text: words(o) })) }))
187
+ const dialog = deep('figure-fasten-dialog')[0]
188
+ if (!dialog) return []
189
+ return [
190
+ ...[...dialog.shadowRoot.querySelectorAll('h4')].map(h => ({ label: words(h) })),
191
+ ...deep('select', dialog.shadowRoot).filter(s => s.previousElementSibling).map(s => ({ label: words(s.previousElementSibling), value: s.value }))
192
+ ]
221
193
  },
222
194
  /** The page point of one of a part's buttons in the parts list (`remove-part`, ...). */
223
195
  partOp(id, op) {