@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
@@ -1,8 +1,10 @@
1
+ import '@material/web/icon/icon.js'
1
2
  import { css, html, LitElement, nothing, unsafeCSS } from 'lit'
2
3
  import { customElement, property, query, state } from 'lit/decorators.js'
3
4
 
4
5
  import { i18next, localize } from '@operato/i18n'
5
6
  import { unitWord } from './unit-words.js'
7
+ import { scrubLabel } from './live.js'
6
8
  import * as THREE from 'three'
7
9
  import { compileFigureAsset } from '@hatiolab/figure-model'
8
10
  import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput } from '@hatiolab/figure-model'
@@ -16,6 +18,7 @@ import { dataValueAt, domainOf, motionWrittenByData, sampledStatesOf, typedValue
16
18
  import { derivedOf, type FigureRevision } from './revision.js'
17
19
  import { authoredSizeOf, bodyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
18
20
  import { previewSizeWordsOf } from './preview-size.js'
21
+ import { playedControlsOf } from './clips.js'
19
22
 
20
23
  /**
21
24
  * The figure placed on a board.
@@ -95,21 +98,43 @@ export class FigurePreview extends localize(i18next)(LitElement) {
95
98
  background-color: rgba(28, 28, 30, 0.72);
96
99
  border-radius: 4px;
97
100
  }
98
- div[foot] {
101
+ /*
102
+ The legend, the size sliders and the controls in one column over the stage: the controls take what is left under
103
+ the two above and scroll inside it. A tall robot's rows in a short preview ran off the stage's top, the keep and
104
+ fold buttons with them, and nothing scrolled (2026-11-12); a legend on two lines in a narrow preview is room taken too.
105
+ */
106
+ div[over] {
99
107
  position: absolute;
100
- left: 8px;
101
- right: 8px;
102
- bottom: 8px;
108
+ inset: 8px;
109
+ display: flex;
110
+ flex-direction: column;
111
+ align-items: flex-start;
112
+ gap: 8px;
113
+ pointer-events: none;
114
+ }
115
+ div[over] > div[legend],
116
+ div[over] > div[sizes] {
117
+ pointer-events: auto;
118
+ }
119
+ div[foot] {
120
+ align-self: stretch;
121
+ flex: 1;
122
+ min-height: 0;
103
123
  display: flex;
104
124
  flex-wrap: wrap-reverse;
105
125
  justify-content: space-between;
106
126
  align-items: flex-end;
127
+ align-content: flex-start;
107
128
  gap: 8px;
108
129
  pointer-events: none;
109
130
  }
110
131
  div[foot] > * { pointer-events: auto; }
111
- div[setup] { max-width: min(320px, 100%); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
112
- div[setup] p { margin: 0; line-height: 1.5; }
132
+ /* One line over the figure -- the part and the button -- its words in its title: three lines covered the figure (user 2026-11-12). */
133
+ div[setup] { max-width: min(320px, 100%); box-sizing: border-box; padding: 6px 8px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: flex; align-items: center; gap: 6px; font-size: var(--ui-t-micro, 11px); }
134
+ div[setup] select { flex: 1 1 auto; }
135
+ div[setup] button { flex: none; white-space: nowrap; }
136
+ /* The controls' last row: as wide as they are, a line over it in place of a box of its own. */
137
+ div[clips] div[setup] { max-width: none; padding-top: 8px; border-top: 1px solid rgba(255, 255, 255, 0.14); border-radius: 0; color: inherit; }
113
138
  /* The help's choice and button in the look of the controls beside it, not the browser's grey (sweep 2026-10-27 U7). */
114
139
  div[setup] select,
115
140
  div[setup] button {
@@ -132,9 +157,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
132
157
  }
133
158
  /* Which is which: side by side means nothing without a legend. */
134
159
  div[legend] {
135
- position: absolute;
136
- top: 8px;
137
- left: 8px;
160
+ max-width: 100%;
161
+ box-sizing: border-box;
138
162
  display: flex;
139
163
  gap: 6px;
140
164
  padding: 4px 8px;
@@ -179,13 +203,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
179
203
  }
180
204
  /* The one figure's size: under the legend, in the controls' look. */
181
205
  div[sizes] {
182
- position: absolute;
183
- top: 40px;
184
- left: 8px;
185
206
  display: grid;
186
207
  gap: 6px;
187
208
  padding: 8px 10px;
188
- max-width: calc(100% - 16px);
209
+ max-width: 100%;
189
210
  box-sizing: border-box;
190
211
  color: rgba(255, 255, 255, 0.92);
191
212
  background-color: rgba(28, 28, 30, 0.72);
@@ -206,8 +227,12 @@ export class FigurePreview extends localize(i18next)(LitElement) {
206
227
  font-variant-numeric: tabular-nums;
207
228
  text-align: right;
208
229
  }
209
- div[sizes] p[said] {
210
- margin: 0;
230
+ /* The size said at the legend's end, on its line: a box of its own under the legend took a second line (user 2026-11-12). */
231
+ div[legend] span[said] {
232
+ padding-left: 6px;
233
+ border-left: 1px solid rgba(255, 255, 255, 0.22);
234
+ align-self: center;
235
+ white-space: nowrap;
211
236
  font-variant-numeric: tabular-nums;
212
237
  font-weight: 600;
213
238
  }
@@ -222,8 +247,12 @@ export class FigurePreview extends localize(i18next)(LitElement) {
222
247
  */
223
248
  div[clips] {
224
249
  margin-left: auto;
250
+ max-height: 100%;
251
+ box-sizing: border-box;
252
+ overflow-y: auto;
253
+ overscroll-behavior: contain;
225
254
  display: grid;
226
- grid-template-columns: auto 20px 96px 44px;
255
+ grid-template-columns: max-content 20px 96px 44px;
227
256
  gap: 6px 8px;
228
257
  align-items: center;
229
258
  padding: 8px 10px;
@@ -240,6 +269,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
240
269
  grid-template-columns: subgrid;
241
270
  align-items: center;
242
271
  }
272
+ /* A name and its unit on one line: 「position.x」 put its 「mm」 on a second line with room beside it (user 2026-11-12). */
273
+ div[clips] label > span:first-child {
274
+ white-space: nowrap;
275
+ }
243
276
  /* A motion group's clip: as wide as the dial, slider and value together, so its name reads. */
244
277
  div[clips] select {
245
278
  grid-column: 2 / -1;
@@ -250,6 +283,28 @@ export class FigurePreview extends localize(i18next)(LitElement) {
250
283
  border: 1px solid rgba(255, 255, 255, 0.22);
251
284
  border-radius: 5px;
252
285
  }
286
+ /* A motion group's clips as buttons: a list the system draws is not seen in a recording of the preview. */
287
+ div[clips] span[clip-choices] {
288
+ grid-column: 2 / -1;
289
+ display: flex;
290
+ flex-wrap: wrap;
291
+ gap: 4px;
292
+ min-width: 0;
293
+ }
294
+ div[clips] span[clip-choices] button {
295
+ font: inherit;
296
+ color: inherit;
297
+ padding: 1px 8px;
298
+ background-color: rgba(255, 255, 255, 0.08);
299
+ border: 1px solid rgba(255, 255, 255, 0.22);
300
+ border-radius: 10px;
301
+ cursor: pointer;
302
+ }
303
+ div[clips] span[clip-choices] button[aria-pressed='true'] {
304
+ background-color: rgba(255, 255, 255, 0.3);
305
+ border-color: rgba(255, 255, 255, 0.6);
306
+ font-weight: 600;
307
+ }
253
308
  div[clips] svg[dial] {
254
309
  width: 20px;
255
310
  height: 20px;
@@ -280,9 +335,65 @@ export class FigurePreview extends localize(i18next)(LitElement) {
280
335
  div[clips] button[fold-controls]:hover {
281
336
  opacity: 1;
282
337
  }
283
- div[clips] button[pose] {
284
- align-self: stretch;
285
- padding: 4px 8px;
338
+ /* The keep, clear and fold line, and the size help's, stay in sight while the rows between them scroll. */
339
+ div[clips] div[actions],
340
+ div[clips] div[setup] {
341
+ position: sticky;
342
+ z-index: 1;
343
+ margin-left: -10px;
344
+ margin-right: -10px;
345
+ padding-left: 10px;
346
+ padding-right: 10px;
347
+ background-color: rgba(28, 28, 30, 0.96);
348
+ }
349
+ div[clips] div[actions] {
350
+ top: -8px;
351
+ margin-top: -8px;
352
+ padding-top: 8px;
353
+ padding-bottom: 4px;
354
+ }
355
+ div[clips] div[setup] {
356
+ bottom: -8px;
357
+ margin-bottom: -8px;
358
+ padding-bottom: 8px;
359
+ }
360
+ div[clips] div[actions] {
361
+ display: flex;
362
+ align-items: center;
363
+ gap: 6px;
364
+ min-width: 0;
365
+ }
366
+ /* Folded, the box is as wide as its button: the icon and the words that say it opens the controls. */
367
+ div[clips][folded] {
368
+ display: flex;
369
+ flex-direction: column;
370
+ align-items: stretch;
371
+ gap: 6px;
372
+ }
373
+ div[clips][folded] button[fold-controls] {
374
+ gap: 4px;
375
+ padding: 2px 4px;
376
+ font-size: var(--ui-t-micro, 11px);
377
+ }
378
+ /* An icon as tall as the two buttons beside it, at the line's right end. */
379
+ div[clips] button[fold-controls] {
380
+ display: inline-flex;
381
+ align-items: center;
382
+ }
383
+ div[clips] button[fold-controls] md-icon {
384
+ --md-icon-size: 16px;
385
+ }
386
+ div[clips] div[actions] button[fold-controls] {
387
+ margin-left: auto;
388
+ padding: 2px 3px;
389
+ border: 1px solid transparent;
390
+ }
391
+ /* On one line with the fold, each word on one line: a label broken inside a word ("기본으 / 로") read badly (user 2026-11-12). */
392
+ div[clips] button[pose],
393
+ div[clips] button[clear-tried] {
394
+ flex: none;
395
+ white-space: nowrap;
396
+ padding: 3px 8px;
286
397
  font: inherit;
287
398
  font-size: var(--ui-t-micro, 11px);
288
399
  color: rgba(255, 255, 255, 0.92);
@@ -291,7 +402,13 @@ export class FigurePreview extends localize(i18next)(LitElement) {
291
402
  border-radius: 5px;
292
403
  cursor: pointer;
293
404
  }
294
- div[clips] button[pose]:hover {
405
+ div[clips] button[pose]:disabled,
406
+ div[clips] button[clear-tried]:disabled {
407
+ opacity: 0.4;
408
+ cursor: default;
409
+ }
410
+ div[clips] button[pose]:not(:disabled):hover,
411
+ div[clips] button[clear-tried]:not(:disabled):hover {
295
412
  background-color: rgba(255, 255, 255, 0.24);
296
413
  }
297
414
  div[clips] span[drive] {
@@ -303,7 +420,20 @@ export class FigurePreview extends localize(i18next)(LitElement) {
303
420
  accent-color: var(--md-sys-color-primary);
304
421
  }
305
422
  /* A value typed, not slid: in the look of the group's clip choice, starting where the sliders start. */
306
- div[clips] input:not([type='range']) {
423
+ /* A name that scrubs its value, in the look the side panel gives it (figure-viewer side-panel-styles \`[scrub]\`). */
424
+ div[clips] label > span[scrub] {
425
+ cursor: ew-resize;
426
+ user-select: none;
427
+ touch-action: none;
428
+ }
429
+ /* A check box in the dial's column, where every field starts. */
430
+ div[clips] input[type='checkbox'] {
431
+ grid-column: 2 / -1;
432
+ justify-self: start;
433
+ width: auto;
434
+ margin: 0;
435
+ }
436
+ div[clips] input:not([type='range']):not([type='checkbox']) {
307
437
  box-sizing: border-box;
308
438
  min-width: 0;
309
439
  font: inherit;
@@ -312,9 +442,15 @@ export class FigurePreview extends localize(i18next)(LitElement) {
312
442
  border: 1px solid rgba(255, 255, 255, 0.22);
313
443
  border-radius: 5px;
314
444
  }
315
- div[clips] input:not([type='range']):not([type='number']) {
316
- grid-column: 3 / -1;
445
+ /*
446
+ Three widths only: a slider, its value's box, and a field that is the whole row's -- a choice, a text, a number with
447
+ no range -- from where the dial starts to the right edge, as the choices are (user 2026-11-12: every width differed).
448
+ */
449
+ div[clips] input:not([type='range']):not([type='number']):not([type='checkbox']),
450
+ div[clips] input[wide] {
451
+ grid-column: 2 / -1;
317
452
  width: auto;
453
+ padding: 1px 4px;
318
454
  }
319
455
  /* A control's value, typed where the slider cannot land on it (a step of the range's hundredth, robot dog 2026-11-03). */
320
456
  div[clips] input[typed] {
@@ -324,7 +460,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
324
460
  font-variant-numeric: tabular-nums;
325
461
  }
326
462
  div[clips] label[sampled] output {
327
- grid-column: 3 / -1;
463
+ grid-column: 2 / -1;
328
464
  text-align: left;
329
465
  }
330
466
  div[clips] output {
@@ -426,7 +562,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
426
562
  ${this.tags.length ? html`<div tags>${this.tags.map(t => html`<span style=${`left:${t.x}px;top:${t.y}px`}>${t.name}</span>`)}</div>` : nothing}
427
563
  ${this.cannot
428
564
  ? html`<div cannot>${this.cannot}</div>`
429
- : html`<div legend>
565
+ : html`<div over><div legend>
430
566
  ${(['one', 'eight'] as const).map(
431
567
  way => html`<button way=${way} aria-pressed=${String(this.way === way)} @click=${() => this.chooseWay(way)}>${i18next.t(`figure.label.preview-way-${way}`)}</button>`
432
568
  )}
@@ -444,16 +580,29 @@ export class FigurePreview extends localize(i18next)(LitElement) {
444
580
  : nothing}
445
581
  </span>`
446
582
  })}
583
+ ${this.way === 'one' && asset && this.trying
584
+ ? html`<span said>${previewSizeWordsOf(asset, this.trying, (key: string, params?: Record<string, string>) => i18next.t(key, params))}</span>`
585
+ : nothing}
447
586
  </div>
448
587
  ${this.way === 'one' && asset && this.trying ? this.renderSizes(asset, this.trying) : nothing}
449
- <div foot>${asset?.appearance.length ? html`<div setup>
450
- <p>${i18next.t('figure.text.preview-setup')}</p>
451
- <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => this.setupPart = (e.target as HTMLSelectElement).value}>
452
- ${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
453
- </select>
454
- <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
455
- </div>` : nothing}
456
- ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0) ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
588
+ <div foot>${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0)
589
+ ? this.renderDrivers(asset!, controls, clips, this.renderSetup(asset, setupPart))
590
+ : this.renderSetup(asset, setupPart)}</div></div>`}`
591
+ }
592
+
593
+ /*
594
+ The size help, shown only where the figure's size can change: the part and the button on one line, its words in its
595
+ title. With controls it is their last row, as wide as they are -- a box of its own left a gap at its right when the
596
+ two stood one over the other, and three lines of it covered the figure (user 2026-11-12).
597
+ */
598
+ private renderSetup(asset: FigureAsset | undefined, setupPart: string) {
599
+ if (!asset?.appearance.length || !(['x', 'y', 'z'] as const).some(axis => sizeSpanOf(asset, axis))) return nothing
600
+ return html`<div setup title=${i18next.t('figure.text.preview-setup')}>
601
+ <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => (this.setupPart = (e.target as HTMLSelectElement).value)}>
602
+ ${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
603
+ </select>
604
+ <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
605
+ </div>`
457
606
  }
458
607
 
459
608
  private lookFrom(view: PreviewView) {
@@ -463,7 +612,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
463
612
 
464
613
  /** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
465
614
  private renderSizes(asset: FigureAsset, size: { x: number; y: number; z: number }) {
466
- const say = (key: string, params?: Record<string, string>) => i18next.t(key, params)
615
+ if (!(['x', 'y', 'z'] as const).some(axis => sizeSpanOf(asset, axis))) return nothing
467
616
  return html`<div sizes>
468
617
  ${(['x', 'y', 'z'] as const).map(axis => {
469
618
  const range = sizeSpanOf(asset, axis)
@@ -476,7 +625,6 @@ export class FigurePreview extends localize(i18next)(LitElement) {
476
625
  <output>${Math.round(size[axis]).toLocaleString()}</output>
477
626
  </label>`
478
627
  })}
479
- <p said>${previewSizeWordsOf(asset, size, say)}</p>
480
628
  </div>`
481
629
  }
482
630
 
@@ -548,26 +696,38 @@ export class FigurePreview extends localize(i18next)(LitElement) {
548
696
  this.scene?.root?.fitCameraToModel?.()
549
697
  }
550
698
 
551
- private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
699
+ private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[], setup: unknown) {
552
700
  const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]))
553
701
  const sampledStates = sampledStatesOf(asset, this.sample)
554
702
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
555
- const fold = html`<button fold-controls aria-expanded=${String(!this.controlsFolded)} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
556
- ${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
703
+ /* An icon, its words in its title: the words took the room of a button (user 2026-11-12). */
704
+ const foldWords = i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')
705
+ const fold = html`<button fold-controls aria-expanded=${String(!this.controlsFolded)} title=${foldWords} aria-label=${foldWords} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
706
+ <md-icon>${this.controlsFolded ? 'unfold_more' : 'unfold_less'}</md-icon>
557
707
  </button>`
558
- if (this.controlsFolded) return html`<div clips folded>${fold}</div>`
708
+ /* Folded, the icon says what it opens: an icon alone in an empty box said nothing (user 2026-11-12). */
709
+ if (this.controlsFolded)
710
+ return html`<div clips folded>
711
+ <button fold-controls aria-expanded="false" @click=${() => (this.controlsFolded = false)}>
712
+ <md-icon>unfold_more</md-icon>${foldWords}
713
+ </button>
714
+ ${setup}
715
+ </div>`
716
+ const played = playedControlsOf(asset, this.driving, this.moving)
717
+ /*
718
+ Keep, clear and fold on one line, there from the start: keep and clear unable until something is tried. Coming and
719
+ going, they moved the rows under them while a slider was being moved (user 2026-11-12).
720
+ */
559
721
  return html`<div clips>
560
- ${fold}
561
- ${changed.length
562
- ? html`<button pose title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
563
- ${i18next.t('figure.button.pose-as-default')}
564
- </button>`
565
- : nothing}
566
- ${Object.keys(this.giving).length
567
- ? html`<button clear-tried title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
568
- ${i18next.t('figure.button.clear-tried')}
569
- </button>`
570
- : nothing}
722
+ <div actions>
723
+ <button pose ?disabled=${!changed.length} title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
724
+ ${i18next.t('figure.button.pose-as-default')}
725
+ </button>
726
+ <button clear-tried ?disabled=${!Object.keys(this.giving).length} title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
727
+ ${i18next.t('figure.button.clear-tried')}
728
+ </button>
729
+ ${fold}
730
+ </div>
571
731
  ${controls.map(control => {
572
732
  /* A control the data tab's sample sets is what the figure shows, as a value row the sample gives: said, not offered. */
573
733
  const fromSample = sampledStates[control.id]
@@ -580,8 +740,20 @@ export class FigurePreview extends localize(i18next)(LitElement) {
580
740
  /* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
581
741
  const step = steps.get(control.id) ?? (control.max - control.min) / 100
582
742
  const label = asset.stateInputs?.[control.id]?.label ?? control.id
743
+ /*
744
+ A control a playing clip drives moves on the figure while its row stood still at its last number -- 0 under a
745
+ walking leg (review 2026-11-11). The row says the clip has it, and is not given a value until the clip stops.
746
+ */
747
+ const by = played.get(control.id)
748
+ if (by)
749
+ return html`<label played title=${i18next.t('figure.text.control-played-explained', { clip: by })}>
750
+ <span>${label} <span drive>${unitWord(control.unit)}</span></span>
751
+ <span></span>
752
+ <input type="range" min=${control.min} max=${control.max} .value=${String(value)} disabled />
753
+ <output>${i18next.t('figure.text.control-played', { clip: by })}</output>
754
+ </label>`
583
755
  return html`<label>
584
- <span>${label} <span drive>${unitWord(control.unit)}</span></span>
756
+ ${this.scrubName(html`${label} <span drive>${unitWord(control.unit)}</span>`, control.unit, value, control.min, control.max, v => this.give(control.id, v))}
585
757
  ${this.renderDial(control, value)}
586
758
  <input
587
759
  type="range"
@@ -591,19 +763,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
591
763
  .value=${String(value)}
592
764
  @input=${(e: Event) => this.give(control.id, Number((e.target as HTMLInputElement).value))}
593
765
  />
594
- <input
595
- typed
596
- type="number"
597
- step="any"
598
- min=${control.min}
599
- max=${control.max}
600
- aria-label=${label}
601
- .value=${String(Math.round(value * 100) / 100)}
602
- @change=${(e: Event) => {
603
- const typed = (e.target as HTMLInputElement).valueAsNumber
604
- if (Number.isFinite(typed)) this.give(control.id, Math.min(Math.max(typed, control.min), control.max))
605
- }}
606
- />
766
+ ${this.typedBox(label, value, control.min, control.max, typed => this.give(control.id, typed))}
607
767
  </label>`
608
768
  })}
609
769
  ${clips.map(clip => {
@@ -613,10 +773,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
613
773
  */
614
774
  const value = this.driving[clip] ?? 0
615
775
  return html`<label>
616
- <span>${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
776
+ ${this.scrubName(html`${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span>`, 'ratio', value, 0, 3, v => this.drive(clip, v))}
617
777
  <span></span>
618
778
  <input type="range" min="0" max="3" step="0.05" .value=${String(value)} @input=${(e: Event) => this.drive(clip, Number((e.target as HTMLInputElement).value))} />
619
- <output>${value.toFixed(2)}</output>
779
+ ${this.typedBox(clip, value, 0, 3, typed => this.drive(clip, typed))}
620
780
  </label>`
621
781
  })}
622
782
  ${valueRowsOf(asset).map(row => this.renderDataValue(row))}
@@ -628,22 +788,67 @@ export class FigurePreview extends localize(i18next)(LitElement) {
628
788
  ? nothing
629
789
  : html`<label>
630
790
  <span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
631
- <select aria-label=${group.label} @change=${(e: Event) => this.move(group.id, { ...playing, clip: (e.target as HTMLSelectElement).value })}>
632
- ${group.clips.map(c => html`<option value=${c} ?selected=${c === playing.clip}>${c}</option>`)}
633
- </select>
791
+ <span clip-choices role="group" aria-label=${group.label}>
792
+ ${group.clips.map(
793
+ c => html`<button type="button" aria-pressed=${String(c === playing.clip)} @click=${() => this.move(group.id, { ...playing, clip: c })}>${c}</button>`
794
+ )}
795
+ </span>
634
796
  </label>`}
635
797
  ${byData.speed
636
798
  ? nothing
637
799
  : html`<label>
638
- <span>${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
800
+ ${this.scrubName(html`${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span>`, 'ratio', playing.speed, 0, 3, v => this.move(group.id, { ...playing, speed: v }))}
639
801
  <span></span>
640
802
  <input type="range" min="0" max="3" step="0.05" .value=${String(playing.speed)} @input=${(e: Event) => this.move(group.id, { ...playing, speed: Number((e.target as HTMLInputElement).value) })} />
641
- <output>${playing.speed.toFixed(2)}</output>
803
+ ${this.typedBox(group.label, playing.speed, 0, 3, typed => this.move(group.id, { ...playing, speed: typed }))}
642
804
  </label>`}`
643
805
  })}
806
+ ${setup}
644
807
  </div>`
645
808
  }
646
809
 
810
+ /**
811
+ * A row's name that scrubs its value when dragged sideways, as the property panel's names do (\`scrubLabel\`): Shift for
812
+ * finer steps, held within the row's range. The preview's values are tried, not recorded, so each move is the value.
813
+ * The sliders ran 360 degrees over 96 pixels, too coarse to land an angle (user 2026-11-12).
814
+ */
815
+ private scrubName(name: unknown, unit: string, value: number, min: number, max: number, set: (value: number) => void) {
816
+ const held = (v: number) => Math.min(Math.max(v, min), max)
817
+ return html`<span
818
+ scrub
819
+ @pointerdown=${(down: PointerEvent) => {
820
+ /* A press that did not move gives nothing: an empty field is not given its slider's start by a click on its name. */
821
+ let moved = false
822
+ scrubLabel(down, { value, unit }, {
823
+ move: v => ((moved = true), set(held(v))),
824
+ release: v => set(held(v)),
825
+ cancel: () => moved && set(value)
826
+ })
827
+ }}
828
+ >${name}</span
829
+ >`
830
+ }
831
+
832
+ /**
833
+ * A slider's value as a box to type in, the same on every row: the value a slider cannot land on, typed (a step of the
834
+ * range's hundredth, robot dog 2026-11-03); one column of boxes, not boxes over plain numbers (user 2026-11-12).
835
+ */
836
+ private typedBox(label: string, value: number, min: number, max: number, set: (typed: number) => void) {
837
+ return html`<input
838
+ typed
839
+ type="number"
840
+ step="any"
841
+ min=${Number.isFinite(min) ? min : nothing}
842
+ max=${Number.isFinite(max) ? max : nothing}
843
+ aria-label=${label}
844
+ .value=${String(Math.round(value * 100) / 100)}
845
+ @change=${(e: Event) => {
846
+ const typed = (e.target as HTMLInputElement).valueAsNumber
847
+ if (Number.isFinite(typed)) set(Math.min(Math.max(typed, min), max))
848
+ }}
849
+ />`
850
+ }
851
+
647
852
  /** A value the data's bindings read, as the board's saved values give it: a number on a slider when it has a range, a choice when it is closed. */
648
853
  private renderDataValue(row: FigureDataRow) {
649
854
  const node = row.node
@@ -656,22 +861,37 @@ export class FigurePreview extends localize(i18next)(LitElement) {
656
861
  if (sampled !== undefined)
657
862
  return html`<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
658
863
  <span>${label} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
659
- <output>${typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
864
+ <output>${typeof sampled === 'boolean' ? i18next.t(sampled ? 'figure.label.data-true' : 'figure.label.data-false') : typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
660
865
  </label>`
661
866
  const fallback = 'default' in node ? node.default : undefined
662
867
  const value = this.valuing[row.path] ?? fallback
663
868
  if (node.kind === 'number') {
664
869
  const ranged = node.min !== undefined && node.max !== undefined && node.max > node.min
870
+ /*
871
+ An angle is drawn as the controls' angles are -- dial, slider, box -- whether or not its field declares a range:
872
+ one with none slides over one turn, and any angle can still be typed. 「방향 deg」 was a bare number field under
873
+ 「머리 각도 deg」 on a slider (user 2026-11-12).
874
+ */
875
+ const angle = node.unit === 'deg'
876
+ const slide = ranged ? { min: node.min!, max: node.max! } : angle ? { min: 0, max: 360 } : undefined
877
+ const scrubFrom = Number(value ?? slide?.min ?? 0)
665
878
  return html`<label>
666
- <span>${label} <span drive>${unitWord(node.unit)}</span></span>
667
- <span></span>
668
- ${ranged
669
- ? html`<input type="range" min=${node.min!} max=${node.max!} step=${Math.max((node.max! - node.min!) / 100, 0.01)} .value=${String(value ?? node.min)} @input=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
670
- <output>${value === undefined ? '' : Number(value).toFixed(2)}</output>`
671
- : html`<input type="number" step="any" .value=${value === undefined ? '' : String(value)} @change=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
672
- <span></span>`}
879
+ ${this.scrubName(html`${label} <span drive>${unitWord(node.unit)}</span>`, node.unit ?? '', Number.isFinite(scrubFrom) ? scrubFrom : 0, ranged ? node.min! : -Infinity, ranged ? node.max! : Infinity, v => this.value(row, String(v)))}
880
+ ${slide
881
+ ? html`${this.renderDial({ unit: node.unit ?? '', ...slide }, Number(value ?? slide.min))}
882
+ <input type="range" min=${slide.min} max=${slide.max} step=${angle ? 1 : Math.max((slide.max - slide.min) / 100, 0.01)} .value=${String(value ?? slide.min)} @input=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
883
+ ${ranged
884
+ ? this.typedBox(label, Number(value ?? slide.min), slide.min, slide.max, typed => this.value(row, String(typed)))
885
+ : this.typedBox(label, Number(value ?? slide.min), -Infinity, Infinity, typed => this.value(row, String(typed)))}`
886
+ : html`<input wide type="number" step="any" aria-label=${label} .value=${value === undefined ? '' : String(value)} @change=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />`}
673
887
  </label>`
674
888
  }
889
+ /* A yes or no is a check box, not a list of two words to open (user 2026-11-12). Unchecked is false, as the board reads it. */
890
+ if (node.kind === 'boolean')
891
+ return html`<label>
892
+ <span>${label}</span>
893
+ <input type="checkbox" aria-label=${label} .checked=${value === true} @change=${(e: Event) => this.value(row, String((e.target as HTMLInputElement).checked))} />
894
+ </label>`
675
895
  const domain = domainOf(node)
676
896
  if (!domain.length)
677
897
  return html`<label>
@@ -10,6 +10,7 @@ import { scrubLabel } from './live.js'
10
10
  import {
11
11
  measureFactor,
12
12
  sameSizeSource,
13
+ sizeAxisWord,
13
14
  sizeInputOf,
14
15
  sizeRelationOf,
15
16
  sizeRelationSteps,
@@ -39,10 +40,13 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
39
40
  :host { display: block; min-width: 0; }
40
41
  .heading, .fields {
41
42
  display: grid;
42
- grid-template-columns: 24px minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
43
+ grid-template-columns: var(--axis-column, 24px) minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
43
44
  gap: 6px;
44
45
  align-items: center;
45
46
  }
47
+ /* A round body's rows name its diameter (「X·Z · 지름」): every row's first column widens alike, so the rows still line up. */
48
+ :host([named-axes]) { --axis-column: 76px; }
49
+ .fields > span:first-child { white-space: nowrap; }
46
50
  .heading { color: var(--md-sys-color-on-surface-variant); padding-bottom: 6px; }
47
51
  .dimension { padding: 3px 0; }
48
52
  .variable { display: flex; align-items: center; justify-content: center; min-height: var(--figure-control-height); }
@@ -98,6 +102,7 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
98
102
  const axes = followableAxes(asset, this.partId)
99
103
  const targets = asset.appearance.map(a => a.target).filter(p => p !== this.partId && followableAxes(asset, p).length)
100
104
  const uniform = asset.sizing === 'uniform'
105
+ this.toggleAttribute('named-axes', axes.some(axis => measureFactor(asset, this.partId, axis) === 2))
101
106
  return html`
102
107
  <div class="heading" aria-hidden="true">
103
108
  <span>${i18next.t('figure.label.size-axis')}</span><span>${i18next.t('figure.label.size-measure')}</span><span>${i18next.t('figure.label.size-follows')}</span><span>${i18next.t('figure.label.size-source')}</span><span></span>
@@ -116,7 +121,7 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
116
121
  }
117
122
  const currentMode = mode === 'custom' ? 'ratio' : mode
118
123
  const input = sizeInputOf(asset, this.partId, axis)
119
- const label = `${axis.toUpperCase()}${measureFactor(asset, this.partId, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
124
+ const label = sizeAxisWord(asset, this.partId, axis, i18next.t('figure.label.size-diameter'))
120
125
  /* The selects show when asked for, or when what it follows is not what its fastening gives -- or nothing is. */
121
126
  const choosing = relation.variable && (this.choosing.has(key) || !sameSizeSource(source, suggested))
122
127
  const selectedSource = source ? (source.of === 'instance' ? '@figure' : source.part) : '@custom'
@@ -11,6 +11,14 @@ export function typeOf(raw: string): string {
11
11
  return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
12
12
  }
13
13
 
14
+ /**
15
+ * The identifier as it is being typed: `typeOf`, but the hyphen just typed at the end stays, so the next word can follow
16
+ * it (tutorial 2026-11-11: `fork-agv` typed key by key came out `forkagv`). The save takes `typeOf` of it.
17
+ */
18
+ export function typingTypeOf(raw: string): string {
19
+ return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '')
20
+ }
21
+
14
22
  /**
15
23
  * Whether this name could be released, by the format's own rules (`type-not-identifier`, `type-is-placeholder`).
16
24
  * Saving under it is not blocked -- it can still change before release -- but the author hears it now.
@@ -0,0 +1,4 @@
1
+ import { ref } from 'lit/directives/ref.js'
2
+
3
+ /** A field opened by a press takes the keys at once, its value selected to be typed over. */
4
+ export const focusOnRender = () => ref(el => el && requestAnimationFrame(() => (el as HTMLInputElement).select()))