@things-factory/figure-ui 10.1.152 → 10.1.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/client/modeller/ai-proposal.ts +14 -13
  2. package/client/modeller/command-preview-controller.ts +210 -0
  3. package/client/modeller/editor-styles.ts +307 -0
  4. package/client/modeller/editor.ts +58 -619
  5. package/client/modeller/figure-first-save.ts +9 -1
  6. package/client/modeller/modeller-keys.ts +60 -0
  7. package/client/modeller/pane-fold.ts +61 -0
  8. package/client/modeller/panel-memory.ts +53 -0
  9. package/client/modeller/refusal-teller.ts +93 -0
  10. package/client/modeller/refusals.ts +18 -1
  11. package/dist-client/modeller/ai-proposal.d.ts +6 -5
  12. package/dist-client/modeller/ai-proposal.js +9 -9
  13. package/dist-client/modeller/ai-proposal.js.map +1 -1
  14. package/dist-client/modeller/clips.d.ts +1 -1
  15. package/dist-client/modeller/command-preview-controller.d.ts +75 -0
  16. package/dist-client/modeller/command-preview-controller.js +178 -0
  17. package/dist-client/modeller/command-preview-controller.js.map +1 -0
  18. package/dist-client/modeller/editor-styles.d.ts +2 -0
  19. package/dist-client/modeller/editor-styles.js +307 -0
  20. package/dist-client/modeller/editor-styles.js.map +1 -0
  21. package/dist-client/modeller/editor.d.ts +5 -50
  22. package/dist-client/modeller/editor.js +69 -641
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-first-save.d.ts +1 -0
  25. package/dist-client/modeller/figure-first-save.js +8 -1
  26. package/dist-client/modeller/figure-first-save.js.map +1 -1
  27. package/dist-client/modeller/modeller-keys.d.ts +22 -0
  28. package/dist-client/modeller/modeller-keys.js +43 -0
  29. package/dist-client/modeller/modeller-keys.js.map +1 -0
  30. package/dist-client/modeller/pane-fold.d.ts +15 -0
  31. package/dist-client/modeller/pane-fold.js +60 -0
  32. package/dist-client/modeller/pane-fold.js.map +1 -0
  33. package/dist-client/modeller/panel-memory.d.ts +15 -0
  34. package/dist-client/modeller/panel-memory.js +54 -0
  35. package/dist-client/modeller/panel-memory.js.map +1 -0
  36. package/dist-client/modeller/refusal-teller.d.ts +36 -0
  37. package/dist-client/modeller/refusal-teller.js +81 -0
  38. package/dist-client/modeller/refusal-teller.js.map +1 -0
  39. package/dist-client/modeller/refusals.d.ts +10 -0
  40. package/dist-client/modeller/refusals.js +9 -0
  41. package/dist-client/modeller/refusals.js.map +1 -1
  42. package/dist-client/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +4 -4
  44. package/test/browser/fix-alignment.check.mjs +13 -2
  45. package/test/browser/links.check.mjs +36 -9
  46. package/test/browser/modeller.check.mjs +25 -0
  47. package/test/browser/parts-page.js +9 -1
  48. package/test/browser/parts.check.mjs +22 -0
  49. package/test/browser/places-mesh.check.mjs +25 -9
  50. package/test/browser/product-page.check.mjs +42 -2
  51. package/test/browser/screen.check.mjs +33 -0
  52. package/test/clip-ai-proposal.test.ts +4 -1
  53. package/test/clip-consent.test.ts +9 -2
  54. package/test/layout.test.ts +3 -1
  55. package/test/refusals.test.ts +18 -10
  56. package/test/theme.test.ts +5 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.152",
3
+ "version": "10.1.153",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,7 +27,7 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.6.2",
30
+ "@hatiolab/figure-model": "^0.6.3",
31
31
  "@hatiolab/things-scene": "^10.1.83",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
@@ -42,8 +42,8 @@
42
42
  "@operato/styles": "^10.0.0",
43
43
  "@operato/utils": "^10.0.0",
44
44
  "@things-factory/ai-assistant": "^10.1.150",
45
- "@things-factory/figure-service": "^10.1.151",
45
+ "@things-factory/figure-service": "^10.1.153",
46
46
  "three": "^0.185.1"
47
47
  },
48
- "gitHead": "67dd923b0d431d726b7f60866bb005a3660852b3"
48
+ "gitHead": "54b3e985a55d5d0b01622d87f385f07518fb5d16"
49
49
  }
@@ -35,10 +35,21 @@ async function until(fn, args, what, ms = 8000) {
35
35
  await sleep(50)
36
36
  }
37
37
  }
38
- const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
38
+ /* The editor and the part's panels have drawn what they were last told: a read never meets the draw before. */
39
+ const settled = () =>
40
+ read(async () => {
41
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
42
+ const drawing = () => [document.querySelector('figure-editor'), ...deep('figure-links')].filter(Boolean)
43
+ for (let round = 0; round < 20; round++) {
44
+ await Promise.all(drawing().map(e => e.updateComplete))
45
+ if (!drawing().some(e => e.isUpdatePending)) return
46
+ }
47
+ })
48
+ /** Pressed once it is on the page and the panels have drawn -- waited for, not looked up once. */
39
49
  async function click(selector, options = {}, what = selector) {
50
+ await settled()
51
+ await until((s, o) => !!findDeep(s, o), [selector, options], `${what} on the page`)
40
52
  const element = await page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
41
- assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
42
53
  await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
43
54
  await element.click()
44
55
  await settled()
@@ -101,6 +101,28 @@ const handle = (selector, test) =>
101
101
  selector,
102
102
  test
103
103
  )
