@things-factory/figure-ui 10.1.81 → 10.1.83

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 (148) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +800 -53
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +357 -94
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/command-preview.d.ts +35 -0
  50. package/dist-client/modeller/command-preview.js +95 -0
  51. package/dist-client/modeller/command-preview.js.map +1 -0
  52. package/dist-client/modeller/editor.d.ts +107 -1
  53. package/dist-client/modeller/editor.js +823 -49
  54. package/dist-client/modeller/editor.js.map +1 -1
  55. package/dist-client/modeller/fields.d.ts +3 -2
  56. package/dist-client/modeller/fields.js +10 -3
  57. package/dist-client/modeller/fields.js.map +1 -1
  58. package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
  59. package/dist-client/modeller/figure-clips.d.ts +1 -1
  60. package/dist-client/modeller/figure-controls.d.ts +1 -1
  61. package/dist-client/modeller/figure-facts.js +2 -2
  62. package/dist-client/modeller/figure-facts.js.map +1 -1
  63. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  64. package/dist-client/modeller/figure-first-save.js +41 -14
  65. package/dist-client/modeller/figure-first-save.js.map +1 -1
  66. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  67. package/dist-client/modeller/figure-history-panel.js +130 -14
  68. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  69. package/dist-client/modeller/figure-links.d.ts +52 -2
  70. package/dist-client/modeller/figure-links.js +366 -97
  71. package/dist-client/modeller/figure-links.js.map +1 -1
  72. package/dist-client/modeller/figure-material-panel.d.ts +1 -1
  73. package/dist-client/modeller/figure-parts.d.ts +7 -0
  74. package/dist-client/modeller/figure-parts.js +55 -2
  75. package/dist-client/modeller/figure-parts.js.map +1 -1
  76. package/dist-client/modeller/figure-preview.d.ts +6 -0
  77. package/dist-client/modeller/figure-preview.js +22 -1
  78. package/dist-client/modeller/figure-preview.js.map +1 -1
  79. package/dist-client/modeller/figure-properties.d.ts +1 -1
  80. package/dist-client/modeller/figure-properties.js +3 -3
  81. package/dist-client/modeller/figure-properties.js.map +1 -1
  82. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  83. package/dist-client/modeller/figure-revert-choice.js +59 -0
  84. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  85. package/dist-client/modeller/first-save.d.ts +7 -0
  86. package/dist-client/modeller/first-save.js +17 -0
  87. package/dist-client/modeller/first-save.js.map +1 -1
  88. package/dist-client/modeller/gizmo.d.ts +0 -6
  89. package/dist-client/modeller/gizmo.js +0 -18
  90. package/dist-client/modeller/gizmo.js.map +1 -1
  91. package/dist-client/modeller/links.d.ts +49 -4
  92. package/dist-client/modeller/links.js +141 -15
  93. package/dist-client/modeller/links.js.map +1 -1
  94. package/dist-client/modeller/motion-ends.d.ts +12 -0
  95. package/dist-client/modeller/motion-ends.js +65 -0
  96. package/dist-client/modeller/motion-ends.js.map +1 -0
  97. package/dist-client/modeller/occupancy.d.ts +14 -1
  98. package/dist-client/modeller/occupancy.js +14 -1
  99. package/dist-client/modeller/occupancy.js.map +1 -1
  100. package/dist-client/modeller/product-session.d.ts +43 -2
  101. package/dist-client/modeller/product-session.js +146 -12
  102. package/dist-client/modeller/product-session.js.map +1 -1
  103. package/dist-client/modeller/refusals.d.ts +13 -0
  104. package/dist-client/modeller/refusals.js +16 -0
  105. package/dist-client/modeller/refusals.js.map +1 -1
  106. package/dist-client/modeller/revert-request.d.ts +16 -0
  107. package/dist-client/modeller/revert-request.js +11 -0
  108. package/dist-client/modeller/revert-request.js.map +1 -0
  109. package/dist-client/modeller/side-panel-styles.js +12 -0
  110. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  111. package/dist-client/modeller/stage-model.d.ts +18 -2
  112. package/dist-client/modeller/stage-model.js +21 -3
  113. package/dist-client/modeller/stage-model.js.map +1 -1
  114. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  115. package/dist-client/pages/figure-modeller-page.js +72 -34
  116. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  117. package/dist-client/tsconfig.tsbuildinfo +1 -1
  118. package/package.json +5 -5
  119. package/test/ai-proposal.test.ts +2 -2
  120. package/test/align.test.ts +50 -5
  121. package/test/browser/acceptance-page.js +187 -0
  122. package/test/browser/build.mjs +5 -1
  123. package/test/browser/revert-page.js +47 -0
  124. package/test/browser/serve-acceptance.mjs +85 -0
  125. package/test/browser/serve-revert.mjs +119 -0
  126. package/test/clip-ai-proposal.test.ts +104 -0
  127. package/test/clip-consent.test.ts +88 -0
  128. package/test/command-preview.test.ts +288 -0
  129. package/test/fields.test.ts +4 -3
  130. package/test/figure-assistant-surface.test.ts +4 -1
  131. package/test/first-minute.test.ts +96 -0
  132. package/test/first-save-suggest.test.ts +42 -0
  133. package/test/gizmo.test.ts +20 -16
  134. package/test/joint-fan.test.ts +2 -1
  135. package/test/layout.test.ts +9 -2
  136. package/test/links.test.ts +9 -5
  137. package/test/proposal-record.test.ts +126 -0
  138. package/test/release-entry.test.ts +28 -0
  139. package/test/release-panel.test.ts +3 -2
  140. package/test/resize-with-placement.test.ts +226 -0
  141. package/test/revert-choice.test.ts +39 -0
  142. package/test/revert-keeping.test.ts +102 -0
  143. package/test/save-landed.test.ts +198 -0
  144. package/translations/en.json +229 -11
  145. package/translations/ja.json +229 -11
  146. package/translations/ko.json +230 -12
  147. package/translations/ms.json +228 -10
  148. package/translations/zh.json +229 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.81",
