@things-factory/figure-ui 10.1.167 → 10.1.169

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 (48) hide show
  1. package/client/figure-assistant.ts +15 -1
  2. package/client/modeller/ai-proposal.ts +60 -11
  3. package/client/modeller/figure-properties.ts +1 -3
  4. package/client/modeller/product-session.ts +32 -13
  5. package/client/modeller/refusals.ts +14 -0
  6. package/client/pages/figure-modeller-page.ts +14 -10
  7. package/dist-client/figure-assistant.d.ts +6 -0
  8. package/dist-client/figure-assistant.js +14 -1
  9. package/dist-client/figure-assistant.js.map +1 -1
  10. package/dist-client/modeller/ai-proposal.d.ts +23 -5
  11. package/dist-client/modeller/ai-proposal.js +55 -13
  12. package/dist-client/modeller/ai-proposal.js.map +1 -1
  13. package/dist-client/modeller/clips.d.ts +1 -1
  14. package/dist-client/modeller/figure-properties.d.ts +1 -2
  15. package/dist-client/modeller/figure-properties.js +1 -3
  16. package/dist-client/modeller/figure-properties.js.map +1 -1
  17. package/dist-client/modeller/product-session.d.ts +14 -4
  18. package/dist-client/modeller/product-session.js +31 -13
  19. package/dist-client/modeller/product-session.js.map +1 -1
  20. package/dist-client/modeller/refusals.d.ts +1 -5
  21. package/dist-client/modeller/refusals.js +14 -1
  22. package/dist-client/modeller/refusals.js.map +1 -1
  23. package/dist-client/pages/figure-modeller-page.js +14 -9
  24. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +7 -7
  28. package/test/ai-proposal.test.ts +115 -5
  29. package/test/browser/acceptance-page.js +3 -1
  30. package/test/browser/product-page.check.mjs +63 -3
  31. package/test/browser/product-page.js +7 -2
  32. package/test/figure-assistant-surface.test.ts +65 -1
  33. package/test/proposal-record.test.ts +18 -6
  34. package/translations/en.json +6 -12
  35. package/translations/es.json +6 -12
  36. package/translations/ja.json +6 -12
  37. package/translations/ko.json +6 -12
  38. package/translations/ms.json +6 -12
  39. package/translations/zh.json +6 -12
  40. package/client/modeller/carrier-declaration.ts +0 -63
  41. package/client/modeller/figure-carrier.ts +0 -117
  42. package/dist-client/modeller/carrier-declaration.d.ts +0 -31
  43. package/dist-client/modeller/carrier-declaration.js +0 -53
  44. package/dist-client/modeller/carrier-declaration.js.map +0 -1
  45. package/dist-client/modeller/figure-carrier.d.ts +0 -26
  46. package/dist-client/modeller/figure-carrier.js +0 -123
  47. package/dist-client/modeller/figure-carrier.js.map +0 -1
  48. package/test/carrier-declaration.test.ts +0 -69
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.167",
3
+ "version": "10.1.169",
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.6.6",
31
- "@hatiolab/things-scene": "^10.1.86",
30
+ "@hatiolab/figure-model": "^0.7.1",
31
+ "@hatiolab/things-scene": "^10.1.90",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
34
  "@operato/graphql": "^10.0.0",
@@ -41,11 +41,11 @@
41
41
  "@operato/slot-layout-editor": "^10.29.25",
42
42
  "@operato/styles": "^10.0.0",
43
43
  "@operato/utils": "^10.0.0",
44
- "@things-factory/ai-assistant": "^10.1.167",
45
- "@things-factory/figure-service": "^10.1.167",
46
- "@things-factory/figure-viewer": "^10.1.166",
44
+ "@things-factory/ai-assistant": "^10.1.169",
45
+ "@things-factory/figure-service": "^10.1.169",
46
+ "@things-factory/figure-viewer": "^10.1.169",
47
47
  "@things-factory/setting-base": "^10.1.167",
48
48
  "three": "^0.185.1"
49
49
  },
50
- "gitHead": "86283c5d35042880be6827f777067fc5e6417eed"
50
+ "gitHead": "9d71d02b443f3e0080b25de1768b982c51a64111"
51
51
  }
@@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url'
9
9
  import { createFigureAsset, addFigureBox, serializeFigureAsset } from '@hatiolab/figure-model'
10
10
 
11
11
  import { createFigureProductSession } from '../client/modeller/product-session.js'
12
- import { pendNewFigure, runFigureProposal, takePendingNewFigure, type FigureApplyDetail } from '../client/modeller/ai-proposal.js'
12
+ import type { FigureProposalStep } from '@hatiolab/figure-model'
13
+ import { askingToLeave, pendNewFigure, runFigureProposal, takeFigureOffer, takePendingNewFigure, type FigureApplyDetail } from '../client/modeller/ai-proposal.js'
13
14
 
14
15
  const ko = JSON.parse(readFileSync(fileURLToPath(new URL('../translations/ko.json', import.meta.url)), 'utf8'))
15
16
  const say = (key: string, params: Record<string, string> = {}) => {
@@ -51,9 +52,18 @@ test('a person\'s edit after the proposal was asked makes it stale -- refused, n
51
52
  assert.equal(s.read().name, 'A')
52
53
  })
53
54
 
