@things-factory/figure-ui 10.1.151 → 10.1.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/client/modeller/ai-proposal.ts +14 -13
  2. package/client/modeller/command-preview-controller.ts +210 -0
  3. package/client/modeller/editor-styles.ts +307 -0
  4. package/client/modeller/editor.ts +65 -634
  5. package/client/modeller/fasten-geometry.ts +32 -0
  6. package/client/modeller/figure-first-save.ts +9 -1
  7. package/client/modeller/figure-links.ts +32 -14
  8. package/client/modeller/mesh-import.ts +0 -10
  9. package/client/modeller/modeller-keys.ts +60 -0
  10. package/client/modeller/pane-fold.ts +61 -0
  11. package/client/modeller/panel-memory.ts +53 -0
  12. package/client/modeller/refusal-teller.ts +93 -0
  13. package/client/modeller/refusals.ts +32 -2
  14. package/dist-client/modeller/ai-proposal.d.ts +6 -5
  15. package/dist-client/modeller/ai-proposal.js +9 -9
  16. package/dist-client/modeller/ai-proposal.js.map +1 -1
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/command-preview-controller.d.ts +75 -0
  19. package/dist-client/modeller/command-preview-controller.js +178 -0
  20. package/dist-client/modeller/command-preview-controller.js.map +1 -0
  21. package/dist-client/modeller/editor-styles.d.ts +2 -0
  22. package/dist-client/modeller/editor-styles.js +307 -0
  23. package/dist-client/modeller/editor-styles.js.map +1 -0
  24. package/dist-client/modeller/editor.d.ts +5 -56
  25. package/dist-client/modeller/editor.js +76 -656
  26. package/dist-client/modeller/editor.js.map +1 -1
  27. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  28. package/dist-client/modeller/fasten-geometry.js +29 -0
  29. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  30. package/dist-client/modeller/figure-first-save.d.ts +1 -0
  31. package/dist-client/modeller/figure-first-save.js +8 -1
  32. package/dist-client/modeller/figure-first-save.js.map +1 -1
  33. package/dist-client/modeller/figure-links.d.ts +9 -2
  34. package/dist-client/modeller/figure-links.js +35 -14
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/mesh-import.d.ts +0 -2
  37. package/dist-client/modeller/mesh-import.js +0 -11
  38. package/dist-client/modeller/mesh-import.js.map +1 -1
  39. package/dist-client/modeller/modeller-keys.d.ts +22 -0
  40. package/dist-client/modeller/modeller-keys.js +43 -0
  41. package/dist-client/modeller/modeller-keys.js.map +1 -0
  42. package/dist-client/modeller/pane-fold.d.ts +15 -0
  43. package/dist-client/modeller/pane-fold.js +60 -0
  44. package/dist-client/modeller/pane-fold.js.map +1 -0
  45. package/dist-client/modeller/panel-memory.d.ts +15 -0
  46. package/dist-client/modeller/panel-memory.js +54 -0
  47. package/dist-client/modeller/panel-memory.js.map +1 -0
  48. package/dist-client/modeller/product-session.d.ts +4 -28
  49. package/dist-client/modeller/refusal-teller.d.ts +36 -0
  50. package/dist-client/modeller/refusal-teller.js +81 -0
  51. package/dist-client/modeller/refusal-teller.js.map +1 -0
  52. package/dist-client/modeller/refusals.d.ts +12 -1
  53. package/dist-client/modeller/refusals.js +26 -1
  54. package/dist-client/modeller/refusals.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +5 -5
  57. package/test/browser/build.mjs +7 -2
  58. package/test/browser/editing.check.mjs +14 -0
  59. package/test/browser/fix-alignment.check.mjs +112 -0
  60. package/test/browser/links.check.mjs +69 -13
  61. package/test/browser/mesh-select.check.mjs +91 -0
  62. package/test/browser/modeller-page.js +36 -0
  63. package/test/browser/modeller-revision.check.mjs +18 -6
  64. package/test/browser/modeller.check.mjs +25 -0
  65. package/test/browser/part-ids.check.mjs +24 -6
  66. package/test/browser/parts-page.js +9 -1
  67. package/test/browser/parts.check.mjs +61 -2
  68. package/test/browser/places-mesh.check.mjs +25 -9
  69. package/test/browser/product-page.check.mjs +42 -2
  70. package/test/browser/row-preview.check.mjs +81 -0
  71. package/test/browser/screen.check.mjs +35 -1
  72. package/test/clip-ai-proposal.test.ts +4 -1
  73. package/test/clip-consent.test.ts +9 -2
  74. package/test/fields.test.ts +5 -9
  75. package/test/fix-alignment.test.ts +32 -0
  76. package/test/layout.test.ts +3 -1
  77. package/test/mesh-import.test.ts +1 -8
  78. package/test/parts-wiring.test.ts +1 -5
  79. package/test/refusals.test.ts +46 -10
  80. package/test/theme.test.ts +5 -15
  81. package/translations/en.json +8 -0
  82. package/translations/es.json +8 -0
  83. package/translations/ja.json +8 -0
  84. package/translations/ko.json +8 -0
  85. package/translations/ms.json +8 -0
  86. package/translations/zh.json +8 -0
@@ -1,12 +1,12 @@
1
- import { LitElement, html, css, nothing, type PropertyValues } from 'lit'
1
+ import { LitElement, html, nothing, type PropertyValues } from 'lit'
2
2
  import { i18next } from '@operato/i18n'
3
3
  import type { OxThreePane } from '@operato/layout'
4
4
  import '@operato/layout'
