@things-factory/figure-ui 10.1.79 → 10.1.82

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 (145) 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 +826 -54
  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 +385 -68
  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/clips.d.ts +1 -1
  50. package/dist-client/modeller/command-preview.d.ts +35 -0
  51. package/dist-client/modeller/command-preview.js +95 -0
  52. package/dist-client/modeller/command-preview.js.map +1 -0
  53. package/dist-client/modeller/editor.d.ts +113 -1
  54. package/dist-client/modeller/editor.js +850 -50
  55. package/dist-client/modeller/editor.js.map +1 -1
  56. package/dist-client/modeller/fields.d.ts +3 -2
  57. package/dist-client/modeller/fields.js +10 -3
  58. package/dist-client/modeller/fields.js.map +1 -1
  59. package/dist-client/modeller/figure-facts.js +2 -2
  60. package/dist-client/modeller/figure-facts.js.map +1 -1
  61. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  62. package/dist-client/modeller/figure-first-save.js +41 -14
  63. package/dist-client/modeller/figure-first-save.js.map +1 -1
  64. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  65. package/dist-client/modeller/figure-history-panel.js +130 -14
  66. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-links.d.ts +51 -1
  68. package/dist-client/modeller/figure-links.js +392 -72
  69. package/dist-client/modeller/figure-links.js.map +1 -1
  70. package/dist-client/modeller/figure-parts.d.ts +7 -0
  71. package/dist-client/modeller/figure-parts.js +55 -2
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.d.ts +6 -0
  74. package/dist-client/modeller/figure-preview.js +22 -1
  75. package/dist-client/modeller/figure-preview.js.map +1 -1
  76. package/dist-client/modeller/figure-properties.js +3 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  79. package/dist-client/modeller/figure-revert-choice.js +59 -0
  80. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  81. package/dist-client/modeller/first-save.d.ts +7 -0
  82. package/dist-client/modeller/first-save.js +17 -0
  83. package/dist-client/modeller/first-save.js.map +1 -1
  84. package/dist-client/modeller/gizmo.d.ts +0 -6
  85. package/dist-client/modeller/gizmo.js +0 -18
  86. package/dist-client/modeller/gizmo.js.map +1 -1
  87. package/dist-client/modeller/links.d.ts +49 -4
  88. package/dist-client/modeller/links.js +141 -15
  89. package/dist-client/modeller/links.js.map +1 -1
  90. package/dist-client/modeller/motion-ends.d.ts +12 -0
  91. package/dist-client/modeller/motion-ends.js +65 -0
  92. package/dist-client/modeller/motion-ends.js.map +1 -0
  93. package/dist-client/modeller/occupancy.d.ts +14 -1
  94. package/dist-client/modeller/occupancy.js +14 -1
  95. package/dist-client/modeller/occupancy.js.map +1 -1
  96. package/dist-client/modeller/product-session.d.ts +43 -2
  97. package/dist-client/modeller/product-session.js +146 -12
  98. package/dist-client/modeller/product-session.js.map +1 -1
  99. package/dist-client/modeller/refusals.d.ts +13 -0
  100. package/dist-client/modeller/refusals.js +16 -0
  101. package/dist-client/modeller/refusals.js.map +1 -1
  102. package/dist-client/modeller/revert-request.d.ts +16 -0
  103. package/dist-client/modeller/revert-request.js +11 -0
  104. package/dist-client/modeller/revert-request.js.map +1 -0
  105. package/dist-client/modeller/side-panel-styles.js +12 -0
  106. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  107. package/dist-client/modeller/stage-model.d.ts +18 -2
  108. package/dist-client/modeller/stage-model.js +21 -3
  109. package/dist-client/modeller/stage-model.js.map +1 -1
  110. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  111. package/dist-client/pages/figure-modeller-page.js +72 -34
  112. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  113. package/dist-client/route.d.ts +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/ai-proposal.test.ts +2 -2
  117. package/test/align.test.ts +50 -5
  118. package/test/browser/acceptance-page.js +187 -0
  119. package/test/browser/build.mjs +5 -1
  120. package/test/browser/revert-page.js +47 -0
  121. package/test/browser/serve-acceptance.mjs +85 -0
  122. package/test/browser/serve-revert.mjs +119 -0
  123. package/test/clip-ai-proposal.test.ts +104 -0
  124. package/test/clip-consent.test.ts +88 -0
  125. package/test/command-preview.test.ts +288 -0
  126. package/test/fields.test.ts +4 -3
  127. package/test/figure-assistant-surface.test.ts +4 -1
  128. package/test/first-minute.test.ts +96 -0
  129. package/test/first-save-suggest.test.ts +42 -0
  130. package/test/gizmo.test.ts +20 -16
  131. package/test/joint-fan.test.ts +2 -1
  132. package/test/layout.test.ts +9 -2
  133. package/test/links.test.ts +7 -4
  134. package/test/proposal-record.test.ts +126 -0
  135. package/test/release-entry.test.ts +28 -0
  136. package/test/release-panel.test.ts +3 -2
  137. package/test/resize-with-placement.test.ts +225 -0
  138. package/test/revert-choice.test.ts +39 -0
  139. package/test/revert-keeping.test.ts +102 -0
  140. package/test/save-landed.test.ts +198 -0
  141. package/translations/en.json +222 -10
  142. package/translations/ja.json +223 -11
  143. package/translations/ko.json +223 -11
  144. package/translations/ms.json +222 -10
  145. package/translations/zh.json +223 -11
@@ -7,9 +7,16 @@ import {
7
7
  type FigurePrimitiveCreation,
8
8
  type FigurePartEdit,
9
9
  type FigureAuthoringAction,
10
- type FigureProposalStep
10
+ type FigureProposalStep,
11
+ type FigureClipConsent
11
12
  } from '@hatiolab/figure-model'
