@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
+ 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'
@@ -96,21 +98,43 @@ export class FigurePreview extends localize(i18next)(LitElement) {
96
98
  background-color: rgba(28, 28, 30, 0.72);
97
99
  border-radius: 4px;
98
100
  }
99
- 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] {
100
107
  position: absolute;
101
- left: 8px;
102
- right: 8px;
103
- 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;
104
123
  display: flex;
105
124
  flex-wrap: wrap-reverse;
106
125
  justify-content: space-between;
107
126
  align-items: flex-end;
127
+ align-content: flex-start;
108
128
  gap: 8px;
109
129
  pointer-events: none;
110
130
  }
111
131
  div[foot] > * { pointer-events: auto; }
112
- div[setup] { max-width: min(320px, 100%); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
113
- 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; }
114
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). */
115
139
  div[setup] select,
116
140
  div[setup] button {
@@ -133,9 +157,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
133
157
  }
134
158
  /* Which is which: side by side means nothing without a legend. */
135
159
  div[legend] {
136
- position: absolute;
137
- top: 8px;
138
- left: 8px;
160
+ max-width: 100%;
161
+ box-sizing: border-box;
139
162
  display: flex;
140
163
  gap: 6px;
141
164
  padding: 4px 8px;
@@ -180,13 +203,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
180
203
  }
181
204
  /* The one figure's size: under the legend, in the controls' look. */