54
- const taker = (result: Partial<FigureApplyDetail>) => ({
55
+ const taker = (result: Partial<FigureApplyDetail>, saved: Array<{ figureId: string; type?: string; name?: string }> = []) => ({
55
56
  offered: [] as FigureApplyDetail[],
56
57
  opened: [] as unknown[],
58
+ asked: [] as string[],
59
+ shown: [] as string[],
60
+ async savedInto(key: string) {
61
+ this.asked.push(key)
62
+ return saved
63
+ },
64
+ openSaved(figureId: string) {
65
+ this.shown.push(figureId)
66
+ },
57
67
  offer(detail: FigureApplyDetail) {
58
68
  this.offered.push(detail)
59
69
  return Object.assign(detail, result)
@@ -72,12 +82,26 @@ test('applied: said with how many commands, and the card carries the version to
72
82
  assert.equal(target.offered[0]!.revision, 3)
73
83
  })
74
84
 
85
+ test('a revision carries the figure it was made for, and only a revision does (audit N3)', async () => {
86
+ const target = taker({ applied: true })
87
+ await runFigureProposal({ steps, figureId: 'a', context: 'c', revision: 0 }, target, say)
88
+ assert.equal(target.offered[0]!.figureId, 'a')
89
+ const fresh = taker({ applied: true })
90
+ await runFigureProposal({ steps, create: true, figureId: 'a' }, fresh, say)
91
+ assert.equal(fresh.offered[0]!.figureId, undefined)
92
+ })
93
+
75
94
  test('refused: a stale version is said as what happened; any other refusal carries its reason', async () => {
76
95
  const stale = await runFigureProposal({ steps }, taker({ applied: false, code: 'REVISION_CONFLICT', error: 'editor changed' }), say)
77
96
  assert.equal(stale.verdict, 'refused')
78
97
  assert.match(stale.note!, /모델이 바뀌어/)
79
98
  const other = await runFigureProposal({ steps }, taker({ applied: false, code: 'ATTACH_FACE', error: 'faces along x' }), say)
80
- assert.match(other.note!, /적용하지 않았습니다: faces along x/)
99
+ /* Said from the code in the reader's words, as the modeller says an edit it refuses -- never the kernel's English (audit N6). */
100
+ assert.match(other.note!, /적용하지 않았습니다: 이 편집은 할 수 없습니다\. \(ATTACH_FACE\)/)
101
+ assert.doesNotMatch(other.note!, /faces along x/)
102
+ const busy = await runFigureProposal({ steps }, taker({ applied: false, code: 'SESSION_SAVING', error: 'save first' }), say)
103
+ assert.match(busy.note!, /저장이 끝난 뒤/)
104
+ assert.doesNotMatch(busy.note!, /save first/)
81
105
  })
82
106
 
83
107
  test('a revision with no modeller to take it is not applied anywhere else', async () => {
@@ -94,8 +118,71 @@ test('a new figure with no new modeller on screen opens one', async () => {
94
118
  assert.equal(target.opened.length, 1)
95
119
  })
96
120
 
97
- test('an empty card is not applied', async () => {
98
- assert.equal((await runFigureProposal({ steps: [] }, taker({ applied: true }), say)).verdict, 'unavailable')
121
+ /*
122
+ The same proposal is one figure (ADR-0061 decision 3, ruled 2026-10-16): a new figure taken from it in the modeller keeps
123
+ the figure's own creation id, so what says the proposal was already made is the save's record of it, read before the
124
+ card runs again.
125
+ */
126
+ test('a new figure\'s card run again after it was saved opens that figure, and makes none', async () => {
127
+ const K = '11111111-1111-4111-8111-111111111111'
128
+ const target = taker({ applied: true }, [{ figureId: 'R', type: 'box', name: 'Box with lid' }])
129
+ const out = await runFigureProposal({ key: K, steps, create: true }, target, say)
130
+ assert.deepEqual(target.asked, [K])
131
+ assert.deepEqual(target.shown, ['R'])
132
+ assert.equal(target.offered.length + target.opened.length, 0, 'not offered to a modeller, no new one opened')
133
+ assert.deepEqual(out, { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names: 'Box with lid' }) })
134
+ })
135
+
136
+ test('a new figure\'s card not saved anywhere yet runs as before; a revision is not looked up', async () => {
137
+ const K = '11111111-1111-4111-8111-111111111111'
138
+ const fresh = taker({})
139
+ assert.equal((await runFigureProposal({ key: K, steps, create: true }, fresh, say)).verdict, 'accepted')
140
+ assert.deepEqual({ asked: fresh.asked, opened: fresh.opened.length }, { asked: [K], opened: 1 })
141
+ const revision = taker({ applied: true }, [{ figureId: 'R' }])
142
+ await runFigureProposal({ key: K, steps, context: 'c', revision: 3 }, revision, say)
143
+ assert.deepEqual({ asked: revision.asked, shown: revision.shown }, { asked: [], shown: [] })
144
+ })
145
+
146
+ /*
147
+ Open (ADR-0061 decision 3, ruled 2026-10-16, not prevented): the same new figure's card run in another tab before the
148
+ first figure is saved finds no record yet, and opens a second figure under the proposal's key.
149
+ */
150
+ test('the same new figure\'s card in another tab before the first save makes a second figure under its key', async () => {
151
+ const K = '11111111-1111-4111-8111-111111111111'
152
+ const ids: string[] = []
153
+ const tab = () =>
154
+ createFigureProductSession({
155
+ load: store.load,
156
+ save: async (q: { id?: string; source: string; creationId?: string }) => (ids.push(q.creationId!), { id: q.id ?? q.creationId!, source: q.source })
157
+ })
158
+ const made = [{ kind: 'add-part', part: { id: 'lid', kind: 'box', dimensions: { width: 100, height: 10, depth: 100 } } }] as any[]
159
+ const first = tab()
160
+ first.startNew()
161
+ const detail: FigureApplyDetail = { key: K, steps: made, create: true }
162
+ const here = { ...taker({}), offer: (d: FigureApplyDetail) => (takeFigureOffer(first, d, { onSavedFigure: false, say }), d) }
163
+ assert.equal((await runFigureProposal(detail, here, say)).verdict, 'accepted')
164
+
165
+ const second = tab()
166
+ const there = {
167
+ ...taker({}),
168
+ async openNew(s: FigureProposalStep[], key?: string) {
169
+ second.startNew()
170
+ second.createUnder(key!)
171
+ second.applyProposal(s, second.read().revision!, second.read().context!, key)
172
+ return { applied: true }
173
+ }
174
+ }
175
+ assert.equal((await runFigureProposal({ key: K, steps: made, create: true }, there, say)).verdict, 'accepted')
176
+ await first.save()
177
+ await second.save()
178
+ assert.deepEqual(ids, [first.read().creationId, K])
179
+ assert.notEqual(ids[0], ids[1], 'two figures')
180
+ })
181
+
182
+ test('an empty card is not applied, and says so (audit N7)', async () => {
183
+ const out = await runFigureProposal({ steps: [] }, taker({ applied: true }), say)
184
+ assert.equal(out.verdict, 'unavailable')
185
+ assert.match(out.note ?? '', /명령이 없어/)
99
186
  })
100
187
 
101
188
  test('a new figure\'s steps wait for the page once, and lapse if no page takes them', async () => {
@@ -110,3 +197,26 @@ test('a new figure\'s steps wait for the page once, and lapse if no page takes t
110
197
  assert.deepEqual(await lapsing, { applied: false, code: 'not-opened' })
111
198
  assert.equal(takePendingNewFigure(), undefined, 'a later new figure does not receive them')
112
199
  })
200
+
201
+ /*
202
+ A new figure's card run from a modeller with unsaved edits waits behind the question whether to leave them (figure
203
+ audit 2026-10-16 N4). Its clock stopped for that question: before, a person who took longer than it to say leave lost
204
+ their edits and got a blank new figure, the steps gone; one who said stay got "could not be opened" ten seconds later.
205
+ */
206
+ test('a new figure waiting behind the question whether to leave: no clock while asked, said at once when the person stays', async () => {
207
+ const leaving = pendNewFigure(steps, undefined, 10)
208
+ const answer = askingToLeave()!
209
+ await new Promise(resolve => setTimeout(resolve, 40))
210
+ answer(true)
211
+ const taken = takePendingNewFigure()
212
+ assert.ok(taken, 'still waiting for the page that opens')
213
+ taken!.settle({ applied: true })
214
+ assert.deepEqual(await leaving, { applied: true })
215
+
216
+ const staying = pendNewFigure(steps, undefined, 10_000)
217
+ askingToLeave()!(false)
218
+ assert.deepEqual(await staying, { applied: false, code: 'not-opened' })
219
+ assert.equal(takePendingNewFigure(), undefined)
220
+
221
+ assert.equal(askingToLeave(), undefined, 'nothing waiting: nothing to hold')
222
+ })
@@ -202,7 +202,9 @@ async function start() {
202
202
  })
203
203
  const dock = {
204
204
  offer: detail => (window.dispatchEvent(new CustomEvent('figure-apply', { detail })), detail),
205
- openNew: async () => ({ applied: false, code: 'not-opened' })
205
+ openNew: async () => ({ applied: false, code: 'not-opened' }),
206
+ savedInto: async () => [],
207
+ openSaved: () => {}
206
208
  }
207
209
  window.acceptance = {
208
210
  open: async id => (await session.open(id), editor.requestUpdate(), editor.dispatchEvent(new CustomEvent('changed'))),
@@ -541,14 +541,30 @@ describe('the page around the figure: the shell, the dock, the properties', () =
541
541
  assert.equal(await beforeUnload(), true)
542
542
  })
543
543
 
544
- test('a new figure gives the dock nothing to talk about, and the rail does not mark the catalogue', async () => {
544
+ /*
545
+ A figure not saved yet is a subject (ADR-0061 decision 3): its conversation is anchored on the creation id its first
546
+ save inserts the row under, so the first save changes nothing the dock knows -- the same conversation goes on.
547
+ */
548
+ test('a new figure: the dock talks about it by its creation id, and its first save keeps the conversation', async () => {
545
549
  await press('button[save]', 'save')
546
550
  await until(() => !product.view().dirty, 'saved')
547
551
  await read(() => assistant.forget())
548
552
  await open(null)
549
- assert.deepEqual(await said(), [{ call: 'clear' }], 'the saved figure\'s conversation taken back, none given')
550
- assert.equal(await read(() => assistant.surface), null)
553
+ const creationId = await read(() => product.page().session.read().creationId)
554
+ assert.match(creationId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
555
+ assert.deepEqual((await said()).filter(s => s.call !== 'notify'), [{ call: 'provide', key: `figure:${creationId}` }])
556
+ assert.deepEqual(await read(() => assistant.surface.anchor()), { anchorType: 'figure', anchorId: creationId })
557
+ assert.deepEqual(await read(() => { const { figureId, editorMode } = assistant.surface.hostContext(); return { figureId, editorMode } }), { figureId: creationId, editorMode: 'new' })
551
558
  assert.equal((await read(() => product.context())).menu, undefined)
559
+ await rename('Gate')
560
+ await read(() => { assistant.forget(); product.forget() })
561
+ await press('button[save]', 'save')
562
+ await press('figure-first-save >>> md-filled-button[slot=confirm]', 'the first save')
563
+ await until(() => product.view().id && !product.view().dirty, 'saved')
564
+ assert.equal((await callsTo('saveFigure'))[0].creationId, creationId)
565
+ assert.equal(await read(() => product.view().id), creationId)
566
+ assert.deepEqual((await said()).filter(s => s.call !== 'notify'), [], 'no new conversation, none taken back')
567
+ assert.deepEqual(await read(() => { const { figureId, editorMode } = assistant.surface.hostContext(); return { figureId, editorMode } }), { figureId: creationId, editorMode: 'existing' })
552
568
  })
553
569
 
554
570
  test('inactive: the surface is taken back', async () => {
@@ -643,6 +659,7 @@ describe('AI proposals reaching the page', () => {
643
659
  const outcome = await read(key => assistant.surface.proposalOutcome(key), K1)
644
660
  assert.deepEqual(outcome, { verdict: 'accepted', note: 'figure.text.proposal-saved-into {"names":"Door"}' })
645
661
  assert.deepEqual(await callsTo('figureProposalOutcome'), [{ proposalKey: K1 }])
662
+ await read(() => product.answer('figureProposalOutcome'))
646
663
  })
647
664
 
648
665
  test("a new figure proposed: the modeller opens a new one, takes the steps under the proposal's key, and the dock is told", async () => {
@@ -661,6 +678,7 @@ describe('AI proposals reaching the page', () => {
661
678
  outcome: { verdict: 'accepted', applied: 1, note: 'figure.text.proposal-applied {"n":"1"}' },
662
679
  told: [{ level: 'info', note: 'figure.text.proposal-applied {"n":"1"}' }]
663
680
  })
681
+ assert.deepEqual(await callsTo('figureProposalOutcome'), [{ proposalKey: K2 }], 'asked whether it was saved before')
664
682
  assert.deepEqual((await read(() => product.navigations())).map(n => n.path), ['/figure-modeller'])
665
683
  const made = await read(() => product.view())
666
684
  assert.equal(made.id, undefined)
@@ -671,6 +689,48 @@ describe('AI proposals reaching the page', () => {
671
689
  const [saved] = await callsTo('saveFigure')
672
690
  assert.deepEqual({ creationId: saved.creationId, proposalKeys: saved.proposalKeys }, { creationId: K2, proposalKeys: [K2] })
673
691
  })
692
+
693
+ /*
694
+ The draft the dock made on a new figure stays that figure's conversation (ADR-0061 decision 3, ruled 2026-10-16): the
695
+ figure keeps its creation id, and the save records the proposal. Run again once saved, the card opens that figure --
696
+ the record, not the row id, is what makes one proposal one figure.
697
+ */
698
+ test('a new figure\'s proposal taken on a new figure keeps its conversation; run again after the save, it opens that figure', async () => {
699
+ /* After the one above: the figure on the page is saved, so a new one opens without a question. */
700
+ await until(() => product.view().id && !product.view().dirty, 'the one above saved')
701
+ await open(null)
702
+ const creationId = await read(() => product.page().session.read().creationId)
703
+ await read(() => { assistant.forget(); product.forget() })
704
+ const run = () =>
705
+ read(
706
+ async ({ key, steps }) => {
707
+ const told = []
708
+ const outcome = await assistant.handlers.proposeFigureCandidate({ key, steps, create: true }, { notify: (level, note) => told.push({ level, note }) })
709
+ return { outcome, told }
710
+ },
711
+ { key: K1, steps: LID }
712
+ )
713
+ assert.deepEqual((await run()).outcome, { verdict: 'accepted', applied: 1, note: 'figure.text.proposal-applied {"n":"1"}' })
714
+ assert.equal(await read(() => product.page().session.read().creationId), creationId, 'the proposal\'s key is not the figure\'s id')
715
+ assert.deepEqual((await said()).filter(s => s.call !== 'notify'), [], 'the same conversation: no surface given or taken back')
716
+ assert.deepEqual(await read(() => product.navigations()), [], 'taken where it was asked')
717
+ await press('button[save]', 'save')
718
+ await press('figure-first-save >>> md-filled-button[slot=confirm]', 'the first save')
719
+ await until(() => product.view().id && !product.view().dirty, 'saved')
720
+ const [saved] = await callsTo('saveFigure')
721
+ assert.deepEqual({ creationId: saved.creationId, proposalKeys: saved.proposalKeys }, { creationId, proposalKeys: [K1] })
722
+
723
+ await open(null)
724
+ await read(() => product.forget())
725
+ const before = await read(() => product.rows().length)
726
+ const names = await read(id => product.rows().find(r => r.id === id).name, creationId)
727
+ const note = `figure.text.proposal-saved-into ${JSON.stringify({ names })}`
728
+ assert.deepEqual(await run(), { outcome: { verdict: 'accepted', note }, told: [{ level: 'info', note }] })
729
+ assert.deepEqual(await callsTo('figureProposalOutcome'), [{ proposalKey: K1 }])
730
+ assert.deepEqual((await read(() => product.navigations())).map(n => n.path), [`/figure-modeller/${creationId}`])
731
+ assert.equal(await read(() => product.rows().length), before, 'no second figure')
732
+ assert.deepEqual(await callsTo('saveFigure'), [])
733
+ })
674
734
  })
675
735
 
676
736
  describe('the catalogue preview', () => {
@@ -61,6 +61,8 @@ const calls = []
61
61
  /* Answers a check wants instead of the server's: field -> function(variables) returning data or throwing. */
62
62
  const overrides = new Map()
63
63
  let made = 0
64
+ /* What the saves recorded of AI proposals: { proposalKey, figureId }. */
65
+ const records = []
64
66
 
65
67
  function row(id) {
66
68
  const found = rows.get(id)
@@ -71,8 +73,10 @@ function row(id) {
71
73
  const answers = {
72
74
  figure: ({ id }) => ({ __typename: 'Figure', properties: null, tags: null, category: null, description: null, score: null, triangles: null, groups: null, thumbnailUpdatedAt: null, updatedAt: null, updater: null, thumbnailHash: null, ...row(id) }),
73
75
  figureTypeNames: () => [...rows.values()].map(r => r.type),
74
- saveFigure: async ({ id, source, type, creationId, thumbnail }) => {
76
+ saveFigure: async ({ id, source, type, creationId, thumbnail, proposalKeys }) => {
75
77
  const saved = id ? row(id) : { id: creationId ?? `made-${++made}`, namespace: null, type: type ?? `figure-${made}`, state: 'draft', version: 0 }
78
+ /* The save's record of the proposals its source holds, as the server keeps it (figure-proposal.ts). */
79
+ for (const key of proposalKeys ?? []) if (!records.some(r => r.proposalKey === key && r.figureId === saved.id)) records.push({ proposalKey: key, figureId: saved.id })
76
80
  /* The picture is kept by the hash of its bytes, as the server keeps it (ADR-0104 decision 6). */
77
81
  Object.assign(saved, { source, name: JSON.parse(source).name, state: 'draft' }, thumbnail === undefined ? {} : { thumbnailHash: await thumbnailHashOf(thumbnail) })
78
82
  rows.set(saved.id, saved)
@@ -87,7 +91,8 @@ const answers = {
87
91
  return { __typename: 'Figure', id, version: released.version, state: 'released', updatedAt: null }
88
92
  },
89
93
  figureRecipeTemplates: () => [],
90
- figureProposalOutcome: () => [],
94
+ figureProposalOutcome: ({ proposalKey }) =>
95
+ records.filter(r => r.proposalKey === proposalKey).map(r => ({ figureId: r.figureId, type: rows.get(r.figureId)?.type, name: rows.get(r.figureId)?.name, recordedAt: null })),
91
96
  figureReplacedDrafts: () => [],
92
97
  figureVersions: () => [],
93
98
  figureReleased: () => null,
@@ -2,7 +2,7 @@
2
2
  * The modeller's AI - the framework dock talks about the figure on screen (design/ai/shared-dock.md §3).
3
3
  *
4
4
  * What the page does with the dock -- ① a saved figure provides a surface anchored on its row ② the draft is read at the
5
- * moment of sending ③ an unsaved figure provides nothing ④ the surface is taken back when the page goes inactive or
5
+ * moment of sending ③ an unsaved figure provides one anchored on its creation id ④ the surface is taken back when the page goes inactive or
6
6
  * leaves ⑤ a proposal is taken through the page, and a new figure's opens a new figure -- is checked on the page itself
7
7
  * in test/browser/product-page.check.mjs. Here: the offer the page takes it by, and the words every outcome is said in.
8
8
  */
@@ -43,6 +43,70 @@ test('the dock applies a proposal through the page, on the version the model saw
43
43
  assert.equal(create.applied, undefined)
44
44
  })
45
45
 
46
+ /*
47
+ The dock reads the draft when the person sends (figure audit 2026-10-16 N5). A save in flight is no edit: reading the
48
+ draft then was refused with the edit guard's Korean "save first" line, which the dock showed as the turn's error.
49
+ */
50
+ test('the draft is read while a save is in flight', async () => {
51
+ const source = serializeFigureAsset(addFigureBox(createFigureAsset('A'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
52
+ let finish: (value: { id: string; source: string }) => void = () => {}
53
+ const session = createFigureProductSession({
54
+ load: async (id: string) => ({ id, source }),
55
+ save: (q: { id?: string; source: string }) => new Promise(resolve => (finish = () => resolve({ id: q.id ?? 'a', source: q.source })))
56
+ })
57
+ await session.open('a')
58
+ session.command({ kind: 'rename', name: 'B' } as never)
59
+ const saving = session.save()
60
+ assert.equal(session.read().saving, true)
61
+ assert.equal(JSON.parse(session.source()).name, 'B')
62
+ finish({ id: 'a', source })
63
+ await saving
64
+ })
65
+
66
+ /*
67
+ A revision is for the figure the model read, and a page with another figure open leaves it untaken (figure audit
68
+ 2026-10-16 N3): the handler then says it needs that figure open. The version alone did not tell them apart -- two
69
+ figures opened in two page loads were both at their first context and revision 0.
70
+ */
71
+ test('a revision made for another figure is left untaken, whatever version it names', async () => {
72
+ const source = serializeFigureAsset(addFigureBox(createFigureAsset('A'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
73
+ const session = createFigureProductSession({ load: async (id: string) => ({ id, source }), save: async (q: { id?: string; source: string }) => ({ id: q.id ?? 'a', source: q.source }) })
74
+ await session.open('a')
75
+ const now = session.read()
76
+ const other: FigureApplyDetail = { key: K1, steps: lid, create: false, figureId: 'b', context: now.context, revision: now.revision }
77
+ assert.equal(takeFigureOffer(session, other, { onSavedFigure: true, say }), false)
78
+ assert.equal(other.applied, undefined)
79
+ assert.equal(session.read().revision, now.revision)
80
+ const mine: FigureApplyDetail = { key: K1, steps: lid, create: false, figureId: 'a', context: now.context, revision: now.revision }
81
+ assert.equal(takeFigureOffer(session, mine, { onSavedFigure: true, say }), true)
82
+ })
83
+
84
+ /*
85
+ A figure not saved yet is a subject too (ADR-0061 decision 3): its conversation is anchored on the creation id the
86
+ first save inserts the row under, so the anchor is the same before and after that save. Taking a new figure's
87
+ proposal on it must not change that id -- the conversation that asked for the draft would be left behind.
88
+ */
89
+ test('a new figure keeps its creation id through a new figure\'s proposal and the first save', async () => {
90
+ const sent: Array<{ id?: string; creationId?: string; proposalKeys?: string[] }> = []
91
+ const session = createFigureProductSession({
92
+ load: async (id: string) => ({ id, source: '' }),
93
+ save: async (q: { id?: string; source: string; creationId?: string; proposalKeys?: string[] }) => {
94
+ sent.push({ id: q.id, creationId: q.creationId, proposalKeys: q.proposalKeys })
95
+ return { id: q.id ?? q.creationId!, source: q.source }
96
+ }
97
+ })
98
+ session.startNew()
99
+ const creationId = session.read().creationId
100
+ assert.match(creationId ?? '', /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
101
+ const create: FigureApplyDetail = { key: K1, steps: lid, create: true }
102
+ assert.equal(takeFigureOffer(session, create, { onSavedFigure: false, say }), true)
103
+ assert.equal(session.read().creationId, creationId, 'the proposal\'s key is not the figure\'s id')
104
+ assert.deepEqual(session.proposalsInEffect(), [K1], 'the save still records the proposal')
105
+ await session.save()
106
+ assert.deepEqual(sent, [{ id: undefined, creationId, proposalKeys: [K1] }])
107
+ assert.deepEqual({ id: session.read().id, creationId: session.read().creationId }, { id: creationId, creationId })
108
+ })
109
+
46
110
  test('a figure proposal is answered by its handler, in words, never left without one', () => {
47
111
  for (const lang of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
48
112
  const words = JSON.parse(readFileSync(fileURLToPath(new URL(`../translations/${lang}.json`, import.meta.url)), 'utf8'))
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * What became of an AI proposal (ADR-0097 §5.6 item 5; AI lane, 2026-09-27). A save names the proposals whose steps its
3
- * text holds -- undone, a proposal is not in it; a new figure made from a proposal is created under the proposal's key.
3
+ * text holds -- undone, a proposal is not in it; a new figure opened for a proposal is created under the proposal's key.
4
4
  * The server side (recorded in the save's transaction, one figure per key) is tested on sqlite in figure-service
5
5
  * (figure-proposal-db).
6
6
  */
@@ -59,16 +59,28 @@ test('a save names the proposals its text holds: undone, not; redone, again; und
59
59
  assert.deepEqual(s.proposalsInEffect(), [K1])
60
60
  })
61
61
 
62
- test('a new figure made from a proposal is created under its key; one being made keeps its own', async () => {
62
+ /*
63
+ Two ways a new figure comes from a proposal (ADR-0061 decision 3, ruled 2026-10-16). Opened for a proposal from another
64
+ screen, it is created under the proposal's key. Taken on the new figure the modeller has open, its conversation is
65
+ anchored on that figure's creation id, which stays; the save records the proposal either way.
66
+ */
67
+ test('a new figure opened for a proposal is created under its key; one taken on the open new figure keeps its id', async () => {
63
68
  const { store, saves } = server()
64
69
  const s = createFigureProductSession(store)
65
70
  s.startNew()
66
- const detail: FigureApplyDetail = { key: K1, steps: lid, create: true }
67
- assert.equal(takeFigureOffer(s, detail, { onSavedFigure: false, say }), true)
71
+ s.createUnder(K1)
72
+ s.applyProposal(lid, s.read().revision!, s.read().context!, K1)
68
73
  await s.save({ type: 'LIDDED' })
69
74
  assert.equal(saves.at(-1)!.creationId, K1, 'created under the proposal key: taken twice, it meets the first figure')
70
75
  assert.deepEqual(saves.at(-1)!.proposalKeys, [K1])
71
76
 
77
+ s.startNew({ discard: true })
78
+ const own = s.read().creationId
79
+ const detail: FigureApplyDetail = { key: K2, steps: lid, create: true }
80
+ assert.equal(takeFigureOffer(s, detail, { onSavedFigure: false, say }), true)
81
+ await s.save({ type: 'LIDDED_2' })
82
+ assert.deepEqual({ creationId: saves.at(-1)!.creationId, proposalKeys: saves.at(-1)!.proposalKeys }, { creationId: own, proposalKeys: [K2] })
83
+
72
84
  /* A new figure the person has touched is theirs: the proposal is not put on it (NEW_FIGURE_UNSAVED), its id stays. */
73
85
  s.startNew({ discard: true })
74
86
  s.command({ kind: 'rename', name: 'Mine' } as never)
@@ -84,13 +96,13 @@ test('the key travels from the card to the modeller, on a revision and on a new
84
96
  const opened: Array<string | undefined> = []
85
97
  await runFigureProposal(
86
98
  { key: K1, steps: lid, create: false, context: 'c', revision: 1 },
87
- { offer: d => (offered.push(d), { ...d, applied: true }), openNew: async () => ({ applied: false }) },
99
+ { offer: d => (offered.push(d), { ...d, applied: true }), openNew: async () => ({ applied: false }), savedInto: async () => [], openSaved: () => {} },
88
100
  say
89
101
  )
90
102
  assert.equal(offered[0]!.key, K1)
91
103
  await runFigureProposal(
92
104
  { key: K2, steps: lid, create: true },
93
- { offer: d => d, openNew: async (_steps, key) => (opened.push(key), { applied: true }) },
105
+ { offer: d => d, openNew: async (_steps, key) => (opened.push(key), { applied: true }), savedInto: async () => [], openSaved: () => {} },
94
106
  say
95
107
  )
96
108
  assert.deepEqual(opened, [K2])
@@ -712,6 +712,7 @@
712
712
  "figure.text.proposal-applied": "applied {n} commands as one step.",
713
713
  "figure.text.proposal-saved-into": "Saved into: {names}",
714
714
  "figure.text.proposal-needs-modeller": "this proposal can be applied only while its figure is open in the modeller.",
715
+ "figure.text.proposal-empty": "this proposal has no commands, so nothing was applied. ask again.",
715
716
  "figure.text.proposal-new-figure-unsaved": "a new figure is being made, so the proposal was not applied. save or discard it, then run it again.",
716
717
  "figure.text.proposal-not-opened": "a new figure could not be opened in the modeller, so the proposal was not applied.",
717
718
  "figure.text.proposal-refused": "the modeller did not apply the proposal: {reason}",
@@ -754,6 +755,11 @@
754
755
  "figure.text.removal-bindings": "{places} went with it.",
755
756
  "figure.text.removal-mounting-face": "The mounting face is released.",
756
757
  "figure.text.edit-refused": "This edit cannot be made. ({code})",
758
+ "figure.text.session-not-open": "open the model first.",
759
+ "figure.text.session-saving": "wait for the save to finish, then edit.",
760
+ "figure.text.session-version-missing": "an AI edit needs the model version it was asked on.",
761
+ "figure.text.session-new-figure-touched": "this can be used only on a new model not yet edited.",
762
+ "figure.text.session-proposal-key": "the proposal's identifier is not valid.",
757
763
  "figure.text.edit-refused-node-limit": "The figure is too large to add more. It holds up to {max} nodes.",
758
764
  "figure.text.document-too-large": "The figure is {at} KiB, over the {max} KiB it can be saved at.",
759
765
  "figure.text.document-too-large-unmeasured": "The figure is over the {max} KiB it can be saved at.",
@@ -973,18 +979,6 @@
973
979
  "figure.text.detail-unreleased": "Not released yet. Showing the saved draft.",
974
980
  "figure.button.detail-edit": "Open editor",
975
981
  "figure.button.download-package": "download package",
976
- "figure.label.group-carrier": "Carrier",
977
- "figure.label.carrier-type": "Carrier type",
978
- "figure.text.carrier-type-placeholder": "CARRIERTYPE",
979
- "figure.label.carrier-size": "Physical size",
980
- "figure.label.carrier-width": "Width",
981
- "figure.label.carrier-length": "Length",
982
- "figure.label.carrier-height": "Height",
983
- "figure.button.declare-carrier": "Declare carrier",
984
- "figure.button.clear-carrier": "Clear",
985
- "figure.text.carrier-type-required": "Enter the carrier type code.",
986
- "figure.text.carrier-type-too-long": "A carrier type code is at most {max} characters.",
987
- "figure.text.carrier-length-positive": "{axis} must be greater than 0.",
988
982
  "figure.button.import-mesh": "import GLB",
989
983
  "figure.text.import-mesh-hint": "brings a GLB file in as a part: the whole file, or one part of it.",
990
984
  "figure.text.mesh-whole-file": "whole file",
@@ -712,6 +712,7 @@
712
712
  "figure.text.proposal-applied": "se aplicaron {n} comandos como un solo paso.",
713
713
  "figure.text.proposal-saved-into": "Guardado en: {names}",
714
714
  "figure.text.proposal-needs-modeller": "esta propuesta solo se puede aplicar mientras su figura esté abierta en el modelador.",
715
+ "figure.text.proposal-empty": "esta propuesta no tiene comandos, por lo que no se aplicó. vuelva a pedirla.",
715
716
  "figure.text.proposal-new-figure-unsaved": "se está creando una nueva figura, por lo que no se aplicó la propuesta. guárdela o descártela y vuelva a ejecutarla.",
716
717
  "figure.text.proposal-not-opened": "no se pudo abrir una nueva figura en el modelador, por lo que no se aplicó la propuesta.",
717
718
  "figure.text.proposal-refused": "el modelador no aplicó la propuesta: {reason}",
@@ -754,6 +755,11 @@
754
755
  "figure.text.removal-bindings": "{places} se eliminaron con ella.",
755
756
  "figure.text.removal-mounting-face": "La cara de montaje se ha liberado.",
756
757
  "figure.text.edit-refused": "No se puede realizar esta edición. ({code})",
758
+ "figure.text.session-not-open": "abra primero el modelo.",
759
+ "figure.text.session-saving": "espere a que termine de guardarse y luego edite.",
760
+ "figure.text.session-version-missing": "una edición de IA necesita la versión del modelo en la que se pidió.",
761
+ "figure.text.session-new-figure-touched": "solo se puede usar en un modelo nuevo que aún no se ha editado.",
762
+ "figure.text.session-proposal-key": "el identificador de la propuesta no es válido.",
757
763
  "figure.text.edit-refused-node-limit": "La figura es demasiado grande para añadir más. Admite hasta {max} nodos.",
758
764
  "figure.text.document-too-large": "La figura ocupa {at} KiB, más que los {max} KiB con los que se puede guardar.",
759
765
  "figure.text.document-too-large-unmeasured": "La figura supera los {max} KiB con los que se puede guardar.",
@@ -973,18 +979,6 @@
973
979
  "figure.text.detail-unreleased": "Aún no publicada. Se muestra el borrador guardado.",
974
980
  "figure.button.detail-edit": "Abrir editor",
975
981
  "figure.button.download-package": "descargar paquete",
976
- "figure.label.group-carrier": "Portador",
977
- "figure.label.carrier-type": "Tipo de portador",
978
- "figure.text.carrier-type-placeholder": "CARRIERTYPE",
979
- "figure.label.carrier-size": "Tamaño físico",
980
- "figure.label.carrier-width": "Ancho",
981
- "figure.label.carrier-length": "Largo",
982
- "figure.label.carrier-height": "Alto",
983
- "figure.button.declare-carrier": "Declarar portador",
984
- "figure.button.clear-carrier": "Quitar",
985
- "figure.text.carrier-type-required": "Introduzca el código del tipo de portador.",
986
- "figure.text.carrier-type-too-long": "El código del tipo de portador tiene como máximo {max} caracteres.",
987
- "figure.text.carrier-length-positive": "{axis} debe ser mayor que 0.",
988
982
  "figure.button.import-mesh": "importar GLB",
989
983
  "figure.text.import-mesh-hint": "trae un archivo GLB como pieza: el archivo entero o una parte de él.",
990
984
  "figure.text.mesh-whole-file": "archivo entero",
@@ -712,6 +712,7 @@
712
712
  "figure.text.proposal-applied": "{n} 個のコマンドを 1 ステップとして適用しました。",
713
713
  "figure.text.proposal-saved-into": "保存された図形に含まれています: {names}",
714
714
  "figure.text.proposal-needs-modeller": "この提案は、その図形がモデラーで開いているときだけ適用できます。",
715
+ "figure.text.proposal-empty": "この提案にはコマンドがないため、適用しませんでした。もう一度依頼してください。",
715
716
  "figure.text.proposal-new-figure-unsaved": "作成中の新しい図形があるため、提案を適用しませんでした。保存または破棄してから、もう一度実行してください。",
716
717
  "figure.text.proposal-not-opened": "新しい図形をモデラーで開けなかったため、提案を適用しませんでした。",
717
718
  "figure.text.proposal-refused": "モデラーは提案を適用しませんでした: {reason}",
@@ -754,6 +755,11 @@
754
755
  "figure.text.removal-bindings": "{places} も一緒に削除しました。",
755
756
  "figure.text.removal-mounting-face": "取付面が外れました。",
756
757
  "figure.text.edit-refused": "この編集はできません。({code})",
758
+ "figure.text.session-not-open": "先にモデルを正しく開いてください。",
759
+ "figure.text.session-saving": "保存が終わってから編集してください。",
760
+ "figure.text.session-version-missing": "AI の編集には、依頼した時点のモデルのバージョンが必要です。",
761
+ "figure.text.session-new-figure-touched": "まだ編集していない新しいモデルにだけ使えます。",
762
+ "figure.text.session-proposal-key": "提案の識別子が正しくありません。",
757
763
  "figure.text.edit-refused-node-limit": "図形が大きいためこれ以上追加できません。ノードは {max} 個までです。",
758
764
  "figure.text.document-too-large": "図形が {at} KiB で、保存できる上限 {max} KiB を超えています。",
759
765
  "figure.text.document-too-large-unmeasured": "図形が保存できる上限 {max} KiB を超えています。",
@@ -973,18 +979,6 @@
973
979
  "figure.text.detail-unreleased": "まだ公開されていません。保存された下書きを表示します。",
974
980
  "figure.button.detail-edit": "編集画面を開く",
975
981
  "figure.button.download-package": "パッケージをダウンロード",
976
- "figure.label.group-carrier": "キャリア",
977
- "figure.label.carrier-type": "キャリアタイプ",
978
- "figure.text.carrier-type-placeholder": "CARRIERTYPE",
979
- "figure.label.carrier-size": "実寸",
980
- "figure.label.carrier-width": "幅",
981
- "figure.label.carrier-length": "長さ",
982
- "figure.label.carrier-height": "高さ",
983
- "figure.button.declare-carrier": "キャリアとして宣言",
984
- "figure.button.clear-carrier": "宣言を解除",
985
- "figure.text.carrier-type-required": "キャリアタイプのコードを入力してください。",
986
- "figure.text.carrier-type-too-long": "キャリアタイプのコードは {max} 文字までです。",
987
- "figure.text.carrier-length-positive": "{axis} は 0 より大きい値にしてください。",
988
982
  "figure.button.import-mesh": "GLB を取り込む",
989
983
  "figure.text.import-mesh-hint": "GLB ファイルを部品として取り込みます。ファイル全体か、その中の 1 つを選びます。",
990
984
  "figure.text.mesh-whole-file": "ファイル全体",
@@ -712,6 +712,7 @@
712
712
  "figure.text.proposal-applied": "명령 {n}개를 한 단계로 적용했습니다.",
713
713
  "figure.text.proposal-saved-into": "저장된 도형에 들어 있습니다: {names}",
714
714
  "figure.text.proposal-needs-modeller": "이 제안은 모델러에 그 도형이 열려 있을 때만 적용할 수 있습니다.",
715
+ "figure.text.proposal-empty": "이 제안에는 명령이 없어 적용하지 않았습니다. 다시 요청해 주세요.",
715
716
  "figure.text.proposal-new-figure-unsaved": "만들고 있는 새 Figure가 있어 제안을 적용하지 않았습니다. 저장하거나 버린 뒤 다시 실행해 주세요.",
716
717
  "figure.text.proposal-not-opened": "새 Figure를 모델러에 열지 못해 제안을 적용하지 않았습니다.",
717
718
  "figure.text.proposal-refused": "모델러가 제안을 적용하지 않았습니다: {reason}",
@@ -754,6 +755,11 @@
754
755
  "figure.text.removal-bindings": "{places} 도 함께 지웠습니다.",
755
756
  "figure.text.removal-mounting-face": "장착면이 풀렸습니다.",
756
757
  "figure.text.edit-refused": "이 편집은 할 수 없습니다. ({code})",
758
+ "figure.text.session-not-open": "먼저 모델을 정상적으로 열어 주세요.",
759
+ "figure.text.session-saving": "저장이 끝난 뒤 편집해 주세요.",
760
+ "figure.text.session-version-missing": "AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.",
761
+ "figure.text.session-new-figure-touched": "손대지 않은 새 모델에만 쓸 수 있습니다.",
762
+ "figure.text.session-proposal-key": "제안 식별자가 올바르지 않습니다.",
757
763
  "figure.text.edit-refused-node-limit": "노드 수 제한으로 부품을 더 추가할 수 없습니다. 최대 노드 수는 {max}개입니다.",
758
764
  "figure.text.document-too-large": "도형이 {at} KiB 로 저장 한도 {max} KiB 를 넘습니다.",
759
765
  "figure.text.document-too-large-unmeasured": "도형이 저장 한도 {max} KiB 를 넘습니다.",
@@ -973,18 +979,6 @@
973
979
  "figure.text.detail-unreleased": "아직 발행되지 않았습니다. 저장된 초안을 보여줍니다.",
974
980
  "figure.button.detail-edit": "편집 화면 열기",
975
981
  "figure.button.download-package": "꾸러미 내려받기",
976
- "figure.label.group-carrier": "캐리어",
977
- "figure.label.carrier-type": "캐리어 유형",
978
- "figure.text.carrier-type-placeholder": "CARRIERTYPE",
979
- "figure.label.carrier-size": "실물 크기",
980
- "figure.label.carrier-width": "폭",
981
- "figure.label.carrier-length": "길이",
982
- "figure.label.carrier-height": "높이",
983
- "figure.button.declare-carrier": "캐리어로 선언",
984
- "figure.button.clear-carrier": "선언 해제",
985
- "figure.text.carrier-type-required": "캐리어 유형 코드를 입력해 주세요.",
986
- "figure.text.carrier-type-too-long": "캐리어 유형 코드는 {max}자까지입니다.",
987
- "figure.text.carrier-length-positive": "{axis} 값은 0보다 커야 합니다.",
988
982
  "figure.button.import-mesh": "GLB 가져오기",
989
983
  "figure.text.import-mesh-hint": "GLB 파일을 부품으로 가져옵니다. 파일 전체나 그 안의 한 부분을 고릅니다.",
990
984
  "figure.text.mesh-whole-file": "파일 전체",