@things-factory/figure-ui 10.1.272 → 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 (56) hide show
  1. package/client/modeller/data-interface.ts +30 -18
  2. package/client/modeller/editor-styles.ts +17 -6
  3. package/client/modeller/editor.ts +2 -0
  4. package/client/modeller/fasten-form.ts +12 -2
  5. package/client/modeller/fasten-geometry.ts +5 -3
  6. package/client/modeller/figure-data-panel.ts +384 -200
  7. package/client/modeller/figure-fasten-drawing.ts +21 -13
  8. package/client/modeller/figure-links.ts +24 -9
  9. package/client/modeller/figure-parts.ts +7 -11
  10. package/client/modeller/figure-preview.ts +258 -76
  11. package/dist-client/modeller/clips.d.ts +1 -4
  12. package/dist-client/modeller/data-interface.d.ts +11 -8
  13. package/dist-client/modeller/data-interface.js +27 -17
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor-styles.js +17 -6
  16. package/dist-client/modeller/editor-styles.js.map +1 -1
  17. package/dist-client/modeller/editor.js +2 -0
  18. package/dist-client/modeller/editor.js.map +1 -1
  19. package/dist-client/modeller/fasten-form.d.ts +5 -0
  20. package/dist-client/modeller/fasten-form.js +8 -2
  21. package/dist-client/modeller/fasten-form.js.map +1 -1
  22. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  23. package/dist-client/modeller/fasten-geometry.js +5 -3
  24. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  25. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  26. package/dist-client/modeller/figure-data-panel.js +382 -190
  27. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  28. package/dist-client/modeller/figure-fasten-drawing.js +21 -13
  29. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  30. package/dist-client/modeller/figure-links.js +31 -16
  31. package/dist-client/modeller/figure-links.js.map +1 -1
  32. package/dist-client/modeller/figure-parts.js +7 -11
  33. package/dist-client/modeller/figure-parts.js.map +1 -1
  34. package/dist-client/modeller/figure-preview.d.ts +13 -0
  35. package/dist-client/modeller/figure-preview.js +257 -77
  36. package/dist-client/modeller/figure-preview.js.map +1 -1
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +8 -7
  39. package/test/browser/editing-page.js +1 -1
  40. package/test/browser/links.check.mjs +30 -0
  41. package/test/browser/modeller.check.mjs +2 -2
  42. package/test/browser/parts-page.js +4 -0
  43. package/test/browser/parts.check.mjs +16 -10
  44. package/test/browser/screen-page.js +12 -1
  45. package/test/browser/screen.check.mjs +142 -11
  46. package/test/browser/serve-response.mjs +5 -2
  47. package/test/browser/side-panel-sweep.check.mjs +171 -10
  48. package/test/data-motion.test.ts +39 -8
  49. package/test/fasten-form.test.ts +25 -0
  50. package/test/fasten-geometry.test.ts +13 -0
  51. package/translations/en.json +23 -11
  52. package/translations/es.json +22 -10
  53. package/translations/ja.json +22 -10
  54. package/translations/ko.json +24 -12
  55. package/translations/ms.json +22 -10
  56. package/translations/zh.json +22 -10
@@ -1,8 +1,10 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
+ import '@material/web/icon/icon.js';
2
3
  import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
3
4
  import { customElement, property, query, state } from 'lit/decorators.js';
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 { create, figureLegendSourcesChanged, registerFigure, unregisterFigure } from '@hatiolab/things-scene';
@@ -93,21 +95,43 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
93
95
  background-color: rgba(28, 28, 30, 0.72);
94
96
  border-radius: 4px;
95
97
  }