3
+ "version": "10.1.83",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -27,8 +27,8 @@
27
27
  "clean": "npm run clean:server && npm run clean:client"
28
28
  },
29
29
  "dependencies": {
30
- "@hatiolab/figure-model": "^0.1.93",
31
- "@hatiolab/things-scene": "^10.1.61",
30
+ "@hatiolab/figure-model": "^0.1.94",
31
+ "@hatiolab/things-scene": "^10.1.62",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -41,8 +41,8 @@
41
41
  "@operato/styles": "^10.0.0",
42
42
  "@operato/utils": "^10.0.0",
43
43
  "@things-factory/ai-assistant": "^10.1.81",
44
- "@things-factory/figure-service": "^10.1.81",
44
+ "@things-factory/figure-service": "^10.1.82",
45
45
  "three": "^0.185.1"
46
46
  },
47
- "gitHead": "0c4f91d92c8214a9b2eca6690a7ed48bd2d828cc"
47
+ "gitHead": "b01824bdb3fd266eac06a7241941ac0a119a6e5d"
48
48
  }
@@ -99,14 +99,14 @@ test('an empty card is not applied', async () => {
99
99
  })
100
100
 
101
101
  test('a new figure\'s steps wait for the page once, and lapse if no page takes them', async () => {
102
- const waiting = pendNewFigure(steps, 50)
102
+ const waiting = pendNewFigure(steps, undefined, 50)
103
103
  const taken = takePendingNewFigure()!
104
104
  assert.equal(taken.steps, steps)
105
105
  assert.equal(takePendingNewFigure(), undefined, 'taken once')
106
106
  taken.settle({ applied: true })
107
107
  assert.deepEqual(await waiting, { applied: true })
108
108
 
109
- const lapsing = pendNewFigure(steps, 10)
109
+ const lapsing = pendNewFigure(steps, undefined, 10)
110
110
  assert.deepEqual(await lapsing, { applied: false, code: 'not-opened' })
111
111
  assert.equal(takePendingNewFigure(), undefined, 'a later new figure does not receive them')
112
112
  })
@@ -33,12 +33,24 @@ test('가운데 X · Z 는 부품 상자의 가운데를 0 에 둔다', () => {
33
33
  assert.equal(Math.round(boxOf(run(probe(), 'centre-z'), 'body')!.centre.z), 0)
34
34
  })
35
35
 
