@things-factory/figure-ui 10.1.152 → 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 (56) 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 +58 -619
  5. package/client/modeller/figure-first-save.ts +9 -1
  6. package/client/modeller/modeller-keys.ts +60 -0
  7. package/client/modeller/pane-fold.ts +61 -0
  8. package/client/modeller/panel-memory.ts +53 -0
  9. package/client/modeller/refusal-teller.ts +93 -0
  10. package/client/modeller/refusals.ts +18 -1
  11. package/dist-client/modeller/ai-proposal.d.ts +6 -5
  12. package/dist-client/modeller/ai-proposal.js +9 -9
  13. package/dist-client/modeller/ai-proposal.js.map +1 -1
  14. package/dist-client/modeller/clips.d.ts +1 -1
  15. package/dist-client/modeller/command-preview-controller.d.ts +75 -0
  16. package/dist-client/modeller/command-preview-controller.js +178 -0
  17. package/dist-client/modeller/command-preview-controller.js.map +1 -0
  18. package/dist-client/modeller/editor-styles.d.ts +2 -0
  19. package/dist-client/modeller/editor-styles.js +307 -0
  20. package/dist-client/modeller/editor-styles.js.map +1 -0
  21. package/dist-client/modeller/editor.d.ts +5 -50
  22. package/dist-client/modeller/editor.js +69 -641
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-first-save.d.ts +1 -0
  25. package/dist-client/modeller/figure-first-save.js +8 -1
  26. package/dist-client/modeller/figure-first-save.js.map +1 -1
  27. package/dist-client/modeller/modeller-keys.d.ts +22 -0
  28. package/dist-client/modeller/modeller-keys.js +43 -0
  29. package/dist-client/modeller/modeller-keys.js.map +1 -0
  30. package/dist-client/modeller/pane-fold.d.ts +15 -0
  31. package/dist-client/modeller/pane-fold.js +60 -0
  32. package/dist-client/modeller/pane-fold.js.map +1 -0
  33. package/dist-client/modeller/panel-memory.d.ts +15 -0
  34. package/dist-client/modeller/panel-memory.js +54 -0
  35. package/dist-client/modeller/panel-memory.js.map +1 -0
  36. package/dist-client/modeller/refusal-teller.d.ts +36 -0
  37. package/dist-client/modeller/refusal-teller.js +81 -0
  38. package/dist-client/modeller/refusal-teller.js.map +1 -0
  39. package/dist-client/modeller/refusals.d.ts +10 -0
  40. package/dist-client/modeller/refusals.js +9 -0
  41. package/dist-client/modeller/refusals.js.map +1 -1
  42. package/dist-client/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +4 -4
  44. package/test/browser/fix-alignment.check.mjs +13 -2
  45. package/test/browser/links.check.mjs +36 -9
  46. package/test/browser/modeller.check.mjs +25 -0
  47. package/test/browser/parts-page.js +9 -1
  48. package/test/browser/parts.check.mjs +22 -0
  49. package/test/browser/places-mesh.check.mjs +25 -9
  50. package/test/browser/product-page.check.mjs +42 -2
  51. package/test/browser/screen.check.mjs +33 -0
  52. package/test/clip-ai-proposal.test.ts +4 -1
  53. package/test/clip-consent.test.ts +9 -2
  54. package/test/layout.test.ts +3 -1
  55. package/test/refusals.test.ts +18 -10
  56. package/test/theme.test.ts +5 -15