12
13
 
14
+ /** Consent to let clips go, and the figure and version it was given on. */
15
+ export interface FigureClipConsentFor extends FigureClipConsent {
16
+ context: string
17
+ revision: number
18
+ }
19
+
13
20
  export interface FigureProductRecord {
14
21
  id: string
15
22
  source: string
@@ -18,6 +25,8 @@ export interface FigureProductRecord {
18
25
  /** Persistence is injected; neither the model engine nor the renderer owns the product ID. */
19
26
  export interface FigureProductStore {
20
27
  load(id: string): Promise<FigureProductRecord>
28
+ /** Every field a save writes, as the server holds it now: to tell whether a save whose answer was lost landed. */
29
+ readBack?(id: string): Promise<FigureProductRecord & { thumbnail?: string; type?: string }>
21
30
  save(request: {
22
31
  id?: string
23
32
  source: string
@@ -27,6 +36,8 @@ export interface FigureProductStore {
27
36
  thumbnail?: string
28
37
  /** The type name a new figure is created under (the first save asks for it). Not sent for an existing one. */
29
38
  type?: string
39
+ /** Keys of the AI proposals whose steps the source holds: the server records them with the save. */
40
+ proposalKeys?: string[]
30
41
  }): Promise<FigureProductRecord>
31
42
  }
32
43
 
@@ -40,9 +51,47 @@ export function createFigureProductSession(store: FigureProductStore) {
40
51
  saving = false,
41
52
  error: string | undefined
42
53
  let creationId: string | undefined
54
+ /*
55
+ The history as steps, each tagged with the AI proposal it came from (or nothing): mirrored here, since the
56
+ workspace's history does not say where a step came from. A proposal is in the model while its step is on `done` --
57
+ undone, it is not; undone and written past, it never comes back. What a save sends as `proposalKeys`.
58
+ */
59
+ let done: Array<string | undefined> = [],
60
+ undone: Array<string | undefined> = []
61
+ function tracked<T>(kind: 'do' | 'undo' | 'redo', run: () => T, tag?: string): T {
62
+ const before = workspace.read().revision
63
+ const result = run()
64
+ if (workspace.read().revision === before) return result
65
+ if (kind === 'do') {
66
+ done.push(tag)
67
+ undone = []
68
+ } else if (kind === 'undo') undone.push(done.pop())
69
+ else done.push(undone.pop())
70
+ return result
71
+ }
72
+ const inEffect = () => [...new Set(done.filter((tag): tag is string => !!tag))]
43
73
  function read() {
44
74
  return { ...workspace.read(), id, loading, saving, error }
45
75
  }
76
+ /* Texts sent whose save was never answered: found on the server, the server is confirmed to hold what was sent. */
77
+ const unconfirmed = new Map<string, { thumbnail?: string }>()
78
+ /*
79
+ Consent to let clips go: given for one edit, on the figure and the version it was asked on. The first edit made under
80
+ it takes it; an edit on another figure or another version is refused rather than let clips go that were never named
81
+ for it (designer 2026-09-27).
82
+ */
83
+ let consent: FigureClipConsentFor | undefined
84
+ const consented = () => {
85
+ if (!consent) return {}
86
+ const given = consent
87
+ consent = undefined
88
+ const view = workspace.read()
89
+ if (view.context !== given.context || view.revision !== given.revision) {
90
+ const stale = Object.assign(Error('확인한 뒤 모델이 바뀌어 적용하지 않았습니다. 다시 해 주세요.'), { code: 'CONSENT_STALE' })
91
+ throw stale
92
+ }
93
+ return { dropClips: given.dropClips }
94
+ }
46
95
  function writable() {
47
96
  const view = workspace.read()
48
97
  if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
@@ -57,6 +106,8 @@ export function createFigureProductSession(store: FigureProductStore) {
57
106
  error = undefined
58
107
  saving = false
59
108
  creationId = undefined
109
+ done = []
110
+ undone = []
60
111
  }
61
112
  function canLeave(discard: boolean) {
62
113
  if (saving) throw Error('저장이 끝난 뒤 모델을 전환해 주세요.')
@@ -64,6 +115,11 @@ export function createFigureProductSession(store: FigureProductStore) {
64
115
  }
65
116
  return {
66
117
  read,
118
+ /**
119
+ * The source the server is confirmed to hold: loaded, or answered by a save. What a revert sends as `expectedSource`
120
+ * -- the server writes over it only while it still holds this. Nothing for a figure not saved yet.
121
+ */
122
+ confirmedSource: () => (id ? baseline : undefined),
67
123
  startNew({ discard = false } = {}) {
68
124
  canLeave(discard)
69
125
  ++epoch
@@ -104,45 +160,79 @@ export function createFigureProductSession(store: FigureProductStore) {
104
160
  // AI must supply the context AND revision captured when it started. Never substitute current values.
105
161
  if (origin === 'ai' && (context === undefined || expectedRevision === undefined))
106
162
  throw Error('AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
107
- return workspace.apply({
108
- ...control,
109
- origin,
110
- context: context ?? control.context,
111
- expectedRevision: expectedRevision ?? control.expectedRevision,
112
- changes
113
- })
163
+ return tracked('do', () =>
164
+ workspace.apply({
165
+ ...control,
166
+ origin,
167
+ context: context ?? control.context,
168
+ expectedRevision: expectedRevision ?? control.expectedRevision,
169
+ changes
170
+ })
171
+ )
114
172
  },
115
173
  /**
116
174
  * An AI proposal (ADR-0094): the modeller's commands, as one step of the history, on the version the model saw.
117
175
  * Never on the current one instead -- a person's edit in between makes the proposal stale, and it is refused.
118
176
  */
119
- applyProposal(steps: readonly FigureProposalStep[], expectedRevision: number, context: string) {
177
+ applyProposal(steps: readonly FigureProposalStep[], expectedRevision: number, context: string, key?: string) {
120
178
  writable()
121
- return workspace.applyProposal({ origin: 'ai', context, expectedRevision, steps })
179
+ /* Never with consent: only the person may let a clip go, and an AI proposal is not the person (designer 2026-09-27). */
180
+ return tracked('do', () => workspace.applyProposal({ origin: 'ai', context, expectedRevision, steps }), key)
181
+ },
182
+ /**
183
+ * A new figure made from an AI proposal is created under the proposal's key (ADR-0097 §5.6 item 5): the same
184
+ * proposal taken twice meets the first figure on the server, never a second one. Only on a new figure not yet
185
+ * touched -- one being made is the person's, and keeps its own.
186
+ */
187
+ createUnder(key: string) {
188
+ if (id || workspace.read().dirty) throw Error('손대지 않은 새 모델에만 쓸 수 있습니다.')
189
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw Error('제안 식별자가 올바르지 않습니다.')
190
+ creationId = key
122
191
  },
192
+ /** The AI proposals whose steps are in the model now, each once. */
193
+ proposalsInEffect: inEffect,
123
194
  /**
124
195
  * A recipe put in by the person (ADR-0095): its resolved commands, as one step of the history, on the version open
125
196
  * now. The same path as a proposal; the person is its origin.
126
197
  */
127
198
  applyRecipe(steps: readonly FigureProposalStep[]) {
128
199
  const at = writable()
129
- return workspace.applyProposal({ origin: 'ui', context: at.context, expectedRevision: at.expectedRevision, steps })
200
+ return tracked('do', () => workspace.applyProposal({ origin: 'ui', context: at.context, expectedRevision: at.expectedRevision, steps, ...consented() }))
130
201
  },
131
202
  add(part: FigurePrimitiveCreation) {
132
- return workspace.addPrimitive({ ...writable(), part })
203
+ return tracked('do', () => workspace.addPrimitive({ ...writable(), part }))
133
204
  },
134
205
  edit(edit: FigurePartEdit) {
135
- return workspace.editPart({ ...writable(), edit })
206
+ return tracked('do', () => workspace.editPart({ ...writable(), edit, ...consented() }))
207
+ },
208
+ /** What a command would do, on a copy (ADR-0097 §5.5): nothing is written, and the answer names its version. */
209
+ previewCommand(action: FigureAuthoringAction) {
210
+ const view = workspace.read()
211
+ if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
212
+ return workspace.previewCommand({ context: view.context, action })
136
213
  },
137
214
  /** One authoring command — what a person means, not a graph edit — as one step of the same history. */
138
215
  command(action: FigureAuthoringAction) {
139
- return workspace.command({ ...writable(), action })
216
+ return tracked('do', () => workspace.command({ ...writable(), action, ...consented() }))
217
+ },
218
+ /**
219
+ * The first edit made inside `run` may let go of exactly these clips -- the ones a CLIPS_DROPPED refusal named and
220
+ * the person agreed to -- on the figure and version they were named on (designer 2026-09-27). Any other edit that
221
+ * would stop a clip is refused and asked about.
222
+ */
223
+ withConsent<T>(given: FigureClipConsentFor, run: () => T): T {
224
+ consent = given
225
+ try {
226
+ return run()
227
+ } finally {
228
+ consent = undefined
229
+ }
140
230
  },
141
231
  undo() {
142
- return workspace.undo(writable())
232
+ return tracked('undo', () => workspace.undo(writable()))
143
233
  },
144
234
  redo() {
145
- return workspace.redo(writable())
235
+ return tracked('redo', () => workspace.redo(writable()))
146
236
  },
147
237
  source() {
148
238
  const { context } = writable()
@@ -159,16 +249,62 @@ export function createFigureProductSession(store: FigureProductStore) {
159
249
  const control = writable(),
160
250
  requestEpoch = epoch
161
251
  const exported = workspace.export(control)
252
+ /* Read with the text it goes with: an edit while saving is not what this save holds. */
253
+ const proposalKeys = inEffect()
162
254
  saving = true
163
255
  try {
164
- const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, ...(id || !type ? {} : { type }) })
256
+ let record: FigureProductRecord
257
+ try {
258
+ record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }) })
259
+ } catch (error) {
260
+ /*
261
+ A save that failed may still have landed: the answer lost on the way back (SAVE_UNREACHABLE), or a second
262
+ try refused because the first did land -- its expected source is the old one (FIGURE_SOURCE_CONFLICT). Only
263
+ those are read back; a refusal on grounds -- permission, validation -- stays a failure whatever the row
264
+ holds. Read back, every field the save writes must be what was sent: the source, the picture, the type
265
+ (designer 2026-09-27: a network failure alone does not say nothing was saved; nor does a matching source
266
+ alone say it was).
267
+ */
268
+ const code = (error as { code?: string })?.code
269
+ const unknown = code === 'SAVE_UNREACHABLE' || code === 'FIGURE_SOURCE_CONFLICT'
270
+ const landed = unknown && id !== undefined && requestEpoch === epoch && store.readBack ? await store.readBack(id).catch(() => undefined) : undefined
271
+ const held = landed ? serializeFigureAsset(parseFigureAsset(landed.source)) : undefined
272
+ const same = !!landed && held === exported.text && (thumbnail === undefined || landed.thumbnail === thumbnail) && (type === undefined || landed.type === type)
273
+ if (same) record = landed!
274
+ else if (
275
+ code === 'FIGURE_SOURCE_CONFLICT' &&
276
+ landed &&
277
+ held !== undefined &&
278
+ unconfirmed.has(held) &&
279
+ /* Every field that earlier save wrote, not the source alone: nothing not compared is written over. */
280
+ (unconfirmed.get(held)!.thumbnail === undefined || landed.thumbnail === unconfirmed.get(held)!.thumbnail)
281
+ ) {
282
+ /*
283
+ The row holds a text this session sent and never heard back about -- edited further since, the new text
284
+ is not it. The server is known to hold what was sent: this save is sent again, once, conditional on that
285
+ server text as the expected source (designer 2026-09-27: "the sent content is confirmed on the server").
286
+ */
287
+ baseline = landed.source
288
+ unconfirmed.clear()
289
+ record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }) })
290
+ } else {
291
+ if (code === 'SAVE_UNREACHABLE') unconfirmed.set(exported.text, { thumbnail })
292
+ throw error
293
+ }
294
+ }
165
295
  if (requestEpoch !== epoch) return read()
166
296
  if (!record.id || (id !== undefined && record.id !== id)) throw Error('저장 응답의 Figure 대상이 다릅니다.')
167
297
  if (serializeFigureAsset(parseFigureAsset(record.source)) !== exported.text)
168
298
  throw Error('저장 응답이 보낸 모델과 다릅니다. 저장 상태를 다시 확인해 주세요.')
169
- workspace.acknowledgeSaved(exported)
299
+ /* The server has it: the next save expects it, whatever happened on screen meanwhile. */
170
300
  id = record.id
171
301
  baseline = record.source
302
+ unconfirmed.clear()
303
+ /*
304
+ Edited while it was saving: what was sent is saved, the edits since are not -- they stay, and the model stays
305
+ unsaved (the workspace refuses a stale acknowledgement). Never marked saved, never wiped.
306
+ */
307
+ if (workspace.read().revision === exported.revision) workspace.acknowledgeSaved(exported)
172
308
  } finally {
173
309
  if (requestEpoch === epoch) saving = false
174
310
  }
@@ -140,3 +140,18 @@ export function inputRangeRefusalOf(error: unknown, say: Say): string | null {
140
140
  const params = { value: number(value!), min: number(min!), max: number(max!) }
141
141
  return axis ? say('figure.text.input-range-size', { ...params, axis: axis.toUpperCase() }) : say('figure.text.input-range', params)
142
142
  }
143
+
144
+ /**
145
+ * Why "follow the size and keep where the figure is placed" was not applied, in the author's words (ADR-0097): the plan
146
+ * says what it would have had to overwrite, and for a part through the plane how far. A code the screen has no words
147
+ * for is said as a refusal with its code, never in the kernel's English.
148
+ */
149
+ export function resizeRefusalOf(plan: { mode: string; refusal?: { code: string; axis?: string; mm?: number } }, part: string, say: Say): string {
150
+ const refusal = plan.refusal
151
+ if (!refusal) return ''
152
+ const place = say(`figure.label.resize-place-${plan.mode}`, {})
153
+ const params = { part, place, axis: (refusal.axis ?? '').toUpperCase(), mm: refusal.mm === undefined ? '' : String(Math.round(refusal.mm)) }
154
+ const known = ['HELD', 'TURNED', 'UNIFORM_POLICY', 'SHAPE_EDIT', 'DIMENSION_RULE', 'POSITION_RULE', 'GAP_CHOICE', 'NO_FACE']
155
+ if (known.includes(refusal.code)) return say(`figure.text.resize-refused-${refusal.code.toLowerCase().replace(/_/g, '-')}`, params)
156
+ return say('figure.text.edit-refused', { code: refusal.code })
157
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The request id a revert or an undo goes with (ADR-0097 §5.6, designer 2026-09-27: a retry after a lost answer never
3
+ * runs the same restore twice).
4
+ *
5
+ * Asked again for the same thing -- the same version over the same confirmed source, or the same kept draft -- after
6
+ * an answer that never came, the id is the one already sent: the server answers with what it did. Anything else is a
7
+ * new request.
8
+ */
9
+ export interface RevertRequest {
10
+ key: string
11
+ requestId: string
12
+ }
13
+
14
+ /** Outcomes that leave it unknown whether the server did it. */
15
+ const UNKNOWN = new Set(['REVERT_UNREACHABLE', 'FIGURE_REVERT_IN_FLIGHT'])
16
+
17
+ export function revertRequestFor(pending: RevertRequest | undefined, key: string, fresh: () => string = () => crypto.randomUUID()): RevertRequest {
18
+ return pending?.key === key ? pending : { key, requestId: fresh() }
19
+ }
20
+
21
+ /** After the answer, or its absence: kept only while it is not known whether the server did it. */
22
+ export function afterRevertAnswer(sent: RevertRequest, code?: string): RevertRequest | undefined {
23
+ return code !== undefined && UNKNOWN.has(code) ? sent : undefined
24
+ }
25
+
26
+ export const revertOutcomeUnknown = (code?: string) => code !== undefined && UNKNOWN.has(code)
@@ -263,6 +263,18 @@ export const SidePanelStyles = css`
263
263
  div[cell] input:focus {
264
264
  outline: none;
265
265
  }
266
+ /* An axis a rule sets: its number, read-only, on a quieter ground (ADR-0097). */
267
+ div[cell][held] {
268
+ background-color: var(--md-sys-color-surface-container);
269
+ border-style: dashed;
270
+ }
271
+ div[cell][held] input {
272
+ color: var(--md-sys-color-on-surface-variant);
273
+ font-style: italic;
274
+ }
275
+ p[held-axes] {
276
+ margin: 2px 0 6px;
277
+ }
266
278
  label[toggle] {
267
279
  display: flex;
268
280
  align-items: center;
@@ -64,8 +64,16 @@ function colours(part: FigureRenderPart, palette: Record<string, string>) {
64
64
  return { color: palette[m.token] ?? '#9a9a9a', preset: m.preset, transparent: !!m.transparent, ...(glow ? { glow } : {}) }
65
65
  }
66
66
 
67
- /** The component state that draws a body where figure-model put it. */
68
- export function componentOf(part: FigureRenderPart, palette: Record<string, string>, board: Board): Record<string, unknown> | null {
67
+ /** Per axis of the body's own frame, the face a size drag keeps: 1 the low face, -1 the high face, 0 the centre. */
68
+ export type ScaleAnchor = Record<'x' | 'y' | 'z', -1 | 0 | 1>
69
+
70
+ /**
71
+ * The component state that draws a body where figure-model put it. With `anchor` -- figure-model's
72
+ * `figureScaleAnchorOf` for its part -- the scene's scale gizmo keeps that face while it is dragged, the face the model
73
+ * keeps when the drag is written; without it the gizmo keeps the face across from the handle held, and the part jumped
74
+ * on release (designer 2026-09-27). Body x · y · z are the component's width · depth · height, as the scene's axes are.
75
+ */
76
+ export function componentOf(part: FigureRenderPart, palette: Record<string, string>, board: Board, anchor?: ScaleAnchor): Record<string, unknown> | null {
69
77
  const frame = frameOf(part)
70
78
  if (!frame) return null
71
79
  const { centre, extent, rotation, origin } = frame
@@ -88,7 +96,8 @@ export function componentOf(part: FigureRenderPart, palette: Record<string, stri
88
96
  zPos: round(world.y - depth / 2),
89
97
  rotationX: round(euler.x),
90
98
  rotation: round(-euler.y),
91
- rotationY: round(euler.z)
99
+ rotationY: round(euler.z),
100
+ ...(anchor ? { scaleAnchor: { ...anchor } } : {})
92
101
  }
93
102
  }
94
103
 
@@ -181,3 +190,16 @@ export function boardFor(parts: readonly FigureRenderPart[], at: Board & { depth
181
190
  depth: Math.max(at.depth ?? 0, Math.ceil(size.y * 1.1), 200)
182
191
  }
183
192
  }
193
+
194
+ /**
195
+ * A body's placement as its fields would read it: its origin in mm and its turn in degrees (rigid@1's X·Y·Z order, as
196
+ * `componentOf` reads it). For a field a rule sets -- a fastened part's height, a turn its joint fixes -- the screen shows
197
+ * this number read-only rather than a blank (designer 2026-09-27).
198
+ */
199
+ export function placementOf(part: FigureRenderPart): { position: [number, number, number]; rotation: [number, number, number] } {
200
+ const r = part.pose.r
201
+ const m = new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1)
202
+ const e = new THREE.Euler().setFromRotationMatrix(m, 'XYZ')
203
+ const deg = (v: number) => Math.round(THREE.MathUtils.radToDeg(v) * 10) / 10 || 0
204
+ return { position: part.pose.t.map(round) as [number, number, number], rotation: [deg(e.x), deg(e.y), deg(e.z)] }
205
+ }
@@ -6,7 +6,7 @@ import { modellerPathOf } from '../modeller/modeller-route.js'
6
6
  import { createFigureProductSession } from '../modeller/product-session.js'
7
7
  import { figureProductStore } from '../graphql/store.js'
8
8
  import { i18next } from '@operato/i18n'
9
- import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
9
+ import { fetchFigure, fetchFigureTypeNames, fetchProposalOutcome, updateFigure } from '../graphql/index.js'
10
10
  import { availableRecipes, recipeGuide } from '../modeller/recipe-source.js'
11
11
  import type { RecipeTemplate } from '../modeller/recipes.js'
12
12
  import { occupancyFromParts } from '../modeller/occupancy.js'
@@ -18,17 +18,18 @@ import '../modeller/figure-verdict.js'
18
18
  import '../modeller/figure-properties.js'
19
19
  import type { FigureBudgetLine } from '../modeller/maturity.js'
20
20
  import { OxPrompt } from '@operato/popup'
21
- import { openPopup } from '@operato/layout'
21
+ import { notify, openPopup } from '@operato/layout'
22
22
  import '../modeller/figure-first-save.js'
23
23
  import { activePalette } from '@hatiolab/things-scene'
24
24
  import {
25
+ askAssistant,
25
26
  clearAssistantSurface,
26
27
  notifyAssistantSurface,
27
28
  provideAssistantSurface
28
29
  } from '@things-factory/ai-assistant/dist-client/index.js'
29
30
  import type { FirstSave } from '../modeller/first-save.js'
30
31
  import { placementRefusalOf } from '../modeller/refusals.js'
31
- import { takePendingNewFigure } from '../modeller/ai-proposal.js'
32
+ import { proposalOutcomeFrom, takeFigureOffer, takePendingNewFigure } from '../modeller/ai-proposal.js'
32
33
 
33
34
  /**
34
35
  * The product page has exactly one authoritative workspace for UI and AI input transactions.
@@ -161,6 +162,9 @@ export class FigureModellerPage extends PageView {
161
162
  label: () => this.session.read().name || id,
162
163
  detail: () => (this.session.read().dirty ? i18next.t('figure.text.unsaved-changes') : ''),
163
164
  anchor: () => ({ anchorType: 'figure', anchorId: id }),
165
+ /* The card's "check the result": the server's record, never the dock's memory; nothing is applied again. */
166
+ proposalOutcome: async (proposalKey: string) =>
167
+ proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params)),
164
168
  hostContext: () => ({
165
169
  surface: 'figure-modeller',
166
170
  editorMode: 'existing',
@@ -169,10 +173,23 @@ export class FigureModellerPage extends PageView {
169
173
  /* The version the model sees; a proposal is applied on it, or refused if the figure moved on (ADR-0094). */
170
174
  context: this.session.read().context,
171
175
  revision: this.session.read().revision,
172
- palette: Object.keys(activePalette())
176
+ palette: Object.keys(activePalette()),
177
+ ...this.assistantFocus()
173
178
  })
174
179
  })
175
180
  }
181
+ /**
182
+ * The part selected and what the preview is trying, with the version they were seen on (ADR-0097 §5.6 item 5). Read
183
+ * when a request is sent; selecting is not a new subject, so the dock is not told about it and the conversation stays.
184
+ */
185
+ private assistantFocus() {
186
+ const focus = this.renderRoot.querySelector<HTMLElement & { assistantFocus?: () => { part?: string; trying?: object } }>('figure-editor')?.assistantFocus?.() ?? {}
187
+ const { context, revision } = this.session.read()
188
+ return {
189
+ ...(focus.part ? { selection: { part: focus.part, context, revision } } : {}),
190
+ ...(focus.trying ? { trying: { ...focus.trying, context, revision } } : {})
191
+ }
192
+ }
176
193
  private withdrawAssistant() {
177
194
  this.assistantFor = undefined
178
195
  clearAssistantSurface(this)
@@ -201,9 +218,19 @@ export class FigureModellerPage extends PageView {
201
218
  if (this.wasDirty && !view.dirty && !view.saving && view.id) this.readRow(view.id)
202
219
  this.wasDirty = !!view.dirty
203
220
  }
221
+ /*
222
+ A released figure changed on the server -- its source saved, or its category or tags -- comes back a draft, and a
223
+ board keeps the released version until it is released again. Said when it happens (D, designer 2026-09-27), not left
224
+ to a badge in the history tab.
225
+ */
226
+ /* True of what boards read: `figureBlueprint` answers a draft row with its latest released version (releasedSourceOf). */
227
+ private toldDemoted(before: string | undefined, after: string | undefined) {
228
+ if (before === 'released' && after === 'draft') notify({ level: 'info', message: i18next.t('figure.text.released-now-draft') })
229
+ }
204
230
  private async readRow(id: string) {
205
231
  try {
206
232
  const figure = await fetchFigure(id)
233
+ if (this.session.read().id === id) this.toldDemoted(this.row.state, figure.state)
207
234
  if (this.session.read().id === id)
208
235
  this.row = {
209
236
  state: figure.state as 'draft' | 'released',
@@ -290,7 +317,15 @@ export class FigureModellerPage extends PageView {
290
317
  try {
291
318
  const result = await updateFigure(id, patch)
292
319
  this.commandError = ''
293
- this.row = { ...this.row, category: result.figure.category ?? undefined, tags: result.figure.tags ?? undefined }
320
+ /* The server's answer on its state too: a category changed on a released figure made it a draft, and the badge stayed. */
321
+ this.toldDemoted(this.row.state, result.figure.state)
322
+ this.row = {
323
+ ...this.row,
324
+ category: result.figure.category ?? undefined,
325
+ tags: result.figure.tags ?? undefined,
326
+ ...(result.figure.state ? { state: result.figure.state as 'draft' | 'released' } : {}),
327
+ ...(result.figure.version !== undefined ? { version: result.figure.version ?? 0 } : {})
328
+ }
294
329
  } catch (error) {
295
330
  this.commandError = error instanceof Error ? error.message : String(error)
296
331
  }
@@ -300,7 +335,22 @@ export class FigureModellerPage extends PageView {
300
335
  /* Keep the rest of the row: rebuilding it from these two dropped the type, and the locked type field went blank. */
301
336
  if (figure?.id && figure.id === this.session.read().id) this.row = { ...this.row, state: figure.state, version: figure.version ?? 0 }
302
337
  }
303
- /** 과거 판으로 되돌렸으면 서버의 원본이 바뀐 것이다 — 다시 연다. 패널은 저장 안 된 것이 있으면 되돌리기를 막는다. */
338
+ /*
339
+ The edits in hand saved before going back to a released version: known saved, or not -- a failure, or an outcome not
340
+ known, holds the revert (designer 2026-09-27, Q-D1). The figure has a row here: there is no first save to ask.
341
+ */
342
+ private saveBeforeRevert = async (): Promise<{ ok: boolean; reason?: string }> => {
343
+ try {
344
+ await this.session.save()
345
+ this.changed()
346
+ return this.session.read().dirty ? { ok: false } : { ok: true }
347
+ } catch (error) {
348
+ this.changed()
349
+ const code = (error as { code?: string })?.code
350
+ return { ok: false, reason: code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : error instanceof Error ? error.message : String(error) }
351
+ }
352
+ }
353
+ /** 과거 판으로 되돌렸으면 서버의 원본이 바뀐 것이다 — 다시 연다. 저장 안 된 것이 있으면 패널이 먼저 저장·버리기·취소를 묻는다. */
304
354
  private reverted = async () => {
305
355
  const id = this.session.read().id
306
356
  if (!id) return
@@ -316,26 +366,7 @@ export class FigureModellerPage extends PageView {
316
366
  private applyAI = (event: Event) => {
317
367
  const detail = (event as CustomEvent).detail
318
368
  if (!this.active || !detail) return
319
- const view = this.session.read()
320
- if (detail.create && this.routeId) return
321
- if (detail.create && view.dirty) {
322
- /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
323
- detail.applied = false
324
- detail.code = 'NEW_FIGURE_UNSAVED'
325
- detail.error = i18next.t('figure.text.proposal-new-figure-unsaved')
326
- return
327
- }
328
- try {
329
- if (detail.create) this.session.applyProposal(detail.steps, view.revision!, view.context!)
330
- else if (detail.steps) this.session.applyProposal(detail.steps, detail.revision, detail.context)
331
- else this.session.apply(detail.changes, 'ai', detail.revision, detail.context)
332
- detail.applied = true
333
- this.changed()
334
- } catch (error) {
335
- detail.applied = false
336
- detail.code = (error as { code?: string })?.code
337
- detail.error = error instanceof Error ? error.message : String(error)
338
- }
369
+ if (takeFigureOffer(this.session, detail, { onSavedFigure: !!this.routeId, say: (key, params) => i18next.t(key, params) })) this.changed()
339
370
  }
340
371
  /*
341
372
  Reloading or closing the tab is not a move the shell asks `canDeactivate` about: a new figure not yet saved was lost
@@ -420,7 +451,9 @@ export class FigureModellerPage extends PageView {
420
451
  if (!waiting) return
421
452
  const view = this.session.read()
422
453
  try {
423
- this.session.applyProposal(waiting.steps, view.revision!, view.context!)
454
+ /* Created under the proposal's key: taken twice, it meets the first figure, never a second (ADR-0097 §5.6 item 5). */
455
+ if (waiting.key) this.session.createUnder(waiting.key)
456
+ this.session.applyProposal(waiting.steps, view.revision!, view.context!, waiting.key)
424
457
  waiting.settle({ applied: true })
425
458
  } catch (error) {
426
459
  waiting.settle({ applied: false, code: (error as { code?: string })?.code, error: error instanceof Error ? error.message : String(error) })
@@ -429,11 +462,14 @@ export class FigureModellerPage extends PageView {
429
462
  /** The first save's question, over the names this domain already uses. */
430
463
  private askFirstSave = async (name: string): Promise<FirstSave | undefined> => {
431
464
  const taken = await fetchFigureTypeNames().catch(() => [] as string[])
432
- return openPopup<FirstSave>(html`<figure-first-save .type=${this.row.type ?? ''} .name=${name} .taken=${taken}></figure-first-save>`, {
465
+ const answer = await openPopup<FirstSave>(html`<figure-first-save .type=${this.row.type ?? ''} .name=${name} .taken=${taken}></figure-first-save>`, {
433
466
  backdrop: true,
434
467
  size: 'small',
435
468
  title: i18next.t('figure.title.save-new-figure')
436
469
  }).result
470
+ /* Kept: a first save that failed opens again on the same identifier, not a new draw (ADR-0097 §5.6, Q-D3). */
471
+ if (answer) this.row = { ...this.row, type: answer.type }
472
+ return answer
437
473
  }
438
474
  get context() {
439
475
  return {
@@ -450,7 +486,8 @@ export class FigureModellerPage extends PageView {
450
486
  render() {
451
487
  if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
452
488
  const view = this.session.read()
453
- return html`<figure-editor .session=${this.session} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @changed=${this.changed}>
489
+ /* A new figure's third way to start: the app's assistant dock, with a request put in its input for the author to finish and send (B, U04). */
490
+ return html`<figure-editor .session=${this.session} .releaseState=${this.row} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} aiDraft @ask-ai-draft=${() => askAssistant({ draft: i18next.t('figure.text.ai-draft-request') })} @changed=${this.changed}>
454
491
  ${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
455
492
  <figure-properties
456
493
  slot="figure"
@@ -491,6 +528,8 @@ export class FigureModellerPage extends PageView {
491
528
  .figureVersion=${this.row.version}
492
529
  .figureType=${this.row.type}
493
530
  .dirty=${!!view.dirty}
531
+ .saveFirst=${this.saveBeforeRevert}
532
+ .serverSource=${() => this.session.confirmedSource()}
494
533
  @fit-base-to-parts=${this.fitToParts}
495
534
  @figure-released=${this.released}
496
535
  @figure-reverted=${this.reverted}
@@ -18,8 +18,8 @@ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
18
18
  window.dispatchEvent(new CustomEvent('figure-apply', { detail }));
19
19
  return detail;
20
20
  },
21
- openNew(steps) {
22
- const opened = pendNewFigure(steps);
21
+ openNew(steps, key) {
22
+ const opened = pendNewFigure(steps, key);
23
23
  navigate(modellerPathOf());
24
24
  return opened;
25
25
  }
@@ -1 +1 @@
1
- {"version":3,"file":"figure-assistant.js","sourceRoot":"","sources":["../client/figure-assistant.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,mDAAmD,CAAA;AAE3F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAA0B,MAAM,2BAA2B,CAAA;AAEpG,MAAM,CAAC,MAAM,qBAAqB,GAAG,wBAAwB,CAAA;AAE7D,uBAAuB,CAAC,qBAAqB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;IACzE,MAAM,OAAO,GAAG,MAAM,iBAAiB,CACrC,QAAQ,EACR;QACE,KAAK,CAAC,MAAyB;YAC7B,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,cAAc,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,CAAA;YACjE,OAAO,MAAM,CAAA;QACf,CAAC;QACD,OAAO,CAAC,KAAK;YACX,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,CAAC,CAAA;YACnC,QAAQ,CAAC,cAAc,EAAE,CAAC,CAAA;YAC1B,OAAO,MAAM,CAAA;QACf,CAAC;KACF,EACD,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CACxC,CAAA;IACD,IAAI,OAAO,CAAC,IAAI;QAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IACjG,OAAO,OAAO,CAAA;AAChB,CAAC,CAAC,CAAA","sourcesContent":["/*\n * What the AI dock does with a figure proposal - figure-ui declares it because figure-service declares the tool\n * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).\n *\n * A proposal is a list of the modeller's own commands (ADR-0094). Running it applies them in the modeller as one step\n * of its history: a revision on the figure open there, at the version the model saw; a new figure on a new figure the\n * modeller opens. The outcome is said in words on every branch (`ai-proposal.ts`).\n */\nimport { i18next } from '@operato/i18n'\nimport { navigate } from '@operato/shell'\nimport { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'\n\nimport { modellerPathOf } from './modeller/modeller-route.js'\nimport { pendNewFigure, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'\n\nexport const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'\n\nregisterProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {\n const outcome = await runFigureProposal(\n proposal,\n {\n offer(detail: FigureApplyDetail) {\n window.dispatchEvent(new CustomEvent('figure-apply', { detail }))\n return detail\n },\n openNew(steps) {\n const opened = pendNewFigure(steps)\n navigate(modellerPathOf())\n return opened\n }\n },\n (key, params) => i18next.t(key, params)\n )\n if (outcome.note) context.notify(outcome.verdict === 'accepted' ? 'info' : 'error', outcome.note)\n return outcome\n})\n"]}
1
+ {"version":3,"file":"figure-assistant.js","sourceRoot":"","sources":["../client/figure-assistant.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,mDAAmD,CAAA;AAE3F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAA0B,MAAM,2BAA2B,CAAA;AAEpG,MAAM,CAAC,MAAM,qBAAqB,GAAG,wBAAwB,CAAA;AAE7D,uBAAuB,CAAC,qBAAqB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;IACzE,MAAM,OAAO,GAAG,MAAM,iBAAiB,CACrC,QAAQ,EACR;QACE,KAAK,CAAC,MAAyB;YAC7B,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,cAAc,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,CAAA;YACjE,OAAO,MAAM,CAAA;QACf,CAAC;QACD,OAAO,CAAC,KAAK,EAAE,GAAG;YAChB,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;YACxC,QAAQ,CAAC,cAAc,EAAE,CAAC,CAAA;YAC1B,OAAO,MAAM,CAAA;QACf,CAAC;KACF,EACD,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CACxC,CAAA;IACD,IAAI,OAAO,CAAC,IAAI;QAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IACjG,OAAO,OAAO,CAAA;AAChB,CAAC,CAAC,CAAA","sourcesContent":["/*\n * What the AI dock does with a figure proposal - figure-ui declares it because figure-service declares the tool\n * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).\n *\n * A proposal is a list of the modeller's own commands (ADR-0094). Running it applies them in the modeller as one step\n * of its history: a revision on the figure open there, at the version the model saw; a new figure on a new figure the\n * modeller opens. The outcome is said in words on every branch (`ai-proposal.ts`).\n */\nimport { i18next } from '@operato/i18n'\nimport { navigate } from '@operato/shell'\nimport { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'\n\nimport { modellerPathOf } from './modeller/modeller-route.js'\nimport { pendNewFigure, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'\n\nexport const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'\n\nregisterProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {\n const outcome = await runFigureProposal(\n proposal,\n {\n offer(detail: FigureApplyDetail) {\n window.dispatchEvent(new CustomEvent('figure-apply', { detail }))\n return detail\n },\n openNew(steps, key) {\n const opened = pendNewFigure(steps, key)\n navigate(modellerPathOf())\n return opened\n }\n },\n (key, params) => i18next.t(key, params)\n )\n if (outcome.note) context.notify(outcome.verdict === 'accepted' ? 'info' : 'error', outcome.note)\n return outcome\n})\n"]}
@@ -39,8 +39,45 @@ export declare function deleteFigure(id: string): Promise<boolean>;
39
39
  * 발행을 푼다(초안으로 돌아온다).
40
40
  */
41
41
  export declare function releaseFigure(id: string, comment?: string): Promise<Figure>;
42
- /** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */
43
- export declare function revertFigureVersion(id: string, version: number): Promise<SaveResult>;
42
+ /** A draft a revert kept when it wrote over it (ADR-0097 §5.6). Not a released version. */
43
+ export interface KeptDraft {
44
+ id: string;
45
+ kind: 'revert' | 'undo';
46
+ state?: string;
47
+ version?: number;
48
+ revertedToVersion?: number;
49
+ restoredAt?: string;
50
+ createdAt?: string;
51
+ creator?: {
52
+ name?: string;
53
+ };
54
+ }
55
+ /**
56
+ * 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 덮이는 초안은 같은 트랜잭션에서 서버가
57
+ * 보관한다. `expectedSource` 는 서버가 들고 있다고 확인된 정본 -- 그 사이 바뀌었으면 거절된다. 답을 못 받았으면
58
+ * (REVERT_UNREACHABLE) 같은 `requestId` 로 다시 묻는다: 서버는 같은 되돌리기로 답하고 두 번 하지 않는다.
59
+ */
60
+ export declare function revertFigureVersion(id: string, version: number, requestId: string, expectedSource: string): Promise<SaveResult & {
61
+ kept: KeptDraft;
62
+ }>;
63
+ /** 되돌리기 취소 -- 보관한 초안을 되살린다. 되돌린 뒤 모델이 바뀌었으면 서버가 거절하고 보관본은 남는다. */
64
+ export declare function undoFigureRevert(keptId: string, requestId: string): Promise<SaveResult & {
65
+ kept: KeptDraft;
66
+ }>;
67
+ /** A figure an AI proposal was saved into (ADR-0097 §5.6 item 5). */
68
+ export interface ProposalOutcome {
69
+ figureId: string;
70
+ type?: string;
71
+ name?: string;
72
+ recordedAt?: string;
73
+ }
74
+ /**
75
+ * 제안이 무엇이 됐나 -- 그 제안을 담은 저장이 들어간 도형들. 비었으면 저장되지 않았다(적용만 하고 저장 전이거나, 되돌렸거나,
76
+ * 적용하지 않았다). 도크의 「결과 확인」이 이것을 묻는다: 도크의 기억이 아니라 서버의 기록이다.
77
+ */
78
+ export declare function fetchProposalOutcome(proposalKey: string): Promise<ProposalOutcome[]>;
79
+ /** 되돌리기가 보관한 초안들, 새것부터 -- 다시 접속해도 여기서 찾는다. */
80
+ export declare function fetchReplacedDrafts(id: string): Promise<KeptDraft[]>;
44
81
  /**
45
82
  * 발행해도 되나 — **누르기 전에** 묻는다.
46
83
  *