@things-factory/figure-ui 10.1.271 → 10.1.273

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 (112) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/data-interface.ts +30 -18
  4. package/client/modeller/editor-styles.ts +17 -6
  5. package/client/modeller/editor.ts +2 -0
  6. package/client/modeller/fasten-form.ts +81 -6
  7. package/client/modeller/fasten-geometry.ts +5 -3
  8. package/client/modeller/figure-cavity-drawing.ts +1 -3
  9. package/client/modeller/figure-clips.ts +101 -8
  10. package/client/modeller/figure-data-panel.ts +384 -200
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +24 -14
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +109 -37
  15. package/client/modeller/figure-parts.ts +7 -11
  16. package/client/modeller/figure-preview.ts +299 -79
  17. package/client/modeller/figure-size-editor.ts +7 -2
  18. package/client/modeller/first-save.ts +8 -0
  19. package/client/modeller/focus-on-render.ts +4 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/size-response.ts +14 -0
  22. package/client/modeller/stage-model.ts +50 -6
  23. package/dist-client/modeller/canvas.d.ts +1 -0
  24. package/dist-client/modeller/canvas.js +3 -2
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +17 -3
  27. package/dist-client/modeller/clips.js +69 -4
  28. package/dist-client/modeller/clips.js.map +1 -1
  29. package/dist-client/modeller/data-interface.d.ts +11 -8
  30. package/dist-client/modeller/data-interface.js +27 -17
  31. package/dist-client/modeller/data-interface.js.map +1 -1
  32. package/dist-client/modeller/editor-styles.js +17 -6
  33. package/dist-client/modeller/editor-styles.js.map +1 -1
  34. package/dist-client/modeller/editor.js +2 -0
  35. package/dist-client/modeller/editor.js.map +1 -1
  36. package/dist-client/modeller/fasten-form.d.ts +13 -0
  37. package/dist-client/modeller/fasten-form.js +72 -7
  38. package/dist-client/modeller/fasten-form.js.map +1 -1
  39. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  40. package/dist-client/modeller/fasten-geometry.js +5 -3
  41. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  42. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  43. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  44. package/dist-client/modeller/figure-clips.d.ts +9 -0
  45. package/dist-client/modeller/figure-clips.js +104 -8
  46. package/dist-client/modeller/figure-clips.js.map +1 -1
  47. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  48. package/dist-client/modeller/figure-data-panel.js +382 -190
  49. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  50. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  51. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-drawing.js +24 -14
  53. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  54. package/dist-client/modeller/figure-first-save.js +4 -3
  55. package/dist-client/modeller/figure-first-save.js.map +1 -1
  56. package/dist-client/modeller/figure-links.d.ts +9 -0
  57. package/dist-client/modeller/figure-links.js +120 -51
  58. package/dist-client/modeller/figure-links.js.map +1 -1
  59. package/dist-client/modeller/figure-parts.js +7 -11
  60. package/dist-client/modeller/figure-parts.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.d.ts +13 -0
  62. package/dist-client/modeller/figure-preview.js +296 -80
  63. package/dist-client/modeller/figure-preview.js.map +1 -1
  64. package/dist-client/modeller/figure-size-editor.js +7 -3
  65. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  66. package/dist-client/modeller/first-save.d.ts +5 -0
  67. package/dist-client/modeller/first-save.js +7 -0
  68. package/dist-client/modeller/first-save.js.map +1 -1
  69. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  70. package/dist-client/modeller/focus-on-render.js +4 -0
  71. package/dist-client/modeller/focus-on-render.js.map +1 -0
  72. package/dist-client/modeller/pivot.d.ts +12 -0
  73. package/dist-client/modeller/pivot.js +59 -0
  74. package/dist-client/modeller/pivot.js.map +1 -0
  75. package/dist-client/modeller/size-response.d.ts +6 -0
  76. package/dist-client/modeller/size-response.js +14 -0
  77. package/dist-client/modeller/size-response.js.map +1 -1
  78. package/dist-client/modeller/stage-model.d.ts +17 -3
  79. package/dist-client/modeller/stage-model.js +44 -6
  80. package/dist-client/modeller/stage-model.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +8 -7
  83. package/test/browser/editing-page.js +1 -1
  84. package/test/browser/editing.check.mjs +6 -5
  85. package/test/browser/links.check.mjs +30 -0
  86. package/test/browser/modeller.check.mjs +2 -2
  87. package/test/browser/parts-page.js +4 -0
  88. package/test/browser/parts.check.mjs +16 -10
  89. package/test/browser/screen-page.js +12 -1
  90. package/test/browser/screen.check.mjs +142 -11
  91. package/test/browser/section.check.mjs +21 -0
  92. package/test/browser/serve-response.mjs +5 -2
  93. package/test/browser/side-panel-sweep.check.mjs +171 -10
  94. package/test/clips.test.ts +49 -4
  95. package/test/data-motion.test.ts +39 -8
  96. package/test/fasten-form.test.ts +69 -0
  97. package/test/fasten-geometry.test.ts +13 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/size-response.test.ts +10 -2
  101. package/test/stage-model.test.ts +23 -1
  102. package/translations/en.json +31 -11
  103. package/translations/es.json +30 -10
  104. package/translations/ja.json +30 -10
  105. package/translations/ko.json +32 -12
  106. package/translations/ms.json +30 -10
  107. package/translations/zh.json +30 -10
  108. package/tutorial/humanoid/README.md +22 -0
  109. package/tutorial/humanoid/chapters.mjs +247 -0
  110. package/tutorial/humanoid/encode.mjs +26 -0
  111. package/tutorial/humanoid/lib.mjs +197 -0
  112. package/tutorial/humanoid/record.mjs +65 -0