@@ -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, figurePartIdFor, 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,9 +29,14 @@ 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'
@@ -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. */
@@ -864,7 +440,7 @@ export class FigureEditor extends LitElement {
864
440
  if (this.selected === id) this.selected = ''
865
441
  this.changed()
866
442
  } catch (error) {
867
- if (this.askClipLoss(error, () => this.removePart(id, refitRoom))) return
443
+ if (this.refusals.askClipLoss(error, () => this.removePart(id, refitRoom))) return
868
444
  /*
869
445
  A part in the declared space goes, and the space is fitted to the parts left, in the same click: every space
870
446
  this product declares is fitted to the parts (「부품에 맞추기」, the AI's fit-room -- no padding), so fitting it
@@ -1136,19 +712,6 @@ export class FigureEditor extends LitElement {
1136
712
  }
1137
713
  }
1138
714
  /** Size response, fastening and motion for the selected part, as authoring commands. */
1139
- /*
1140
- 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
1141
- the panel, which draws itself again for nothing (sixteen times for one edit).
1142
- */
1143
- private previewWordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
1144
- private previewWordsNow(): string[] {
1145
- const preview = this.currentPreview()
1146
- const language = i18next.language
1147
- if (this.previewWordsSaid && this.previewWordsSaid.preview === preview && this.previewWordsSaid.part === this.selected && this.previewWordsSaid.language === language) return this.previewWordsSaid.words
1148
- const words = previewWordsOf(preview, this.selected, (key, params) => i18next.t(key, params))
1149
- this.previewWordsSaid = { preview, part: this.selected, language, words }
1150
- return words
1151
- }
1152
715
  private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
1153
716
  const shown = this.shown()
1154
717
  if (!shown) return null
@@ -1158,13 +721,13 @@ export class FigureEditor extends LitElement {
1158
721
  .partId=${this.selected}
1159
722
  ?locked=${unavailable}
1160
723
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1161
- .previewWords=${this.previewWordsNow()}
1162
- .detachWords=${this.detachWords(shown)}
724
+ .previewWords=${this.commandPreview.words()}
725
+ .detachWords=${this.commandPreview.detachWords(shown)}
1163
726
  .overhangWords=${section === 'shape' ? this.overhangOf(shown).words : NO_WORDS}
1164
727
  .overhangNote=${section === 'shape' ? this.overhangOf(shown).note : NO_WORDS}
1165
728
  .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1166
729
  @go-fasten=${() => this.goFasten()}
1167
- @preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
730
+ @preview-command=${(e: CustomEvent) => this.commandPreview.ask(e.detail)}
1168
731
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1169
732
  ></figure-links>`
1170
733
  }
@@ -1239,7 +802,7 @@ export class FigureEditor extends LitElement {
1239
802
  this.changed()
1240
803
  return true
1241
804
  } catch (error) {
1242
- if (this.askClipLoss(error, () => this.linkCommand(action, from))) return false
805
+ if (this.refusals.askClipLoss(error, () => this.linkCommand(action, from))) return false
1243
806
  const code = (error as { code?: string })?.code
1244
807
  /* Something stands on a face this takes away: ask where it goes, there, rather than only saying no. */
1245
808
  const need = (error as { details?: FigureFaceMappingNeed }).details
@@ -1637,117 +1200,6 @@ export class FigureEditor extends LitElement {
1637
1200
  await this.updateComplete
1638
1201
  await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
1639
1202
  }
1640
- /** The fastening form's command, seen before it is done; null clears what was shown. */
1641
- /**
1642
- * 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
1643
- * rest: a number typed digit by digit asks once, for the number it ends on. Clearing is at once, and the same question on
1644
- * the same version is not asked again.
1645
- */
1646
- private previewTimer?: ReturnType<typeof setTimeout>
1647
- private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
1648
- clearTimeout(this.previewTimer)
1649
- if (!candidate || !this.session || !this.selected) {
1650
- this.preview = undefined
1651
- return
1652
- }
1653
- const view = this.session.read()
1654
- const key = JSON.stringify(candidate)
1655
- const same = this.preview && this.preview.key === key && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision
1656
- if (same) return
1657
- /*
1658
- What another command would do is not said of this one while this one is worked out: a row's pitch refused stayed
1659
- on screen for the count chosen after it (:3300, 2026-10-11).
1660
- */
1661
- if (this.preview && this.preview.key !== key) this.preview = undefined
1662
- this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS)
1663
- }
1664
- private computePreview(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } }) {
1665
- if (!this.session || !this.selected) return
1666
- const view = this.session.read()
1667
- const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
1668
- try {
1669
- const seen = this.session.previewCommand(candidate.action)
1670
- let face: FigureFaceArea | undefined
1671
- if (candidate.face) {
1672
- try {
1673
- const shown = this.shown()!
1674
- face = figurePartFaceOf(shown.asset, candidate.face.part, candidate.face.face, shown.inputs)
1675
- } catch {
1676
- face = undefined
1677
- }
1678
- }
1679
- /* A row is read on the figure the command would make, by figure-model, as drawn. */
1680
- const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.shown()!.inputs) : undefined
1681
- this.preview = {
1682
- ...held,
1683
- context: seen.context,
1684
- revision: seen.revision,
1685
- geometry: seen.geometry,
1686
- moves: seen.moves,
1687
- added: seen.added,
1688
- ...(row ? { row } : {}),
1689
- clipsWouldGo: seen.clipsWouldGo,
1690
- face,
1691
- faceUnknown: !!candidate.face && !face
1692
- }
1693
- } catch (error) {
1694
- /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
1695
- this.preview = { ...held, refused: this.said(error) }
1696
- }
1697
- }
1698
- /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
1699
- private currentPreview(): (HeldPreview & { part: string }) | undefined {
1700
- const p = this.preview
1701
- const view = this.session?.read()
1702
- /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
1703
- if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties') return undefined
1704
- return p
1705
- }
1706
- /*
1707
- What taking the selected part off would do, worked out on a copy before the button is pressed (ADR-0097 §5.5): whether
1708
- it stays where it stands now, what stays fastened to it, which clips would go. "It stays where it is" is said only
1709
- when the copy shows it does.
1710
- */
1711
- private detachTimer?: ReturnType<typeof setTimeout>
1712
- private detachAsked?: { shown: FigureRevision; part: string }
1713
- /**
1714
- * Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
1715
- * edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
1716
- */
1717
- private detachWords(shown: FigureRevision): string[] {
1718
- const part = this.selected
1719
- if (!this.session || !shown.asset.joints?.some(j => j.body1.part === part)) return NO_WORDS
1720
- const key = `detach|${part}`
1721
- const said = derivedHeld<string[]>(shown, key)
1722
- if (said) return said
1723
- if (this.detachAsked?.shown !== shown || this.detachAsked.part !== part) {
1724
- this.detachAsked = { shown, part }
1725
- clearTimeout(this.detachTimer)
1726
- this.detachTimer = setTimeout(() => {
1727
- if (this.shown() !== shown || this.selected !== part) return
1728
- derivedOf(shown, key, () => this.computeDetachWords(shown, part))
1729
- this.requestUpdate()
1730
- }, PREVIEW_SETTLE_MS)
1731
- }
1732
- return NO_WORDS
1733
- }
1734
- private computeDetachWords(shown: FigureRevision, part: string): string[] {
1735
- const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1736
- let words: string[]
1737
- try {
1738
- const seen = this.session!.previewCommand({ kind: 'detach', part })
1739
- const own = seen.moves.some(m => m.body === part)
1740
- words = own
1741
- ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key: `detach|${part}`, moves: seen.moves }, part, say)]
1742
- : /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []
1743
- const children = (shown.asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => partLabelOf(j.body1.part))
1744
- if (children.length) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
1745
- if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
1746
- } catch (error) {
1747
- words = [say('figure.text.detach-refused', { reason: this.said(error) })]
1748
- }
1749
- return words
1750
- }
1751
1203
  /*
1752
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
1753
1205
  to it -- said apart from the room, nothing moved. Read once per version, part and language. The note says it is wider
@@ -1803,12 +1255,10 @@ export class FigureEditor extends LitElement {
1803
1255
  }
1804
1256
  /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1805
1257
  private previewShown(): { ghost: FigureRenderPart[]; highlight: FigureRenderPart | null } {
1806
- const p = this.currentPreview()
1258
+ const p = this.commandPreview.current()
1807
1259
  /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1808
1260
  if (!p && this.tab === 'motion' && this.middle === 'edit') return { ghost: this.motionEnds()?.ghost ?? [], highlight: null }
1809
- if (!p || p.refused) return { ghost: [], highlight: null }
1810
- if (this.previewDrawn?.of !== p) this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) }
1811
- return this.previewDrawn
1261
+ return this.commandPreview.drawnOf(p)
1812
1262
  }
1813
1263
  /**
1814
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
@@ -1851,23 +1301,12 @@ export class FigureEditor extends LitElement {
1851
1301
  }
1852
1302
  updated(changed: PropertyValues<this>) {
1853
1303
  super.updated?.(changed)
1854
- this.tell(changed)
1855
- if (this.restorePanelFor !== undefined) {
1856
- const key = this.restorePanelFor
1857
- this.restorePanelFor = undefined
1858
- requestAnimationFrame(async () => {
1859
- const panel = this.propertiesPanel()
1860
- if (!panel || (this.selected || '@model') !== key) return
1861
- await Promise.all([...panel.querySelectorAll<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-links')].map(el => el.updateComplete))
1862
- const kept = this.panelMemory.get(key)
1863
- if (kept) this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]!))
1864
- panel.scrollTop = kept?.top ?? 0
1865
- })
1866
- }
1304
+ this.refusals.tell(changed)
1305
+ this.panelMemory.restore(() => this.selected)
1867
1306
  const panes = this.renderRoot.querySelector<OxThreePane>('ox-three-pane') ?? undefined
1868
1307
  if (panes !== this.panes) {
1869
1308
  this.panes = panes
1870
- this.fitPanesTo(this.getBoundingClientRect().width)
1309
+ this.paneFold.fitTo(this.getBoundingClientRect().width)
1871
1310
  }
1872
1311
  }
1873
1312
  /**