96
- div[foot] {
98
+ /*
99
+ The legend, the size sliders and the controls in one column over the stage: the controls take what is left under
100
+ 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
101
+ fold buttons with them, and nothing scrolled (2026-11-12); a legend on two lines in a narrow preview is room taken too.
102
+ */
103
+ div[over] {
97
104
  position: absolute;
98
- left: 8px;
99
- right: 8px;
100
- bottom: 8px;
105
+ inset: 8px;
106
+ display: flex;
107
+ flex-direction: column;
108
+ align-items: flex-start;
109
+ gap: 8px;
110
+ pointer-events: none;
111
+ }
112
+ div[over] > div[legend],
113
+ div[over] > div[sizes] {
114
+ pointer-events: auto;
115
+ }
116
+ div[foot] {
117
+ align-self: stretch;
118
+ flex: 1;
119
+ min-height: 0;
101
120
  display: flex;
102
121
  flex-wrap: wrap-reverse;
103
122
  justify-content: space-between;
104
123
  align-items: flex-end;
124
+ align-content: flex-start;
105
125
  gap: 8px;
106
126
  pointer-events: none;
107
127
  }
108
128
  div[foot] > * { pointer-events: auto; }
109
- div[setup] { max-width: min(320px, 100%); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
110
- div[setup] p { margin: 0; line-height: 1.5; }
129
+ /* One line over the figure -- the part and the button -- its words in its title: three lines covered the figure (user 2026-11-12). */
130
+ 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); }
131
+ div[setup] select { flex: 1 1 auto; }
132
+ div[setup] button { flex: none; white-space: nowrap; }
133
+ /* The controls' last row: as wide as they are, a line over it in place of a box of its own. */
134
+ 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; }
111
135
  /* The help's choice and button in the look of the controls beside it, not the browser's grey (sweep 2026-10-27 U7). */
112
136
  div[setup] select,
113
137
  div[setup] button {
@@ -130,9 +154,8 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
130
154
  }
131
155
  /* Which is which: side by side means nothing without a legend. */
132
156
  div[legend] {
133
- position: absolute;
134
- top: 8px;
135
- left: 8px;
157
+ max-width: 100%;
158
+ box-sizing: border-box;
136
159
  display: flex;
137
160
  gap: 6px;
138
161
  padding: 4px 8px;
@@ -177,13 +200,10 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
177
200
  }
178
201
  /* The one figure's size: under the legend, in the controls' look. */
179
202
  div[sizes] {
180
- position: absolute;
181
- top: 40px;
182
- left: 8px;
183
203
  display: grid;
184
204
  gap: 6px;
185
205
  padding: 8px 10px;
186
- max-width: calc(100% - 16px);
206
+ max-width: 100%;
187
207
  box-sizing: border-box;
188
208
  color: rgba(255, 255, 255, 0.92);
189
209
  background-color: rgba(28, 28, 30, 0.72);
@@ -204,8 +224,12 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
204
224
  font-variant-numeric: tabular-nums;
205
225
  text-align: right;
206
226
  }