104
+ /**
105
+ * Pressed as the panels stand once drawn: no fastening panel with an update still to draw, then the element itself, not a
106
+ * point read before. A point read too early is pressed after the panel has moved under it (2026-10-11: the door leaf's
107
+ * "fasten" read, then its scroll put back a frame later, and the click fell on another button).
108
+ */
109
+ async function press(selector, text, what = text ?? selector) {
110
+ await read(async () => {
111
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
112
+ for (let round = 0; round < 20 && deep('figure-links').some(e => e.isUpdatePending); round++) await Promise.all(deep('figure-links').map(e => e.updateComplete))
113
+ })
114
+ const element = await page().evaluateHandle(
115
+ (selector, text) => {
116
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
117
+ return deep(selector).find(e => text === undefined || e.textContent.replace(/\s+/g, ' ').trim().includes(text)) ?? null
118
+ },
119
+ selector,
120
+ text
121
+ )
122
+ assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
123
+ await element.click()
124
+ await settled()
125
+ }
104
126
  /** Chooses an option of the select labelled `label`, as a person does: the select changes, once. */
105
127
  async function choose(label, value, selector = 'select') {
106
128
  const select = await handle(selector, label)
@@ -322,7 +344,7 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
322
344
 
323
345
  test('started, it asks what to fasten to, with nothing chosen', async () => {
324
346
  await pick('leaf')
325
- await click(await at('button[act]', 'figure.button.fasten-start'), 'fasten-start')
347
+ await press('button[act]', 'figure.button.fasten-start')
326
348
  /* The form is the panel's, drawn after the editor's update. */
327
349
  const target = await until(() => look.select('figure.label.target-part'), [], 'the fastening form')
328
350
  assert.deepEqual([target.value, target.options[0].text], ['', 'figure.label.target-part-placeholder'])
@@ -368,7 +390,7 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
368
390
 
369
391
  test('"fasten it first" from the motion tab opens this part\'s fastening, as it is now, in view, on the properties tab', async () => {
370
392
  await pick('leaf')
371
- await click(await at('button[act]', 'figure.button.fasten-start'), 'fasten-start')
393
+ await press('button[act]', 'figure.button.fasten-start')
372
394
  await choose('figure.label.target-part', 'frame')
373
395
  /* The panel scrolled back to its head, the fastening below the fold. */
374
396
  const panel = await at('section[fastening]')
@@ -406,6 +428,14 @@ describe('a new fastening for a door leaf: seen before it is done', () => {
406
428
  })
407
429
  })
408
430
 
431
+ /*
432
+ * 정렬·간격 고치기 pressed as the panel stands once it has drawn: a point read before it has, then clicked, can land where
433
+ * the button was, and nothing happens.
434
+ */
435
+ async function pressFixAlignment() {
436
+ await press('div[overhang] button[fix-alignment]', undefined, 'fix alignment')
437
+ }
438
+
409
439
  describe('past the edge of the face a part is fastened to', () => {
410
440
  before(async () => {
411
441
  let a = fasten(box(createFigureAsset('Overhangs'), 'frame', [1000, 100, 400], [0, 50, 0]), 'frame', 'plane')
@@ -422,7 +452,7 @@ describe('past the edge of the face a part is fastened to', () => {
422
452
  await pick('out')
423
453
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 1, [], 'said past the edge')
424
454
  const start = await state()
425
- await click(await at('div[overhang] button', 'figure.button.fix-alignment'), 'fix alignment')
455
+ await pressFixAlignment()
426
456
  await until(r => probe.state().revision === r + 1, [start.revision], 'one step')
427
457
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-out').length === 0, [], 'no longer past the edge')
428
458
  })
@@ -436,7 +466,7 @@ describe('past the edge of the face a part is fastened to', () => {
436
466
  await pick('cap')
437
467
  await until(() => look.all('div[overhang] p', 'figure.text.overhang-unknown').length === 1, [], 'said not known')
438
468
  const start = await state()
439
- await click(await at('div[overhang] button', 'figure.button.fix-alignment'), 'fix alignment')
469
+ await pressFixAlignment()
440
470
  await until(() => look.all('p[fix-note]', 'figure.text.fix-alignment-unmeasured').length === 1, [], 'why it cannot')
441
471
  assert.equal((await state()).revision, start.revision, 'nothing written')
442
472
  })
@@ -489,11 +519,8 @@ describe('a bench: what each part is fastened to, what detaching would do, a row
489
519
  await pick('lamp')
490
520
  assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [false], 'folded')
491
521
  await read(() => look.hear('preview-command'))
492
- /*
493
- Clicked until it is open: the first click puts the focus in the row, which opens the fold, and the summary's own
494
- toggle then closes it again -- two clicks open it (seen 2026-10-11).
495
- */
496
- for (let i = 0; i < 2 && !(await all('details[row-fold]'))[0].open; i++) await click(await at('details[row-fold] > summary'), 'the row')
522
+ /* One click opens it, and it stays open while the row is worked. */
523
+ await click(await at('details[row-fold] > summary'), 'the row')
497
524
  assert.deepEqual((await all('details[row-fold]')).map(d => d.open), [true], 'open while it is worked')
498
525
  await until(() => look.all('section[row] input[type=number]').length > 0, [], 'the pitch field in the open row')
499
526
  await type(await handle('section[row] input[type=number]'), '250')
@@ -900,6 +900,31 @@ describe('the row section opens on one click', () => {
900
900
  await click(at)
901
901
  assert.equal(await summary.evaluate(s => s.parentElement.open), false, 'folded after a second')
902
902
  })
903
+
904
+ test('a part picked again: a fold opened at once stays open -- the folds kept for the part are put back first', async () => {
905
+ await mount('rollers')
906
+ await click(await read(() => probe.row('mark')))
907
+ await click(await read(() => probe.row('roller')))
908
+ const open = await read(async () => {
909
+ const deep = (selector, root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(selector, e.shadowRoot)))
910
+ const editor = deep('figure-editor')[0]
911
+ /*
912
+ The panel is drawn for the part once the editor and its links have updated; a click can come any time after
913
+ that, before a frame is drawn. Here it comes right then.
914
+ */
915
+ editor.selected = 'mark'
916
+ await editor.updateComplete
917
+ await Promise.all(deep('figure-links').map(links => links.updateComplete))
918
+ await null
919
+ const summary = deep('details[row-fold] > summary')[0]
920
+ summary.click()
921
+ const fold = summary.parentElement
922
+ const opened = fold.open
923
+ await new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))
924
+ return [opened, fold.open]
925
+ })
926
+ assert.deepEqual(open, [true, true], 'opened by the click, and still open a frame later')
927
+ })
903
928
  })
904
929
 
905
930
  test('the page raised no error', () => {
@@ -5,7 +5,7 @@
5
5
  * Only reading: every change the check makes goes through the mouse and the keyboard.
6
6
  */
7
7
  import './editing-page.js'
8
- import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
8
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
9
9
 
10
10
  const PALETTE = {
11
11
  'palette.dark': '#2b2b2b',
@@ -36,6 +36,14 @@ Object.assign(window.figures, {
36
36
  return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } }))
37
37
  },
38
38
  /* A box with rounded corners: made from the box, still called what it is. */
39
+ /* A housing and a stray part on the floor, the space the figure takes declared from both. */
40
+ gate: () => {
41
+ let a = figure('Gate')
42
+ a = addFigureBox(a, { id: 'housing', size: { x: 300, y: 1000, z: 300 }, position: { x: 0, y: 500, z: 0 } })
43
+ a = addFigureBox(a, { id: 'stray', size: { x: 100, y: 100, z: 100 }, position: { x: 600, y: 50, z: 0 } })
44
+ for (const part of ['housing', 'stray']) a = applyFigureAuthoring(a, { kind: 'attach', part, face: 'bottom', to: { plane: 'mounting-plane' } })
45
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' }))
46
+ },
39
47
  rounded: () => {
40
48
  let a = figure('Rounded')
41
49
  a = addFigureBox(a, { id: 'tray', size: { x: 600, y: 200, z: 400 }, position: { x: 0, y: 100, z: 0 } })
@@ -239,6 +239,28 @@ describe('a part renamed in its name field: one step, still selected; a name ref
239
239
  })
240
240
  })