182
205
  div[sizes] {
183
- position: absolute;
184
- top: 40px;
185
- left: 8px;
186
206
  display: grid;
187
207
  gap: 6px;
188
208
  padding: 8px 10px;
189
- max-width: calc(100% - 16px);
209
+ max-width: 100%;
190
210
  box-sizing: border-box;
191
211
  color: rgba(255, 255, 255, 0.92);
192
212
  background-color: rgba(28, 28, 30, 0.72);
@@ -207,8 +227,12 @@ export class FigurePreview extends localize(i18next)(LitElement) {
207
227
  font-variant-numeric: tabular-nums;
208
228
  text-align: right;
209
229
  }
210
- div[sizes] p[said] {
211
- 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;
212
236
  font-variant-numeric: tabular-nums;
213
237
  font-weight: 600;
214
238
  }
@@ -223,8 +247,12 @@ export class FigurePreview extends localize(i18next)(LitElement) {
223
247
  */
224
248
  div[clips] {
225
249
  margin-left: auto;
250
+ max-height: 100%;
251
+ box-sizing: border-box;
252
+ overflow-y: auto;
253
+ overscroll-behavior: contain;
226
254
  display: grid;
227
- grid-template-columns: auto 20px 96px 44px;
255
+ grid-template-columns: max-content 20px 96px 44px;
228
256
  gap: 6px 8px;
229
257
  align-items: center;
230
258
  padding: 8px 10px;
@@ -241,6 +269,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
241
269
  grid-template-columns: subgrid;
242
270
  align-items: center;
243
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
+ }
244
276
  /* A motion group's clip: as wide as the dial, slider and value together, so its name reads. */
245
277
  div[clips] select {
246
278
  grid-column: 2 / -1;
@@ -303,9 +335,65 @@ export class FigurePreview extends localize(i18next)(LitElement) {
303
335
  div[clips] button[fold-controls]:hover {
304
336
  opacity: 1;
305
337
  }
306
- div[clips] button[pose] {
307
- align-self: stretch;
308
- 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;
309
397
  font: inherit;
310
398
  font-size: var(--ui-t-micro, 11px);
311
399
  color: rgba(255, 255, 255, 0.92);
@@ -314,7 +402,13 @@ export class FigurePreview extends localize(i18next)(LitElement) {
314
402
  border-radius: 5px;
315
403
  cursor: pointer;
316
404
  }
317
- 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 {
318
412
  background-color: rgba(255, 255, 255, 0.24);
319
413
  }
320
414
  div[clips] span[drive] {
@@ -326,7 +420,20 @@ export class FigurePreview extends localize(i18next)(LitElement) {
326
420
  accent-color: var(--md-sys-color-primary);
327
421
  }
328
422
  /* A value typed, not slid: in the look of the group's clip choice, starting where the sliders start. */
329
- 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']) {
330
437
  box-sizing: border-box;
331
438
  min-width: 0;
332
439
  font: inherit;
@@ -335,9 +442,15 @@ export class FigurePreview extends localize(i18next)(LitElement) {
335
442
  border: 1px solid rgba(255, 255, 255, 0.22);
336
443
  border-radius: 5px;
337
444
  }
338
- div[clips] input:not([type='range']):not([type='number']) {
339
- 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;
340
452
  width: auto;
453
+ padding: 1px 4px;
341
454
  }
342
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). */
343
456
  div[clips] input[typed] {
@@ -347,7 +460,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
347
460
  font-variant-numeric: tabular-nums;
348
461
  }
349
462
  div[clips] label[sampled] output {
350
- grid-column: 3 / -1;
463
+ grid-column: 2 / -1;
351
464
  text-align: left;
352
465
  }
353
466
  div[clips] output {
@@ -449,7 +562,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
449
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}
450
563
  ${this.cannot
451
564
  ? html`<div cannot>${this.cannot}</div>`
452
- : html`<div legend>
565
+ : html`<div over><div legend>
453
566
  ${(['one', 'eight'] as const).map(
454
567
  way => html`<button way=${way} aria-pressed=${String(this.way === way)} @click=${() => this.chooseWay(way)}>${i18next.t(`figure.label.preview-way-${way}`)}</button>`
455
568
  )}
@@ -467,16 +580,29 @@ export class FigurePreview extends localize(i18next)(LitElement) {
467
580
  : nothing}
468
581
  </span>`
469
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}
470
586
  </div>
471
587
  ${this.way === 'one' && asset && this.trying ? this.renderSizes(asset, this.trying) : nothing}
472
- <div foot>${asset?.appearance.length ? html`<div setup>
473
- <p>${i18next.t('figure.text.preview-setup')}</p>
474
- <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => this.setupPart = (e.target as HTMLSelectElement).value}>
475
- ${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
476
- </select>
477
- <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
478
- </div>` : nothing}
479
- ${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>`
480
606
  }
481
607
 
482
608
  private lookFrom(view: PreviewView) {
@@ -486,7 +612,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
486
612
 
487
613
  /** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
488
614
  private renderSizes(asset: FigureAsset, size: { x: number; y: number; z: number }) {
489
- 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
490
616
  return html`<div sizes>
491
617
  ${(['x', 'y', 'z'] as const).map(axis => {
492
618
  const range = sizeSpanOf(asset, axis)
@@ -499,7 +625,6 @@ export class FigurePreview extends localize(i18next)(LitElement) {
499
625
  <output>${Math.round(size[axis]).toLocaleString()}</output>
500
626
  </label>`
501
627
  })}
502
- <p said>${previewSizeWordsOf(asset, size, say)}</p>
503
628
  </div>`
504
629
  }
505
630
 
@@ -571,27 +696,38 @@ export class FigurePreview extends localize(i18next)(LitElement) {
571
696
  this.scene?.root?.fitCameraToModel?.()
572
697
  }
573
698
 
574
- private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
699
+ private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[], setup: unknown) {
575
700
  const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]))
576
701
  const sampledStates = sampledStatesOf(asset, this.sample)
577
702
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
578
- const fold = html`<button fold-controls aria-expanded=${String(!this.controlsFolded)} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
579
- ${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>
580
707
  </button>`
581
- 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>`
582
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
+ */
583
721
  return html`<div clips>
584
- ${fold}
585
- ${changed.length
586
- ? html`<button pose title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
587
- ${i18next.t('figure.button.pose-as-default')}
588
- </button>`
589
- : nothing}
590
- ${Object.keys(this.giving).length
591
- ? html`<button clear-tried title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
592
- ${i18next.t('figure.button.clear-tried')}
593
- </button>`
594
- : 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>
595
731
  ${controls.map(control => {
596
732
  /* A control the data tab's sample sets is what the figure shows, as a value row the sample gives: said, not offered. */
597
733
  const fromSample = sampledStates[control.id]
@@ -617,7 +753,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
617
753
  <output>${i18next.t('figure.text.control-played', { clip: by })}</output>
618
754
  </label>`
619
755
  return html`<label>
620
- <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))}
621
757
  ${this.renderDial(control, value)}
622
758
  <input
623
759
  type="range"
@@ -627,19 +763,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
627
763
  .value=${String(value)}
628
764
  @input=${(e: Event) => this.give(control.id, Number((e.target as HTMLInputElement).value))}
629
765
  />
630
- <input
631
- typed
632
- type="number"
633
- step="any"
634
- min=${control.min}
635
- max=${control.max}
636
- aria-label=${label}
637
- .value=${String(Math.round(value * 100) / 100)}
638
- @change=${(e: Event) => {
639
- const typed = (e.target as HTMLInputElement).valueAsNumber
640
- if (Number.isFinite(typed)) this.give(control.id, Math.min(Math.max(typed, control.min), control.max))
641
- }}
642
- />
766
+ ${this.typedBox(label, value, control.min, control.max, typed => this.give(control.id, typed))}
643
767
  </label>`
644
768
  })}
645
769
  ${clips.map(clip => {
@@ -649,10 +773,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
649
773
  */
650
774
  const value = this.driving[clip] ?? 0
651
775
  return html`<label>
652
- <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))}
653
777
  <span></span>
654
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))} />
655
- <output>${value.toFixed(2)}</output>
779
+ ${this.typedBox(clip, value, 0, 3, typed => this.drive(clip, typed))}
656
780
  </label>`
657
781
  })}
658
782
  ${valueRowsOf(asset).map(row => this.renderDataValue(row))}
@@ -673,15 +797,58 @@ export class FigurePreview extends localize(i18next)(LitElement) {
673
797
  ${byData.speed
674
798
  ? nothing
675
799
  : html`<label>
676
- <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 }))}
677
801
  <span></span>
678
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) })} />
679
- <output>${playing.speed.toFixed(2)}</output>
803
+ ${this.typedBox(group.label, playing.speed, 0, 3, typed => this.move(group.id, { ...playing, speed: typed }))}
680
804
  </label>`}`
681
805
  })}
806
+ ${setup}
682
807
  </div>`
683
808
  }
684
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
+
685
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. */
686
853
  private renderDataValue(row: FigureDataRow) {
687
854
  const node = row.node
@@ -694,22 +861,37 @@ export class FigurePreview extends localize(i18next)(LitElement) {
694
861
  if (sampled !== undefined)
695
862
  return html`<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
696
863
  <span>${label} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
697
- <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>
698
865
  </label>`
699
866
  const fallback = 'default' in node ? node.default : undefined
700
867
  const value = this.valuing[row.path] ?? fallback
701
868
  if (node.kind === 'number') {
702
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)
703
878
  return html`<label>
704
- <span>${label} <span drive>${unitWord(node.unit)}</span></span>
705
- <span></span>
706
- ${ranged
707
- ? 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)} />
708
- <output>${value === undefined ? '' : Number(value).toFixed(2)}</output>`
709
- : html`<input type="number" step="any" .value=${value === undefined ? '' : String(value)} @change=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
710
- <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)} />`}
711
887
  </label>`
712
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>`
713
895
  const domain = domainOf(node)
714
896
  if (!domain.length)
715
897
  return html`<label>
@@ -96,10 +96,7 @@ export declare function copiedTrackEdit(asset: FigureAsset, clip: Clip, track: C
96
96
  kind: "set-driver";
97
97
  state: string;
98
98
  clip: string;
99
- keys: {
100
- seconds: number;
101
- value: number;
102
- }[];
99
+ keys: ClipKey[];
103
100
  loop: "hold" | "wrap";
104
101
  interpolation: "linear" | "step" | "smooth";
105
102
  accumulates: boolean;
@@ -30,8 +30,9 @@ export declare function fitsField(node: FigureDataNode, choice: BehaviorChoice):
30
30
  import { methodOf, type DataMethod } from '@things-factory/figure-viewer/dist-client/data-connection.js';
31
31
  export { methodOf, type DataMethod };
32
32
  /**
33
- * The methods a field allows (ADR-0166 2): a number as it is, scaled or by a range table; a text or true/false by a code
34
- * table, or as it is where a behavior takes it so; a list as it is (its items). A new field is made by a table.
33
+ * The methods a field allows (ADR-0166 2, amendment 1): a number as it is, scaled, by a code table (status codes sent as
34
+ * numbers, 4002) or by a range table; a text or true/false by a code table, or as it is where a behavior takes it so; a
35
+ * list as it is (its items). A new field is made by a table.
35
36
  */
36
37
  export declare function methodsOf(node: FigureDataNode | null, choices: BehaviorChoice[]): DataMethod[];
37
38
  /** The method a binding is shown by. */
@@ -45,28 +46,30 @@ export declare const domainOf: (node: FigureDataNode) => FigureDataValue[];
45
46
  */
46
47
  export declare function bindingDraftOf(asset: FigureAsset, accessor: string, node: FigureDataNode, choice: BehaviorChoice, method?: 'value' | 'scale'): FigureDataValueBinding;
47
48
  /** The first code a new code table names, and the next ones: `CODE`, `CODE2`, … */
49
+ /** A new code for a number field's code table: one past the largest taken, or 0 (ADR-0166 amendment 1). */
50
+ export declare function freeNumberCodeOf(taken: string[]): string;
48
51
  export declare function freeCodeOf(taken: string[]): string;
49
52
  /** A legend role from a binding's id: lower case letters, digits and `-`. */
50
53
  export declare const legendRoleOf: (id: string) => string;
51
54
  /**
52
55
  * A first table on a field (ADR-0166 10): a row per value of a closed field, an open field's rows typed later, one
53
- * interval over the whole of a number. A new field is made by the table (P2): a code table's a text whose values are the
54
- * table's keys, starting with one code; a range table's a number. Returns the binding and, for a new field, the schema
55
- * with it.
56
+ * interval over the whole of a number. The field is one the schema has: the data tab adds a binding in the field's row.
56
57
  */
57
58
  export declare function tableDraftOf(asset: FigureAsset, field: {
58
59
  path: string;
59
60
  node: FigureDataNode;
60
- } | {
61
- name: string;
62
61
  }, method: 'code' | 'ranges', targets: FigureDataTargets): {
63
62
  binding: FigureDataTableBinding;
64
- schema?: FigureDataField[];
65
63
  };
66
64
  /** The table with these targets: each look keeps only the columns they take. */
67
65
  export declare function tableWithTargets(b: FigureDataTableBinding, targets: FigureDataTargets): FigureDataTableBinding;
68
66
  /** The table with one look set: the whole look given, a column left out cleared. */
69
67
  export declare const tableWithLook: (b: FigureDataTableBinding, key: string, look: FigureDataTableEntry) => FigureDataTableBinding;
68
+ /**
69
+ * The table whose other values keep what it last showed, or go to the default look (an empty one): the 「그대로 둠」 of
70
+ * the 「그 밖의 값」 row (ADR-0166 amendment 1).
71
+ */
72
+ export declare const tableWithKeep: (b: FigureDataTableBinding, keep: boolean) => FigureDataTableBinding;
70
73
  /** The table's rows renamed, added or taken away, in order; `default` kept last. */
71
74
  export declare function tableWithKeys(b: FigureDataTableBinding, rows: Array<[string, FigureDataTableEntry]>): FigureDataTableBinding;
72
75
  /**