@@ -89,7 +89,28 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
89
89
  }
90
90
  details[fasten-detail] > div[form] {
91
91
  margin-top: 8px;
92
- max-width: 320px;
92
+ max-width: 420px;
93
+ }
94
+ /* Where across the face, as buttons: a list the system draws is not seen in a recording, nor reached by a key in every browser. */
95
+ div[align-choices] {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ gap: 4px;
99
+ }
100
+ div[align-choices] button {
101
+ font: inherit;
102
+ font-size: var(--ui-t-small, 11.5px);
103
+ padding: 3px 8px;
104
+ border-radius: 12px;
105
+ border: 1px solid var(--md-sys-color-outline-variant, #c4c7c5);
106
+ background: transparent;
107
+ color: inherit;
108
+ cursor: pointer;
109
+ }
110
+ div[align-choices] button[aria-pressed='true'] {
111
+ border-color: var(--md-sys-color-primary, #0d8f86);
112
+ background: var(--md-sys-color-primary-container, #cdeeea);
113
+ font-weight: 600;
93
114
  }
94
115
  span[fasten-summary] {
95
116
  opacity: 0.8;
@@ -253,7 +274,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
253
274
  <summary>
254
275
  ${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}
255
276
  </summary>
256
- ${plan.alignRows.length ? this.renderAlignment(plan.alignRows) : nothing}
277
+ ${(plan.alignAxes ?? plan.alignRows).length ? this.renderAlignment(plan.alignAxes ?? plan.alignRows) : nothing}
257
278
  <div form>
258
279
  <span>${i18next.t('figure.label.gap')}</span>
259
280
  <input
@@ -384,34 +405,24 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
384
405
  : 'centre'
385
406
  : (f.align[axis] ?? 'centre')
386
407
  return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
387
- <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
388
- <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
389
- ${
390
- edges
391
- ? html`<option value="min" ?selected=${how === 'min'}>
392
- ${i18next.t(`figure.label.align-${axis}-min`)}
393
- </option>
394
- <option value="max" ?selected=${how === 'max'}>
395
- ${i18next.t(`figure.label.align-${axis}-max`)}
396
- </option>`
397
- : nothing
398
- }
399
- ${
400
- f.alignInput?.[axis]
401
- ? html`<option value="input" ?selected=${how === 'input'}>
402
- ${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) })}
403
- </option>`
404
- : nothing
405
- }
406
- <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
407
- ${
408
- this.revision && hasInstanceSize(this.revision.asset, axis)
409
- ? html`<option value="share" ?selected=${how === 'share'}>
410
- ${i18next.t('figure.label.align-share')}
411
- </option>`
412
- : nothing
413
- }
414
- </select>
408
+ <div align-choices role="group" aria-label=${i18next.t('figure.label.align-' + axis)}>
409
+ ${[
410
+ { value: 'centre' as Alignment, words: i18next.t('figure.label.align-centre'), shown: true },
411
+ { value: 'min' as Alignment, words: i18next.t(`figure.label.align-${axis}-min`), shown: edges },
412
+ { value: 'max' as Alignment, words: i18next.t(`figure.label.align-${axis}-max`), shown: edges },
413
+ {
414
+ value: 'input' as Alignment,
415
+ words: f.alignInput?.[axis] ? i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) }) : '',
416
+ shown: !!f.alignInput?.[axis]
417
+ },
418
+ { value: 'mm' as Alignment, words: i18next.t('figure.label.align-mm'), shown: true },
419
+ { value: 'share' as Alignment, words: i18next.t('figure.label.align-share'), shown: !!this.revision && hasInstanceSize(this.revision.asset, axis) }
420
+ ]
421
+ .filter(choice => choice.shown)
422
+ .map(
423
+ choice => html`<button type="button" aria-pressed=${String(how === choice.value)} @click=${() => set(axis, choice.value)}>${choice.words}</button>`
424
+ )}
425
+ </div>
415
426
  ${
416
427
  how === 'mm' || how === 'share'
417
428
  ? html`<span></span
@@ -6,6 +6,7 @@ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-
6
6
  import { figureAroundNormalOf, figureAroundOf, figureCaughtAngleOf, figureSurfaceAnglesOf, figureSurfacePlaceOf } from '@hatiolab/figure-model'
7
7
  import type { FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
8
8
 
9
+ import { focusOnRender } from './focus-on-render.js'
9
10
  import { alignmentAt, drawingSpanOf, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
10
11
  import type { Alignment } from './links.js'
11
12
 
@@ -72,22 +73,28 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
72
73
  svg text {
73
74
  font: inherit;
74
75
  }
76
+ /*
77
+ The ways on one line, a small side view beside each word: three cards of a drawing over a word made the window
78
+ tall, and the fasten button below went off a laptop's screen (user 2026-11-12). The chosen one's gap is dragged
79
+ on its small view still; the gap's field is in the details.
80
+ */
75
81
  div[modes] {
76
- display: grid;
77
- grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
78
- gap: 6px;
82
+ display: flex;
83
+ flex-wrap: wrap;
84
+ gap: 4px;
79
85
  }
80
86
  div[modes] svg {
81
- width: 48px;
82
- height: 33px;
87
+ flex: none;
88
+ width: 28px;
89
+ height: 20px;
83
90
  }
84
91
  /* A choice drawn, not a box to press (the side's rule: no boxed buttons): the chosen one on the primary's ground. */
85
92
  div[modes] button {
86
- display: grid;
87
- justify-items: center;
88
- align-content: center;
89
- gap: 2px;
90
- padding: 4px 4px 6px;
93
+ display: inline-flex;
94
+ align-items: center;
95
+ gap: 4px;
96
+ padding: 2px 8px 2px 4px;
97
+ white-space: nowrap;
91
98
  font: inherit;
92
99
  color: var(--md-sys-color-on-surface);
93
100
  background: none;
@@ -122,8 +129,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
122
129
  border: 1px solid var(--md-sys-color-primary);
123
130
  }
124
131
  div[modes] button[on] {
125
- background: var(--md-sys-color-primary-container);
126
- color: var(--md-sys-color-on-primary-container);
132
+ background: var(--md-sys-color-primary-container, #d8e2ff);
133
+ color: var(--md-sys-color-on-primary-container, #001a41);
127
134
  }
128
135
  /* The box and the face seen from above side by side, one under the other where the window is narrow. */
129
136
  div[face-row] {
@@ -332,7 +339,9 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
332
339
  const { h, v, sign } = screenOf(view.axes)
333
340
  const W = WHERE_W,
334
341
  H = WHERE_H
335
- const span = (a: Axis) => drawingSpanOf(view.face[a], view.part[a])
342
+ /* Where the part is set, not where it is being dragged: the drawing's scale holds still under the pointer. */
343
+ const set = (a: Axis) => offsetOf(this.align[a], this.alignMm[a], view.face[a], view.part[a])
344
+ const span = (a: Axis) => drawingSpanOf(view.face[a], view.part[a], set(a))
336
345
  const k = Math.min((W - 40) / span(h), (H - 40) / span(v))
337
346
  this.scale = k
338
347
  const cx = W / 2,
@@ -432,7 +441,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
432
441
  this.change({ axis: a, how: 'mm', mm: Math.round(value) })
433
442
  this.released()
434
443
  }}
435
- @blur=${() => (this.typing = undefined)} />`
444
+ @blur=${() => (this.typing = undefined)}
445
+ ${focusOnRender()} />`
436
446
  }
437
447
 
438
448
  private grab(ev: PointerEvent, at: Record<Axis, number>, caught: Partial<Record<Axis, string>>) {
@@ -7,7 +7,7 @@ import { CommonPopupStyles, FormFieldStyles } from '@operato/styles'
7
7
  import '@material/web/button/filled-button.js'
8
8
  import '@material/web/button/text-button.js'
9
9
 
10
- import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, type FirstSave } from './first-save.js'
10
+ import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, typingTypeOf, type FirstSave } from './first-save.js'
11
11
 
12
12
  /**
13
13
  * The first save of a new figure: its type and its name, asked once, in the suite's popup look.
@@ -97,13 +97,13 @@ export class FigureFirstSave extends LitElement {
97
97
  .value=${this.type}
98
98
  placeholder=${i18next.t('figure.text.figure-type-example')}
99
99
  @input=${(e: Event) => {
100
- this.type = typeOf((e.target as HTMLInputElement).value)
100
+ this.type = typingTypeOf((e.target as HTMLInputElement).value)
101
101
  ;(e.target as HTMLInputElement).value = this.type
102
102
  this.refusal = null
103
103
  }}
104
104
  @keydown=${this.onKey}
105
105
  />
106
- ${this.type && !releasableType(this.type)
106
+ ${typeOf(this.type) && !releasableType(typeOf(this.type))
107
107
  ? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>`
108
108
  : this.suggested && this.type === this.suggested
109
109
  ? html`<div class="note">${i18next.t('figure.text.identifier-suggested')}</div>`
@@ -132,6 +132,7 @@ export class FigureFirstSave extends LitElement {
132
132
  }
133
133
 
134
134
  private confirm = () => {
135
+ this.type = typeOf(this.type)
135
136
  this.refusal = firstSaveRefusalOf(this.type, this.taken)
136
137
  if (this.refusal) return
137
138
  const namespace = this.namespaces.length > 1 ? this.chosenNamespace : undefined
@@ -50,7 +50,7 @@ import {
50
50
  stateSizeFixedOf,
51
51
  type StateSizeForm
52
52
  } from './links.js'
53
- import { measureFactor } from './size-response.js'
53
+ import { measureFactor, sizeAxisWord } from './size-response.js'
54
54
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
55
55
  import { insideAlignmentOf } from './fasten-geometry.js'
56
56
  import { fastenFormOf, fastenPlanOf, fastenSentenceOf } from './fasten-form.js'
@@ -58,9 +58,22 @@ import type { FigureRevision } from './revision.js'
58
58
  import { HelpToggles } from './help.js'
59
59
  import { groupMenu, GroupMenuStyles } from './group-menu.js'
60
60
  import { jointAxisWord } from './joint-axis.js'
61
+ import { PIVOT_POINTS, pivotPointOf, pivotPointsOf, type PivotPoint } from './pivot.js'
61
62
  import { placeTerms } from '@things-factory/figure-viewer/dist-client/detail-facts.js'
62
63
 
63
64
  const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 })
65
+
66
+ /**
67
+ * The motion a part's form starts from: a part hung on another part most often turns there -- a thigh at the hip, a door
68
+ * at its frame -- so it starts as a turn; a part on the plane, or on nothing, as a slide (review 2026-11-11: eight
69
+ * joints of a humanoid, each changed from slide to turn by hand).
70
+ */
71
+ export function motionStartOf(revision: FigureRevision | undefined, part: string): MotionForm {
72
+ const own = revision ? fasteningOf(revision, part) : undefined
73
+ return own && !own.toPlane && own.target ? { ...freshMotion(), kind: 'turn', min: -90, max: 90 } : freshMotion()
74
+ }
75
+
76
+ const sameMotion = (a: MotionForm, b: MotionForm) => JSON.stringify(a) === JSON.stringify(b)
64
77
  const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
65
78
  const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as FigureFace, label: '', accepts: '', capacity: 1 })