241
241
 
242
+ describe('a part in the declared space removed: at once, the space fitted to the parts left, and said so (2026-09-27)', () => {
243
+ test('one step, nothing asked, the space still declared, and one notice that it was fitted again', async () => {
244
+ await mount('box', 'gate')
245
+ await pick('stray')
246
+ const before = await revision()
247
+ const space = () => read(() => JSON.parse(document.querySelector('figure-editor').session.source()).occupancy ?? null)
248
+ const was = await space()
249
+ assert.ok(was, 'declared to begin with')
250
+ await click(await read(() => parts.op('remove-part')), 'remove')
251
+ assert.deepEqual(await ids(), ['housing'])
252
+ assert.equal(await revision(), before + 1, 'one step')
253
+ assert.equal(await read(() => editing.prompt()), null, 'nothing asked')
254
+ const now = await space()
255
+ assert.ok(now, 'the space is still declared')
256
+ assert.notDeepEqual(now, was, 'fitted to the part left')
257
+ const told = await read(() => editing.told())
258
+ assert.deepEqual(told.map(t => t.level), ['info'], JSON.stringify(told))
259
+ /* The page loads no translations: the words are the key. */
260
+ assert.ok(told[0].message.includes('figure.text.removal-space-refitted'), told[0].message)
261
+ })
262
+ })
263
+
242
264
  describe('the list narrowed: the selected row keeps its name and its buttons in sight', () => {
243
265
  before(() => mount('box', 'three'))
244
266
  after(() => read(() => parts.narrow(0)))
@@ -42,24 +42,38 @@ async function until(fn, args, what, ms = 8000) {
42
42
  await sleep(50)
43
43
  }
44
44
  }
45
- const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
45
+ /*
46
+ * Every element the check reads has drawn what it was last told: the editor, and the places panel and the shared slot
47
+ * layout editor inside it, which draw after the editor does -- awaited until none of them has a draw pending, so a read
48
+ * never meets the one before.
49
+ */
50
+ const settled = () =>
51
+ read(async () => {
52
+ const deep = (s, root = document) => [...root.querySelectorAll(s)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(s, e.shadowRoot)))
53
+ const drawing = () => [document.querySelector('figure-editor'), ...deep('figure-links, figure-slot-layout-panel, ox-slot-layout-editor')].filter(Boolean)
54
+ for (let round = 0; round < 20; round++) {
55
+ await Promise.all(drawing().map(e => e.updateComplete))
56
+ if (!drawing().some(e => e.isUpdatePending)) return
57
+ }
58
+ })
46
59
 
