@things-factory/figure-ui 10.1.81 → 10.1.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +826 -54
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +385 -68
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/clips.d.ts +1 -1
  50. package/dist-client/modeller/command-preview.d.ts +35 -0
  51. package/dist-client/modeller/command-preview.js +95 -0
  52. package/dist-client/modeller/command-preview.js.map +1 -0
  53. package/dist-client/modeller/editor.d.ts +113 -1
  54. package/dist-client/modeller/editor.js +850 -50
  55. package/dist-client/modeller/editor.js.map +1 -1
  56. package/dist-client/modeller/fields.d.ts +3 -2
  57. package/dist-client/modeller/fields.js +10 -3
  58. package/dist-client/modeller/fields.js.map +1 -1
  59. package/dist-client/modeller/figure-facts.js +2 -2
  60. package/dist-client/modeller/figure-facts.js.map +1 -1
  61. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  62. package/dist-client/modeller/figure-first-save.js +41 -14
  63. package/dist-client/modeller/figure-first-save.js.map +1 -1
  64. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  65. package/dist-client/modeller/figure-history-panel.js +130 -14
  66. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-links.d.ts +51 -1
  68. package/dist-client/modeller/figure-links.js +392 -72
  69. package/dist-client/modeller/figure-links.js.map +1 -1
  70. package/dist-client/modeller/figure-parts.d.ts +7 -0
  71. package/dist-client/modeller/figure-parts.js +55 -2
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.d.ts +6 -0
  74. package/dist-client/modeller/figure-preview.js +22 -1
  75. package/dist-client/modeller/figure-preview.js.map +1 -1
  76. package/dist-client/modeller/figure-properties.js +3 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  79. package/dist-client/modeller/figure-revert-choice.js +59 -0
  80. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  81. package/dist-client/modeller/first-save.d.ts +7 -0
  82. package/dist-client/modeller/first-save.js +17 -0
  83. package/dist-client/modeller/first-save.js.map +1 -1
  84. package/dist-client/modeller/gizmo.d.ts +0 -6
  85. package/dist-client/modeller/gizmo.js +0 -18
  86. package/dist-client/modeller/gizmo.js.map +1 -1
  87. package/dist-client/modeller/links.d.ts +49 -4
  88. package/dist-client/modeller/links.js +141 -15
  89. package/dist-client/modeller/links.js.map +1 -1
  90. package/dist-client/modeller/motion-ends.d.ts +12 -0
  91. package/dist-client/modeller/motion-ends.js +65 -0
  92. package/dist-client/modeller/motion-ends.js.map +1 -0
  93. package/dist-client/modeller/occupancy.d.ts +14 -1
  94. package/dist-client/modeller/occupancy.js +14 -1
  95. package/dist-client/modeller/occupancy.js.map +1 -1
  96. package/dist-client/modeller/product-session.d.ts +43 -2
  97. package/dist-client/modeller/product-session.js +146 -12
  98. package/dist-client/modeller/product-session.js.map +1 -1
  99. package/dist-client/modeller/refusals.d.ts +13 -0
  100. package/dist-client/modeller/refusals.js +16 -0
  101. package/dist-client/modeller/refusals.js.map +1 -1
  102. package/dist-client/modeller/revert-request.d.ts +16 -0
  103. package/dist-client/modeller/revert-request.js +11 -0
  104. package/dist-client/modeller/revert-request.js.map +1 -0
  105. package/dist-client/modeller/side-panel-styles.js +12 -0
  106. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  107. package/dist-client/modeller/stage-model.d.ts +18 -2
  108. package/dist-client/modeller/stage-model.js +21 -3
  109. package/dist-client/modeller/stage-model.js.map +1 -1
  110. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  111. package/dist-client/pages/figure-modeller-page.js +72 -34
  112. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  113. package/dist-client/route.d.ts +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +5 -5
  116. package/test/ai-proposal.test.ts +2 -2
  117. package/test/align.test.ts +50 -5
  118. package/test/browser/acceptance-page.js +187 -0
  119. package/test/browser/build.mjs +5 -1
  120. package/test/browser/revert-page.js +47 -0
  121. package/test/browser/serve-acceptance.mjs +85 -0
  122. package/test/browser/serve-revert.mjs +119 -0
  123. package/test/clip-ai-proposal.test.ts +104 -0
  124. package/test/clip-consent.test.ts +88 -0
  125. package/test/command-preview.test.ts +288 -0
  126. package/test/fields.test.ts +4 -3
  127. package/test/figure-assistant-surface.test.ts +4 -1
  128. package/test/first-minute.test.ts +96 -0
  129. package/test/first-save-suggest.test.ts +42 -0
  130. package/test/gizmo.test.ts +20 -16
  131. package/test/joint-fan.test.ts +2 -1
  132. package/test/layout.test.ts +9 -2
  133. package/test/links.test.ts +7 -4
  134. package/test/proposal-record.test.ts +126 -0
  135. package/test/release-entry.test.ts +28 -0
  136. package/test/release-panel.test.ts +3 -2
  137. package/test/resize-with-placement.test.ts +225 -0
  138. package/test/revert-choice.test.ts +39 -0
  139. package/test/revert-keeping.test.ts +102 -0
  140. package/test/save-landed.test.ts +198 -0
  141. package/translations/en.json +222 -10
  142. package/translations/ja.json +223 -11
  143. package/translations/ko.json +223 -11
  144. package/translations/ms.json +222 -10
  145. package/translations/zh.json +223 -11
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from 'lit'
4
4
  import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