36
- test('바닥은 부품의 가장 낮은 점을 y 0 에 둔다 — 돌린 부품은 돈 부피로', () => {
37
- const flat = boxOf(run(probe(), 'floor'), 'body')!
38
- assert.equal(Math.round(flat.centre.y - flat.extent.y / 2), 0)
36
+ /*
37
+ * 기준면에 맞추기는 도형의 배치를 따른다 — figure-model 의 한 규칙(`figurePlaneAlignChanges`, 설계자 2026-09-27). 바닥은
38
+ * 아랫면을 y 0 에, 천장은 윗면을 y 0 에. 한 번 옮길 뿐 붙이지 않는다. 돌린 부품은 돈 부피로.
39
+ */
40
+ test('기준면 맞추기 — 배치를 따르고, 한 번 옮길 뿐 붙이지 않는다', async () => {
41
+ const { figurePlaneAlignChanges, applyFigureAuthoring } = await import('@hatiolab/figure-model')
42
+ const floor = probe()
43
+ const flat = boxOf(applied(floor, figurePlaneAlignChanges(floor, 'body').changes), 'body')!
44
+ assert.equal(Math.round(flat.centre.y - flat.extent.y / 2), 0, '바닥: 아랫면이 y 0')
39
45
  const turned = probe({ x: 0, y: 300, z: 0 }, { x: 0, y: 0, z: 45 })
40
- const landed = boxOf(run(turned, 'floor'), 'body')!
46
+ const landed = boxOf(applied(turned, figurePlaneAlignChanges(turned, 'body').changes), 'body')!
41
47
  assert.ok(Math.abs(landed.centre.y - landed.extent.y / 2) <= 1, `45° 돈 부품의 모서리가 바닥에 닿는다 (${landed.centre.y - landed.extent.y / 2})`)
48
+ const hung = applyFigureAuthoring(probe({ x: 0, y: -300, z: 0 }), { kind: 'set-placement', placement: 'ceiling' })
49
+ const top = boxOf(applied(hung, figurePlaneAlignChanges(hung, 'body').changes), 'body')!
50
+ assert.equal(Math.round(top.centre.y + top.extent.y / 2), 0, '천장: 윗면이 y 0')
51
+ const editor = read('../client/modeller/editor.ts')
52
+ assert.ok(editor.includes('const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)'))
53
+ assert.ok(!read('../client/modeller/align.ts').includes("case 'floor'"), '화면의 제 산수는 없다')
42
54
  })
43
55
 
44
56
  test('눈금은 1 · 2 · 5 간격으로 자리와 크기를 맞추고, 크기를 0 으로 만들지 않는다', () => {
@@ -59,7 +71,7 @@ test('대칭은 복제본을 가운데 면 반대편 같은 거리에 둔다 ·
59
71
  })
60
72
 
61
73
  test('붙인 부품처럼 옮길 수 없는 것은 맞추기도 없다', () => {
62
- assert.deepEqual(alignChanges(probe(), 'body', { translate: false, rotate: false, scale: false }, 'floor'), [])
74
+ assert.deepEqual(alignChanges(probe(), 'body', { translate: false, rotate: false, scale: false }, 'centre-x'), [])
63
75
  assert.match(read('../client/modeller/editor.ts'), /if \(!this\.selected \|\| !reach\.translate\) return null/)
64
76
  })
65
77
 
@@ -67,3 +79,36 @@ test('the grid step reads well -- 27 or 33 would make coordinates unreadable --
67
79
  assert.deepEqual([3000, 1000, 137, 4].map(niceStep), [500, 200, 20, 1])
68
80
  assert.ok(niceStep(0) > 0 && niceStep(-100) > 0)
69
81
  })
82
+
83
+ /* Designer 2026-09-27: a tilted part put on the plane touches it at a point or an edge -- said so, before and after; a ball is not said to be tilted. */
84
+ test('a tilted part is told it touches by a point or an edge, a ball by its lowest, in every language', async () => {
85
+ const { figurePlaneAlignChanges, applyFigureAuthoring } = await import('@hatiolab/figure-model')
86
+ const tilted = applyFigureAuthoring(probe({ x: 0, y: 300, z: 0 }, { x: 0, y: 0, z: 45 }), { kind: 'set-placement', placement: 'floor' })
87
+ assert.equal(figurePlaneAlignChanges(tilted, 'body').contact, 'tilted')
88
+ assert.equal(figurePlaneAlignChanges(probe({ x: 0, y: 300, z: 0 }), 'body').contact, 'face')
89
+ const editor = read('../client/modeller/editor.ts')
90
+ assert.ok(editor.includes("const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''"), 'the tooltip follows the contact')
91
+ assert.ok(editor.includes("this.notice = i18next.t(`figure.text.aligned-${contact}-${asset.placement ?? 'floor'}`)"), 'and so does the word after')
92
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
93
+ const t = JSON.parse(read(`../translations/${lang}.json`))
94
+ for (const placement of ['floor', 'ceiling', 'wall'])
95
+ for (const contact of ['tilted', 'extreme'])
96
+ assert.ok(t[`figure.text.align-to-${placement}-${contact}`] && t[`figure.text.aligned-${contact}-${placement}`], `${lang} ${placement} ${contact}`)
97
+ }
98
+ const ko = JSON.parse(read('../translations/ko.json'))
99
+ assert.equal(ko['figure.text.aligned-tilted-floor'], '기울기를 유지한 채 바닥에 맞췄습니다. 가장 낮은 점 또는 모서리가 닿습니다.')
100
+ assert.ok(!/기울|기운/.test(ko['figure.text.aligned-extreme-floor'] + ko['figure.text.align-to-floor-extreme']), 'a ball is not said to be tilted')
101
+ })
102
+
103
+ /* Designer 2026-09-27: the gizmo's reason for a tilted part named the floor's lowest point in every placement. */
104
+ test('the size gizmo\'s reason names what the placement keeps, in every language', () => {
105
+ const editor = read('../client/modeller/editor.ts')
106
+ assert.equal(editor.match(/'figure\.text\.gizmo-size-turned-fastening'[^\n]*extreme: this\.keptExtremeWord\(\)/g)?.length, 3, 'every place it is said')
107
+ for (const lang of ['en', 'ja', 'ko', 'ms', 'zh']) {
108
+ const t = JSON.parse(read(`../translations/${lang}.json`))
109
+ assert.ok(t['figure.text.gizmo-size-turned-fastening'].includes('{extreme}'), lang)
110
+ for (const placement of ['floor', 'ceiling', 'wall', 'center']) assert.ok(t[`figure.label.kept-extreme-${placement}`], `${lang} ${placement}`)
111
+ }
112
+ const ko = JSON.parse(read('../translations/ko.json'))
113
+ assert.equal(ko['figure.label.kept-extreme-ceiling'], '가장 높은 점')
114
+ })
@@ -0,0 +1,187 @@
1
+ /*
2
+ * The page a person opens to check ADR-0097 by hand: the modeller as the browser check drives it (`modeller-page.js`),
3
+ * in Korean, on a store in memory -- nothing is saved to a server, nothing is shared. `?model=<name>` mounts one of the
4
+ * test figures below; the list is at `?`.
5
+ *
6
+ * Run and stop: see serve-acceptance.mjs.
7
+ */
8
+ import './modeller-page.js'
9
+ import 'figure-ui-client/modeller/figure-properties.js'
10
+ import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
11
+ import { runFigureProposal, takeFigureOffer } from 'figure-ui-client/modeller/ai-proposal.js'
12
+ import { i18next } from '@operato/i18n'
13
+ import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, editFigurePart, proposeFigureOccupancy, serializeFigureAsset } from '@hatiolab/figure-model'
14
+
15
+ const PALETTE = { 'palette.dark': '#2b2b2b', 'palette.neutral': '#9a9a9a', 'palette.primary': '#477b9e', 'palette.accent': '#f79c40', 'palette.light': '#dddddd' }
16
+
17
+ /** A 400 mm box and nothing else, the figure placed so: the author's first minute (ADR-0097 §5.1). */
18
+ function box(placement, position) {
19
+ let a = createFigureAsset(`Probe ${placement}`)
20
+ a.palette = { ...a.palette, ...PALETTE }
21
+ a = addFigureBox(a, { id: 'box', size: { x: 400, y: 400, z: 400 }, position })
22
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement }))
23
+ }
24
+
25
+ function turned(rotation, placement = 'floor', position = { x: 0, y: 400, z: 0 }) {
26
+ let a = createFigureAsset(`Turned ${rotation.y ? 'about Y' : 'tilted'}${placement === 'floor' ? '' : ` (${placement})`}`)
27
+ a.palette = { ...a.palette, ...PALETTE }
28
+ a = addFigureBox(a, { id: 'box', size: { x: 400, y: 400, z: 400 }, position, rotation })
29
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement }))
30
+ }
31
+
32
+ export const MODELS = {
33
+ /* A new figure: nothing in it yet (ADR-0097 B, the first minute). */
34
+ empty: () => {
35
+ const a = createFigureAsset('New figure')
36
+ a.palette = { ...a.palette, ...PALETTE }
37
+ return serializeFigureAsset(a)
38
+ },
39
+ floor: () => box('floor', { x: 200, y: 200, z: 150 }),
40
+ ceiling: () => box('ceiling', { x: 200, y: -200, z: 150 }),
41
+ wall: () => box('wall', { x: 200, y: 200, z: 200 }),
42
+ center: () => box('center', { x: 200, y: 200, z: 150 }),
43
+ /* Two parts, for moving between the model and its parts (B.1). */
44
+ bench: () => {
45
+ let a = createFigureAsset('Work bench')
46
+ a.palette = { ...a.palette, ...PALETTE }
47
+ a = addFigureBox(a, { id: 'leg', size: { x: 60, y: 700, z: 60 }, position: { x: -400, y: 350, z: 0 } })
48
+ a = addFigureBox(a, { id: 'top', size: { x: 1200, y: 40, z: 600 }, position: { x: 0, y: 720, z: 0 } })
49
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'declare-occupancy', proposal: proposeFigureOccupancy(a), placement: 'floor' }))
50
+ },
51
+ /* A ceiling figure's part off its ceiling, for the one-off align (to the ceiling, by its top). */
52
+ 'ceiling-off': () => box('ceiling', { x: 0, y: -600, z: 0 }),
53
+ /* Turned about the vertical: its bottom stays flat on the floor as it is sized. */
54
+ 'turned-y': () => turned({ x: 0, y: 30, z: 0 }),
55
+ /* Tilted: the size gizmo is withheld; its fields keep its lowest point. */
56
+ tilted: () => turned({ x: 0, y: 0, z: 30 }),
57
+ /* Tilted in the other modes: a ceiling keeps its highest point, a wall its backmost, a centred figure its centre. */
58
+ 'tilted-ceiling': () => turned({ x: 0, y: 0, z: 30 }, 'ceiling', { x: 0, y: -400, z: 0 }),
59
+ 'tilted-wall': () => turned({ x: 0, y: 30, z: 0 }, 'wall', { x: 0, y: 300, z: 400 }),
60
+ 'tilted-center': () => turned({ x: 0, y: 0, z: 30 }, 'center', { x: 100, y: 300, z: 50 }),
61
+ /* Two doors, each swinging on a clip: an edit that would stop a clip is asked first (designer 2026-09-27). */
62
+ doors: () => {
63
+ let a = createFigureAsset('Doors')
64
+ a.palette = { ...a.palette, ...PALETTE }
65
+ a = addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
66
+ for (const [id, x] of [['left', -500], ['right', 500]]) {
67
+ a = addFigureBox(a, { id, size: { x: 900, y: 1800, z: 40 }, position: { x, y: 1000, z: 0 } })
68
+ a = applyFigureAuthoring(a, { kind: 'attach', part: id, face: 'bottom', to: { part: 'frame', face: 'top' } })
69
+ const j = a.joints.find(k => k.body1.part === id)
70
+ a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: `${id}-open` } } })
71
+ a = editFigurePart(a, { kind: 'set-driver', state: `${id}-open`, clip: `${id}-swing`, duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
72
+ }
73
+ return serializeFigureAsset(a)
74
+ },
75
+ /* A frame and a roller fastened on it: made into a row on the screen, then its length and pitch changed (C, scenario 2). */
76
+ conveyor: () => {
77
+ let a = createFigureAsset('Conveyor')
78
+ a.palette = { ...a.palette, ...PALETTE }
79
+ a = addFigureBox(a, { id: 'frame', size: { x: 2000, y: 100, z: 600 }, position: { x: 0, y: 400, z: 0 } })
80
+ a = addFigureBox(a, { id: 'roller', size: { x: 60, y: 60, z: 560 }, position: { x: 0, y: 480, z: 0 } })
81
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' } })
82
+ return serializeFigureAsset(a)
83
+ },
84
+ /* A device fastened on a bench top 300 mm off centre: the top narrowed on the screen brings it past the edge (C, scenario 1, Q3). */
85
+ 'bench-device': () => {
86
+ let a = createFigureAsset('Bench with device')
87
+ a.palette = { ...a.palette, ...PALETTE }
88
+ a = addFigureBox(a, { id: 'leg', size: { x: 60, y: 700, z: 60 }, position: { x: 0, y: 350, z: 0 } })
89
+ a = addFigureBox(a, { id: 'top', size: { x: 1200, y: 40, z: 600 }, position: { x: 0, y: 720, z: 0 } })
90
+ a = addFigureBox(a, { id: 'device', size: { x: 100, y: 100, z: 100 }, position: { x: 300, y: 790, z: 100 } })
91
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'top', face: 'bottom', to: { part: 'leg', face: 'top' } })
92
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'device', face: 'bottom', to: { part: 'top', face: 'top' }, align: { x: { mm: 300 }, z: { mm: 100 } } })
93
+ return serializeFigureAsset(a)
94
+ },
95
+ /* A door leaf fastened to its frame, not moving yet: its motion is set on the screen (C, scenario 3). */
96
+ door: () => {
97
+ let a = createFigureAsset('Door')
98
+ a.palette = { ...a.palette, ...PALETTE }
99
+ a = addFigureBox(a, { id: 'frame', size: { x: 1200, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
100
+ a = addFigureBox(a, { id: 'leaf', size: { x: 900, y: 1800, z: 40 }, position: { x: 0, y: 1000, z: 0 } })
101
+ a = addFigureBox(a, { id: 'handle', size: { x: 30, y: 150, z: 60 }, position: { x: 350, y: 1000, z: 50 } })
102
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } })
103
+ a = applyFigureAuthoring(a, { kind: 'attach', part: 'handle', face: 'back', to: { part: 'leaf', face: 'front' }, align: { x: { mm: 350 } } })
104
+ return serializeFigureAsset(a)
105
+ },
106
+ /* A ball: no face to lay on the floor, and not said to be tilted. */
107
+ ball: () => {
108
+ let a = createFigureAsset('Ball')
109
+ a.palette = { ...a.palette, ...PALETTE }
110
+ a = addFigurePrimitive(a, { id: 'ball', kind: 'sphere', dimensions: { radiusX: 200, radiusY: 200, radiusZ: 200 }, position: { x: 0, y: 500, z: 0 } })
111
+ return serializeFigureAsset(applyFigureAuthoring(a, { kind: 'set-placement', placement: 'floor' }))
112
+ },
113
+ /* 500 mm above the floor: keep the gap, or be brought onto it. */
114
+ 'floor-gap': () => box('floor', { x: 0, y: 700, z: 0 }),
115
+ /* 100 mm through the floor: a protrusion may be meant. */
116
+ 'floor-through': () => box('floor', { x: 0, y: 100, z: 0 })
117
+ }
118
+
119
+ async function start() {
120
+ const ko = await fetch('/ko.json').then(r => r.json())
121
+ i18next.addResourceBundle('ko', 'translations', ko, true, true)
122
+ await i18next.changeLanguage('ko')
123
+ const name = new URLSearchParams(location.search).get('model')
124
+ if (!name || !MODELS[name]) {
125
+ document.body.innerHTML = `<main style="padding:24px;font:15px sans-serif"><h1>ADR-0097 acceptance</h1><ul>${Object.keys(MODELS)
126
+ .map(m => `<li><a href="?model=${m}">${m}</a></li>`)
127
+ .join('')}</ul></main>`
128
+ return
129
+ }
130
+ await window.harness.mount('box', MODELS[name]())
131
+ /*
132
+ As the modeller page has it: one session for the screen, and another model opened in it by id (B.1: "another model
133
+ leaves nothing of the last"). The sources are these test figures, in memory; saving keeps them here, nowhere else.
134
+ */
135
+ const stored = Object.fromEntries(Object.keys(MODELS).map(id => [id, MODELS[id]()]))
136
+ const saves = []
137
+ const session = createFigureProductSession({
138
+ load: async id => ({ id, source: stored[id] }),
139
+ save: async request => (saves.push(request.id ?? name), (stored[request.id ?? name] = request.source), { id: request.id ?? name, source: request.source })
140
+ })
141
+ await session.open(name)
142
+ /*
143
+ As the modeller page slots it: the figure's own panel (name, placement, space, catalog), kept to the draft; and the
144
+ AI start path, which in the app opens the assistant dock -- here it only says so, there is no dock on this page.
145
+ */
146
+ const editor = document.querySelector('figure-editor')
147
+ editor.session = session
148
+ /* Opens another test figure in the same session and screen, as following a link to another figure does. */
149
+ /*
150
+ An AI proposal, as the dock hands it on (figure-assistant.ts: runFigureProposal, offered by a 'figure-apply' window
151
+ event) and as the modeller page takes it (figure-modeller-page.ts: takeFigureOffer). The dock and the page element
152
+ are this page's stand-ins; the two functions are the product's.
153
+ */
154
+ const say = (key, params) => i18next.t(key, params)
155
+ window.addEventListener('figure-apply', event => {
156
+ if (takeFigureOffer(session, event.detail, { onSavedFigure: false, say })) editor.requestUpdate(), editor.dispatchEvent(new CustomEvent('changed'))
157
+ })
158
+ const dock = {
159
+ offer: detail => (window.dispatchEvent(new CustomEvent('figure-apply', { detail })), detail),
160
+ openNew: async () => ({ applied: false, code: 'not-opened' })
161
+ }
162
+ window.acceptance = {
163
+ open: async id => (await session.open(id), editor.requestUpdate(), editor.dispatchEvent(new CustomEvent('changed'))),
164
+ /* The AI proposes these steps on the version open now; what the dock would say comes back. */
165
+ propose: steps => runFigureProposal({ steps, context: session.read().context, revision: session.read().revision }, dock, say),
166
+ saves: () => [...saves]
167
+ }
168
+ const properties = document.createElement('figure-properties')
169
+ properties.slot = 'figure'
170
+ const keep = () => (properties.source = editor.session.source())
171
+ keep()
172
+ editor.addEventListener('changed', keep)
173
+ /* The figure panel's commands, as the modeller page runs them: one step of the session's history. */
174
+ properties.addEventListener('command', event => {
175
+ try {
176
+ session.command(event.detail.action)
177
+ } catch (error) {
178
+ alert(String(error.message ?? error))
179
+ }
180
+ editor.requestUpdate()
181
+ keep()
182
+ })
183
+ editor.appendChild(properties)
184
+ editor.aiDraft = true
185
+ editor.addEventListener('ask-ai-draft', () => alert('(acceptance page) In the app this opens the assistant dock with a draft request.'))
186
+ }
187
+ start()
@@ -3,7 +3,8 @@
3
3
  * a stale bundle passes a check the source fails) and bundles it with the page into one file.