47
60
  async function mount(asset) {
48
61
  await read(text => harness.mount('given', text), serializeFigureAsset(asset))
49
62
  await until(() => look.drawn().length > 0, [], 'the board is drawn')
50
63
  }
51
- /** The element `findDeep` finds, as a handle the mouse and keyboard act on. */
52
- const handle = (selector, options = {}) => page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
64
+ /** The element `findDeep` finds, once it is on the page -- waited for, not looked up once -- as a handle to act on. */
65
+ async function handle(selector, options = {}, what = selector) {
66
+ await until((s, o) => !!findDeep(s, o), [selector, options], `${what} on the page`)
67
+ return page().evaluateHandle((s, o) => findDeep(s, o), selector, options)
68
+ }
53
69
  async function click(selector, options = {}, what = selector) {
54
- const element = await handle(selector, options)
55
- assert.ok(await element.evaluate(e => !!e), `${what} is on the page`)
70
+ const element = await handle(selector, options, what)
56
71
  await element.evaluate(e => e.scrollIntoView({ block: 'center' }))
57
72
  await element.click()
58
73
  await settled()
59
74
  }
60
75
  async function type(selector, options, text) {
61
76
  const field = await handle(selector, options)
62
- assert.ok(await field.evaluate(e => !!e), `${selector} is on the page`)
63
77
  await field.evaluate(e => e.scrollIntoView({ block: 'center' }))
64
78
  await field.click({ clickCount: 3 })
65
79
  await page().keyboard.type(text)
@@ -68,7 +82,6 @@ async function type(selector, options, text) {
68
82
  }
69
83
  async function choose(selector, options, value) {
70
84
  const select = await handle(selector, options)
71
- assert.ok(await select.evaluate(e => !!e), `${selector} is on the page`)
72
85
  await select.select(value)
73
86
  await settled()
74
87
  }
@@ -120,7 +133,8 @@ describe('a GLB made into a mesh part', () => {
120
133
  })
121
134
 
122
135
  test('the file chooser takes GLB files', async () => {
123
- assert.equal(await read(() => findDeep('input[mesh-file]').accept), '.glb,model/gltf-binary')
136
+ const input = await handle('input[mesh-file]', {}, 'the palette\'s file input')
137
+ assert.equal(await input.evaluate(i => i.accept), '.glb,model/gltf-binary')
124
138
  })
125
139
 
126
140
  test('a file with one node: uploaded, added at once as one step, named by the file, selected', async () => {
@@ -142,7 +156,7 @@ describe('a GLB made into a mesh part', () => {
142
156
  await read(a => meshServer.answer({ upload: a }), crane)
143
157
  const start = await state()
144
158
  await importFile()
145
- await until(() => !!findDeep('figure-mesh-choice'), [], 'the question')
159
+ await until(() => (findDeep('figure-mesh-choice')?.shadowRoot?.querySelectorAll('li button').length ?? 0) === 3, [], 'the question and its three choices')
146
160
  const choices = await read(() => [...findDeep('figure-mesh-choice').shadowRoot.querySelectorAll('li button')].map(b => b.textContent.replace(/\s+/g, ' ').trim()))
147
161
  assert.equal(choices.length, 3)
148
162
  assert.match(choices[0], /^figure\.text\.mesh-whole-file/)
@@ -186,6 +200,7 @@ describe('places made by a rule: the shared slot layout editor on a part\'s face
186
200
  await pick('fork')
187
201
  const tab = await read(l => probe.tab(l), 'figure.label.tab-short-capability')
188
202
  await page().mouse.click(tab.x, tab.y)
203
+ await until(() => look.tab() === 'figure.label.tab-short-capability', [], 'the capability tab on')
189
204
  await settled()
190
205
  })
191
206
 
@@ -193,6 +208,7 @@ describe('places made by a rule: the shared slot layout editor on a part\'s face
193
208
  assert.equal(await has('figure-slot-layout-panel'), false)
194
209
  await click('md-filter-chip[data-value="rule"]', {}, 'the by-a-rule choice')
195
210
  await until(() => !!findDeep('ox-slot-layout-editor'), [], 'the editor')
211
+ await settled()
196
212
  assert.equal(await has('button[data-action="use-default"]'), false, 'no default: the figure\'s layout is the only one')
197
213
  })
198
214
 
@@ -10,6 +10,8 @@
10
10
  import { test, describe, before, after } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
 
13
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
14
+
13
15
  import { buildPage } from './build.mjs'
14
16
  import { openPage } from './serve.mjs'
15
17
 
@@ -203,7 +205,9 @@ describe('the first save of a new figure', () => {
203
205
  await until(() => !product.deep('figure-first-save').length, 'the question closed')
204
206
  await press('button[save]', 'save')
205
207
  await until(() => product.deep('figure-first-save')[0]?.shadowRoot.querySelector('#type'), 'asked again')
206
- assert.equal((await firstSave()).type, 'my-lift')
208
+ /* Asked again, the name is focused again: the browser honours `autofocus` once per document. */
209
+ await until(() => { let a = document.activeElement; while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement; return a?.id === 'name' }, 'the name focused again')
210
+ assert.deepEqual(await firstSave(), { first: 'name', type: 'my-lift', focused: 'name' })
207
211
  await read(() => {
208
212
  const name = product.deep('figure-first-save >>> #name')[0]
209
213
  name.focus()
@@ -566,6 +570,42 @@ describe('the page around the figure: the shell, the dock, the properties', () =
566
570
  })
567
571
  })
568
572
 
573
+ describe('a placement the figure\'s parts refuse, chosen on the properties tab (wall placement, 09-25)', () => {
574
+ /* A post standing on the floor's plane by its bottom: a wall takes the back face, so the wall is refused. */
575
+ const post = () => {
576
+ const a = addFigureBox(createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 500, z: 0 } })
577
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'attach', part: 'post', face: 'bottom', to: { plane: 'mounting-plane' } }))
578
+ }
579
+ const placement = () =>
580
+ read(() => {
581
+ const select = product.deep('figure-modeller-page >>> figure-properties >>> select').find(s => s.previousElementSibling?.textContent.trim() === 'figure.label.figure-placement')
582
+ return select ? select.value : null
583
+ })
584
+ const revision = () => read(() => product.page().session.read().revision)
585
+
586
+ before(async () => {
587
+ await fresh()
588
+ await read(text => product.seed({ id: 'p', type: 'post', source: text }), post())
589
+ await open('p')
590
+ })
591
+
592
+ test('refused: nothing written, the reason said in words beside the tabs, and the list back on the placement it has', async () => {
593
+ assert.equal(await placement(), 'floor')
594
+ const start = await revision()
595
+ await read(() => {
596
+ const select = product.deep('figure-modeller-page >>> figure-properties >>> select').find(s => s.previousElementSibling?.textContent.trim() === 'figure.label.figure-placement')
597
+ select.value = 'wall'
598
+ select.dispatchEvent(new Event('change', { bubbles: true }))
599
+ })
600
+ const said = await until(() => product.deep('figure-modeller-page >>> p[fit-error]')[0]?.textContent.trim(), 'the refusal said')
601
+ /* Said from the kernel's details (ADR-0127): the page loads no translations, so the words are the key with its values. */
602
+ assert.match(said, /^figure\.text\./, said)
603
+ assert.ok(said.includes('post') && said.includes('back'), `names the part and the face the wall takes: ${said}`)
604
+ assert.equal(await revision(), start, 'nothing written')
605
+ assert.equal(await placement(), 'floor', 'the list says the placement the figure has')
606
+ })
607
+ })
608
+
569
609
  describe('AI proposals reaching the page', () => {
570
610
  before(async () => {
571
611
  await fresh()
@@ -593,7 +633,7 @@ describe('AI proposals reaching the page', () => {
593
633
  test('a proposal that would stop a clip is refused whole, the clip named', async () => {
594
634
  const before = await read(() => product.view())
595
635
  const refused = await offer({ steps: [{ kind: 'remove-part', target: 'leaf' }], create: false, context: before.context, revision: before.revision })
596
- assert.deepEqual({ applied: refused.applied, code: refused.code, clips: refused.clips }, { applied: false, code: 'CLIPS_DROPPED', clips: ['swing'] })
636
+ assert.deepEqual({ applied: refused.applied, code: refused.code, clips: refused.clips?.map(c => c.clip) }, { applied: false, code: 'CLIPS_DROPPED', clips: ['swing'] })
597
637
  const after = await read(() => product.view())
598
638
  assert.deepEqual({ source: after.source, revision: after.revision }, { source: before.source, revision: before.revision })
599
639
  })
@@ -287,6 +287,39 @@ describe('the frame: parts · canvas · one side of six tabs, and the bar over t
287
287
  })
288
288
  })
289
289
 
290
+ describe('the editor is drawn in the shell\'s tokens (2026-09-27)', () => {
291
+ /* Set apart from any theme, so a colour read back can only have come from the token named. */
292
+ const TOKENS = { '--md-sys-color-secondary-container': 'rgb(1, 2, 3)', '--md-sys-color-on-secondary-container': 'rgb(4, 5, 6)', '--md-sys-color-primary': 'rgb(7, 8, 9)' }
293
+ before(async () => {
294
+ await mount('box')
295
+ await read(tokens => Object.entries(tokens).forEach(([name, value]) => screenCheck.editor().style.setProperty(name, value)), TOKENS)
296
+ })
297
+ after(() => read(tokens => Object.keys(tokens).forEach(name => screenCheck.editor().style.removeProperty(name)), TOKENS))
298
+
299
+ test('a pressed button is drawn in the container the shell matched for both themes', async () => {
300
+ await pressRow('box')
301
+ await press('the move tool', () => screenCheck.editor().shadowRoot.querySelector('button[tool][aria-label="figure.button.gizmo-translate"]'))
302
+ await until('the move tool pressed', () => screenCheck.editor().shadowRoot.querySelector('button[tool][aria-pressed="true"]'))
303
+ /* Off the button: under the pointer it is drawn as hovered. */
304
+ await page().mouse.move(0, 0)
305
+ const drawn = await read(() => {
306
+ const root = screenCheck.editor().shadowRoot
307
+ const tool = getComputedStyle(root.querySelector('button[tool][aria-pressed="true"]'))
308
+ const mode = getComputedStyle(root.querySelector('button[mode][aria-pressed="true"]'))
309
+ return { tool: { background: tool.backgroundColor, color: tool.color }, mode: { color: mode.color, border: mode.borderTopColor } }
310
+ })
311
+ assert.deepEqual(drawn, { tool: { background: 'rgb(1, 2, 3)', color: 'rgb(4, 5, 6)' }, mode: { color: 'rgb(4, 5, 6)', border: 'rgb(7, 8, 9)' } })
312
+ })
313
+
314
+ test('the side draws no edge of its own and stacks over nothing: the frame draws the line between panes', async () => {
315
+ const aside = await read(() => {
316
+ const style = getComputedStyle(screenCheck.editor().shadowRoot.querySelector('aside'))
317
+ return { zIndex: style.zIndex, borderLeft: style.borderLeftWidth }
318
+ })
319
+ assert.deepEqual(aside, { zIndex: 'auto', borderLeft: '0px' })
320
+ })
321
+ })
322
+
290
323
  describe('a part chosen: each tab in its place, the figure-wide tabs give way, the list\'s root goes back', () => {
291
324
  before(() => mount('arm'))
292
325
 
@@ -10,6 +10,7 @@ import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart,
10
10
 
11
11
  import { createFigureProductSession } from '../client/modeller/product-session.js'
12
12
  import { runFigureProposal, takeFigureOffer, type FigureApplyDetail } from '../client/modeller/ai-proposal.js'
13
+ import { clipLossOf } from '../client/modeller/refusals.js'
13
14
 
14
15
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
15
16
  const ko = JSON.parse(read('../translations/ko.json'))
@@ -45,7 +46,7 @@ test('a proposal that would stop a clip is refused whole: nothing written, nothi
45
46
  const out = await runFigureProposal({ steps, context: context!, revision: revision! }, pageOf(s), say)
46
47
  assert.equal(out.verdict, 'refused')
47
48
  assert.equal(out.errorCode, 'CLIPS_DROPPED')
48
- assert.match(out.note!, /swing/)
49
+ assert.equal(out.note, say('figure.text.proposal-would-drop-clips', { clips: 'swing' }), 'said as the clips it would stop, not the kernel\'s English')
49
50
  assert.equal(s.source(), before, 'not even the rename before it: refused whole')
50
51
  assert.equal(s.read().revision, revision)
51
52
  assert.equal(s.read().dirty, false, 'nothing to save')
@@ -93,6 +94,8 @@ test('an AI proposal made while a person\'s consent is held is refused; clips, f
93
94
  s.withConsent({ dropClips: ['swing/open'], context: context!, revision: revision! }, () => takeFigureOffer(s, detail, { onSavedFigure: false, say }))
94
95
  assert.equal(detail.applied, false)
95
96
  assert.equal(detail.code, 'CLIPS_DROPPED')
97
+ /* Recorded as figure-model refused it, and read where every refusal is read. */
98
+ assert.deepEqual(clipLossOf(detail)?.map(c => `${c.clip} ${c.why}`), ['swing gone'])
96
99
  assert.equal(s.source(), before)
97
100
  assert.equal(s.read().revision, revision)
98
101
  assert.equal(s.read().dirty, false)
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'
10
10
  import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, parseFigureAsset, serializeFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
11
11
 
12
12
  import { createFigureProductSession } from '../client/modeller/product-session.js'
13
+ import { clipLossOf } from '../client/modeller/refusals.js'
13
14
 
14
15
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
15
16
 
@@ -37,9 +38,15 @@ test('refused and named first; agreed, exactly those go in one step; undo brings
37
38
  const s = createFigureProductSession(store)
38
39
  await s.open('doors')
39
40
  const revision = s.read().revision
40
- assert.throws(() => s.command(fixed(s, 'left')), (e: { code?: string; clips?: Array<{ id: string; clip: string; why: string }> }) =>
41
- e.code === 'CLIPS_DROPPED' && e.clips?.map(c => `${c.clip} ${c.why}`).join() === 'left-swing gone')
41
+ assert.throws(() => s.command(fixed(s, 'left')), (e: { code?: string; details?: { reason?: string; clips?: Array<{ id: string; clip: string; why: string }> } }) =>
42
+ e.code === 'CLIPS_DROPPED' && e.details?.reason === 'clips-dropped' && e.details.clips?.map(c => `${c.clip} ${c.why}`).join() === 'left-swing gone')
42
43
  assert.equal(s.read().revision, revision, 'nothing written')
44
+ /* The screen reads the refusal in one place: the clips to ask about, and nothing for any other refusal. */
45
+ const refusalOf = (run: () => unknown) => { try { run() } catch (error) { return error } }
46
+ assert.deepEqual(clipLossOf(refusalOf(() => s.command(fixed(s, 'left'))))?.map(c => `${c.clip} ${c.why}`), ['left-swing gone'])
47
+ const other = refusalOf(() => s.edit({ kind: 'remove-part', target: 'nowhere' }))
48
+ assert.ok((other as { code?: string })?.code, 'another refusal')
49
+ assert.equal(clipLossOf(other), null)
43
50
  assert.deepEqual(clipsOf(s), ['left-swing', 'right-swing'])
44
51
  const ids = ['left-swing/left-open']
45
52
  const at = () => ({ context: s.read().context!, revision: s.read().revision! })
@@ -16,7 +16,9 @@ const renderBody = editor.slice(editor.indexOf(' render() {'), editor.indexOf('
16
16
  test('띠 — 칸 접기 · 되돌리기 · 전체 보기 · 저장 상태 · 저장, 말은 번역에서', () => {
17
17
  /* The bar itself -- its folds, undo · redo · fit, the status and save -- is the browser check's (test/browser/screen.check.mjs). */
18
18
  assert.doesNotMatch(renderBody, /[가-힣]/, '편집기의 화면 말에 한국어가 박혀 있지 않다')
19
- const keys = [...editor.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
19
+ /* The editor's words, and those of the parts it hands its work to. */
20
+ const said = [editor, ...['pane-fold', 'refusal-teller', 'command-preview-controller'].map(name => read(`../client/modeller/${name}.ts`))].join('\n')
21
+ const keys = [...said.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1])
20
22
  for (const lang of ['ko', 'en', 'ja', 'zh', 'ms', 'es']) {
21
23
  const table = JSON.parse(read(`../translations/${lang}.json`))
22
24
  for (const key of keys) assert.ok(table[key], `${lang}: ${key}`)
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
13
13
  import * as fm from '@hatiolab/figure-model'
14
14
  import type { FigureAsset } from '@hatiolab/figure-model'
15
15
  import {
16
+ clipLossOf,
16
17
  editRefusalOf,
17
18
  inDeclaredRoom,
18
19
  inputRangeRefusalOf,
@@ -22,6 +23,7 @@ import {
22
23
  placedRefusalOf,
23
24
  placementRefusalOf,
24
25
  recipeRefusalOf,
26
+ refusalRecordOf,
25
27
  removalNoticeOf,
26
28
  repeatPitchRefusalOf,
27
29
  sizeRangeRefusalOf
@@ -133,11 +135,7 @@ test('공간 선언에 든 부품 — 한 번 눌러 지우고, 공간은 남은
133
135
  const after = fm.editFigurePart(a, { kind: 'remove-part', target: 'stray', refitRoom: true })
134
136
  assert.equal(after.document.model.nodes.some(n => n.id === 'stray'), false)
135
137
  assert.ok(after.occupancy?.bounds, '공간은 남은 부품으로 다시 선언된다')
136
- const editor = readFileSync(fileURLToPath(new URL('../client/modeller/editor.ts', import.meta.url)), 'utf8')
137
- const remove = editor.slice(editor.indexOf(' private removePart('), editor.indexOf(' private toggleHiddenPart('))
138
- assert.match(remove, /if \(!refitRoom && inDeclaredRoom\(error\)\) \{\s*this\.removePart\(id, true\)/, '거절 없이 곧바로 다시 맞춰 지운다')
139
- assert.match(remove, /figure\.text\.removal-space-refitted/, '그렇다고 한 줄')
140
- assert.doesNotMatch(remove, /this\.retry = \{/, '묻거나 권하지 않는다')
138
+ /* Removed in one click, nothing asked, the space fitted to the parts left and said so: parts.check.mjs. */
141
139
  assert.ok(ko['figure.text.removal-space-refitted'])
142
140
  })
143
141
 
@@ -153,11 +151,7 @@ test('배치를 바꿀 수 없는 까닭 — 평면에 붙은 부품과 새 평
153
151
  assert.ok(said.includes('post (bottom)') && said.includes('벽 기반') && said.includes('back'), said)
154
152
  assert.equal(editRefusalOf(refused, say), said, '편집 거절의 말도 같다')
155
153
  assert.equal(placementRefusalOf(refusalOf(() => act(box(), declaring({ min: 0, max: 4000 }))), say), null)
156
- /* The placement list sends its command through the page, not the editor: the page says it in words too, and the list goes back. */
157
- const page = readFileSync(fileURLToPath(new URL('../client/pages/figure-modeller-page.ts', import.meta.url)), 'utf8')
158
- assert.ok(page.includes('editRefusalOf(error, (key, params) => i18next.t(key, params))'), '페이지 경로도 사람의 말로')
159
- const props = readFileSync(fileURLToPath(new URL('../client/modeller/figure-properties.ts', import.meta.url)), 'utf8')
160
- assert.ok(props.includes('select.value = current'), '거절되면 목록이 원래 배치로 돌아온다')
154
+ /* The placement list sends its command through the page: said there in words, and the list back on the placement it has (product-page.check.mjs). */
161
155
  })
162
156
 
163
157
  test('범위 밖 값은 받을 수 있는 범위를 숫자로 말한다 — 커널의 영어가 아니다 (Figure 레인 :3300, 2026-09-26)', () => {
@@ -244,6 +238,20 @@ test('기즈모로 옮길 수 없는 부품 — 관절이 자리를 정하는지
244
238
  assert.equal(placedRefusalOf(refusalOf(() => act(conveyor(), { kind: 'set-repeat', part: 'frame', pitch: 10 })), '프레임', keyed), null, '다른 EDIT_TARGET 은 이 말이 아니다')
245
239
  })
246
240
 
241
+ test('옮겨 적은 거절 — 기록은 커널의 거절과 같은 모양이라 같은 말로 읽힌다 (ADR-0127)', () => {
242
+ const a = act(fm.addFigureBox(fm.createFigureAsset('Post'), { id: 'post', size: { x: 100, y: 1000, z: 100 }, position: { x: 0, y: 0, z: 0 } }), {
243
+ kind: 'attach',
244
+ part: 'post',
245
+ face: 'bottom',
246
+ to: { plane: 'mounting-plane' }
247
+ })
248
+ const refused = refusalOf(() => act(a, { kind: 'declare-occupancy', proposal: fm.proposeFigureOccupancy(a), placement: 'wall' }))
249
+ const record = refusalRecordOf(refused)
250
+ assert.ok(record.details, 'its details kept')
251
+ assert.equal(editRefusalOf(record, say), editRefusalOf(refused, say))
252
+ assert.equal(clipLossOf(record), null, 'no clips to ask about')
253
+ })
254
+
247
255
  test('부품 이름 — 이름 규칙은 커널 한 벌이고, 화면은 거절의 값으로 말한다 (ADR-0127)', () => {
248
256
  const rename = (asset: FigureAsset, target: string, name: string) => refusalOf(() => fm.editFigurePart(asset, { kind: 'rename-part', target, name } as never))
249
257
  assert.match(partNameRefusalOf(rename(conveyor(), 'frame', '1x'), keyed)!, /part-name-invalid .*"name":"1x"/)
@@ -14,6 +14,7 @@ import { DEFAULT_VIEW, WORKBENCH } from '../client/modeller/figure-view.ts'
14
14
 
15
15
  const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
16
16
  const editor = read('../client/modeller/editor.ts')
17
+ const styles = read('../client/modeller/editor-styles.ts')
17
18
 
18
19
  /** Hex colours that are not a `var(--token, #fallback)` fallback. */
19
20
  const fixed = (text: string) =>
@@ -23,6 +24,7 @@ const fixed = (text: string) =>
23
24
 
24
25
  test('편집기에 박힌 색이 없다 — 토큰의 대체값만 남는다', () => {
25
26
  assert.deepEqual(fixed(editor), [], 'editor.ts')
27
+ assert.deepEqual(fixed(styles), [], 'editor-styles.ts')
26
28
  })
27
29
 
28
30
  test('캔버스 바탕은 작업대 회색 — 판의 하늘색이라 환경 탭에서 바꾼다', () => {
@@ -30,23 +32,11 @@ test('캔버스 바탕은 작업대 회색 — 판의 하늘색이라 환경 탭
30
32
  assert.equal(DEFAULT_VIEW.skyColor, WORKBENCH, '3D 바탕의 처음 값도 작업대 회색')
31
33
  })
32
34
 
33
- test('눌린 단추는 셸이 두 테마 대비를 맞춘 칸을 쓴다 — 오류는 앱의 알림이 그린다 (2026-09-27)', () => {
34
- const pressed = editor.slice(editor.indexOf("button[aria-pressed='true']"), editor.indexOf('}', editor.indexOf("button[aria-pressed='true']")))
35
- assert.match(pressed, /--md-sys-color-secondary-container\)/)
36
- assert.match(pressed, /--md-sys-color-on-secondary-container\)/)
37
- assert.match(pressed, /border-color: var\(--md-sys-color-primary\)/)
38
- })
39
-
40
35
  test('편집기는 창이 아니라 자기 폭으로 접는다 — 레일이 폭을 먼저 가져간다', () => {
41
36
  /* The widths - 200 | 1fr | 340, 160 | 300 under 860px, one column with the canvas first under 560px - are
42
37
  ox-three-pane's, measured in @operato/layout's own test. The editor must not write the grid again. */
43
- assert.doesNotMatch(editor, /@media \(max-width/, '창 폭 기준이 남아 있다 — 레일을 펼치면 편집기가 넘친다')
44
- assert.doesNotMatch(editor, /grid-template-columns/, '편집기가 격자를 다시 쓰지 않는다')
38
+ assert.doesNotMatch(editor + styles, /@media \(max-width/, '창 폭 기준이 남아 있다 — 레일을 펼치면 편집기가 넘친다')
39
+ assert.doesNotMatch(editor + styles, /grid-template-columns/, '편집기가 격자를 다시 쓰지 않는다')
45
40
  })
46
41
 
47
- test('양쪽 칸은 띠에서 접는다 — 접으면 캔버스가 그 폭을 갖고, 오른쪽 칸은 무엇도 가리지 않는다', () => {
48
- /* The bar folding the editor's own frame, and the canvas taking the width, are the browser check's (test/browser/screen.check.mjs). */
49
- const aside = editor.slice(editor.indexOf(' aside {'), editor.indexOf('}', editor.indexOf(' aside {')))
50
- assert.doesNotMatch(aside, /z-index/)
51
- assert.doesNotMatch(aside, /border-left/, '칸 경계선은 틀이 긋는다 — 두 줄이 되지 않게')
52
- })
42
+ /* A pressed button drawn in the shell's containers, and the side drawing no edge and stacking over nothing, are the browser check's (test/browser/screen.check.mjs). */