@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
@@ -23,8 +23,8 @@ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
23
23
  window.dispatchEvent(new CustomEvent('figure-apply', { detail }))
24
24
  return detail
25
25
  },
26
- openNew(steps) {
27
- const opened = pendNewFigure(steps)
26
+ openNew(steps, key) {
27
+ const opened = pendNewFigure(steps, key)
28
28
  navigate(modellerPathOf())
29
29
  return opened
30
30
  }
@@ -2,6 +2,8 @@ import gql from 'graphql-tag'
2
2
  import { i18next } from '@operato/i18n'
3
3
  import { client } from '@operato/graphql'
4
4
 
5
+ import { reached, refusedIn } from './reached.js'
6
+
5
7
  import type {
6
8
  Figure,
7
9
  FigureFinding,
@@ -208,21 +210,108 @@ export async function releaseFigure(id: string, comment?: string): Promise<Figur
208
210
  return unwrap<Figure>(response, 'releaseFigure')
209
211
  }
210
212
 
211
- /** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */
212
- export async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {
213
- const response = await client.mutate({
214
- mutation: gql`
215
- mutation RevertFigureVersion($id: String!, $version: Float!) {
216
- revertFigureVersion(id: $id, version: $version) {
217
- figure { ${FIGURE_LIST_FIELDS} source }
218
- violations { code message at }
213
+ /** A draft a revert kept when it wrote over it (ADR-0097 §5.6). Not a released version. */
214
+ export interface KeptDraft {
215
+ id: string
216
+ kind: 'revert' | 'undo'
217
+ state?: string
218
+ version?: number
219
+ revertedToVersion?: number
220
+ restoredAt?: string
221
+ createdAt?: string
222
+ creator?: { name?: string }
223
+ }
224
+
225
+ const KEPT_FIELDS = `id kind state version revertedToVersion restoredAt createdAt creator { name }`
226
+
227
+ /**
228
+ * 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 덮이는 초안은 같은 트랜잭션에서 서버가
229
+ * 보관한다. `expectedSource` 는 서버가 들고 있다고 확인된 정본 -- 그 사이 바뀌었으면 거절된다. 답을 못 받았으면
230
+ * (REVERT_UNREACHABLE) 같은 `requestId` 로 다시 묻는다: 서버는 같은 되돌리기로 답하고 두 번 하지 않는다.
231
+ */
232
+ export async function revertFigureVersion(
233
+ id: string,
234
+ version: number,
235
+ requestId: string,
236
+ expectedSource: string
237
+ ): Promise<SaveResult & { kept: KeptDraft }> {
238
+ const response = await reached(
239
+ client.mutate({
240
+ mutation: gql`
241
+ mutation RevertFigureVersion($id: String!, $version: Float!, $requestId: String!, $expectedSource: String!) {
242
+ revertFigureVersion(id: $id, version: $version, requestId: $requestId, expectedSource: $expectedSource) {
243
+ figure { ${FIGURE_LIST_FIELDS} source }
244
+ violations { code message at }
245
+ kept { ${KEPT_FIELDS} }
246
+ }
247
+ }
248
+ `,
249
+ variables: { id, version, requestId, expectedSource }
250
+ }),
251
+ 'REVERT_UNREACHABLE'
252
+ )
253
+ refusedIn(response)
254
+ return unwrap<SaveResult & { kept: KeptDraft }>(response, 'revertFigureVersion')
255
+ }
256
+
257
+ /** 되돌리기 취소 -- 보관한 초안을 되살린다. 되돌린 뒤 모델이 바뀌었으면 서버가 거절하고 보관본은 남는다. */
258
+ export async function undoFigureRevert(keptId: string, requestId: string): Promise<SaveResult & { kept: KeptDraft }> {
259
+ const response = await reached(
260
+ client.mutate({
261
+ mutation: gql`
262
+ mutation UndoFigureRevert($keptId: String!, $requestId: String!) {
263
+ undoFigureRevert(keptId: $keptId, requestId: $requestId) {
264
+ figure { ${FIGURE_LIST_FIELDS} source }
265
+ violations { code message at }
266
+ kept { ${KEPT_FIELDS} }
267
+ }
219
268
  }
269
+ `,
270
+ variables: { keptId, requestId }
271
+ }),
272
+ 'REVERT_UNREACHABLE'
273
+ )
274
+ refusedIn(response)
275
+ return unwrap<SaveResult & { kept: KeptDraft }>(response, 'undoFigureRevert')
276
+ }
277
+
278
+ /** A figure an AI proposal was saved into (ADR-0097 §5.6 item 5). */
279
+ export interface ProposalOutcome {
280
+ figureId: string
281
+ type?: string
282
+ name?: string
283
+ recordedAt?: string
284
+ }
285
+
286
+ /**
287
+ * 제안이 무엇이 됐나 -- 그 제안을 담은 저장이 들어간 도형들. 비었으면 저장되지 않았다(적용만 하고 저장 전이거나, 되돌렸거나,
288
+ * 적용하지 않았다). 도크의 「결과 확인」이 이것을 묻는다: 도크의 기억이 아니라 서버의 기록이다.
289
+ */
290
+ export async function fetchProposalOutcome(proposalKey: string): Promise<ProposalOutcome[]> {
291
+ const response = await client.query({
292
+ query: gql`
293
+ query FigureProposalOutcome($proposalKey: String!) {
294
+ figureProposalOutcome(proposalKey: $proposalKey) { figureId type name recordedAt }
220
295
  }
221
296
  `,
222
- variables: { id, version }
297
+ variables: { proposalKey },
298
+ fetchPolicy: 'network-only'
223
299
  })
300
+ return unwrap<ProposalOutcome[]>(response, 'figureProposalOutcome') ?? []
301
+ }
224
302
 
225
- return unwrap<SaveResult>(response, 'revertFigureVersion')
303
+ /** 되돌리기가 보관한 초안들, 새것부터 -- 다시 접속해도 여기서 찾는다. */
304
+ export async function fetchReplacedDrafts(id: string): Promise<KeptDraft[]> {
305
+ const response = await client.query({
306
+ query: gql`
307
+ query FigureReplacedDrafts($id: String!) {
308
+ figureReplacedDrafts(id: $id) { ${KEPT_FIELDS} }
309
+ }
310
+ `,
311
+ variables: { id },
312
+ fetchPolicy: 'network-only'
313
+ })
314
+ return unwrap<KeptDraft[]>(response, 'figureReplacedDrafts')
226
315
  }
227
316
 
228
317
  /**
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A request that never reached the server, or whose answer never came back, is told apart from a refusal: what
3
+ * happened to the write is not known (`unreachable`, SAVE_UNREACHABLE for a save). The caller finds out before saying
4
+ * more -- a save reads the server's copy back, a revert asks again with the same request id.
5
+ */
6
+ export async function reached<T>(request: Promise<T>, unreachable = 'SAVE_UNREACHABLE'): Promise<T> {
7
+ try {
8
+ return await request
9
+ } catch (error) {
10
+ const lost = (error as { networkError?: unknown })?.networkError || error instanceof TypeError
11
+ if (lost) throw Object.assign(Error('서버에 연결하지 못했습니다.'), { code: unreachable, cause: error })
12
+ /* A refusal keeps the server's code, so a conflict is told from a refusal on grounds. */
13
+ const refused = (error as { graphQLErrors?: Array<{ message: string; extensions?: { code?: string } }> })?.graphQLErrors
14
+ if (refused?.length) throw Object.assign(Error(refused.map(e => e.message).join('\n')), { code: refused[0]!.extensions?.code, cause: error })
15
+ throw error
16
+ }
17
+ }
18
+
19
+ /** Errors carried in the answer (the client's error policy lets them through), thrown with the server's code. */
20
+ export function refusedIn(response: { errors?: readonly unknown[] }) {
21
+ if (response.errors?.length)
22
+ throw Object.assign(Error(response.errors.map(e => (e as { message: string }).message).join('\n')), {
23
+ code: (response.errors[0] as { extensions?: { code?: string } }).extensions?.code
24
+ })
25
+ }
@@ -1,6 +1,7 @@
1
1
  import gql from 'graphql-tag'
2
2
  import { client } from '@operato/graphql'
3
3
  import { fetchFigure } from './index.js'
4
+ import { reached } from './reached.js'
4
5
  import type { FigureProductStore, FigureProductRecord } from '../modeller/product-session.js'
5
6
 
6
7
  /** source is sent intact. No conversion into the old parts/base authoring representation. */
@@ -10,10 +11,30 @@ export const figureProductStore: FigureProductStore = {
10
11
  if (!record?.source) throw Error('Figure 정본이 없습니다.')
11
12
  return { id: record.id, source: record.source }
12
13
  },
14
+ /* What the server holds of every field a save writes, read back after a save whose outcome is not known. */
15
+ async readBack(id) {
16
+ const response = await client.query({
17
+ query: gql`
18
+ query ($id: String!) {
19
+ figure(id: $id) {
20
+ id
21
+ source
22
+ thumbnail
23
+ type
24
+ }
25
+ }
26
+ `,
27
+ variables: { id },
28
+ fetchPolicy: 'network-only'
29
+ })
30
+ const figure = response.data?.figure as { id: string; source: string; thumbnail?: string | null; type?: string } | undefined
31
+ if (!figure?.source) throw Error('Figure 정본이 없습니다.')
32
+ return { id: figure.id, source: figure.source, thumbnail: figure.thumbnail ?? undefined, type: figure.type }
33
+ },
13
34
  async save(request) {
14
- const response = await client.mutate({
35
+ const response = await reached(client.mutate({
15
36
  mutation: gql`
16
- mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String) {
37
+ mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String, $proposalKeys: [String!]) {
17
38
  saveFigure(
18
39
  source: $source
19
40
  id: $id
@@ -21,6 +42,7 @@ export const figureProductStore: FigureProductStore = {
21
42
  creationId: $creationId
22
43
  thumbnail: $thumbnail
23
44
  type: $type
45
+ proposalKeys: $proposalKeys
24
46
  ) {
25
47
  id
26
48
  source
@@ -28,8 +50,9 @@ export const figureProductStore: FigureProductStore = {
28
50
  }
29
51
  `,
30
52
  variables: request
31
- })
32
- if (response.errors?.length) throw Error(response.errors.map(e => e.message).join('\n'))
53
+ }))
54
+ if (response.errors?.length)
55
+ throw Object.assign(Error(response.errors.map(e => e.message).join('\n')), { code: (response.errors[0] as { extensions?: { code?: string } }).extensions?.code })
33
56
  const result = response.data?.saveFigure as FigureProductRecord | undefined
34
57
  if (!result?.id || !result.source) throw Error('서버가 저장 결과를 돌려주지 않았습니다.')
35
58
  return result
@@ -6,9 +6,12 @@
6
6
  * Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.
7
7
  */
8
8
  import type { FigureProposalStep } from '@hatiolab/figure-model'
9
+ import type { FigureProductSession } from './product-session.js'
9
10
 
10
11
  /** What the card carries, as `proposeFigureCandidate` returned it. */
11
12
  export interface FigureProposalCard {
13
+ /** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */
14
+ key?: string
12
15
  steps?: FigureProposalStep[]
13
16
  create?: boolean
14
17
  figureId?: string
@@ -18,6 +21,7 @@ export interface FigureProposalCard {
18
21
 
19
22
  /** The `figure-apply` event's detail. The page that takes it sets `applied`, and `error` when refused. */
20
23
  export interface FigureApplyDetail {
24
+ key?: string
21
25
  steps: FigureProposalStep[]
22
26
  create: boolean
23
27
  context?: string
@@ -25,6 +29,8 @@ export interface FigureApplyDetail {
25
29
  applied?: boolean
26
30
  error?: string
27
31
  code?: string
32
+ /** The clips a refused proposal would have stopped (CLIPS_DROPPED), by name. */
33
+ clips?: string[]
28
34
  }
29
35
 
30
36
  export type Say = (key: string, params?: Record<string, string>) => string
@@ -33,7 +39,7 @@ export interface FigureProposalTarget {
33
39
  /** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */
34
40
  offer(detail: FigureApplyDetail): FigureApplyDetail
35
41
  /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */
36
- openNew(steps: FigureProposalStep[]): Promise<{ applied: boolean; error?: string; code?: string }>
42
+ openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>
37
43
  }
38
44
 
39
45
  export interface FigureProposalOutcome {
@@ -43,19 +49,75 @@ export interface FigureProposalOutcome {
43
49
  note?: string
44
50
  }
45
51
 
52
+ /**
53
+ * The dock's "check the result" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:
54
+ * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,
55
+ * which the dock keeps as "not known" and never turns into a refusal. Nothing is run again.
56
+ */
57
+ export function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {
58
+ if (!saved.length) return undefined
59
+ const names = saved.map(f => f.name || f.type || f.figureId).join(', ')
60
+ return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }
61
+ }
62
+
46
63
  /** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */
47
64
  const STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])
48
65
 
49
- function refused(code: string | undefined, error: string | undefined, say: Say): FigureProposalOutcome {
66
+ function refused(code: string | undefined, error: string | undefined, say: Say, clips?: string[]): FigureProposalOutcome {
67
+ /*
68
+ A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not
69
+ asked about -- the person edits by hand if they mean it (designer 2026-09-27).
70
+ */
50
71
  const note =
51
72
  code && STALE.has(code)
52
73
  ? say('figure.text.proposal-stale')
53
74
  : code === 'NEW_FIGURE_UNSAVED'
54
75
  ? (error ?? '')
55
- : say('figure.text.proposal-refused', { reason: error ?? '' })
76
+ : code === 'CLIPS_DROPPED'
77
+ ? say('figure.text.proposal-would-drop-clips', { clips: (clips ?? []).join(', ') })
78
+ : say('figure.text.proposal-refused', { reason: error ?? '' })
56
79
  return { verdict: 'refused', errorCode: code ?? 'refused', note }
57
80
  }
58
81
 
82
+ /**
83
+ * The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands
84
+ * as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new
85
+ * figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a
86
+ * proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.
87
+ */
88
+ export function takeFigureOffer(
89
+ session: FigureProductSession,
90
+ detail: FigureApplyDetail & { changes?: unknown },
91
+ { onSavedFigure, say }: { onSavedFigure: boolean; say: Say }
92
+ ): boolean {
93
+ const view = session.read()
94
+ if (detail.create && onSavedFigure) return false
95
+ if (detail.create && view.dirty) {
96
+ /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
97
+ detail.applied = false
98
+ detail.code = 'NEW_FIGURE_UNSAVED'
99
+ detail.error = say('figure.text.proposal-new-figure-unsaved')
100
+ return false
101
+ }
102
+ try {
103
+ if (detail.create) {
104
+ /* Created under the proposal's key: taken twice, it meets the first figure, never a second (ADR-0097 §5.6 item 5). */
105
+ if (detail.key) session.createUnder(detail.key)
106
+ session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)
107
+ } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)
108
+ else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)
109
+ detail.applied = true
110
+ return true
111
+ } catch (error) {
112
+ detail.applied = false
113
+ detail.code = (error as { code?: string })?.code
114
+ detail.error = error instanceof Error ? error.message : String(error)
115
+ const clips = (error as { clips?: Array<{ clip: string }> })?.clips
116
+ if (clips) detail.clips = clips.map(c => c.clip)
117
+ return false
118
+ }
119
+ }
120
+
59
121
  export async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {
60
122
  const steps = card?.steps
61
123
  if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }
@@ -66,14 +128,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
66
128
  note: say('figure.text.proposal-applied', { n: String(steps.length) })
67
129
  })
68
130
 
69
- const detail = target.offer({ steps, create, context: card.context, revision: card.revision })
131
+ const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })
70
132
  if (detail.applied) return accepted()
71
- if (detail.applied === false) return refused(detail.code, detail.error, say)
133
+ if (detail.applied === false) return refused(detail.code, detail.error, say, detail.clips)
72
134
 
73
135
  /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */
74
136
  if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }
75
137
 
76
- const opened = await target.openNew(steps)
138
+ const opened = await target.openNew(steps, card.key)
77
139
  if (opened.applied) return accepted()
78
140
  return opened.code === 'not-opened'
79
141
  ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }
@@ -85,13 +147,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
85
147
  modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
86
148
  figure must not receive steps nobody is waiting for.
87
149
  */
88
- let pending: { steps: FigureProposalStep[]; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined
150
+ let pending: { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined
89
151
 
90
- export function pendNewFigure(steps: FigureProposalStep[], ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
152
+ export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
91
153
  pending?.settle({ applied: false, code: 'not-opened' })
92
154
  return new Promise(resolve => {
93
155
  const mine = {
94
156
  steps,
157
+ key,
95
158
  settle: (result: { applied: boolean; error?: string; code?: string }) => {
96
159
  clearTimeout(timer)
97
160
  if (pending === mine) pending = undefined
@@ -104,7 +167,9 @@ export function pendNewFigure(steps: FigureProposalStep[], ms = 10_000): Promise
104
167
  }
105
168
 
106
169
  /** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
107
- export function takePendingNewFigure(): { steps: FigureProposalStep[]; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined {
170
+ export function takePendingNewFigure():
171
+ | { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }
172
+ | undefined {
108
173
  const taken = pending
109
174
  pending = undefined
110
175
  return taken
@@ -14,7 +14,8 @@ import type { GizmoReach } from './gizmo.js'
14
14
  * Every tool writes the part's own position (and, for snap, size) inputs — the same ones the gizmo writes, and only
15
15
  * where the gizmo could (`GizmoReach`).
16
16
  */
17
- export type AlignTool = 'centre-x' | 'centre-z' | 'floor' | 'snap'
17
+ /* The plane tool is figure-model's now, by placement (`figurePlaneAlignChanges`); these stay the screen's. */
18
+ export type AlignTool = 'centre-x' | 'centre-z' | 'snap'
18
19
 
19
20
  const AXES = ['x', 'y', 'z'] as const
20
21
 
@@ -50,8 +51,6 @@ export function alignChanges(asset: FigureAsset, part: string, reach: GizmoReach
50
51
  return [set(at('x'), value(at('x')) - box.centre.x)]
51
52
  case 'centre-z':
52
53
  return [set(at('z'), value(at('z')) - box.centre.z)]
53
- case 'floor':
54
- return [set(at('y'), value(at('y')) - (box.centre.y - box.extent.y / 2))]
55
54
  case 'snap': {
56
55
  const step = gridStepOf(asset)
57
56
  const round = (n: number) => Math.round(n / step) * step
@@ -6,8 +6,9 @@ import { create, Model, niceGridStep } from '@hatiolab/things-scene'
6
6
  import type { GizmoReach } from './gizmo.js'
7
7
  import type { FigureRenderPart } from './renderer.js'
8
8
  import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from './figure-view.js'
9
- import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board } from './stage-model.js'
9
+ import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board, type ScaleAnchor } from './stage-model.js'
10
10
  import { JOINT_FAN } from './joint-fan.js'
11
+ import { PREVIEW, TARGET_FACE } from './command-preview.js'
11
12
  import { ownerOf } from './fields.js'
12
13
 
13
14
  /*
@@ -81,6 +82,8 @@ export class FigureCanvas extends LitElement {
81
82
  `
82
83
  @property({ attribute: false }) parts: FigureRenderPart[] = []
83
84
  @property({ attribute: false }) palette: Record<string, string> = {}
85
+ /** The face a size drag keeps, per part (figure-model's `figureScaleAnchorOf`), so the drag shows what is written. */
86
+ @property({ attribute: false }) anchors: Record<string, ScaleAnchor> = {}
84
87
  /** Which figure is open. Another one stands the board up again. */
85
88
  @property() context = ''
86
89
  @property() selected = ''
@@ -93,8 +96,16 @@ export class FigureCanvas extends LitElement {
93
96
  * would reach the editor as an edit.
94
97
  */
95
98
  @property({ type: Boolean }) viewOnly = false
99
+ /** The editor shows a new figure's start card over the canvas: the canvas's own one-line hint gives way to it. */
100
+ @property({ type: Boolean }) startShown = false
96
101
  /** Where the selected part turns and how far (`joint-fan`): shown, never picked, never saved. */
97
102
  @property({ attribute: false }) fan: FigureRenderPart | null = null
103
+ /**
104
+ * A command seen before it is done (ADR-0097 §5.5): the bodies it would move, where they would stand, translucent,
105
+ * and the face they would be fastened to, lit. Shown, never picked, never saved; gone when the command is.
106
+ */
107
+ @property({ attribute: false }) ghost: FigureRenderPart[] = []
108
+ @property({ attribute: false }) highlight: FigureRenderPart | null = null
98
109
 
99
110
  @query('div[stage]') private stage!: HTMLDivElement
100
111
 
@@ -106,7 +117,7 @@ export class FigureCanvas extends LitElement {
106
117
  private writing = false
107
118
 
108
119
  render() {
109
- return html`<div stage></div>${this.parts.length ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
120
+ return html`<div stage></div>${this.parts.length || this.startShown ? nothing : html`<p>${i18next.t('figure.text.add-a-shape-to-start')}</p>`}`
110
121
  }
111
122
 
112
123
  firstUpdated() {
@@ -117,7 +128,7 @@ export class FigureCanvas extends LitElement {
117
128
 
118
129
  protected updated(changed: PropertyValues) {
119
130
  if (!this.hasUpdated) return
120
- if (changed.has('parts') || changed.has('palette') || changed.has('context') || changed.has('fan')) this.standUp()
131
+ if (['parts', 'palette', 'context', 'fan', 'anchors', 'ghost', 'highlight'].some(key => changed.has(key))) this.standUp()
121
132
  if (changed.has('view')) this.applyView()
122
133
  if (changed.has('selected')) this.showSelected()
123
134
  }
@@ -210,10 +221,12 @@ export class FigureCanvas extends LitElement {
210
221
  }
211
222
 
212
223
  private components(): Record<string, unknown>[] {
213
- const parts = this.parts.map(part => componentOf(part, this.palette, this.board!)).filter((c): c is Record<string, unknown> => !!c)
224
+ const parts = this.parts.map(part => componentOf(part, this.palette, this.board!, this.anchors[ownerOf(part.id)])).filter((c): c is Record<string, unknown> => !!c)
214
225
  /* The fan in the accent colour, translucent; `capturable: false` keeps clicks, the marquee and the gizmo off it. */
215
226
  const fan = this.fan ? componentOf(this.fan, { ...this.palette, [JOINT_FAN]: this.palette['palette.accent'] ?? FAN_COLOUR }, this.board!) : null
216
- return fan ? [...parts, { ...fan, capturable: false, locked: true }] : parts
227
+ const accent = this.palette['palette.accent'] ?? FAN_COLOUR
228
+ const shown = [...(fan ? [fan] : []), ...this.ghost.map(part => componentOf(part, { ...this.palette, [PREVIEW]: accent, [TARGET_FACE]: accent }, this.board!)), ...(this.highlight ? [componentOf(this.highlight, { ...this.palette, [TARGET_FACE]: accent }, this.board!)] : [])]
229
+ return [...parts, ...shown.filter((c): c is Record<string, unknown> => !!c).map(c => ({ ...c, capturable: false, locked: true }))]
217
230
  }
218
231
 
219
232
  /** The components standing now, by name. Not held between calls: the scene stands its board up again at times. */
@@ -0,0 +1,113 @@
1
+ import type { FigureBodyMove, FigureClipLoss, FigureFaceArea, FigureRowReading } from '@hatiolab/figure-model'
2
+
3
+ import type { FigureRenderPart } from './renderer.js'
4
+
5
+ /*
6
+ * A command seen before it is done, as the modeller shows it (ADR-0097 §5.5, designer 2026-09-27): the bodies it would
7
+ * move, translucent where they would stand; the face they would meet, lit; and in words, how far and how each turns --
8
+ * the turn as figure-model gives it, one axis and one angle in the figure's frame.
9
+ */
10
+
11
+ /* The material tokens of what a command would do: the bodies where they would stand, and the face they would meet. */
12
+ export const PREVIEW = '__preview'
13
+ export const TARGET_FACE = '__target-face'
14
+
15
+ export type Say = (key: string, params?: Record<string, string>) => string
16
+
17
+ /** A preview as the editor holds it: bound to the figure, version and command it was worked out for. */
18
+ export interface HeldPreview {
19
+ context: string
20
+ revision: number
21
+ /** The command, as text: a preview for another command is not this one's. */
22
+ key: string
23
+ geometry?: unknown[]
24
+ moves?: FigureBodyMove[]
25
+ /** Bodies it would draw that are not drawn now: a row's copies. */
26
+ added?: string[]
27
+ /** The row it would make, read on the preview's figure. */
28
+ row?: FigureRowReading
29
+ clipsWouldGo?: FigureClipLoss[]
30
+ face?: FigureFaceArea
31
+ /** Whether a face was asked for and could not be shown. */
32
+ faceUnknown?: boolean
33
+ /** Why the command would be refused; nothing else is shown then. */
34
+ refused?: string
35
+ }
36
+
37
+ /** The moving bodies where they would stand, drawn translucent. The ones that stay are not drawn twice. */
38
+ export function ghostPartsOf(preview: HeldPreview | undefined): FigureRenderPart[] {
39
+ if (!preview?.geometry || !(preview.moves?.length || preview.added?.length)) return []
40
+ const moving = new Set([...(preview.moves ?? []).map(m => m.body), ...(preview.added ?? [])])
41
+ return (preview.geometry as FigureRenderPart[])
42
+ .filter(body => body && typeof body === 'object' && 'shape' in body && 'pose' in body && moving.has(body.id))
43
+ .map(body => ({ ...body, id: `${PREVIEW}:${body.id}`, material: { token: PREVIEW, transparent: true } }))
44
+ }
45
+
46
+ /** How thick the lit face is, and how far off the face it floats, so it neither hides nor flickers with it. */
47
+ const THICKNESS = 2
48
+ const LIFT = 1
49
+
50
+ /** The face the part would meet, lit: a thin plate over a flat face, a thin disc over a round one. */
51
+ export function faceHighlightOf(face: FigureFaceArea | undefined): FigureRenderPart | null {
52
+ if (!face) return null
53
+ /* Its own x · y · z are the face's two sides and its normal, as the kernel gives them. */
54
+ const r = [0, 1, 2].map(i => [face.u[i]!, face.normal[i]!, face.v[i]!])
55
+ const t = [0, 1, 2].map(i => face.centre[i]! + face.normal[i]! * (LIFT + THICKNESS / 2))
56
+ const shape: FigureRenderPart['shape'] = face.round
57
+ ? { provider: 'cylinder', dimensions: { radius: face.size.u / 2, length: THICKNESS } }
58
+ : { provider: 'box', dimensions: { width: face.size.u, height: THICKNESS, depth: face.size.v } }
59
+ /* Solid: a translucent plate over a light face was hardly seen (2026-09-27, :3391); it covers the face it names. */
60
+ return { id: TARGET_FACE, shape, pose: { r, t }, material: { token: TARGET_FACE } }
61
+ }
62
+
63
+ const mm = (v: number) => `${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(Math.round(v * 10) / 10).toLocaleString()}`
64
+
65
+ /**
66
+ * What the command would do, in words: the part's own move and turn, what goes with it, the clips it would stop, and
67
+ * why nothing can be shown when the command would be refused.
68
+ */
69
+ export function previewWordsOf(preview: HeldPreview | undefined, part: string, say: Say): string[] {
70
+ if (!preview) return []
71
+ if (preview.refused) return [say('figure.text.preview-refused', { reason: preview.refused })]
72
+ /* A row: what it would be, read as drawn; its part does not move, it is copied. */
73
+ if (preview.row) return [...rowWordsOf(preview.row, say), ...(preview.clipsWouldGo?.length ? [say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') })] : [])]
74
+ const words: string[] = []
75
+ const own = preview.moves?.find(m => m.body === part)
76
+ if (!own) words.push(say('figure.text.preview-stays'))
77
+ else {
78
+ const axes = (['x', 'y', 'z'] as const).map((axis, i) => [axis, own.translate[i]!] as const).filter(([, v]) => Math.abs(v) >= 0.05)
79
+ if (axes.length) words.push(say('figure.text.preview-moves', { by: axes.map(([axis, v]) => `${axis.toUpperCase()} ${mm(v)}`).join(' · ') }))
80
+ if (own.turn) {
81
+ /* Signed by the right hand about the axis, as the figure's frame has it. */
82
+ const degrees = mm(own.turn.degrees)
83
+ words.push(
84
+ typeof own.turn.axis === 'string'
85
+ ? say('figure.text.preview-turns-named', { axis: own.turn.axis.toUpperCase(), degrees })
86
+ : say('figure.text.preview-turns-slanted', { axis: own.turn.axis.map(v => v.toFixed(2)).join(', '), degrees })
87
+ )
88
+ }
89
+ }
90
+ /* What goes with it: parts fastened to it, each row once, with its count. */
91
+ const others = new Map<string, number>()
92
+ for (const m of preview.moves ?? []) if (m.part !== part) others.set(m.part, (others.get(m.part) ?? 0) + 1)
93
+ if (others.size)
94
+ words.push(say('figure.text.preview-carries', { parts: [...others].map(([name, n]) => (n > 1 ? say('figure.text.preview-copies', { part: name, n: String(n) }) : name)).join(', ') }))
95
+ if (preview.clipsWouldGo?.length) words.push(say('figure.text.preview-clips', { clips: preview.clipsWouldGo.map(c => c.clip).join(', ') }))
96
+ if (preview.faceUnknown) words.push(say('figure.text.preview-face-unknown'))
97
+ return words
98
+ }
99
+
100
+ /** A row in words, as figure-model reads it from what is drawn: how many, how far apart, what is left at the ends, what rides it. */
101
+ export function rowWordsOf(row: FigureRowReading | undefined, say: Say): string[] {
102
+ if (!row) return []
103
+ const words = [
104
+ row.pitch !== undefined
105
+ ? say('figure.text.row-count-pitch', { part: row.part, count: String(row.count), pitch: String(row.pitch), axis: row.axis.toUpperCase() })
106
+ : say('figure.text.row-count-uneven', { part: row.part, count: String(row.count), axis: row.axis.toUpperCase() })
107
+ ]
108
+ if (!row.margins) words.push(say('figure.text.row-ends-unknown'))
109
+ else if (row.centred) words.push(say('figure.text.row-ends-centred', { mm: String(row.margins.low) }))
110
+ else words.push(say('figure.text.row-ends', { low: String(row.margins.low), high: String(row.margins.high) }))
111
+ if (row.companions.length) words.push(say('figure.text.row-companions', { parts: row.companions.map(c => say('figure.text.preview-copies', { part: c.part, n: String(c.count) })).join(', ') }))
112
+ return words
113
+ }