5
- import { notify, openPopup } from '@operato/layout'
5
+ import { openPopup } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
9
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
12
  import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
@@ -18,7 +18,8 @@ import './figure-parts.js'
18
18
  import './figure-material-panel.js'
19
19
  import './figure-section-panel.js'
20
20
  import './figure-section-editor.js'
21
- import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf, type HeldPreview } from './command-preview.js'
21
+ import { rowWordsOf } from './command-preview.js'
22
+ import { CommandPreview, NO_WORDS } from './command-preview-controller.js'
22
23
  import { motionEndsOf, type MotionEnds } from './motion-ends.js'
23
24
  import './figure-links.js'
24
25
  import './figure-controls.js'
@@ -28,14 +29,19 @@ import './figure-settings.js'
28
29
  import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
29
30
  import './figure-capability-panel.js'
30
31
  import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './gizmo.js'
31
- import { keyActionOf } from './keys.js'
32
+ import type { KeyAction } from './keys.js'
33
+ import { ModellerKeys } from './modeller-keys.js'
34
+ import { PaneFold } from './pane-fold.js'
35
+ import { PanelMemory } from './panel-memory.js'
36
+ import { RefusalTeller, type Retry } from './refusal-teller.js'
32
37
  import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './fields.js'
33
38
  import { SidePanelStyles } from './side-panel-styles.js'
39
+ import { EditorStyles } from './editor-styles.js'
34
40
  import { ScrollbarStyles } from '@operato/styles'
35
41
  import { alignChanges, canMirror, mirrorChanges, type AlignTool } from './align.js'
36
42
  import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
37
43
  import { paletteRecipe } from './palette.js'
38
- import { meshChoicesOf, meshPartIdOf, meshPartOf } from './mesh-import.js'
44
+ import { meshChoicesOf, meshPartOf } from './mesh-import.js'
39
45
  import './figure-mesh-choice.js'
40
46
  import { uploadFigureMesh } from '../graphql/index.js'
41
47
  import { slotMarksOf } from './slot-layout-host.js'
@@ -54,7 +60,7 @@ import type { FigureRenderPart } from './renderer.js'
54
60
  import type { FigureBudgetLine } from './maturity.js'
55
61
  import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
56
62
  import { jointFanOf } from './joint-fan.js'
57
- import { derivedHeld, derivedOf, type FigureRevision } from './revision.js'
63
+ import { derivedOf, type FigureRevision } from './revision.js'
58
64
  import { jointDragOf } from './joint-drag.js'
59
65
  import type { FirstSave } from './first-save.js'
60
66
  import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
@@ -67,332 +73,13 @@ import { startingShapeKindOfBody } from './starting-shape.js'
67
73
  */
68
74
  type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'history'
69
75
 
70
- /** Whether the narrow fold was said in this browser before; true from now on. Storage refused: said, as before. */
71
- function foldSaid(): boolean {
72
- try {
73
- if (localStorage.getItem('figure-fold-said')) return true
74
- localStorage.setItem('figure-fold-said', '1')
75
- } catch {
76
- /* No storage here: said every time, as it was. */
77
- }
78
- return false
79
- }
80
-
81
76
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
82
- /** The words a panel is given when there are none: one array, so it does not read as a change. */
83
- const NO_WORDS: string[] = []
84
77
  const NO_OVERHANG = { words: NO_WORDS, note: NO_WORDS }
85
78
 
86
- /** How long a command's candidate rests before its preview is worked out. */
87
- const PREVIEW_SETTLE_MS = 150
88
-
89
79
  @customElement('figure-editor')