207
- div[sizes] p[said] {
208
- margin: 0;
227
+ /* 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). */
228
+ div[legend] span[said] {
229
+ padding-left: 6px;
230
+ border-left: 1px solid rgba(255, 255, 255, 0.22);
231
+ align-self: center;
232
+ white-space: nowrap;
209
233
  font-variant-numeric: tabular-nums;
210
234
  font-weight: 600;
211
235
  }
@@ -220,8 +244,12 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
220
244
  */
221
245
  div[clips] {
222
246
  margin-left: auto;
247
+ max-height: 100%;
248
+ box-sizing: border-box;
249
+ overflow-y: auto;
250
+ overscroll-behavior: contain;
223
251
  display: grid;
224
- grid-template-columns: auto 20px 96px 44px;
252
+ grid-template-columns: max-content 20px 96px 44px;
225
253
  gap: 6px 8px;
226
254
  align-items: center;
227
255
  padding: 8px 10px;
@@ -238,6 +266,10 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
238
266
  grid-template-columns: subgrid;
239
267
  align-items: center;
240
268
  }
269
+ /* 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). */
270
+ div[clips] label > span:first-child {
271
+ white-space: nowrap;
272
+ }
241
273
  /* A motion group's clip: as wide as the dial, slider and value together, so its name reads. */
242
274
  div[clips] select {
243
275
  grid-column: 2 / -1;
@@ -300,9 +332,65 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
300
332
  div[clips] button[fold-controls]:hover {
301
333
  opacity: 1;
302
334
  }
303
- div[clips] button[pose] {
304
- align-self: stretch;
305
- padding: 4px 8px;
335
+ /* The keep, clear and fold line, and the size help's, stay in sight while the rows between them scroll. */
336
+ div[clips] div[actions],
337
+ div[clips] div[setup] {
338
+ position: sticky;
339
+ z-index: 1;
340
+ margin-left: -10px;
341
+ margin-right: -10px;
342
+ padding-left: 10px;
343
+ padding-right: 10px;
344
+ background-color: rgba(28, 28, 30, 0.96);
345
+ }
346
+ div[clips] div[actions] {
347
+ top: -8px;
348
+ margin-top: -8px;
349
+ padding-top: 8px;
350
+ padding-bottom: 4px;
351
+ }
352
+ div[clips] div[setup] {
353
+ bottom: -8px;
354
+ margin-bottom: -8px;
355
+ padding-bottom: 8px;
356
+ }
357
+ div[clips] div[actions] {
358
+ display: flex;
359
+ align-items: center;
360
+ gap: 6px;
361
+ min-width: 0;
362
+ }
363
+ /* Folded, the box is as wide as its button: the icon and the words that say it opens the controls. */
364
+ div[clips][folded] {
365
+ display: flex;
366
+ flex-direction: column;
367
+ align-items: stretch;
368
+ gap: 6px;
369
+ }
370
+ div[clips][folded] button[fold-controls] {
371
+ gap: 4px;
372
+ padding: 2px 4px;
373
+ font-size: var(--ui-t-micro, 11px);
374
+ }
375
+ /* An icon as tall as the two buttons beside it, at the line's right end. */
376
+ div[clips] button[fold-controls] {
377
+ display: inline-flex;
378
+ align-items: center;
379
+ }
380
+ div[clips] button[fold-controls] md-icon {
381
+ --md-icon-size: 16px;
382
+ }
383
+ div[clips] div[actions] button[fold-controls] {
384
+ margin-left: auto;
385
+ padding: 2px 3px;
386
+ border: 1px solid transparent;
387
+ }
388
+ /* On one line with the fold, each word on one line: a label broken inside a word ("기본으 / 로") read badly (user 2026-11-12). */
389
+ div[clips] button[pose],
390
+ div[clips] button[clear-tried] {
391
+ flex: none;
392
+ white-space: nowrap;
393
+ padding: 3px 8px;
306
394
  font: inherit;
307
395
  font-size: var(--ui-t-micro, 11px);
308
396
  color: rgba(255, 255, 255, 0.92);
@@ -311,7 +399,13 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
311
399
  border-radius: 5px;
312
400
  cursor: pointer;
313
401
  }
314
- div[clips] button[pose]:hover {
402
+ div[clips] button[pose]:disabled,
403
+ div[clips] button[clear-tried]:disabled {
404
+ opacity: 0.4;
405
+ cursor: default;
406
+ }
407
+ div[clips] button[pose]:not(:disabled):hover,
408
+ div[clips] button[clear-tried]:not(:disabled):hover {
315
409
  background-color: rgba(255, 255, 255, 0.24);
316
410
  }
317
411
  div[clips] span[drive] {
@@ -323,7 +417,20 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
323
417
  accent-color: var(--md-sys-color-primary);
324
418
  }
325
419
  /* A value typed, not slid: in the look of the group's clip choice, starting where the sliders start. */
326
- div[clips] input:not([type='range']) {
420
+ /* A name that scrubs its value, in the look the side panel gives it (figure-viewer side-panel-styles \`[scrub]\`). */
421
+ div[clips] label > span[scrub] {
422
+ cursor: ew-resize;
423
+ user-select: none;
424
+ touch-action: none;
425
+ }
426
+ /* A check box in the dial's column, where every field starts. */
427
+ div[clips] input[type='checkbox'] {
428
+ grid-column: 2 / -1;
429
+ justify-self: start;
430
+ width: auto;
431
+ margin: 0;
432
+ }
433
+ div[clips] input:not([type='range']):not([type='checkbox']) {
327
434
  box-sizing: border-box;
328
435
  min-width: 0;
329
436
  font: inherit;
@@ -332,9 +439,15 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
332
439
  border: 1px solid rgba(255, 255, 255, 0.22);
333
440
  border-radius: 5px;
334
441
  }
335
- div[clips] input:not([type='range']):not([type='number']) {
336
- grid-column: 3 / -1;
442
+ /*
443
+ 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
444
+ no range -- from where the dial starts to the right edge, as the choices are (user 2026-11-12: every width differed).
445
+ */
446
+ div[clips] input:not([type='range']):not([type='number']):not([type='checkbox']),
447
+ div[clips] input[wide] {
448
+ grid-column: 2 / -1;
337
449
  width: auto;
450
+ padding: 1px 4px;
338
451
  }
339
452
  /* A control's value, typed where the slider cannot land on it (a step of the range's hundredth, robot dog 2026-11-03). */
340
453
  div[clips] input[typed] {
@@ -344,7 +457,7 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
344
457
  font-variant-numeric: tabular-nums;
345
458
  }
346
459
  div[clips] label[sampled] output {
347
- grid-column: 3 / -1;
460
+ grid-column: 2 / -1;
348
461
  text-align: left;
349
462
  }
350
463
  div[clips] output {
@@ -398,7 +511,7 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
398
511
  ${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}
399
512
  ${this.cannot
400
513
  ? html `<div cannot>${this.cannot}</div>`
401
- : html `<div legend>
514
+ : html `<div over><div legend>
402
515
  ${['one', 'eight'].map(way => html `<button way=${way} aria-pressed=${String(this.way === way)} @click=${() => this.chooseWay(way)}>${i18next.t(`figure.label.preview-way-${way}`)}</button>`)}
403
516
  <span views>
404
517
  ${PREVIEW_VIEWS.map(view => html `<button view=${view} aria-pressed=${String(this.looking === view)} @click=${() => this.lookFrom(view)}>${i18next.t(`figure.label.preview-view-${view}`)}</button>`)}
@@ -412,16 +525,29 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
412
525
  : nothing}
413
526
  </span>`;
414
527
  })}
528
+ ${this.way === 'one' && asset && this.trying
529
+ ? html `<span said>${previewSizeWordsOf(asset, this.trying, (key, params) => i18next.t(key, params))}</span>`
530
+ : nothing}
415
531
  </div>
416
532
  ${this.way === 'one' && asset && this.trying ? this.renderSizes(asset, this.trying) : nothing}
417
- <div foot>${asset?.appearance.length ? html `<div setup>
418
- <p>${i18next.t('figure.text.preview-setup')}</p>
419
- <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e) => this.setupPart = e.target.value}>
420
- ${asset.appearance.map(p => html `<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
421
- </select>
422
- <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
423
- </div>` : nothing}
424
- ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0) ? this.renderDrivers(asset, controls, clips) : nothing}</div>`}`;
533
+ <div foot>${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0)
534
+ ? this.renderDrivers(asset, controls, clips, this.renderSetup(asset, setupPart))
535
+ : this.renderSetup(asset, setupPart)}</div></div>`}`;
536
+ }
537
+ /*
538
+ The size help, shown only where the figure's size can change: the part and the button on one line, its words in its
539
+ 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
540
+ two stood one over the other, and three lines of it covered the figure (user 2026-11-12).
541
+ */
542
+ renderSetup(asset, setupPart) {
543
+ if (!asset?.appearance.length || !['x', 'y', 'z'].some(axis => sizeSpanOf(asset, axis)))
544
+ return nothing;
545
+ return html `<div setup title=${i18next.t('figure.text.preview-setup')}>
546
+ <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e) => (this.setupPart = e.target.value)}>
547
+ ${asset.appearance.map(p => html `<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
548
+ </select>
549
+ <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
550
+ </div>`;
425
551
  }
426
552
  lookFrom(view) {
427
553
  this.scene?.root?.setCameraView?.(view);
@@ -429,7 +555,8 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
429
555
  }
430
556
  /** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
431
557
  renderSizes(asset, size) {
432
- const say = (key, params) => i18next.t(key, params);
558
+ if (!['x', 'y', 'z'].some(axis => sizeSpanOf(asset, axis)))
559
+ return nothing;
433
560
  return html `<div sizes>
434
561
  ${['x', 'y', 'z'].map(axis => {
435
562
  const range = sizeSpanOf(asset, axis);
@@ -443,7 +570,6 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
443
570
  <output>${Math.round(size[axis]).toLocaleString()}</output>
444
571
  </label>`;
445
572
  })}
446
- <p said>${previewSizeWordsOf(asset, size, say)}</p>
447
573
  </div>`;
448
574
  }
449
575
  /*
@@ -518,28 +644,38 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
518
644
  this.scene?.fit?.('ratio');
519
645
  this.scene?.root?.fitCameraToModel?.();
520
646
  }
521
- renderDrivers(asset, controls, clips) {
647
+ renderDrivers(asset, controls, clips, setup) {
522
648
  const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]));
523
649
  const sampledStates = sampledStatesOf(asset, this.sample);
524
650
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id]);
525
- const fold = html `<button fold-controls aria-expanded=${String(!this.controlsFolded)} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
526
- ${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
651
+ /* An icon, its words in its title: the words took the room of a button (user 2026-11-12). */
652
+ const foldWords = i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold');
653
+ const fold = html `<button fold-controls aria-expanded=${String(!this.controlsFolded)} title=${foldWords} aria-label=${foldWords} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
654
+ <md-icon>${this.controlsFolded ? 'unfold_more' : 'unfold_less'}</md-icon>
527
655
  </button>`;
656
+ /* Folded, the icon says what it opens: an icon alone in an empty box said nothing (user 2026-11-12). */
528
657
  if (this.controlsFolded)
529
- return html `<div clips folded>${fold}</div>`;
658
+ return html `<div clips folded>
659
+ <button fold-controls aria-expanded="false" @click=${() => (this.controlsFolded = false)}>
660
+ <md-icon>unfold_more</md-icon>${foldWords}
661
+ </button>
662
+ ${setup}
663
+ </div>`;
530
664
  const played = playedControlsOf(asset, this.driving, this.moving);
665
+ /*
666
+ Keep, clear and fold on one line, there from the start: keep and clear unable until something is tried. Coming and
667
+ going, they moved the rows under them while a slider was being moved (user 2026-11-12).
668
+ */
531
669
  return html `<div clips>
532
- ${fold}
533
- ${changed.length
534
- ? html `<button pose title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
535
- ${i18next.t('figure.button.pose-as-default')}
536
- </button>`
537
- : nothing}
538
- ${Object.keys(this.giving).length
539
- ? html `<button clear-tried title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
540
- ${i18next.t('figure.button.clear-tried')}
541
- </button>`
542
- : nothing}
670
+ <div actions>
671
+ <button pose ?disabled=${!changed.length} title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
672
+ ${i18next.t('figure.button.pose-as-default')}
673
+ </button>
674
+ <button clear-tried ?disabled=${!Object.keys(this.giving).length} title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
675
+ ${i18next.t('figure.button.clear-tried')}
676
+ </button>
677
+ ${fold}
678
+ </div>
543
679
  ${controls.map(control => {
544
680
  /* A control the data tab's sample sets is what the figure shows, as a value row the sample gives: said, not offered. */
545
681
  const fromSample = sampledStates[control.id];
@@ -565,7 +701,7 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
565
701
  <output>${i18next.t('figure.text.control-played', { clip: by })}</output>
566
702
  </label>`;
567
703
  return html `<label>
568
- <span>${label} <span drive>${unitWord(control.unit)}</span></span>
704
+ ${this.scrubName(html `${label} <span drive>${unitWord(control.unit)}</span>`, control.unit, value, control.min, control.max, v => this.give(control.id, v))}
569
705
  ${this.renderDial(control, value)}
570
706
  <input
571
707
  type="range"
@@ -575,20 +711,7 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
575
711
  .value=${String(value)}
576
712
  @input=${(e) => this.give(control.id, Number(e.target.value))}
577
713
  />
578
- <input
579
- typed
580
- type="number"
581
- step="any"
582
- min=${control.min}
583
- max=${control.max}
584
- aria-label=${label}
585
- .value=${String(Math.round(value * 100) / 100)}
586
- @change=${(e) => {
587
- const typed = e.target.valueAsNumber;
588
- if (Number.isFinite(typed))
589
- this.give(control.id, Math.min(Math.max(typed, control.min), control.max));
590
- }}
591
- />
714
+ ${this.typedBox(label, value, control.min, control.max, typed => this.give(control.id, typed))}
592
715
  </label>`;
593
716
  })}
594
717
  ${clips.map(clip => {
@@ -598,10 +721,10 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
598
721
  */
599
722
  const value = this.driving[clip] ?? 0;
600
723
  return html `<label>
601
- <span>${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
724
+ ${this.scrubName(html `${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span>`, 'ratio', value, 0, 3, v => this.drive(clip, v))}
602
725
  <span></span>
603
726
  <input type="range" min="0" max="3" step="0.05" .value=${String(value)} @input=${(e) => this.drive(clip, Number(e.target.value))} />
604
- <output>${value.toFixed(2)}</output>
727
+ ${this.typedBox(clip, value, 0, 3, typed => this.drive(clip, typed))}
605
728
  </label>`;
606
729
  })}
607
730
  ${valueRowsOf(asset).map(row => this.renderDataValue(row))}
@@ -620,14 +743,56 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
620
743
  ${byData.speed
621
744
  ? nothing
622
745
  : html `<label>
623
- <span>${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
746
+ ${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 }))}
624
747
  <span></span>
625
748
  <input type="range" min="0" max="3" step="0.05" .value=${String(playing.speed)} @input=${(e) => this.move(group.id, { ...playing, speed: Number(e.target.value) })} />
626
- <output>${playing.speed.toFixed(2)}</output>
749
+ ${this.typedBox(group.label, playing.speed, 0, 3, typed => this.move(group.id, { ...playing, speed: typed }))}
627
750
  </label>`}`;
628
751
  })}
752
+ ${setup}
629
753
  </div>`;
630
754
  }
755
+ /**
756
+ * A row's name that scrubs its value when dragged sideways, as the property panel's names do (\`scrubLabel\`): Shift for
757
+ * finer steps, held within the row's range. The preview's values are tried, not recorded, so each move is the value.
758
+ * The sliders ran 360 degrees over 96 pixels, too coarse to land an angle (user 2026-11-12).
759
+ */
760
+ scrubName(name, unit, value, min, max, set) {
761
+ const held = (v) => Math.min(Math.max(v, min), max);
762
+ return html `<span
763
+ scrub
764
+ @pointerdown=${(down) => {
765
+ /* A press that did not move gives nothing: an empty field is not given its slider's start by a click on its name. */
766
+ let moved = false;
767
+ scrubLabel(down, { value, unit }, {
768
+ move: v => ((moved = true), set(held(v))),
769
+ release: v => set(held(v)),
770
+ cancel: () => moved && set(value)
771
+ });
772
+ }}
773
+ >${name}</span
774
+ >`;
775
+ }
776
+ /**
777
+ * 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
778
+ * range's hundredth, robot dog 2026-11-03); one column of boxes, not boxes over plain numbers (user 2026-11-12).
779
+ */
780
+ typedBox(label, value, min, max, set) {
781
+ return html `<input
782
+ typed
783
+ type="number"
784
+ step="any"
785
+ min=${Number.isFinite(min) ? min : nothing}
786
+ max=${Number.isFinite(max) ? max : nothing}
787
+ aria-label=${label}
788
+ .value=${String(Math.round(value * 100) / 100)}
789
+ @change=${(e) => {
790
+ const typed = e.target.valueAsNumber;
791
+ if (Number.isFinite(typed))
792
+ set(Math.min(Math.max(typed, min), max));
793
+ }}
794
+ />`;
795
+ }
631
796
  /** 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. */
632
797
  renderDataValue(row) {
633
798
  const node = row.node;
@@ -640,22 +805,37 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
640
805
  if (sampled !== undefined)
641
806
  return html `<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
642
807
  <span>${label} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
643
- <output>${typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
808
+ <output>${typeof sampled === 'boolean' ? i18next.t(sampled ? 'figure.label.data-true' : 'figure.label.data-false') : typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
644
809
  </label>`;
645
810
  const fallback = 'default' in node ? node.default : undefined;
646
811
  const value = this.valuing[row.path] ?? fallback;
647
812
  if (node.kind === 'number') {
648
813
  const ranged = node.min !== undefined && node.max !== undefined && node.max > node.min;
814
+ /*
815
+ An angle is drawn as the controls' angles are -- dial, slider, box -- whether or not its field declares a range:
816
+ one with none slides over one turn, and any angle can still be typed. 「방향 deg」 was a bare number field under
817
+ 「머리 각도 deg」 on a slider (user 2026-11-12).
818
+ */
819
+ const angle = node.unit === 'deg';
820
+ const slide = ranged ? { min: node.min, max: node.max } : angle ? { min: 0, max: 360 } : undefined;
821
+ const scrubFrom = Number(value ?? slide?.min ?? 0);
649
822
  return html `<label>
650
- <span>${label} <span drive>${unitWord(node.unit)}</span></span>
651
- <span></span>
652
- ${ranged
653
- ? 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) => this.value(row, e.target.value)} />
654
- <output>${value === undefined ? '' : Number(value).toFixed(2)}</output>`
655
- : html `<input type="number" step="any" .value=${value === undefined ? '' : String(value)} @change=${(e) => this.value(row, e.target.value)} />
656
- <span></span>`}
823
+ ${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)))}
824
+ ${slide
825
+ ? html `${this.renderDial({ unit: node.unit ?? '', ...slide }, Number(value ?? slide.min))}
826
+ <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) => this.value(row, e.target.value)} />
827
+ ${ranged
828
+ ? this.typedBox(label, Number(value ?? slide.min), slide.min, slide.max, typed => this.value(row, String(typed)))
829
+ : this.typedBox(label, Number(value ?? slide.min), -Infinity, Infinity, typed => this.value(row, String(typed)))}`
830
+ : html `<input wide type="number" step="any" aria-label=${label} .value=${value === undefined ? '' : String(value)} @change=${(e) => this.value(row, e.target.value)} />`}
657
831
  </label>`;
658
832
  }
833
+ /* 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. */
834
+ if (node.kind === 'boolean')
835
+ return html `<label>
836
+ <span>${label}</span>
837
+ <input type="checkbox" aria-label=${label} .checked=${value === true} @change=${(e) => this.value(row, String(e.target.checked))} />
838
+ </label>`;
659
839
  const domain = domainOf(node);
660
840
  if (!domain.length)
661
841
  return html `<label>