- import { FIGURE_FACES } from '@hatiolab/figure-model'
7
+ import { FIGURE_FACES, planFigureResize } from '@hatiolab/figure-model'
8
8
  import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource, FigureFace } from '@hatiolab/figure-model'
9
9
 
10
10
  import {
@@ -38,7 +38,13 @@ import {
38
38
  placeIdFor,
39
39
  placesOn,
40
40
  rowAxesOf,
41
+ rowBlockOf,
42
+ fixedJointOf,
43
+ rowOf,
41
44
  positionsWorkedOut,
45
+ resizeAxesOf,
46
+ fieldWordsOf,
47
+ measureNow,
42
48
  type Alignment,
43
49
  type Axis,
44
50
  type Follow,
@@ -58,7 +64,8 @@ const freshFasten = () => ({
58
64
  gap: 0,
59
65
  twist: 0,
60
66
  align: {} as Partial<Record<Axis, Alignment>>,
61
- alignMm: {} as Partial<Record<Axis, number>>
67
+ alignMm: {} as Partial<Record<Axis, number>>,
68
+ alignInput: {} as Partial<Record<Axis, string>>
62
69
  })
63
70
  const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 })
64
71
  const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
@@ -97,6 +104,30 @@ export class FigureLinks extends localize(i18next)(LitElement) {
97
104
  b[bound] {
98
105
  font-weight: 600;
99
106
  }
107
+ /* What each direction does now: read-only rows. */
108
+ div[resize-now] { display: grid; gap: 4px; margin: 12px 0; }
109
+ div[resize-now] div[row] { grid-template-columns: 1fr auto auto auto; align-items: center; gap: 8px; }
110
+ div[resize-now] span[measure] { font-variant-numeric: tabular-nums; }
111
+ button[link] { background: none; border: none; padding: 0; color: var(--md-sys-color-primary, #477b9e); text-decoration: underline; cursor: pointer; font: inherit; }
112
+ /* What will be applied: a pending choice, boxed apart from the rows above. */
113
+ fieldset[resize-pending] { display: grid; gap: 8px; margin: 12px 0; padding: 10px 12px; border: 1px dashed var(--border-color, #8888); border-radius: 6px; }
114
+ fieldset[resize-pending] label { display: flex; align-items: center; gap: 6px; }
115
+ fieldset[resize-pending] small { opacity: 0.7; }
116
+ div[resize-held] { display: grid; gap: 8px; margin: 12px 0; padding: 10px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
117
+ div[resize-held] p { margin: 0; display: flex; align-items: center; gap: 6px; }
118
+ p[fasten-mode] { margin: 0; font-weight: 600; }
119
+ p[fasten-mode][editing] { color: var(--md-sys-color-primary, #477b9e); }
120
+ section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
121
+ details { margin-top: 14px; }
122
+ summary { cursor: pointer; padding: 8px 0; }
123
+ /* What the command would do, said beside the result drawn on the canvas. */
124
+ ul[preview-words] { margin: 10px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
125
+ div[row-basis] { display: grid; gap: 4px; margin: 8px 0; }
126
+ div[row-basis] label { display: flex; align-items: center; gap: 6px; }
127
+ div[overhang] { display: grid; gap: 6px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--md-sys-color-outline-variant, #8884); }
128
+ div[overhang] p { margin: 0; }
129
+ ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; font-size: 0.8rem; opacity: 0.85; }
130
+ span[fasten-summary] { opacity: 0.8; }
100
131
  /* The difference a following measure keeps: under its choice, on the same line as its words. */
101
132
  label[inline-plus] {
102
133
  display: flex;
@@ -118,7 +149,31 @@ export class FigureLinks extends localize(i18next)(LitElement) {
118
149
  @property({ type: Boolean }) locked = false
119
150
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
120
151
  @property() section: 'shape' | 'places' | 'motion' = 'shape'
152
+ /** What the fastening command would do, in words (the editor works it out: `command-preview.ts`). */
153
+ @property({ attribute: false }) previewWords: string[] = []
154
+ /** Where the selected part's motion takes it, at both ends of its range (the editor draws them on the canvas). */
155
+ @property({ attribute: false }) motionEnds: { control: string; min: number; max: number; unit: string } | null = null
156
+ /** Past the edge of the face it is fastened to, or parts fastened to it past theirs (ADR-0097 §5.5, Q3). */
157
+ @property({ attribute: false }) overhangWords: string[] = []
158
+ /** Wider than the face it is fastened to: how far past each edge, and whether off to one side -- a fact, not a warning. */
159
+ @property({ attribute: false }) overhangNote: string[] = []
160
+ /** What taking this part off would do, said beside the buttons that do it (worked out on a copy by the editor). */
161
+ @property({ attribute: false }) detachWords: string[] = []
162
+ /*
163
+ The fastening command the form describes now, and the face it aims at: told to the editor whenever it changes, so
164
+ the result is seen before it is done (ADR-0097 §5.5). Nothing is told while no target is chosen.
165
+ */
166
+ private candidate?: { action: FigureAuthoringAction; face?: { part: string; face: string } }
167
+ private told = ''
168
+ /* Two forms describe a command here: the fastening and the row. The one being worked on is the one seen. */
169
+ @state() private working: 'fasten' | 'row' = 'fasten'
170
+ private fastenCandidate?: { action: FigureAuthoringAction; face?: { part: string; face: string } }
171
+ private rowCandidate?: FigureAuthoringAction
121
172
 
173
+ /** The directions ticked for "follow the size, attached", per part, once the author has ticked or fixed any. */
174
+ @state() private pendingAxes: Record<string, Axis[]> = {}
175
+ /** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
176
+ @state() private spotlit = false
122
177
  @state() private fasten = freshFasten()
123
178
  @state() private motion: MotionForm = freshMotion()
124
179
  @state() private place = freshPlace()
@@ -130,8 +185,12 @@ export class FigureLinks extends localize(i18next)(LitElement) {
130
185
  The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
131
186
  last part's choices over fastened a shutter curtain level with a right edge nobody chose for it (2026-09-25).
132
187
  */
133
- willUpdate(changed: Map<string, unknown>) {
134
- if (changed.has('partId') && changed.get('partId') !== undefined) {
188
+ /* The part the forms were loaded for. A panel made with a part already chosen loaded nothing, and said "editing the
189
+ fastening it has" over an empty target (2026-09-27, :3391, the doors). */
190
+ private loadedFor?: string
191
+ willUpdate(_changed: Map<string, unknown>) {
192
+ if (this.asset && this.partId && this.partId !== this.loadedFor) {
193
+ this.loadedFor = this.partId
135
194
  /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
136
195
  const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null
137
196
  this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
@@ -142,8 +201,28 @@ export class FigureLinks extends localize(i18next)(LitElement) {
142
201
  }
143
202
  }
144
203
 
204
+ updated() {
205
+ /* Only the section that holds the fastening form speaks of it: the panel has one of these per section, and the
206
+ others' silence cleared what it showed (2026-09-27, :3391). */
207
+ if (this.section !== 'shape' && !this.told) return
208
+ const said = JSON.stringify(this.candidate ?? null)
209
+ if (said === this.told) return
210
+ this.told = said
211
+ this.dispatchEvent(new CustomEvent('preview-command', { detail: this.candidate ?? null, bubbles: true, composed: true }))
212
+ }
213
+
214
+ disconnectedCallback() {
215
+ super.disconnectedCallback()
216
+ /* The panel left: what it showed goes with it. */
217
+ if (this.told && this.told !== 'null') this.dispatchEvent(new CustomEvent('preview-command', { detail: null, bubbles: true, composed: true }))
218
+ this.told = ''
219
+ }
220
+
145
221
  render() {
146
222
  const asset = this.asset
223
+ this.candidate = undefined
224
+ this.fastenCandidate = undefined
225
+ this.rowCandidate = undefined
147
226
  if (!asset || !this.partId) return nothing
148
227
  if (this.section === 'motion')
149
228
  return html`<section><h3>${i18next.t('figure.label.motion')}${this.help.button('figure.text.motion-explained')}</h3>${this.help.hint('figure.text.motion-explained')}
@@ -151,18 +230,20 @@ export class FigureLinks extends localize(i18next)(LitElement) {
151
230
  if (this.section === 'places')
152
231
  return html`<section><h3>${i18next.t('figure.label.places-on-part', { part: this.partId })}${this.help.button('figure.text.place-explained')}</h3>${this.help.hint('figure.text.place-explained')}
153
232
  ${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html`<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}</section>`
154
- return html`
233
+ const shown = html`
155
234
  <section>
156
- <h3>${i18next.t('figure.label.dimensions')}</h3>
235
+ <h3>${i18next.t('figure.label.resize-intent')}</h3>
157
236
  ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
158
237
  </section>
159
- <section>
238
+ <section fastening ?spotlit=${this.spotlit} @focusin=${() => (this.working = 'fasten')}>
160
239
  <h3>${i18next.t('figure.label.placement')}${this.help.button('figure.text.fastening-explained')}</h3>${this.help.hint('figure.text.fastening-explained')}
161
240
  ${this.renderPositions(asset)}
162
241
  ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
163
242
  </section>
164
243
  ${this.renderRow(asset)}
165
244
  `
245
+ this.candidate = this.working === 'row' && this.rowCandidate ? { action: this.rowCandidate } : this.fastenCandidate
246
+ return shown
166
247
  }
167
248
 
168
249
  /** Where things are held on this part: a seat things rest on, or a grip things are held in. */
@@ -236,16 +317,95 @@ export class FigureLinks extends localize(i18next)(LitElement) {
236
317
  }
237
318
 
238
319
  private renderDimensions(asset: FigureAsset) {
320
+ /*
321
+ The kernel's plan for "follow the size and keep where the figure is placed" (ADR-0097), with the directions the
322
+ author ticked below -- a pending choice, drawn apart from the rows above, which only say what each direction does
323
+ now (designer 2026-09-27: the immediate and the pending are not mixed in one look).
324
+ */
325
+ const editable = followableAxes(asset, this.partId)
326
+ const choice = resizeAxesOf(asset, this.partId, this.pendingAxes[this.partId])
327
+ const axes = choice.axes
328
+ const plan = planFigureResize(asset, { part: this.partId, axes })
329
+ const mode = plan.mode
330
+ const round = editable.length === 2
331
+ const word = (axis: Axis) => i18next.t(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
332
+ const scope =
333
+ axes.length && axes.length === editable.length
334
+ ? i18next.t(`figure.text.resize-scope-all-${editable.length}`)
335
+ : i18next.t('figure.text.resize-scope-only', { axes: axes.map(word).join(' · ') })
336
+ const ask = (gap?: 'keep' | 'plane') =>
337
+ this.dispatchEvent(new CustomEvent('resize-with-placement', { detail: { request: { part: this.partId, axes, ...(gap ? { gap } : {}) } }, bubbles: true, composed: true }))
338
+ const gapChoice = plan.refusal?.code === 'GAP_CHOICE' ? plan.refusal : undefined
339
+ /* Brought onto the plane, where it moves -- said on the button before it is pressed. */
340
+ const onto = gapChoice ? planFigureResize(asset, { part: this.partId, axes, gap: 'plane' }).moves[0] : undefined
341
+ const place = i18next.t(`figure.label.resize-place-${mode}`)
342
+ const tick = (axis: Axis, on: boolean) => {
343
+ const next = on ? [...new Set([...axes, axis])] : axes.filter(one => one !== axis)
344
+ this.pendingAxes = { ...this.pendingAxes, [this.partId]: next }
345
+ }
239
346
  const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
240
- const round = followableAxes(asset, this.partId).length === 2
241
347
  /* One step for the whole part (figure-model follow-size): the size is declared from the parts if there is none. */
242
- const followsAll = followableAxes(asset, this.partId).every(axis => followOf(asset, this.partId, axis).of === 'instance')
348
+ const followsAll = editable.every(axis => followOf(asset, this.partId, axis).of === 'instance')
243
349
  const whole = followsAll
244
350
  ? nothing
245
351
  : html`<button act follow-size ?disabled=${this.locked} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
246
352
  <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
247
353
  </button>`
248
- return html`${whole}${followableAxes(asset, this.partId).map(axis => {
354
+ const held = plan.refusal?.code === 'HELD'
355
+ return html`<p hint>${i18next.t('figure.text.resize-intent')}</p>
356
+ ${asset.sizing === 'uniform' ? html`<p hint>${i18next.t('figure.text.resize-uniform-warning')}</p>` : nothing}
357
+ <div resize-now>${editable.map(axis => {
358
+ const now = followOf(asset, this.partId, axis)
359
+ const following = now.of === 'instance' && now.axis === axis
360
+ const custom = now.of !== 'own' && !following
361
+ const mm = measureNow(asset, this.partId, axis)
362
+ return html`<div row>
363
+ <b>${word(axis)}</b>
364
+ <span measure>${mm === undefined ? '—' : `${Math.round(mm)} mm`}</span>
365
+ <span>${i18next.t(custom ? 'figure.label.resize-custom' : following ? 'figure.label.resize-following' : 'figure.label.follows-nothing')}</span>
366
+ ${following
367
+ ? html`<button link resize-unfollow=${axis} ?disabled=${this.locked}
368
+ @click=${() => {
369
+ /* Set back to fixed: the author's choice for this part from now on, not a blank to fill with "all". */
370
+ this.pendingAxes = { ...this.pendingAxes, [this.partId]: axes.filter(one => one !== axis) }
371
+ this.follow(asset, axis, 'own', now)
372
+ }}>${i18next.t('figure.button.resize-unfollow')}</button>`
373
+ : nothing}
374
+ </div>`
375
+ })}</div>
376
+ ${held
377
+ ? this.renderHeld(asset)
378
+ : html`<fieldset resize-pending ?disabled=${this.locked || asset.sizing === 'uniform'}>
379
+ <legend>${i18next.t('figure.label.resize-pending')}</legend>
380
+ ${editable.map(axis => {
381
+ const now = followOf(asset, this.partId, axis)
382
+ const following = now.of === 'instance' && now.axis === axis
383
+ const custom = now.of !== 'own' && !following
384
+ return html`<label>
385
+ <input type="checkbox" resize-tick=${axis} .checked=${axes.includes(axis)} ?disabled=${following || custom}
386
+ @change=${(e: Event) => tick(axis, (e.target as HTMLInputElement).checked)} />
387
+ ${word(axis)}${following ? html` <small>${i18next.t('figure.text.resize-already-following')}</small>` : nothing}
388
+ </label>`
389
+ })}
390
+ <p hint resize-basis>${i18next.t(`figure.text.resize-basis-${choice.basis}`)}</p>
391
+ <p hint>${i18next.t('figure.text.placement-intent-' + mode)}</p>
392
+ ${gapChoice
393
+ ? html`<p hint gap-choice>${i18next.t(gapChoice.mm! > 0 ? 'figure.text.resize-gap-off' : 'figure.text.resize-gap-through', { place, mm: String(Math.abs(Math.round(gapChoice.mm! * 10) / 10)) })}</p>
394
+ <button act resize-keep-gap @click=${() => ask('keep')}>
395
+ ${i18next.t(gapChoice.mm! > 0 ? 'figure.button.resize-keep-gap' : 'figure.button.resize-keep-through', { scope, mm: String(Math.abs(Math.round(gapChoice.mm! * 10) / 10)) })}
396
+ </button>
397
+ <button act resize-onto-plane @click=${() => ask('plane')}>
398
+ ${i18next.t('figure.button.resize-onto-plane-' + mode, { scope })}
399
+ ${onto ? html`<small>${i18next.t('figure.text.resize-move', { axis: onto.axis.toUpperCase(), mm: String(Math.round((onto.to - onto.from) * 10) / 10) })}</small>` : nothing}
400
+ </button>`
401
+ : html`<button act resize-with-placement ?disabled=${choice.basis === 'none' || plan.refusal?.code === 'UNIFORM_POLICY'} @click=${() => ask()}>
402
+ ${i18next.t('figure.button.resize-scoped-' + mode, { scope })}
403
+ </button>`}
404
+ ${!plan.refusal && plan.replaces.length
405
+ ? html`<p hint resize-replaces>${i18next.t('figure.text.resize-replaces', { fields: [...new Set(plan.replaces.map(id => fieldWordsOf(id, key => i18next.t(key))))].join(' · '), place })}</p>`
406
+ : nothing}
407
+ </fieldset>`}
408
+ <details><summary>${i18next.t('figure.label.resize-advanced')}</summary>${whole}${followableAxes(asset, this.partId).map(axis => {
249
409
  const now = followOf(asset, this.partId, axis)
250
410
  const value = choiceOf(now)
251
411
  /* A cylinder's two measures are its radius and its length; its X and Y name them. */
@@ -285,7 +445,68 @@ export class FigureLinks extends localize(i18next)(LitElement) {
285
445
  </label>`
286
446
  : nothing}
287
447
  </div>`
288
- })}`
448
+ })}</details>`
449
+ }
450
+
451
+ /**
452
+ * "Open attachment settings": the fastening this part has now -- its face, what it is on, the gap -- in the form below,
453
+ * brought into view with the focus on it. The form is loaded again first: the part was fastened by "follow size,
454
+ * attached" while it was selected, so the form still held what it had before (designer 2026-09-27: it opened the
455
+ * holding places instead).
456
+ */
457
+ /** The fastening's details open, focus in them: where the gap and the place across the face are changed. */
458
+ private async openDetail() {
459
+ const detail = this.renderRoot.querySelector<HTMLDetailsElement>('details[fasten-detail]')
460
+ if (!detail) return
461
+ detail.open = true
462
+ this.working = 'fasten'
463
+ await this.updateComplete
464
+ detail.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
465
+ detail.querySelector<HTMLElement>('select, input')?.focus({ preventScroll: true })
466
+ }
467
+
468
+ /** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
469
+ async showFastening() {
470
+ if (this.asset) await this.openFastening(this.asset)
471
+ }
472
+
473
+ private async openFastening(asset: FigureAsset) {
474
+ const own = fasteningOf(asset, this.partId)
475
+ this.fasten = own ? ({ ...freshFasten(), ...own } as ReturnType<typeof freshFasten>) : freshFasten()
476
+ this.spotlit = true
477
+ await this.updateComplete
478
+ const section = this.renderRoot.querySelector<HTMLElement>('section[fastening]')
479
+ section?.scrollIntoView({ block: 'start', behavior: 'smooth' })
480
+ /* Into the form itself, not the section's help button. */
481
+ ;(section?.querySelector<HTMLElement>('select, input') ?? section?.querySelector<HTMLElement>('button'))?.focus({ preventScroll: true })
482
+ setTimeout(() => (this.spotlit = false), 1600)
483
+ }
484
+
485
+ /**
486
+ * What holds the part, said as its state -- 「아랫면을 바닥에 유지 중」 -- with the ways to change it: open the fastening,
487
+ * or take it off. Right after "follow size, attached" this is the result, not a refusal (designer 2026-09-27).
488
+ */
489
+ private renderHeld(asset: FigureAsset) {
490
+ const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
491
+ const face = joint ? i18next.t(`figure.label.face-${joint.body1.face}`) : ''
492
+ const gap = typeof joint?.origin?.gap === 'number' ? joint.origin.gap : 0
493
+ const where = !joint
494
+ ? i18next.t('figure.text.held-by-rule')
495
+ : 'plane' in joint.body0
496
+ ? i18next.t('figure.text.held-on-plane', { face, place: i18next.t(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
497
+ : i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(`figure.label.face-${joint.body0.face}`) })
498
+ return html`<div resize-held>
499
+ <p><md-icon>link</md-icon> ${where}</p>
500
+ <button act open-fastening @click=${() => this.openFastening(asset)}>
501
+ ${i18next.t('figure.button.open-fastening')}
502
+ </button>
503
+ ${joint
504
+ ? html`<button act detach-held ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
505
+ ${i18next.t('figure.button.detach-held')}
506
+ </button>
507
+ ${this.renderDetachWords()}`
508
+ : nothing}
509
+ </div>`
289
510
  }
290
511
 
291
512
  /** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
@@ -347,14 +568,16 @@ export class FigureLinks extends localize(i18next)(LitElement) {
347
568
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES
348
569
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
349
570
  const aim = f.toPlane ? ('plane' as const) : f.target ? { part: f.target, face: targetFace } : undefined
350
- this.fasten = { ...f, ...(aim ? placeKeptOf(asset, this.partId, aim, !!f.twist) : { align: {}, alignMm: {} }) }
571
+ this.fasten = { ...f, ...(aim ? placeKeptOf(asset, this.partId, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) }
351
572
  }
352
573
 
353
574
  private renderFastening(asset: FigureAsset) {
354
575
  const held = heldBy(asset, this.partId)
355
576
  const f = this.fasten
356
577
  /* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
357
- const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one) && !hangsFrom(asset, one, this.partId))
578
+ const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
579
+ /* Shown, not dropped, and not choosable, with the reason (ADR-0097 §5.5): fastening it would close a loop. */
580
+ const loops = (one: string) => hangsFrom(asset, one, this.partId)
358
581
  /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
359
582
  /* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
360
583
  const plane = mountingPlaneOf(asset)
@@ -367,15 +590,66 @@ export class FigureLinks extends localize(i18next)(LitElement) {
367
590
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES
368
591
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
369
592
  const aim = f.toPlane ? ('plane' as const) : { part: f.target, face: targetFace }
370
- const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist) : undefined
593
+ const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist, f.alignInput) : undefined
594
+ const action =
595
+ f.toPlane || f.target
596
+ ? ({
597
+ kind: 'attach',
598
+ part: this.partId,
599
+ face: myFace,
600
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
601
+ ...(f.toPlane ? {} : { facing: f.facing }),
602
+ ...(f.gap ? { gap: f.gap } : {}),
603
+ twist: f.twist,
604
+ ...(align ? { align } : {}),
605
+ replace: !!held
606
+ } as FigureAuthoringAction)
607
+ : undefined
608
+ if (action) this.fastenCandidate = { action, ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } }) }
609
+ /* What the details hold that is not their default, and where on the face it sits: said while they are folded. */
610
+ const detail = [
611
+ ...(f.facing === 'flush' && !f.toPlane ? [i18next.t('figure.label.facing-flush')] : []),
612
+ ...Object.entries(align ?? {}).map(([axis, one]) =>
613
+ one === 'min' || one === 'max'
614
+ ? i18next.t(`figure.text.fasten-summary-edge-${one}`, { axis: axis.toUpperCase() })
615
+ : typeof one.mm === 'number'
616
+ ? i18next.t('figure.text.fasten-summary-off-centre', { axis: axis.toUpperCase(), mm: String(Math.round(one.mm * 10) / 10) })
617
+ : i18next.t('figure.text.fasten-summary-follows', { axis: axis.toUpperCase() })
618
+ ),
619
+ ...(f.gap ? [i18next.t('figure.text.fasten-summary-gap', { mm: String(f.gap) })] : []),
620
+ ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
621
+ ]
622
+ /*
623
+ Editing the fastening it has, or fastening it anew: said, with the relation named, so the form is not taken for a
624
+ blank new one while it holds the current (designer 2026-09-27). Applied unchanged, the same fastening is written
625
+ again, fields and all (resize-with-placement.test).
626
+ */
627
+ const current = asset.joints?.find(j => j.body1.part === this.partId)
628
+ const editing = current
629
+ ? i18next.t('figure.text.fasten-editing', {
630
+ face: i18next.t(`figure.label.face-${current.body1.face}`),
631
+ target: 'plane' in current.body0 ? i18next.t('figure.label.mounting-plane') : `${current.body0.part} · ${i18next.t(`figure.label.face-${current.body0.face}`)}`
632
+ })
633
+ : i18next.t('figure.text.fasten-new')
371
634
  return html`
635
+ <p fasten-mode ?editing=${!!current}>${editing}</p>
636
+ ${this.overhangNote.length ? html`<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
637
+ ${this.overhangWords.length
638
+ ? html`<div overhang role="status">
639
+ ${this.overhangWords.map(line => html`<p>${line}</p>`)}
640
+ ${current
641
+ ? html`<button act @click=${() => this.openDetail()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
642
+ : nothing}
643
+ </div>`
644
+ : nothing}
372
645
  ${held
373
646
  ? html`<div pair>
374
647
  <span>${i18next.t('figure.label.fastened-to')}: <b bound>${'plane' in held ? i18next.t('figure.label.mounting-plane') : held.part}</b></span>
375
648
  <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
376
649
  <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
377
650
  </button>
378
- </div>`
651
+ </div>
652
+ ${this.renderDetachWords()}`
379
653
  : nothing}
380
654
  <div form>
381
655
  <span>${i18next.t('figure.label.my-face')}</span>
@@ -393,46 +667,47 @@ export class FigureLinks extends localize(i18next)(LitElement) {
393
667
  <span>${i18next.t('figure.label.target-part')}</span>
394
668
  <select @change=${(e: Event) => this.aimAt(asset, { ...f, target: (e.target as HTMLSelectElement).value })}>
395
669
  <option value="" ?selected=${!f.target}>—</option>
396
- ${targets.map(one => html`<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
670
+ ${targets.map(one =>
671
+ loops(one)
672
+ ? html`<option value=${one} disabled>${i18next.t('figure.text.target-would-loop', { part: one, mine: this.partId })}</option>`
673
+ : html`<option value=${one} ?selected=${one === f.target}>${one}</option>`
674
+ )}
397
675
  </select>
398
676
  <span>${i18next.t('figure.label.target-face')}</span>
399
677
  <select @change=${(e: Event) => this.aimAt(asset, { ...f, targetFace: (e.target as HTMLSelectElement).value as FigureFace })}>
400
678
  ${theirFaces.map(face => html`<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
401
679
  </select>
402
- <span>${i18next.t('figure.label.facing-meet')}</span>
403
- <select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
404
- <option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
405
- <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
406
- </select>
407
680
  </div>`}
408
- ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
409
- <div form>
410
- <span>${i18next.t('figure.label.gap')}</span>
411
- <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
412
- <span>${i18next.t('figure.label.twist')}</span>
413
- <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => (this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 })} />
414
- </div>
415
- <button
416
- act
417
- ?disabled=${this.locked || (!f.toPlane && !f.target)}
418
- @click=${() =>
419
- this.command({
420
- kind: 'attach',
421
- part: this.partId,
422
- face: myFace,
423
- to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
424
- ...(f.toPlane ? {} : { facing: f.facing }),
425
- ...(f.gap ? { gap: f.gap } : {}),
426
- twist: f.twist,
427
- ...(align ? { align } : {}),
428
- replace: !!held
429
- } as FigureAuthoringAction)}
430
- >
431
- <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
681
+ <details fasten-detail>
682
+ <summary>${i18next.t('figure.label.fasten-detail')} · <span fasten-summary>${detail.length ? detail.join(' · ') : i18next.t('figure.text.fasten-summary-default')}</span></summary>
683
+ ${f.toPlane
684
+ ? nothing
685
+ : html`<div form>
686
+ <span>${i18next.t('figure.label.facing')}</span>
687
+ <select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
688
+ <option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
689
+ <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
690
+ </select>
691
+ </div>`}
692
+ ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
693
+ <div form>
694
+ <span>${i18next.t('figure.label.gap')}</span>
695
+ <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
696
+ <span>${i18next.t('figure.label.twist')}</span>
697
+ <input type="number" step="any" .value=${String(f.twist)} @change=${(e: Event) => (this.fasten = { ...f, twist: Number((e.target as HTMLInputElement).value) || 0 })} />
698
+ </div>
699
+ </details>
700
+ ${action && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
701
+ <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
702
+ <md-icon>link</md-icon>${i18next.t(current ? 'figure.button.fasten-update' : 'figure.button.fasten')}
432
703
  </button>
433
704
  `
434
705
  }
435
706
 
707
+ private renderDetachWords() {
708
+ return this.detachWords.length ? html`<ul detach-words>${this.detachWords.map(line => html`<li>${line}</li>`)}</ul>` : nothing
709
+ }
710
+
436
711
  /**
437
712
  * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
438
713
  * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
@@ -451,6 +726,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
451
726
  ? html`<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
452
727
  <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
453
728
  : nothing}
729
+ ${f.alignInput?.[axis]
730
+ ? html`<option value="input" ?selected=${how === 'input'}>${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) })}</option>`
731
+ : nothing}
454
732
  <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
455
733
  ${this.asset && hasInstanceSize(this.asset, axis)
456
734
  ? html`<option value="share" ?selected=${how === 'share'}>${i18next.t('figure.label.align-share')}</option>`
@@ -468,37 +746,59 @@ export class FigureLinks extends localize(i18next)(LitElement) {
468
746
  * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
469
747
  * (the editor's) shows it and takes it away.
470
748
  */
749
+ private renderPreviewWords() {
750
+ return this.previewWords.length ? html`<ul preview-words role="status">${this.previewWords.map(line => html`<li>${line}</li>`)}</ul>` : nothing
751
+ }
752
+
753
+ /**
754
+ * A row (ADR-0097 §5.5): said as what it makes, "arranged in a row". Where it cannot be made here, why, rather than
755
+ * nothing. Filling the length, the centre spacing is the author's; a count of their own is spread evenly over the
756
+ * length -- figure-model draws it so, and the form does not ask for a spacing it would not use.
757
+ */
471
758
  private renderRow(asset: FigureAsset) {
759
+ if (rowOf(asset, this.partId)) return nothing
760
+ const blocked = rowBlockOf(asset, this.partId)
472
761
  const axes = rowAxesOf(asset, this.partId)
473
- if (!axes.length) return nothing
474
762
  const r = this.row
763
+ if (blocked || !axes.length)
764
+ return html`<section row>
765
+ <h3>${i18next.t('figure.label.repeat')}</h3>
766
+ <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
767
+ ${blocked === 'unfastened'
768
+ ? html`<button act @click=${() => this.openFastening(asset)}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
769
+ : nothing}
770
+ </section>`
475
771
  const axis = axes.includes(r.axis as Axis) ? (r.axis as Axis) : axes[0]!
476
- const ok = r.pitch > 0 && (r.fills || (Number.isSafeInteger(r.count) && r.count >= 1))
477
- return html`<section>
772
+ const ok = r.fills ? r.pitch > 0 : Number.isSafeInteger(r.count) && r.count >= 1
773
+ const action = ok
774
+ ? ({ kind: 'make-repeat', part: this.partId, axis, pitch: r.fills ? r.pitch : Math.max(r.pitch, 1), ...(r.fills ? {} : { count: r.count }) } as FigureAuthoringAction)
775
+ : undefined
776
+ this.rowCandidate = action
777
+ const set = (next: typeof r) => ((this.row = next), (this.working = 'row'))
778
+ return html`<section row @focusin=${() => (this.working = 'row')}>
478
779
  <h3>${i18next.t('figure.label.repeat')}</h3>
479
780
  <div form>
480
781
  <span>${i18next.t('figure.label.repeat-axis')}</span>
481
- <select @change=${(e: Event) => (this.row = { ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
782
+ <select @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
482
783
  ${axes.map(a => html`<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
483
784
  </select>
484
- <span>${i18next.t('figure.label.repeat-pitch')} (mm)</span>
485
- <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => (this.row = { ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
486
785
  </div>
487
- <label toggle>
488
- <input type="checkbox" .checked=${r.fills} @change=${(e: Event) => (this.row = { ...r, fills: (e.target as HTMLInputElement).checked })} />
489
- ${i18next.t('figure.label.repeat-fills')}
490
- </label>
786
+ <div row-basis role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
787
+ <label><input type="radio" name="row-basis" .checked=${r.fills} @change=${() => set({ ...r, fills: true })} />${i18next.t('figure.label.repeat-fills')}</label>
788
+ <label><input type="radio" name="row-basis" .checked=${!r.fills} @change=${() => set({ ...r, fills: false })} />${i18next.t('figure.label.row-by-count')}</label>
789
+ </div>
491
790
  ${r.fills
492
- ? nothing
791
+ ? html`<div form>
792
+ <span>${i18next.t('figure.label.repeat-pitch')} · mm</span>
793
+ <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => set({ ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
794
+ </div>`
493
795
  : html`<div form>
494
- <span>${i18next.t('figure.label.repeat-count')}</span>
495
- <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => (this.row = { ...r, count: Number((e.target as HTMLInputElement).value) })} />
496
- </div>`}
497
- <button
498
- act
499
- ?disabled=${this.locked || !ok}
500
- @click=${() => this.command({ kind: 'make-repeat', part: this.partId, axis, pitch: r.pitch, ...(r.fills ? {} : { count: r.count }) } as FigureAuthoringAction)}
501
- >
796
+ <span>${i18next.t('figure.label.repeat-count')}</span>
797
+ <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => set({ ...r, count: Number((e.target as HTMLInputElement).value) })} />
798
+ </div>
799
+ <p hint>${i18next.t('figure.text.row-count-spreads')}</p>`}
800
+ ${action && this.working === 'row' ? this.renderPreviewWords() : nothing}
801
+ <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
502
802
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
503
803
  </button>
504
804
  </section>`
@@ -551,7 +851,21 @@ export class FigureLinks extends localize(i18next)(LitElement) {
551
851
  const now = motionOf(asset, this.partId)
552
852
  const joint = asset.joints?.find(j => j.body1.part === this.partId)
553
853
  if (now && !(this.editingMotion && joint)) {
554
- return html`<div pair>
854
+ /* Which part moves, against what, which way, how far -- by name (ADR-0097 §5.5). */
855
+ const against = joint ? ('plane' in joint.body0 ? i18next.t('figure.label.mounting-plane') : joint.body0.part) : ''
856
+ const ends = this.motionEnds
857
+ return html`<p motion-summary>
858
+ ${i18next.t('figure.text.motion-summary', {
859
+ part: this.partId,
860
+ against,
861
+ kind: i18next.t('figure.label.motion-' + now.kind),
862
+ axis: String(now.axis).toUpperCase()
863
+ })}
864
+ </p>
865
+ ${ends
866
+ ? html`<p hint motion-ends>${i18next.t('figure.text.motion-ends-shown', { min: String(ends.min), max: String(ends.max), unit: ends.unit })}</p>`
867
+ : nothing}
868
+ <div pair>
555
869
  <span>
556
870
  ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
557
871
  ${now.travel
@@ -566,7 +880,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
566
880
  <button
567
881
  act
568
882
  ?disabled=${this.locked || !joint}
569
- @click=${() => joint && this.command({ kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1, ...(joint.origin ? { origin: joint.origin } : {}) } })}
883
+ @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}
570
884
  >
571
885
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
572
886
  </button>
@@ -584,7 +898,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
584
898
  </div>`
585
899
  }
586
900
  /* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
587
- if (!joint) return html`<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>`
901
+ if (!joint)
902
+ return html`<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>
903
+ <button act go-fasten @click=${() => this.dispatchEvent(new CustomEvent('go-fasten', { bubbles: true, composed: true }))}>
904
+ <md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}
905
+ </button>`
588
906
  const m = this.motion
589
907
  const unit = m.kind === 'slide' ? 'mm' : 'deg'
590
908
  /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
@@ -667,4 +985,3 @@ export class FigureLinks extends localize(i18next)(LitElement) {
667
985
  this.dispatchEvent(new CustomEvent('command', { detail: { action }, bubbles: true, composed: true }))
668
986
  }
669
987
  }
670
-