90
80
  export class FigureEditor extends LitElement {
91
81
  /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
92
- static styles = [ScrollbarStyles, SidePanelStyles, css`
93
- :host {
94
- display: flex;
95
- flex-direction: column;
96
- min-height: 0;
97
- height: 100%;
98
- /* The editor folds by the width it is given, not the window's (UX ruling, 2026-09-24). */
99
- container-type: inline-size;
100
- font: 13px sans-serif;
101
- color: var(--md-sys-color-on-surface);
102
- }
103
- /* The bar over the work is ox-canvas-bar (ADR-0060 decision 8): the folds at its edges, undo, redo and
104
- fit in the suite's places. What the editor puts in it - the mode switch, the status, save - is styled here. */
105
- ox-canvas-bar button[mode] {
106
- height: 28px;
107
- padding: 0 10px;
108
- border-radius: 999em;
109
- border-color: transparent;
110
- background: none;
111
- }
112
- ox-canvas-bar button[mode][aria-pressed='true'] {
113
- border-color: var(--md-sys-color-primary);
114
- }
115
- /* The gizmo's tools, drawn as the bar's own icon buttons (undo, redo, fit). */
116
- ox-canvas-bar .gizmo-tools {
117
- display: inline-flex;
118
- gap: 2px;
119
- padding-left: 4px;
120
- border-left: 1px solid var(--md-sys-color-outline-variant);
121
- }
122
- ox-canvas-bar button[tool] {
123
- display: inline-grid;
124
- place-items: center;
125
- width: 32px;
126
- height: 32px;
127
- padding: 0;
128
- border: none;
129
- border-radius: var(--md-sys-shape-corner-small, 8px);
130
- background: transparent;
131
- color: var(--md-sys-color-on-surface-variant);
132
- cursor: pointer;
133
- }
134
- ox-canvas-bar button[tool]:hover:not(:disabled) {
135
- background: var(--md-sys-color-surface-container-high);
136
- color: var(--md-sys-color-on-surface);
137
- }
138
- ox-canvas-bar button[tool][aria-pressed='true'] {
139
- background: var(--md-sys-color-secondary-container);
140
- color: var(--md-sys-color-on-secondary-container);
141
- }
142
- ox-canvas-bar button[tool]:disabled {
143
- opacity: 0.38;
144
- cursor: default;
145
- }
146
- /*
147
- Slotted into the bar from here, the tools do not get the bar's own icon size (20px, set inside its shadow): they
148
- showed at md-icon's 24px, heavier than undo, redo and fit beside them. The same size, and the bar's focus ring.
149
- */
150
- ox-canvas-bar button[tool] md-icon {
151
- --md-icon-size: 20px;
152
- }
153
- ox-canvas-bar button[tool]:focus-visible {
154
- outline: 2px solid var(--md-sys-color-primary);
155
- outline-offset: 1px;
156
- }
157
- figure-preview {
158
- min-width: 0;
159
- min-height: 0;
160
- }
161
- /* The edit canvas stays standing while the preview is in front (a 3D scene costs a third of a second to make), out of sight. */
162
- div[middle][hidden] {
163
- display: none;
164
- }
165
- /* The preview, once made, stays standing out of sight too: its 3D scene is not made again each time it is asked for. */
166
- figure-preview[hidden] {
167
- display: none;
168
- }
169
- ox-canvas-bar .status {
170
- padding: 0 8px;
171
- color: var(--md-sys-color-on-surface-variant);
172
- }
173
- ox-canvas-bar .status[dirty] {
174
- color: var(--md-sys-color-primary);
175
- font-weight: 600;
176
- }
177
- ox-canvas-bar button[save] {
178
- height: 32px;
179
- padding: 0 14px;
180
- border: none;
181
- border-radius: 999em;
182
- color: var(--md-sys-color-on-primary);
183
- background: var(--md-sys-color-primary);
184
- }
185
- /* The three panes are ox-three-pane's (ADR-0060 decision 8): widths 200 | 1fr | 340, 160 | 300 under
186
- 860px and one column with the middle first under 560px are the frame's, as are the dividers. */
187
- ox-three-pane {
188
- flex: 1;
189
- min-height: 0;
190
- }
191
- figure-parts {
192
- min-height: 0;
193
- }
194
- figure-canvas {
195
- flex: 1;
196
- min-width: 0;
197
- min-height: 0;
198
- }
199
- div[middle] {
200
- position: relative;
201
- display: flex;
202
- min-width: 0;
203
- min-height: 0;
204
- }
205
- /* A new figure's first question, over the empty canvas. */
206
- div[start] {
207
- position: absolute;
208
- inset: 50% auto auto 50%;
209
- z-index: 10;
210
- color: var(--md-sys-color-on-surface);
211
- transform: translate(-50%, -50%);
212
- width: min(440px, calc(100% - 32px));
213
- box-sizing: border-box;
214
- padding: 20px;
215
- border-radius: 12px;
216
- background: var(--md-sys-color-surface, #fff);
217
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
218
- }
219
- div[start] button[start-button] {
220
- width: auto;
221
- margin: 0;
222
- font-size: var(--ui-t-body);
223
- height: 36px;
224
- padding: 0 16px;
225
- border: 1px solid var(--md-sys-color-outline);
226
- border-radius: 999em;
227
- color: var(--md-sys-color-primary);
228
- background: var(--md-sys-color-surface, #fff);
229
- cursor: pointer;
230
- }
231
- div[start] button[start-button]:hover:not(:disabled) {
232
- background: var(--md-sys-color-primary-container);
233
- }
234
- div[start] button[start-button][pressed] {
235
- color: var(--md-sys-color-on-primary);
236
- background: var(--md-sys-color-primary);
237
- border-color: var(--md-sys-color-primary);
238
- }
239
- div[start] button[start-button]:disabled {
240
- opacity: 0.5;
241
- cursor: default;
242
- }
243
- div[start] h2 {
244
- margin: 0 0 12px;
245
- font-size: var(--ui-t-title);
246
- }
247
- div[start-paths] {
248
- display: grid;
249
- gap: 14px;
250
- }
251
- div[start-paths] h3 {
252
- margin: 0 0 6px;
253
- font-size: var(--ui-t-body);
254
- }
255
- div[start-shapes] {
256
- display: flex;
257
- gap: 8px;
258
- flex-wrap: wrap;
259
- }
260
- div[start] small {
261
- display: block;
262
- margin-top: 4px;
263
- color: var(--md-sys-color-on-surface-variant);
264
- }
265
- /* The side: tabs over one panel at a time, each using the whole height. */
266
- aside {
267
- display: flex;
268
- flex-direction: column;
269
- min-height: 0;
270
- background: var(--md-sys-color-surface-container-lowest);
271
- }
272
- div[tabs] {
273
- display: flex;
274
- flex: none;
275
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
276
- }
277
- button[tab] {
278
- flex: 1;
279
- position: relative;
280
- display: flex;
281
- flex-direction: column;
282
- align-items: center;
283
- justify-content: center;
284
- gap: 1px;
285
- min-width: 0;
286
- height: 48px;
287
- padding: 0;
288
- border: none;
289
- border-radius: 0;
290
- border-bottom: 2px solid transparent;
291
- background: none;
292
- color: var(--md-sys-color-on-surface-variant);
293
- }
294
- button[tab]:hover:not([on]) {
295
- background: var(--md-sys-color-surface-container);
296
- }
297
- button[tab][on] {
298
- color: var(--md-sys-color-primary);
299
- border-bottom-color: var(--md-sys-color-primary);
300
- }
301
- button[tab] md-icon {
302
- font-size: 20px;
303
- width: 20px;
304
- height: 20px;
305
- }
306
- button[tab] span[tab-label] {
307
- max-width: 100%;
308
- overflow: hidden;
309
- font-size: 0.66rem;
310
- line-height: 1.2;
311
- white-space: nowrap;
312
- text-overflow: ellipsis;
313
- }
314
- span[badge] {
315
- position: absolute;
316
- top: 3px;
317
- right: 2px;
318
- padding: 0 5px;
319
- font-size: 0.62rem;
320
- font-weight: 700;
321
- line-height: 1.5;
322
- color: var(--md-sys-color-on-primary-container);
323
- background: var(--md-sys-color-primary-container);
324
- border-radius: 999em;
325
- pointer-events: none;
326
- }
327
- /* The sections inside carry the margins (side-panel-styles); the panel only scrolls. */
328
- div[panel] {
329
- flex: 1;
330
- min-height: 0;
331
- overflow: auto;
332
- padding: 0;
333
- }
334
- div[panel][flush] {
335
- padding: 0;
336
- }
337
- /* Hidden, not removed: a panel keeps what it holds (the verdict its last answer) while another tab is open. */
338
- [off] {
339
- display: none !important;
340
- }
341
- /* Which part the panel speaks of. */
342
- div[subject] {
343
- position: sticky;
344
- top: 0;
345
- z-index: 1;
346
- display: grid;
347
- gap: 4px;
348
- padding: 10px 12px;
349
- background: var(--md-sys-color-surface-container-lowest);
350
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
351
- }
352
- /* The panel's three sizes (side-panel-styles): the part's kind at the title's, its two lines at the hint's. */
353
- div[subject-summary] {
354
- display: grid;
355
- gap: 2px;
356
- }
357
- div[subject-summary] b {
358
- font-size: var(--ui-t-body, 13px);
359
- font-weight: 600;
360
- }
361
- div[subject-summary] b span {
362
- font-weight: 400;
363
- color: var(--md-sys-color-on-surface-variant);
364
- }
365
- div[subject-summary] small {
366
- color: var(--md-sys-color-on-surface-variant);
367
- }
368
- button {
369
- font: inherit;
370
- box-sizing: border-box;
371
- height: 32px;
372
- border: 1px solid var(--md-sys-color-outline-variant);
373
- border-radius: 6px;
374
- background: var(--md-sys-color-surface);
375
- color: inherit;
376
- padding: 0 8px;
377
- min-width: 0;
378
- }
379
- button {
380
- cursor: pointer;
381
- }
382
- button:disabled {
383
- opacity: 0.45;
384
- cursor: default;
385
- }
386
- button[aria-pressed='true'] {
387
- background: var(--md-sys-color-secondary-container);
388
- color: var(--md-sys-color-on-secondary-container);
389
- border-color: var(--md-sys-color-primary);
390
- }
391
- .hint {
392
- color: var(--md-sys-color-on-surface-variant);
393
- line-height: 1.5;
394
- }
395
- `]
82
+ static styles = [ScrollbarStyles, SidePanelStyles, EditorStyles]
396
83
  @property({ attribute: false }) session?: FigureProductSession
397
84
  /** The saved figure's state and version, as the server gave them (the page reads the row). */
398
85
  @property({ attribute: false }) releaseState?: { state?: 'draft' | 'released'; version: number }
@@ -425,7 +112,7 @@ export class FigureEditor extends LitElement {
425
112
  */
426
113
  @state() private faceMapping?: { action: FigureAuthoringAction; need: FigureFaceMappingNeed; from: 'section-editor' | 'panel' }
427
114
  /** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
428
- @state() private retry?: { label: string; action?: FigureAuthoringAction; run?: () => void }
115
+ @state() private retry?: Retry
429
116
  @state() private tab: SideTab = 'properties'
430
117
  /** The frame the bar folds - found after the first render, since the bar is drawn before it. */
431
118
  @state() private panes?: OxThreePane
@@ -467,13 +154,14 @@ export class FigureEditor extends LitElement {
467
154
  @state() private live: LiveValues = NOTHING_LIVE
468
155
  /** The part whose drawn section is open in the section editor (ADR-0100 §3.2), or null. */
469
156
  @state() private sectionEditing: string | null = null
470
- /**
471
- * A command seen before it is done (ADR-0097 §5.5): worked out on a copy for the figure, version, part and command it
472
- * names, and shown only while all four are still the ones on screen. Nothing of it is saved or undone.
473
- */
474
- @state() private preview?: HeldPreview & { part: string }
475
- private previewDrawn?: { of: HeldPreview; ghost: FigureRenderPart[]; highlight: FigureRenderPart | null }
476
157
  private lastContext: string | null = null
158
+ private commandPreview = new CommandPreview(this, {
159
+ session: () => this.session,
160
+ selected: () => this.selected,
161
+ shown: () => this.shown(),
162
+ formsInSight: () => this.middle === 'edit' && this.tab === 'properties',
163
+ said: error => this.said(error)
164
+ })
477
165
  /**
478
166
  * Move, turn and size on the canvas, in the bar's tools: the gizmo the W · E · R keys give, found without knowing the
479
167
  * keys (2026-09-26 -- a person dragging the part turned the camera). Each is offered only where the part's own
@@ -501,29 +189,22 @@ export class FigureEditor extends LitElement {
501
189
  )}
502
190
  </span>`
503
191
  }
504
- /** The modeller's keys (`keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. */
505
- private onKey = (e: KeyboardEvent) => {
506
- const view = this.session?.read()
507
- if (!view || view.loading || view.saving || !view.context || !this.isConnected) return
508
- /*
509
- * The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
510
- * Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
511
- */
512
- if (this.sectionEditing) return
192
+ private keys = new ModellerKeys(this, {
193
+ taking: () => {
194
+ const view = this.session?.read()
195
+ /*
196
+ * The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
197
+ * Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
198
+ */
199
+ return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && !this.sectionEditing
200
+ },
201
+ scene: () => this.renderRoot.querySelector<FigureCanvas>('figure-canvas'),
202
+ selected: () => this.selected,
203
+ act: action => this.keyAction(action)
204
+ })
205
+ private keyAction(action: KeyAction) {
206
+ const view = this.session!.read()
513
207
  const canvas = this.renderRoot.querySelector<FigureCanvas>('figure-canvas')
514
- const path = e.composedPath()
515
- const action = keyActionOf(
516
- { key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: path[0] as HTMLElement, inScene: !!canvas && path.includes(canvas) },
517
- this.selected
518
- )
519
- if (!action) return
520
- e.preventDefault()
521
- /*
522
- The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
523
- board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
524
- part placed by a rule wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
525
- */
526
- e.stopPropagation()
527
208
  if (action === 'translate' || action === 'rotate' || action === 'scale') canvas && this.useGizmo(action)
528
209
  else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
529
210
  else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
@@ -531,118 +212,16 @@ export class FigureEditor extends LitElement {
531
212
  else if (action === 'remove') this.removePart(this.selected)
532
213
  else this.selected = ''
533
214
  }
534
- connectedCallback() {
535
- super.connectedCallback()
536
- window.addEventListener('keydown', this.onKey, true)
537
- this.widthWatch.observe(this)
538
- }
539
- disconnectedCallback() {
540
- window.removeEventListener('keydown', this.onKey, true)
541
- this.widthWatch.disconnect()
542
- clearTimeout(this.previewTimer)
543
- clearTimeout(this.detachTimer)
544
- super.disconnectedCallback()
545
- }
546
- /*
547
- Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
548
- FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
549
- bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
550
- Said once in this browser, not on every opening (2026-10-02): the list folds as before, the notice does not come back.
551
- */
552
- private static readonly FOLD_START_UNDER = 1100
553
- private foldedForWidth = false
554
- private keptOpenByAuthor = false
555
- private widthWatch = new ResizeObserver(([entry]) => this.fitPanesTo(entry!.contentRect.width))
556
- private fitPanesTo(width: number) {
557
- const panes = this.panes
558
- if (!panes || !width) return
559
- if (this.foldedForWidth && !panes.foldStart) this.keptOpenByAuthor = true
560
- if (width < FigureEditor.FOLD_START_UNDER) {
561
- if (!panes.foldStart && !this.keptOpenByAuthor) {
562
- panes.foldStart = true
563
- this.foldedForWidth = true
564
- if (!foldSaid()) notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
565
- }
566
- } else {
567
- if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
568
- this.foldedForWidth = false
569
- this.keptOpenByAuthor = false
570
- }
571
- }
572
- /**
573
- * Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
574
- * notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
575
- * delete and fit the space again, narrow the range -- asks in the app's prompt and goes on when confirmed; any
576
- * other refusal is the app's notification, which stays until it is closed.
577
- */
578
- private toldViewError = ''
579
- private tell(changed: PropertyValues) {
580
- if (changed.has('error') && this.error) {
581
- const retry = this.retry
582
- if (retry) this.ask(this.error, retry)
583
- else notify({ level: 'error', message: this.error })
584
- }
585
- if (changed.has('notice') && this.notice) notify({ level: 'info', message: this.notice })
586
- const viewError = this.session?.read()?.error ?? ''
587
- if (viewError && viewError !== this.toldViewError) notify({ level: 'error', message: viewError })
588
- this.toldViewError = viewError
589
- }
590
- /*
591
- An edit that would stop clips is refused by figure-model with the clips named (CLIPS_DROPPED). The person is told
592
- which, and why, before anything is written; agreed, the same edit is made again letting exactly those go, as one
593
- step undo brings back (designer 2026-09-27).
594
- */
595
- private askClipLoss(error: unknown, again: () => void): boolean {
596
- if ((error as { code?: string })?.code !== 'CLIPS_DROPPED') return false
597
- const clips = (error as { clips: FigureClipLoss[] }).clips
598
- /* The consent is for this edit, on this figure as it is now: held with it while the person reads. */
599
- const session = this.session!
600
- const { context, revision } = session.read()
601
- void this.confirmClipLoss(clips, again, { session, context: context!, revision: revision! })
602
- return true
603
- }
604
- private async confirmClipLoss(clips: FigureClipLoss[], again: () => void, asked: { session: FigureProductSession; context: string; revision: number }) {
605
- const agreed = await OxPrompt.open({
606
- type: 'warning',
607
- text: i18next.t('figure.text.clips-would-go', { clips: clips.map(c => i18next.t(`figure.text.clip-${c.why}`, { clip: c.clip })).join(', ') }),
608
- confirmButton: { text: i18next.t('figure.button.let-clips-go') },
609
- cancelButton: { text: i18next.t('figure.button.keep-editing') }
610
- })
611
- if (!agreed) return
612
- /* Changed while the person read -- another figure, or an edit in between: the consent was not for this; nothing is applied. */
613
- const now = this.session === asked.session ? asked.session.read() : undefined
614
- if (!now || now.context !== asked.context || now.revision !== asked.revision) {
615
- this.error = i18next.t('figure.text.clips-consent-stale')
616
- return
617
- }
618
- asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)
619
- }
620
- private async ask(message: string, retry: { label: string; action?: FigureAuthoringAction; run?: () => void }) {
621
- const agreed = await OxPrompt.open({
622
- type: 'question',
623
- text: message,
624
- confirmButton: { text: retry.label },
625
- cancelButton: { text: i18next.t('figure.button.keep-editing') }
626
- })
627
- if (!agreed) return
628
- if (retry.run) retry.run()
629
- else this.linkCommand(retry.action!)
630
- }
631
- /*
632
- Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
633
- to one finds it as it was left (designer 2026-09-27, B.1). The path stays held at the top, so a scroll kept cannot
634
- hide which part the panel speaks of. Another model starts clean.
635
- */
636
- private panelMemory = new Map<string, { top: number; open: boolean[] }>()
637
- private restorePanelFor?: string
638
- private propertiesPanel() {
639
- return this.renderRoot.querySelector<HTMLElement>('div[panel]')
640
- }
641
- private foldsOf(panel: HTMLElement): HTMLDetailsElement[] {
642
- const folds = [...panel.querySelectorAll('details')]
643
- for (const inner of panel.querySelectorAll('figure-links')) folds.push(...(inner.shadowRoot?.querySelectorAll('details') ?? []))
644
- return folds
645
- }
215
+ private paneFold = new PaneFold(this, () => this.panes)
216
+ private refusals = new RefusalTeller({
217
+ session: () => this.session,
218
+ error: () => this.error,
219
+ notice: () => this.notice,
220
+ retry: () => this.retry,
221
+ refuse: message => (this.error = message),
222
+ command: action => this.linkCommand(action)
223
+ })
224
+ private panelMemory = new PanelMemory(() => this.renderRoot)
646
225
  /** The preview is made when first asked for, and kept (hidden) after that; it is taken down with the editor. */
647
226
  private previewMounted = false
648
227
  protected willUpdate(_changed: PropertyValues) {
@@ -655,10 +234,7 @@ export class FigureEditor extends LitElement {
655
234
  this.hiddenParts = new Set()
656
235
  this.panelMemory.clear()
657
236
  } else if (_changed.has('selected')) {
658
- const panel = this.propertiesPanel()
659
- const was = (_changed.get('selected') as string | undefined) ?? ''
660
- if (panel) this.panelMemory.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) })
661
- this.restorePanelFor = this.selected || '@model'
237
+ this.panelMemory.leave((_changed.get('selected') as string | undefined) ?? '', this.selected)
662
238
  }
663
239
  if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
664
240
  if (_changed.has('selected')) this.gizmoMode = undefined
@@ -714,7 +290,7 @@ export class FigureEditor extends LitElement {
714
290
  this.notice = ''
715
291
  this.changed()
716
292
  } catch (error) {
717
- if (this.askClipLoss(error, () => this.run(action))) return
293
+ if (this.refusals.askClipLoss(error, () => this.run(action))) return
718
294
  this.error = this.said(error)
719
295
  } finally {
720
296
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
@@ -750,14 +326,6 @@ export class FigureEditor extends LitElement {
750
326
  private shown(): FigureRevision | undefined {
751
327
  return this.session?.revisionNow()
752
328
  }
753
- /**
754
- * The figure's parts by id, one per authored part as the parts list shows them. Not the drawn bodies: a row's copies
755
- * are drawn under ids of their own, so a part made into a row was missing from those and its id was offered to the
756
- * next part, which figure-model refused (DUPLICATE_PART).
757
- */
758
- private partIds(): Set<string> {
759
- return new Set(this.session?.revisionNow()?.asset.appearance.map(look => look.target) ?? [])
760
- }
761
329
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
762
330
  private hold(values: Record<string, number>, edit?: FigurePartEdit) {
763
331
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
@@ -811,10 +379,8 @@ export class FigureEditor extends LitElement {
811
379
  private add(kind: FigurePaletteKind) {
812
380
  if (!this.session) return
813
381
  this.run(() => {
814
- const ids = this.partIds()
815
- let n = 1
816
- while (ids.has(`${kind}-${n}`)) n++
817
- const part = paletteRecipe(kind, `${kind}-${n}`)
382
+ /* The id is figure-model's to choose: what is in use is its rule, not a list the screen keeps. */
383
+ const part = paletteRecipe(kind, figurePartIdFor(this.session!.revisionNow()!.asset, kind))
818
384
  this.session!.add(part)
819
385
  this.selected = part.id
820
386
  })
@@ -846,8 +412,7 @@ export class FigureEditor extends LitElement {
846
412
  if (!choice) return
847
413
  const node = upload.summary.nodes.find(n => n.index === (choice as { node?: number }).node)
848
414
  this.run(() => {
849
- const taken = this.partIds()
850
- const id = meshPartIdOf(node?.name ?? file.name.replace(/\.glb$/i, ''), taken)
415
+ const id = figurePartIdFor(this.session!.revisionNow()!.asset, node?.name ?? file.name.replace(/\.glb$/i, ''))
851
416
  this.session!.add(meshPartOf(upload, choice as { node?: number }, id))
852
417
  this.selected = id
853
418
  })
@@ -858,9 +423,8 @@ export class FigureEditor extends LitElement {
858
423
  */
859
424
  private duplicatePart(id: string) {
860
425
  this.run(() => {
861
- const before = this.partIds()
862
- this.session!.edit({ kind: 'duplicate-part', target: id })
863
- this.selected = [...this.partIds()].find(part => !before.has(part)) ?? id
426
+ /* The copy is the part the edit made, as figure-model named it. */
427
+ this.selected = this.session!.edit({ kind: 'duplicate-part', target: id }).made?.[0] ?? id
864
428
  })
865
429
  }
866
430
  private movePart(id: string, to: number) {
@@ -876,7 +440,7 @@ export class FigureEditor extends LitElement {
876
440
  if (this.selected === id) this.selected = ''
877
441
  this.changed()
878
442
  } catch (error) {
879
- if (this.askClipLoss(error, () => this.removePart(id, refitRoom))) return
443
+ if (this.refusals.askClipLoss(error, () => this.removePart(id, refitRoom))) return
880
444
  /*
881
445
  A part in the declared space goes, and the space is fitted to the parts left, in the same click: every space
882
446
  this product declares is fitted to the parts (「부품에 맞추기」, the AI's fit-room -- no padding), so fitting it
@@ -1040,9 +604,7 @@ export class FigureEditor extends LitElement {
1040
604
  private mirror(axis: 'x' | 'z') {
1041
605
  this.run(() => {
1042
606
  const id = this.selected
1043
- const before = this.partIds()
1044
- this.session!.edit({ kind: 'duplicate-part', target: id })
1045
- const copy = [...this.partIds()].find(part => !before.has(part))
607
+ const copy = this.session!.edit({ kind: 'duplicate-part', target: id }).made?.[0]
1046
608
  if (!copy) return
1047
609
  this.session!.apply(mirrorChanges(this.shown()!, id, copy, axis))
1048
610
  this.selected = copy
@@ -1150,19 +712,6 @@ export class FigureEditor extends LitElement {
1150
712
  }
1151
713
  }
1152
714
  /** Size response, fastening and motion for the selected part, as authoring commands. */
1153
- /*
1154
- What a panel is handed is the same object while it says the same thing: a new array each render reads as a change to
1155
- the panel, which draws itself again for nothing (sixteen times for one edit).
1156
- */
1157
- private previewWordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
1158
- private previewWordsNow(): string[] {
1159
- const preview = this.currentPreview()
1160
- const language = i18next.language
1161
- if (this.previewWordsSaid && this.previewWordsSaid.preview === preview && this.previewWordsSaid.part === this.selected && this.previewWordsSaid.language === language) return this.previewWordsSaid.words
1162
- const words = previewWordsOf(preview, this.selected, (key, params) => i18next.t(key, params))
1163
- this.previewWordsSaid = { preview, part: this.selected, language, words }
1164
- return words
1165
- }
1166
715
  private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
1167
716
  const shown = this.shown()
1168
717
  if (!shown) return null
@@ -1172,13 +721,13 @@ export class FigureEditor extends LitElement {
1172
721
  .partId=${this.selected}
1173
722
  ?locked=${unavailable}
1174
723
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1175
- .previewWords=${this.previewWordsNow()}
1176
- .detachWords=${this.detachWords(shown)}
724
+ .previewWords=${this.commandPreview.words()}
725
+ .detachWords=${this.commandPreview.detachWords(shown)}
1177
726
  .overhangWords=${section === 'shape' ? this.overhangOf(shown).words : NO_WORDS}
1178
727
  .overhangNote=${section === 'shape' ? this.overhangOf(shown).note : NO_WORDS}
1179
728
  .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1180
729
  @go-fasten=${() => this.goFasten()}
1181
- @preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
730
+ @preview-command=${(e: CustomEvent) => this.commandPreview.ask(e.detail)}
1182
731
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1183
732
  ></figure-links>`
1184
733
  }
@@ -1253,7 +802,7 @@ export class FigureEditor extends LitElement {
1253
802
  this.changed()
1254
803
  return true
1255
804
  } catch (error) {
1256
- if (this.askClipLoss(error, () => this.linkCommand(action, from))) return false
805
+ if (this.refusals.askClipLoss(error, () => this.linkCommand(action, from))) return false
1257
806
  const code = (error as { code?: string })?.code
1258
807
  /* Something stands on a face this takes away: ask where it goes, there, rather than only saying no. */
1259
808
  const need = (error as { details?: FigureFaceMappingNeed }).details
@@ -1651,111 +1200,6 @@ export class FigureEditor extends LitElement {
1651
1200
  await this.updateComplete
1652
1201
  await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
1653
1202
  }
1654
- /** The fastening form's command, seen before it is done; null clears what was shown. */
1655
- /**
1656
- * What a command would do is worked out on a copy of the figure -- several compiles of it -- so it waits for the field to
1657
- * rest: a number typed digit by digit asks once, for the number it ends on. Clearing is at once, and the same question on
1658
- * the same version is not asked again.
1659
- */
1660
- private previewTimer?: ReturnType<typeof setTimeout>
1661
- private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
1662
- clearTimeout(this.previewTimer)
1663
- if (!candidate || !this.session || !this.selected) {
1664
- this.preview = undefined
1665
- return
1666
- }
1667
- const view = this.session.read()
1668
- const same = this.preview && this.preview.key === JSON.stringify(candidate) && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision
1669
- if (same) return
1670
- this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
1671
- }
1672
- private computePreview(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } }) {
1673
- if (!this.session || !this.selected) return
1674
- const view = this.session.read()
1675
- const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
1676
- try {
1677
- const seen = this.session.previewCommand(candidate.action)
1678
- let face: FigureFaceArea | undefined
1679
- if (candidate.face) {
1680
- try {
1681
- const shown = this.shown()!
1682
- face = figurePartFaceOf(shown.asset, candidate.face.part, candidate.face.face, shown.inputs)
1683
- } catch {
1684
- face = undefined
1685
- }
1686
- }
1687
- /* A row is read on the figure the command would make, by figure-model, as drawn. */
1688
- const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.shown()!.inputs) : undefined
1689
- this.preview = {
1690
- ...held,
1691
- context: seen.context,
1692
- revision: seen.revision,
1693
- geometry: seen.geometry,
1694
- moves: seen.moves,
1695
- added: seen.added,
1696
- ...(row ? { row } : {}),
1697
- clipsWouldGo: seen.clipsWouldGo,
1698
- face,
1699
- faceUnknown: !!candidate.face && !face
1700
- }
1701
- } catch (error) {
1702
- /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
1703
- this.preview = { ...held, refused: this.said(error) }
1704
- }
1705
- }
1706
- /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
1707
- private currentPreview(): (HeldPreview & { part: string }) | undefined {
1708
- const p = this.preview
1709
- const view = this.session?.read()
1710
- /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
1711
- if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties') return undefined
1712
- return p
1713
- }
1714
- /*
1715
- What taking the selected part off would do, worked out on a copy before the button is pressed (ADR-0097 §5.5): whether
1716
- it stays where it stands now, what stays fastened to it, which clips would go. "It stays where it is" is said only
1717
- when the copy shows it does.
1718
- */
1719
- private detachTimer?: ReturnType<typeof setTimeout>
1720
- private detachAsked?: { shown: FigureRevision; part: string }
1721
- /**
1722
- * Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
1723
- * edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
1724
- */
1725
- private detachWords(shown: FigureRevision): string[] {
1726
- const part = this.selected
1727
- if (!this.session || !shown.asset.joints?.some(j => j.body1.part === part)) return NO_WORDS
1728
- const key = `detach|${part}`
1729
- const said = derivedHeld<string[]>(shown, key)
1730
- if (said) return said
1731
- if (this.detachAsked?.shown !== shown || this.detachAsked.part !== part) {
1732
- this.detachAsked = { shown, part }
1733
- clearTimeout(this.detachTimer)
1734
- this.detachTimer = setTimeout(() => {
1735
- if (this.shown() !== shown || this.selected !== part) return
1736
- derivedOf(shown, key, () => this.computeDetachWords(shown, part))
1737
- this.requestUpdate()
1738
- }, PREVIEW_SETTLE_MS)
1739
- }
1740
- return NO_WORDS
1741
- }
1742
- private computeDetachWords(shown: FigureRevision, part: string): string[] {
1743
- const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1744
- let words: string[]
1745
- try {
1746
- const seen = this.session!.previewCommand({ kind: 'detach', part })
1747
- const own = seen.moves.some(m => m.body === part)
1748
- words = own
1749
- ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key: `detach|${part}`, moves: seen.moves }, part, say)]
1750
- : /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []
1751
- const children = (shown.asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => partLabelOf(j.body1.part))
1752
- if (children.length) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
1753
- if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
1754
- } catch (error) {
1755
- words = [say('figure.text.detach-refused', { reason: this.said(error) })]
1756
- }
1757
- return words
1758
- }
1759
1203
  /*
1760
1204
  Past the edge of the face a part is fastened to (ADR-0097 §5.5, Q3): the selected part's own, and the parts fastened
1761
1205
  to it -- said apart from the room, nothing moved. Read once per version, part and language. The note says it is wider
@@ -1811,12 +1255,10 @@ export class FigureEditor extends LitElement {
1811
1255
  }
1812
1256
  /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1813
1257
  private previewShown(): { ghost: FigureRenderPart[]; highlight: FigureRenderPart | null } {
1814
- const p = this.currentPreview()
1258
+ const p = this.commandPreview.current()
1815
1259
  /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1816
1260
  if (!p && this.tab === 'motion' && this.middle === 'edit') return { ghost: this.motionEnds()?.ghost ?? [], highlight: null }
1817
- if (!p || p.refused) return { ghost: [], highlight: null }
1818
- if (this.previewDrawn?.of !== p) this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) }
1819
- return this.previewDrawn
1261
+ return this.commandPreview.drawnOf(p)
1820
1262
  }
1821
1263
  /**
1822
1264
  * What the author has in view, for the AI dock to hear with a request (ADR-0097 §5.6 item 5): the part selected and
@@ -1859,23 +1301,12 @@ export class FigureEditor extends LitElement {
1859
1301
  }
1860
1302
  updated(changed: PropertyValues<this>) {
1861
1303
  super.updated?.(changed)
1862
- this.tell(changed)
1863
- if (this.restorePanelFor !== undefined) {
1864
- const key = this.restorePanelFor
1865
- this.restorePanelFor = undefined
1866
- requestAnimationFrame(async () => {
1867
- const panel = this.propertiesPanel()
1868
- if (!panel || (this.selected || '@model') !== key) return
1869
- await Promise.all([...panel.querySelectorAll<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-links')].map(el => el.updateComplete))
1870
- const kept = this.panelMemory.get(key)
1871
- if (kept) this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]!))
1872
- panel.scrollTop = kept?.top ?? 0
1873
- })
1874
- }
1304
+ this.refusals.tell(changed)
1305
+ this.panelMemory.restore(() => this.selected)
1875
1306
  const panes = this.renderRoot.querySelector<OxThreePane>('ox-three-pane') ?? undefined
1876
1307
  if (panes !== this.panes) {
1877
1308
  this.panes = panes
1878
- this.fitPanesTo(this.getBoundingClientRect().width)
1309
+ this.paneFold.fitTo(this.getBoundingClientRect().width)
1879
1310
  }
1880
1311
  }
1881
1312
  /**