4
4
  *
5
5
  * FIGURE_MODEL=<dir of a figure-model build> bundles that build instead of the installed one -- for a change to
6
- * figure-model that is not published yet.
6
+ * figure-model that is not published yet. THINGS_SCENE=<dir of a things-scene build> does the same for things-scene
7
+ * (its browser bundle, `things-scene.mjs`).
7
8
  */
8
9
  import { execFileSync } from 'node:child_process'
9
10
  import { mkdirSync, rmSync, existsSync } from 'node:fs'
@@ -32,6 +33,8 @@ export async function buildPage(page = 'modeller-page.js') {
32
33
 
33
34
  const figureModel = process.env.FIGURE_MODEL ? resolve(process.env.FIGURE_MODEL, 'dist/index.js') : undefined
34
35
  if (figureModel && !existsSync(figureModel)) throw Error(`FIGURE_MODEL has no build: ${figureModel}`)
36
+ const thingsScene = process.env.THINGS_SCENE ? resolve(process.env.THINGS_SCENE, 'things-scene.mjs') : undefined
37
+ if (thingsScene && !existsSync(thingsScene)) throw Error(`THINGS_SCENE has no build: ${thingsScene}`)
35
38
 
36
39
  const bundle = await rollup({
37
40
  input: join(HERE, page),
@@ -45,6 +48,7 @@ export async function buildPage(page = 'modeller-page.js') {
45
48
  resolveId(id) {
46
49
  if (id.startsWith('figure-ui-client/')) return join(emitted, id.slice('figure-ui-client/'.length))
47
50
  if (figureModel && id === '@hatiolab/figure-model') return figureModel
51
+ if (thingsScene && id === '@hatiolab/things-scene') return thingsScene
48
52
  return null
49
53
  }
50
54
  },
@@ -0,0 +1,47 @@
1
+ /*
2
+ * The history panel on its own, for checking ADR-0097 §5.6 by hand and by the browser pane: revert keeps the draft it
3
+ * writes over, a lost answer is asked again with the same request, the newest kept draft can be taken back.
4
+ *
5
+ * The panel is the product's. The page around it stands in for the modeller page: it holds the source the server is
6
+ * confirmed to hold, and reads it again after a revert (the product page reopens the figure). Behind /graphql are
7
+ * figure-service's own revert functions on sqlite in memory -- see serve-revert.mjs.
8
+ */
9
+ import 'figure-ui-client/modeller/figure-history-panel.js'
10
+ import { i18next } from '@operato/i18n'
11
+
12
+ async function ask(operationName, variables = {}) {
13
+ const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operationName, variables }) })
14
+ return (await response.json()).data?.[operationName]
15
+ }
16
+
17
+ async function start() {
18
+ const ko = await fetch('/ko.json').then(r => r.json())
19
+ i18next.addResourceBundle('ko', 'translations', ko, true, true)
20
+ await i18next.changeLanguage('ko')
21
+
22
+ const figure = await ask('harnessFigure')
23
+ let confirmed = figure.source
24
+ const panel = document.createElement('figure-history-panel')
25
+ panel.figureId = figure.id
26
+ panel.figureState = figure.state
27
+ panel.figureVersion = figure.version
28
+ panel.dirty = false
29
+ panel.serverSource = () => confirmed
30
+ panel.saveFirst = async () => ({ ok: true })
31
+ panel.addEventListener('figure-reverted', async () => {
32
+ const now = await ask('harnessFigure')
33
+ confirmed = now.source
34
+ panel.figureState = now.state
35
+ })
36
+ document.body.appendChild(panel)
37
+
38
+ window.harness = {
39
+ /* The server does each revert or undo, then its answer never comes back -- until turned off. */
40
+ losing: on => ask('harnessLosing', { on }),
41
+ /* Another session saves meanwhile. */
42
+ editElsewhere: () => ask('harnessEditElsewhere'),
43
+ rows: () => ask('harnessRows'),
44
+ confirmed: () => JSON.parse(confirmed).name
45
+ }
46
+ }
47
+ start()
@@ -0,0 +1,85 @@
1
+ /*
2
+ * Serves the ADR-0097 acceptance page on a port of its own, for a person to check in a browser. No server is shared,
3
+ * nothing is installed or published: the page runs the modeller on a store in memory.
4
+ *
5
+ * FIGURE_MODEL=<figure-model checkout, built> THINGS_SCENE=<things-scene checkout, built> \
6
+ * node test/browser/serve-acceptance.mjs # from packages/figure-ui; PORT=3391 by default
7
+ * open http://localhost:3391/ # the list of test figures; ?model=floor etc.
8
+ * Ctrl+C stops it.
9
+ *
10
+ * Unset FIGURE_MODEL or THINGS_SCENE bundle the installed packages instead -- which do not hold the change yet.
11
+ *
12
+ * The page wears the product's clothes: OPERATO_APP (an app root, operato-figure by default) gives the theme the app
13
+ * build assembles -- every module's client/themes, in the builder's order, the first of each name kept -- and the icon
14
+ * and text fonts its shell links. The body is `light` as the app's theme setting makes it, or `dark` with ?theme=dark.
15
+ */
16
+ import { createServer } from 'node:http'
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import { dirname, join, normalize, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { createRequire } from 'node:module'
21
+ import { execFileSync } from 'node:child_process'
22
+ import { buildPage } from './build.mjs'
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url))
25
+ const bundle = await buildPage('acceptance-page.js')
26
+ const dir = dirname(bundle)
27
+ const ko = readFileSync(resolve(HERE, '../../translations/ko.json'))
28
+ const APP = resolve(process.env.OPERATO_APP ?? resolve(HERE, '../../../../../../operato-application/packages/operato-figure'))
29
+ /* The builder's ThemeOverridePlugin, run from the app root so its module order is the app's. */
30
+ const themeFiles = JSON.parse(
31
+ execFileSync(
32
+ process.execPath,
33
+ [
34
+ '-e',
35
+ `const path=require('path'),glob=require('glob');const {orderedModuleNames,appRootPath}=require('@things-factory/env');const app=require(appRootPath+'/package.json');const files={};[...orderedModuleNames].reverse().forEach(m=>{const mp=app.name==m?appRootPath:path.dirname(require.resolve(m+'/package.json'));const pj=require(path.join(mp,'package.json'));if(!pj.browser)return;for(const f of glob.sync(path.join(path.dirname(path.join(mp,pj.browser)),'themes','*.css'))){const n=path.basename(f);if(!files[n])files[n]=f}});process.stdout.write('\\n@@THEME@@'+JSON.stringify(Object.values(files)))`
36
+ ],
37
+ { cwd: APP, stdio: ['ignore', 'pipe', 'ignore'] }
38
+ )
39
+ .toString()
40
+ /* @things-factory/env prints its module lists as it loads: the list is what follows the marker. */
41
+ .split('@@THEME@@')
42
+ .pop()
43
+ )
44
+ const theme = themeFiles.map(f => readFileSync(f, 'utf8')).join('\n')
45
+ const appRequire = createRequire(join(APP, 'package.json'))
46
+ const HTML = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ADR-0097 acceptance</title>
47
+ <link href="/node_modules/material-symbols/index.css" rel="stylesheet" />
48
+ <link href="/node_modules/@fontsource/roboto/index.css" rel="stylesheet" />
49
+ <link rel="stylesheet" href="/theme.css" />
50
+ <style>html, body { height: 100%; } body { display: flex; } figure-editor { flex: 1; min-width: 0; min-height: 0; }</style>
51
+ <script>addEventListener('DOMContentLoaded', () => document.body.classList.add(new URLSearchParams(location.search).get('theme') === 'dark' ? 'dark' : 'light'))</script>
52
+ </head><body><script type="module" src="/page.js"></script></body></html>`
53
+ /* A file the shell links from /node_modules, resolved from the app as its server resolves it. */
54
+ function fromApp(path) {
55
+ const [, scope, name, rest] = /^\/node_modules\/((?:@[^/]+\/)?)([^/]+)\/(.*)$/.exec(path) ?? []
56
+ if (!name) return undefined
57
+ try {
58
+ return join(dirname(appRequire.resolve(`${scope}${name}/package.json`)), rest)
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+ const TYPES = { '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf' }
64
+ const port = Number(process.env.PORT ?? 3391)
65
+ createServer((req, res) => {
66
+ const path = req.url.split('?')[0]
67
+ const file = join(dir, normalize(path).replace(/^([/\\])+/, ''))
68
+ const asset = path.startsWith('/node_modules/') ? fromApp(path) : undefined
69
+ if (path === '/theme.css') {
70
+ res.writeHead(200, { 'Content-Type': 'text/css', 'Cache-Control': 'no-store' })
71
+ res.end(theme)
72
+ } else if (asset && existsSync(asset)) {
73
+ res.writeHead(200, { 'Content-Type': TYPES[asset.slice(asset.lastIndexOf('.'))] ?? 'application/octet-stream' })
74
+ res.end(readFileSync(asset))
75
+ } else if (path === '/ko.json') {
76
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
77
+ res.end(ko)
78
+ } else if (path.endsWith('.js') && existsSync(file)) {
79
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' })
80
+ res.end(readFileSync(file))
81
+ } else {
82
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
83
+ res.end(HTML)
84
+ }
85
+ }).listen(port, () => console.log(`ADR-0097 acceptance on http://localhost:${port}/ -- theme from ${themeFiles.length} files of ${APP}; Ctrl+C stops it`))
@@ -0,0 +1,119 @@
1
+ /*
2
+ * Serves revert-page.js on a port of its own (3392 by default) with figure-service's own revert functions behind
3
+ * /graphql, on sqlite in memory: nothing is shared, installed or published, and no database but the one in memory is
4
+ * touched. Build figure-service's server first (its dist-server is what runs).
5
+ *
6
+ * node test/browser/serve-revert.mjs # from packages/figure-ui
7
+ *
8
+ * Seeded: one figure with version 1 released and a draft after it. `harness*` operations are this page's own: drop the
9
+ * answers, save from another session, read the rows.
10
+ */
11
+ import { createServer } from 'node:http'
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { dirname, join, normalize, resolve } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { createRequire } from 'node:module'
16
+ import { buildPage } from './build.mjs'
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url))
19
+ const SERVICE = resolve(HERE, '../../../figure-service')
20
+ const need = createRequire(join(SERVICE, 'package.json'))
21
+ const DIST = join(SERVICE, 'dist-server/service/figure')
22
+ if (!existsSync(join(DIST, 'figure-revert.js'))) throw Error(`figure-service has no build: ${DIST}`)
23
+
24
+ const { DataSource } = need('typeorm')
25
+ const { Domain, NamingStrategy } = need('@things-factory/shell')
26
+ const { entityClosure } = need(resolve(SERVICE, '../ops-master/test/support/database.cjs'))
27
+ const { createFigureAsset, addFigureBox, serializeFigureAsset } = need('@hatiolab/figure-model')
28
+ const { Figure } = need(join(DIST, 'figure.js'))
29
+ const { FigureHistory } = need(join(DIST, 'figure-history.js'))
30
+ const { FigureReplacedDraft } = need(join(DIST, 'figure-replaced-draft.js'))
31
+ const { revertKeepingDraft, undoRevert, replacedDraftsOf } = need(join(DIST, 'figure-revert.js'))
32
+ const { compareAndSwapFigure } = need(join(DIST, 'figure-write.js'))
33
+
34
+ const db = new DataSource({
35
+ type: 'better-sqlite3',
36
+ database: ':memory:',
37
+ synchronize: true,
38
+ entities: entityClosure([Figure, FigureHistory, FigureReplacedDraft]),
39
+ namingStrategy: new NamingStrategy()
40
+ })
41
+ await db.initialize()
42
+ const text = (name, height) =>
43
+ serializeFigureAsset(addFigureBox(createFigureAsset(name), { id: 'body', size: { x: 100, y: height, z: 100 }, position: { x: 0, y: height / 2, z: 0 } }))
44
+ const domain = { id: (await db.getRepository(Domain).save({ name: 'revert', subdomain: 'revert' })).id }
45
+ const figure = await db.getRepository(Figure).save({ type: 'REVERT_PROBE', name: 'probe', source: text('Draft after v1', 300), state: 'draft', version: 1, domain })
46
+ await db.getRepository(FigureHistory).save({ originalId: figure.id, version: 1, type: 'REVERT_PROBE', name: 'probe', source: text('Released v1', 100), state: 'released', comment: 'first release', domain })
47
+ /* Answers of reverts and undos dropped after the server did them -- every one, the browser's own resend too, until turned off. */
48
+ let losing = false
49
+ let edits = 0
50
+
51
+ const plain = value => JSON.parse(JSON.stringify(value ?? null))
52
+ const OPS = {
53
+ harnessFigure: () => db.getRepository(Figure).findOneOrFail({ where: { id: figure.id } }),
54
+ harnessLosing: ({ on }) => ((losing = !!on), losing),
55
+ harnessEditElsewhere: async () => {
56
+ const now = await db.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })
57
+ await compareAndSwapFigure(c => db.getRepository(Figure).update(c, { source: text(`Saved elsewhere ${++edits}`, 500 + edits) }), figure.id, domain.id, now.source)
58
+ return true
59
+ },
60
+ harnessRows: async () => ({
61
+ figure: JSON.parse((await db.getRepository(Figure).findOneOrFail({ where: { id: figure.id } })).source).name,
62
+ kept: (await db.getRepository(FigureReplacedDraft).find({ order: { createdAt: 'ASC' } })).map(k => ({
63
+ kind: k.kind,
64
+ held: JSON.parse(k.source).name,
65
+ restored: !!k.restoredAt,
66
+ requestId: k.requestId
67
+ }))
68
+ }),
69
+ FigureVersions: async () =>
70
+ (await db.getRepository(FigureHistory).find({ where: { originalId: figure.id }, order: { version: 'DESC' } })).map(v => ({ ...v, updater: null })),
71
+ InspectFigure: () => ({ blocked: false, findings: [] }),
72
+ FigureReplacedDrafts: ({ id }) => replacedDraftsOf(db.manager, domain, id),
73
+ RevertFigureVersion: v => db.transaction(tx => revertKeepingDraft(tx, domain, undefined, v)),
74
+ UndoFigureRevert: v => db.transaction(tx => undoRevert(tx, domain, undefined, v))
75
+ }
76
+ const LOSABLE = new Set(['RevertFigureVersion', 'UndoFigureRevert'])
77
+
78
+ const bundle = await buildPage('revert-page.js')
79
+ const dir = dirname(bundle)
80
+ const ko = readFileSync(resolve(HERE, '../../translations/ko.json'))
81
+ const HTML = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Revert keeping</title>
82
+ <style>body { margin: 0; font-family: sans-serif; } figure-history-panel { width: 360px; height: 100vh; }</style>
83
+ </head><body class="light"><script type="module" src="/page.js"></script></body></html>`
84
+
85
+ const port = Number(process.env.PORT ?? 3392)
86
+ createServer(async (req, res) => {
87
+ const path = req.url.split('?')[0]
88
+ if (path === '/graphql' && req.method === 'POST') {
89
+ let body = ''
90
+ for await (const chunk of req) body += chunk
91
+ const { operationName, variables } = JSON.parse(body)
92
+ const op = OPS[operationName]
93
+ const field = operationName[0].toLowerCase() + operationName.slice(1)
94
+ const name = { FigureReplacedDrafts: 'figureReplacedDrafts', InspectFigure: 'inspectFigure' }[operationName] ?? field
95
+ let payload
96
+ try {
97
+ if (!op) throw Object.assign(Error(`not served here: ${operationName}`), { extensions: { code: 'NOT_SERVED' } })
98
+ const result = plain(await op(variables ?? {}))
99
+ payload = { data: { [name]: operationName in { RevertFigureVersion: 1, UndoFigureRevert: 1 } ? { violations: [], ...result } : result } }
100
+ } catch (error) {
101
+ payload = { errors: [{ message: error.message, extensions: { code: error.extensions?.code } }], data: null }
102
+ }
103
+ if (losing && LOSABLE.has(operationName)) {
104
+ req.socket.destroy()
105
+ return
106
+ }
107
+ res.writeHead(200, { 'Content-Type': 'application/json' })
108
+ res.end(JSON.stringify(payload))
109
+ } else if (path === '/ko.json') {
110
+ res.writeHead(200, { 'Content-Type': 'application/json' })
111
+ res.end(ko)
112
+ } else if (path.endsWith('.js') && existsSync(join(dir, normalize(path).replace(/^([/\\])+/, '')))) {
113
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' })
114
+ res.end(readFileSync(join(dir, normalize(path).replace(/^([/\\])+/, ''))))
115
+ } else {
116
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
117
+ res.end(HTML)
118
+ }
119
+ }).listen(port, () => console.log(`revert keeping on http://localhost:${port}/ -- sqlite in memory; Ctrl+C stops it`))