66
79
 
@@ -82,9 +95,31 @@ const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as Figu
82
95
  export class FigureLinks extends localize(i18next)(LitElement) {
83
96
  private readonly help = new HelpToggles(this)
84
97
  static styles = [SidePanelStyles, GroupMenuStyles, css`
98
+ /* A motion's delete and edit as icons at the line's end, their words in their titles: two word buttons took the line (user 2026-11-12). */
99
+ div[pair] > button[act][icon] {
100
+ flex: none;
101
+ width: var(--figure-control-height, 28px);
102
+ padding: 0;
103
+ justify-content: center;
104
+ }
105
+ /* The size-by-state form folded under its summary, opened to add one. */
106
+ details[state-size-add][open] > summary {
107
+ margin-bottom: 6px;
108
+ }
85
109
  :host {
86
110
  display: block;
87
111
  }
112
+ /* The turn's axis through the part's top, middle or bottom, by name, under the numbers it fills in. */
113
+ div[pivot-points] {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 8px;
117
+ margin: 4px 0 8px;
118
+ }
119
+ div[pivot-points] > span {
120
+ color: var(--md-sys-color-on-surface-variant);
121
+ font-size: var(--ui-t-small, 11.5px);
122
+ }
88
123
  /* A group's sub-question, under its title: quieter than the title, as a label is. */
89
124
  p[group-question] {
90
125
  margin: 4px 0 0;
@@ -216,7 +251,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
216
251
  if (changed.has('asset') || changed.has('partId')) this.fixNote = ''
217
252
  if (this.asset && this.partId && this.partId !== this.loadedFor) {
218
253
  this.loadedFor = this.partId
219
- this.motion = freshMotion()
254
+ this.motion = motionStartOf(this.revision, this.partId)
220
255
  this.place = freshPlace()
221
256
  this.placeEditing = null
222
257
  this.row = freshRow()
@@ -224,6 +259,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
224
259
  this.rowCandidate = undefined
225
260
  this.editingMotion = false
226
261
  this.motionAxis = 0
262
+ } else if (changed.has('revision') && this.partId && !this.editingMotion) {
263
+ /* Fastened since the form was loaded, a form nobody has touched starts as that part's motion now does. */
264
+ const was = motionStartOf(changed.get('revision') as FigureRevision | undefined, this.partId)
265
+ if (sameMotion(this.motion, was)) this.motion = motionStartOf(this.revision, this.partId)
227
266
  }
228
267
  }
229
268
 
@@ -624,7 +663,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
624
663
  const part = this.partId
625
664
  const axes = followableAxes(asset, part)
626
665
  if (!axes.length) return nothing
627
- const word = (axis: Axis) => `${axis.toUpperCase()}${measureFactor(asset, part, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
666
+ const word = (axis: Axis) => sizeAxisWord(asset, part, axis, i18next.t('figure.label.size-diameter'))
628
667
  const sized = axes.flatMap(axis => {
629
668
  const f = followOf(asset, part, axis)
630
669
  return f.of === 'state' ? [{ axis, f }] : []
@@ -645,7 +684,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
645
684
  : { ...form, id: value }
646
685
  }
647
686
  const number = (key: 'min' | 'max' | 'atMin' | 'atMax') => (e: Event) => (this.stateSize = { ...form, axis, [key]: (e.target as HTMLInputElement).valueAsNumber })
648
- return html`<h4 state-size>${i18next.t('figure.label.state-size')}</h4>
687
+ return html`${sized.length ? html`<h4 state-size>${i18next.t('figure.label.state-size')}</h4>` : nothing}
649
688
  ${sized.map(({ axis: one, f }) => {
650
689
  const k = measureFactor(asset, part, one)
651
690
  const input = controls.find(c => c.id === f.input)
@@ -667,7 +706,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
667
706
  </div>`
668
707
  })}
669
708
  ${free.length
670
- ? html`<div form state-size-form>
709
+ ? /* Folded until asked for: six lines of a form few parts use pushed the controls below it down (user 2026-11-12). */
710
+ html`<details state-size-add>
711
+ <summary>${i18next.t('figure.button.add-state-size')}</summary>
712
+ <div form state-size-form>
671
713
  <span>${i18next.t('figure.label.state-size-measure')}</span>
672
714
  <select ?disabled=${this.locked} @change=${(e: Event) => (this.stateSize = { ...form, axis: (e.target as HTMLSelectElement).value as Axis, atMax: NaN })}>
673
715
  ${free.map(one => html`<option value=${one} ?selected=${one === axis}>${word(one)}</option>`)}
@@ -696,7 +738,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
696
738
  if (!command) return
697
739
  this.command(command)
698
740
  this.stateSize = freshStateSize()
699
- }}><md-icon>open_in_full</md-icon>${i18next.t('figure.button.add-state-size')}</button>`
741
+ }}><md-icon>add</md-icon>${i18next.t('figure.button.add')}</button>
742
+ </details>`
700
743
  : nothing}`
701
744
  }
702
745
 
@@ -760,7 +803,33 @@ export class FigureLinks extends localize(i18next)(LitElement) {
760
803
  </div>`
761
804
  )}
762
805
  </div>
763
- </div>`
806
+ </div>
807
+ ${this.renderPivotPoints(m)}`
808
+ }
809
+
810
+ /*
811
+ The axis put through the part's top end, middle or bottom end by name: the numbers above are measured from the
812
+ joint's origin, which a person cannot see (tutorial run 2026-11-12: a shoulder given Y 120 "to reach the shoulder"
813
+ turned about a point above it). The button that matches what is typed stands pressed.
814
+ */
815
+ private pivotCache?: { key: string; points: ReturnType<typeof pivotPointsOf> }
816
+ private renderPivotPoints(m: MotionForm) {
817
+ if (!this.revision || m.throughFollows) return nothing
818
+ const key = `${this.revision.revision}:${this.partId}:${m.axis}`
819
+ if (this.pivotCache?.key !== key) this.pivotCache = { key, points: pivotPointsOf(this.revision, this.partId, m.axis as Axis) }
820
+ const points = this.pivotCache.points
821
+ if (!points) return nothing
822
+ return html`<div pivot-points>
823
+ <span>${i18next.t('figure.label.pivot-at')}</span>
824
+ <ox-input-select-buttons
825
+ .value=${pivotPointOf(points, m.through, m.axis as Axis) ?? ''}
826
+ .options=${PIVOT_POINTS.map(p => ({ value: p, display: i18next.t(`figure.label.pivot-${p}`) }))}
827
+ @change=${(e: CustomEvent) => {
828
+ const at = points[e.detail as PivotPoint]
829
+ if (at) this.motion = { ...m, through: { ...at } }
830
+ }}
831
+ ></ox-input-select-buttons>
832
+ </div>`
764
833
  }
765
834
 
766
835
  private renderMotion(asset: FigureAsset) {
@@ -796,14 +865,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
796
865
  : nothing}
797
866
  </span>
798
867
  ${count > 1
799
- ? html`<button act remove-axis ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: withoutAxis(joint, k) })}>
800
- <md-icon>close</md-icon>${i18next.t('figure.button.remove-axis')}
868
+ ? html`<button act icon remove-axis title=${i18next.t('figure.button.remove-axis')} aria-label=${i18next.t('figure.button.remove-axis')} ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: withoutAxis(joint, k) })}>
869
+ <md-icon>delete</md-icon>
801
870
  </button>`
802
- : html`<button act ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}>
803
- <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
871
+ : html`<button act icon title=${i18next.t('figure.button.remove-motion')} aria-label=${i18next.t('figure.button.remove-motion')} ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}>
872
+ <md-icon>delete</md-icon>
804
873
  </button>`}
805
- <button act ?disabled=${this.locked || !joint} @click=${() => edit(k)}>
806
- <md-icon>edit</md-icon>${i18next.t('figure.button.change-motion')}
874
+ <button act icon title=${i18next.t('figure.button.change-motion')} aria-label=${i18next.t('figure.button.change-motion')} ?disabled=${this.locked || !joint} @click=${() => edit(k)}>
875
+ <md-icon>edit</md-icon>
807
876
  </button>
808
877
  </div>`
809
878
  }
@@ -844,6 +913,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
844
913
  const adding = k > 0 && k >= figureMotionsOf(joint).length
845
914
  const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
846
915
  const refusal = motionFormRefusalOf(m, follow)
916
+ /* Following another joint is offered after the part's own control: a row above it moved every field down a row, and
917
+ a name typed there chose a joint to follow (review 2026-11-11). */
847
918
  return html`
848
919
  ${k > 0 ? html`<p motion-axis-heading><b>${i18next.t('figure.label.motion-axis-n', { n: k + 1 })}</b></p>` : nothing}
849
920
  <div pair>
@@ -858,31 +929,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
858
929
  ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
859
930
  </select>`}
860
931
  </div>
861
- ${m.followSum
862
- ? html`<div form>
863
- <span>${i18next.t('figure.label.motion-follow')}</span>
864
- <span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
865
- <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
866
- <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
867
- </div>`
868
- : leaders.length
869
- ? html`<div form>
870
- <span>${i18next.t('figure.label.motion-follow')}</span>
871
- <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
872
- <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
873
- ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
874
- </select>
875
- </div>`
876
- : nothing}
877
- ${m.followSum
932
+ ${m.followSum || follow
878
933
  ? nothing
879
- : follow
880
- ? html`<div form>
881
- <span>${i18next.t('figure.label.motion-multiplier')}</span>
882
- <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
883
- <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
884
- <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
885
- </div>`
886
934
  : html`<div form>
887
935
  ${m.kind === 'slide'
888
936
  ? html`<span>${i18next.t('figure.label.motion-travel')}</span>
@@ -910,6 +958,30 @@ export class FigureLinks extends localize(i18next)(LitElement) {
910
958
  <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
911
959
  </div>`}
912
960
  </div>`}
961
+ ${m.followSum
962
+ ? html`<div form>
963
+ <span>${i18next.t('figure.label.motion-follow')}</span>
964
+ <span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
965
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
966
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
967
+ </div>`
968
+ : leaders.length
969
+ ? html`<div form>
970
+ <span>${i18next.t('figure.label.motion-follow')}</span>
971
+ <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
972
+ <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
973
+ ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
974
+ </select>
975
+ </div>`
976
+ : nothing}
977
+ ${!m.followSum && follow
978
+ ? html`<div form>
979
+ <span>${i18next.t('figure.label.motion-multiplier')}</span>
980
+ <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
981
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
982
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
983
+ </div>`
984
+ : nothing}
913
985
  ${m.kind === 'slide' ? nothing : this.renderThrough(m)}
914
986
  <button
915
987
  act
@@ -444,9 +444,10 @@ export class FigureParts extends localize(i18next)(LitElement) {
444
444
  li[selected] div[ops] {
445
445
  display: flex;
446
446
  }
447
+ /* As tall as the eye beside the name: a chosen row is no taller than the others. */
447
448
  div[ops] button {
448
449
  display: flex;
449
- padding: 2px;
450
+ padding: 1px;
450
451
  color: inherit;
451
452
  background: none;
452
453
  border: none;
@@ -465,17 +466,12 @@ export class FigureParts extends localize(i18next)(LitElement) {
465
466
  color: inherit;
466
467
  }
467
468
  /*
468
- Under 200 px the selected row's four buttons pushed its name out and the last one past the edge (seen at 140,
469
- 2026-09-24). They go to a line of their own under the name.
469
+ The chosen row keeps the height of the others, however narrow the list: its buttons stay beside the name, which is
470
+ cut short (its whole name is in the title and the panel's head). Going to a line of their own under 200 px
471
+ (2026-09-24), they pushed every row under it down as a part was chosen (user 2026-11-12).
470
472
  */
471
- @container (max-width: 199px) {
472
- li[selected] {
473
- flex-wrap: wrap;
474
- }
475
- li[selected] div[ops] {
476
- flex-basis: 100%;
477
- justify-content: flex-end;
478
- }
473
+ div[ops] {
474
+ flex: none;
479
475
  }
480
476
  p[quiet] {
481
477
  margin: 0;