@things-factory/figure-ui 10.1.224 → 10.1.226

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 (84) hide show
  1. package/client/modeller/consumer-capabilities.ts +15 -0
  2. package/client/modeller/editor-styles.ts +21 -3
  3. package/client/modeller/editor.ts +41 -27
  4. package/client/modeller/figure-capability-panel.ts +37 -10
  5. package/client/modeller/figure-clips.ts +3 -2
  6. package/client/modeller/figure-controls.ts +4 -3
  7. package/client/modeller/figure-data-panel.ts +41 -21
  8. package/client/modeller/figure-links.ts +5 -5
  9. package/client/modeller/figure-material-panel.ts +2 -0
  10. package/client/modeller/figure-preview.ts +115 -8
  11. package/client/modeller/figure-recipe-catalog.ts +4 -0
  12. package/client/modeller/figure-slot-layout-panel.ts +25 -8
  13. package/client/modeller/figure-verdict.ts +4 -1
  14. package/client/modeller/product-session.ts +3 -2
  15. package/client/modeller/unit-words.ts +11 -0
  16. package/client/pages/figure-modeller-page.ts +5 -4
  17. package/dist-client/modeller/consumer-capabilities.d.ts +11 -0
  18. package/dist-client/modeller/consumer-capabilities.js +14 -0
  19. package/dist-client/modeller/consumer-capabilities.js.map +1 -1
  20. package/dist-client/modeller/editor-styles.js +21 -3
  21. package/dist-client/modeller/editor-styles.js.map +1 -1
  22. package/dist-client/modeller/editor.js +43 -29
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-capability-panel.d.ts +3 -0
  25. package/dist-client/modeller/figure-capability-panel.js +35 -11
  26. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-clips.js +3 -2
  28. package/dist-client/modeller/figure-clips.js.map +1 -1
  29. package/dist-client/modeller/figure-controls.js +4 -3
  30. package/dist-client/modeller/figure-controls.js.map +1 -1
  31. package/dist-client/modeller/figure-data-panel.js +42 -21
  32. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  33. package/dist-client/modeller/figure-links.js +5 -5
  34. package/dist-client/modeller/figure-links.js.map +1 -1
  35. package/dist-client/modeller/figure-material-panel.js +2 -0
  36. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  37. package/dist-client/modeller/figure-preview.d.ts +3 -0
  38. package/dist-client/modeller/figure-preview.js +124 -8
  39. package/dist-client/modeller/figure-preview.js.map +1 -1
  40. package/dist-client/modeller/figure-recipe-catalog.js +4 -0
  41. package/dist-client/modeller/figure-recipe-catalog.js.map +1 -1
  42. package/dist-client/modeller/figure-slot-layout-panel.d.ts +1 -1
  43. package/dist-client/modeller/figure-slot-layout-panel.js +25 -8
  44. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  45. package/dist-client/modeller/figure-verdict.js +4 -1
  46. package/dist-client/modeller/figure-verdict.js.map +1 -1
  47. package/dist-client/modeller/product-session.d.ts +4 -2
  48. package/dist-client/modeller/product-session.js +3 -2
  49. package/dist-client/modeller/product-session.js.map +1 -1
  50. package/dist-client/modeller/unit-words.d.ts +5 -0
  51. package/dist-client/modeller/unit-words.js +12 -0
  52. package/dist-client/modeller/unit-words.js.map +1 -0
  53. package/dist-client/pages/figure-modeller-page.js +5 -4
  54. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +10 -10
  57. package/test/ai-proposal.test.ts +2 -2
  58. package/test/browser/editing-page.js +3 -3
  59. package/test/browser/editing.check.mjs +5 -5
  60. package/test/browser/links-page.js +1 -1
  61. package/test/browser/links.check.mjs +2 -3
  62. package/test/browser/modeller-page.js +4 -4
  63. package/test/browser/modeller-revision.check.mjs +5 -5
  64. package/test/browser/modeller.check.mjs +5 -5
  65. package/test/browser/parts.check.mjs +2 -2
  66. package/test/browser/product-page.check.mjs +31 -0
  67. package/test/browser/props.check.mjs +3 -4
  68. package/test/browser/row-preview.check.mjs +3 -3
  69. package/test/browser/screen.check.mjs +13 -11
  70. package/test/browser/side-panel-sweep.check.mjs +182 -0
  71. package/test/consumer-capabilities.test.ts +7 -0
  72. package/test/figure-assistant-surface.test.ts +1 -1
  73. package/test/first-save.test.ts +2 -2
  74. package/test/links.test.ts +1 -1
  75. package/test/product-session.test.ts +5 -5
  76. package/test/proposal-record.test.ts +3 -3
  77. package/test/refusals.test.ts +1 -1
  78. package/test/revert-keeping.test.ts +1 -1
  79. package/translations/en.json +24 -12
  80. package/translations/es.json +24 -12
  81. package/translations/ja.json +25 -13
  82. package/translations/ko.json +83 -71
  83. package/translations/ms.json +24 -12
  84. package/translations/zh.json +24 -12
@@ -7,6 +7,7 @@
7
7
  import { isFigureConsumerCapability } from '@hatiolab/figure-model'
8
8
  import type { FigureAsset } from '@hatiolab/figure-model'
9
9
  import { figureCapabilityDescriptors, type FigureCapabilityDescriptor, type FigureCapabilitySetting } from '@hatiolab/things-scene'
10
+ import { RUNGS, type Rung } from '@things-factory/figure-viewer/dist-client/capability-rung.js'
10
11
 
11
12
  /** The products' capabilities this modeller knows: registered descriptors with a namespaced name. */
12
13
  export const consumerDescriptors = (): FigureCapabilityDescriptor[] => figureCapabilityDescriptors().filter(d => isFigureConsumerCapability(d.name))
@@ -24,6 +25,20 @@ export function needsMissing(descriptor: FigureCapabilityDescriptor, asset: Figu
24
25
  return descriptor.needs.filter(need => (Array.isArray(need) ? !need.some(has) : !has(need as string))).map(need => (Array.isArray(need) ? need.join(' | ') : (need as string)))
25
26
  }
26
27
 
28
+ /**
29
+ * What a product's capability needs that the figure lacks, as the tab offers it: the item tag, and the rungs that would
30
+ * give the names it needs (Holdable | FlowNode and Capacity | FlowNode: hold or flow). Its own names are not shown to the
31
+ * author (sweep 2026-10-27 U19); `names` keeps a need no rung gives.
32
+ */
33
+ export function needsOffered(descriptor: FigureCapabilityDescriptor, asset: FigureAsset): { item: boolean; rungs: Rung[]; names: string[] } {
34
+ const missing = needsMissing(descriptor, asset)
35
+ const groups = descriptor.needs.filter(need => need !== 'item').map(need => (Array.isArray(need) ? need : [need as string]))
36
+ const gives = (rung: Rung) => groups.every(group => group.some(name => (RUNGS[rung] as readonly string[]).includes(name)))
37
+ const lacking = missing.filter(m => m !== 'item')
38
+ const rungs = lacking.length ? (['hold', 'flow'] as const).filter(gives) : []
39
+ return { item: missing.includes('item'), rungs, names: lacking.length && !rungs.length ? lacking : [] }
40
+ }
41
+
27
42
  /** The names with a product's capability put on or taken off; the shared ones as they are. */
28
43
  export function namesWith(asset: FigureAsset, name: string, on: boolean): string[] {
29
44
  const names = asset.document.capabilities.names ?? []
@@ -19,7 +19,8 @@ export const EditorStyles = css`
19
19
  /* The bar over the work is ox-canvas-bar (ADR-0060 decision 8): the folds at its edges, undo, redo and
20
20
  fit in the suite's places. What the editor puts in it - the mode switch, the status, save - is styled here. */
21
21
  ox-canvas-bar button[mode] {
22
- height: 28px;
22
+ /* The bar's height, beside its 32px icons and the save (sweep 2026-10-27 U25). */
23
+ height: 32px;
23
24
  border: 1px solid transparent;
24
25
  padding: 0 10px;
25
26
  border-radius: 999em;
@@ -207,6 +208,22 @@ export const EditorStyles = css`
207
208
  margin-top: 4px;
208
209
  color: var(--md-sys-color-on-surface-variant);
209
210
  }
211
+ div[start] button[start-more] {
212
+ display: block;
213
+ text-align: left;
214
+ margin: 4px 0 0;
215
+ padding: 0;
216
+ font: inherit;
217
+ font-size: var(--ui-t-small);
218
+ color: var(--md-sys-color-primary);
219
+ background: none;
220
+ border: none;
221
+ cursor: pointer;
222
+ }
223
+ /* No rule under the last way to start: it closed nothing (sweep 2026-10-27 U23). */
224
+ div[start-paths] > section:last-of-type {
225
+ border-bottom: none;
226
+ }
210
227
  /* The side: tabs over one panel at a time, each using the whole height. */
211
228
  /*
212
229
  The side's words at the panel's size (side-panel-styles: titles body, the rest small). The editor's host is at body
@@ -339,11 +356,12 @@ export const EditorStyles = css`
339
356
  min-height: 0;
340
357
  overflow: auto;
341
358
  }
342
- /* Which part the panel speaks of. */
359
+ /* Which part the panel speaks of. Above z-index 1: a chip's label (md-filter-chip) sits at 1 and, coming later, was
360
+ drawn over it when the panel scrolled (sweep 2026-10-27 U2). */
343
361
  div[subject] {
344
362
  position: sticky;
345
363
  top: 0;
346
- z-index: 1;
364
+ z-index: 2;
347
365
  display: grid;
348
366
  gap: 4px;
349
367
  padding: 10px 12px;
@@ -1,5 +1,7 @@
1
1
  import { LitElement, html, nothing, type PropertyValues } from 'lit'
2
2
  import { i18next } from '@operato/i18n'
3
+ import { isMacOS } from '@operato/utils'
4
+ import { unitWord } from './unit-words.js'
3
5
  import type { OxThreePane } from '@operato/layout'
4
6
  import '@operato/layout'
5
7
  import { openPopup } from '@operato/layout'
@@ -887,6 +889,14 @@ export class FigureEditor extends LitElement {
887
889
  <summary><h3>${i18next.t('figure.label.group-more')}</h3></summary>
888
890
  <div more-body>
889
891
  ${tools ? html`<div group-fields>${tools}</div>` : nothing}
892
+ <figure-material-panel
893
+ show="more"
894
+ .body=${{ ...part, id: this.selected }}
895
+ .palette=${palette}
896
+ ?locked=${unavailable}
897
+ @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
898
+ @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
899
+ ></figure-material-panel>
890
900
  <figure-section-panel
891
901
  embedded
892
902
  .body=${{ ...part, id: this.selected }}
@@ -898,14 +908,6 @@ export class FigureEditor extends LitElement {
898
908
  @command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
899
909
  @section-editor-open=${() => (this.sectionEditing = this.selected)}
900
910
  ></figure-section-panel>
901
- <figure-material-panel
902
- show="more"
903
- .body=${{ ...part, id: this.selected }}
904
- .palette=${palette}
905
- ?locked=${unavailable}
906
- @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
907
- @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
908
- ></figure-material-panel>
909
911
  </div>
910
912
  </details>
911
913
  </section>`
@@ -1143,7 +1145,7 @@ export class FigureEditor extends LitElement {
1143
1145
  ${title('figure.text.board-size-range-explained')}
1144
1146
  ${(['x', 'y', 'z'] as const).map(
1145
1147
  axis => html`<div inline-field>
1146
- <label>${i18next.t('figure.label.resize-' + axis)} <span unit>mm</span></label>
1148
+ <label>${i18next.t('figure.label.resize-' + axis)} <span unit>(mm)</span></label>
1147
1149
  <div pair range>
1148
1150
  ${(['min', 'max'] as const).map(
1149
1151
  (bound, i) => html`${i ? html`<span dash aria-hidden="true">–</span>` : nothing}<input
@@ -1279,7 +1281,7 @@ export class FigureEditor extends LitElement {
1279
1281
  <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label>${axes.length ? html`<select aria-label=${i18next.t('figure.label.repeat-axis')} ?disabled=${unavailable} .value=${live(axis)} @change=${(e: Event) => update({ axis: (e.target as HTMLSelectElement).value.toUpperCase() as 'X' | 'Y' | 'Z' })}>${axes.map(a => html`<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}</select>` : html`<span value>${axis.toUpperCase()}</span>`}</div>
1280
1282
  <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label>${!fits && record ? html`<input type="number" min="1" step="1" aria-label=${i18next.t('figure.label.repeat-count')} .value=${live(String(repeat.count))} ?disabled=${unavailable} @change=${(e: Event) => update({ count: (e.target as HTMLInputElement).valueAsNumber })}>` : html`<span value>${repeat.count}</span>`}</div>
1281
1283
  <div inline-field>
1282
- <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
1284
+ <label>${i18next.t('figure.label.repeat-gap')} <span unit>(mm)</span></label>
1283
1285
  ${fits ? html`<input
1284
1286
  type="number"
1285
1287
  step="any"
@@ -1371,8 +1373,8 @@ export class FigureEditor extends LitElement {
1371
1373
  </label>
1372
1374
  ${open
1373
1375
  ? html`<div inline-field><label>${i18next.t('figure.label.board-name')}</label><input aria-label="figure.label.board-name" .value=${form!.label} @change=${edit('label')} /></div>
1374
- <div inline-field><label>${i18next.t('figure.label.least')} <span unit>mm</span></label><input type="number" step="any" aria-label="figure.label.least" .value=${String(form!.min)} @change=${edit('min')} /></div>
1375
- <div inline-field><label>${i18next.t('figure.label.most')} <span unit>mm</span></label><input type="number" step="any" aria-label="figure.label.most" .value=${String(form!.max)} @change=${edit('max')} /></div>
1376
+ <div inline-field><label>${i18next.t('figure.label.least')} <span unit>(mm)</span></label><input type="number" step="any" aria-label="figure.label.least" .value=${String(form!.min)} @change=${edit('min')} /></div>
1377
+ <div inline-field><label>${i18next.t('figure.label.most')} <span unit>(mm)</span></label><input type="number" step="any" aria-label="figure.label.most" .value=${String(form!.max)} @change=${edit('max')} /></div>
1376
1378
  <button act publish-pitch ?disabled=${unavailable || !form!.label.trim()} @click=${() => run(steps())}>
1377
1379
  <md-icon>${listed ? 'check' : 'ios_share'}</md-icon>${i18next.t(listed ? 'figure.button.apply' : 'figure.button.publish-input')}
1378
1380
  </button>`
@@ -1601,9 +1603,12 @@ export class FigureEditor extends LitElement {
1601
1603
  }
1602
1604
  /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1603
1605
  private renderTab(tab: SideTab, icon: string, label: string) {
1604
- /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1605
- const name = i18next.t(tab === 'response' && this.selected ? 'figure.label.tab-arrangement' : 'figure.label.tab-short-' + tab)
1606
- return html`<button tab ?on=${this.tab === tab} title=${tab === 'response' && this.selected ? name : label} aria-label=${name} @click=${() => (this.tab = tab)}>
1606
+ /*
1607
+ Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. One name
1608
+ whatever is picked: the size tab was 「반응형」 with nothing picked and 「배치」 with a part (sweep 2026-10-27 J2).
1609
+ */
1610
+ const name = i18next.t('figure.label.tab-short-' + tab)
1611
+ return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1607
1612
  <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1608
1613
  </button>`
1609
1614
  }
@@ -1700,7 +1705,13 @@ export class FigureEditor extends LitElement {
1700
1705
  </button>`
1701
1706
  )}
1702
1707
  </div>
1703
- <small>${i18next.t('figure.text.start-shape-more')}</small>
1708
+ ${this.panes?.foldStart
1709
+ ? /* The list it points to is folded at this width: a way to it, not words about a list not there (sweep 2026-10-27 U23). */
1710
+ html`<button start-more @click=${() => {
1711
+ this.panes!.foldStart = false
1712
+ this.requestUpdate()
1713
+ }}>${i18next.t('figure.button.start-shape-more')}</button>`
1714
+ : html`<small>${i18next.t('figure.text.start-shape-more')}</small>`}
1704
1715
  </section>
1705
1716
  ${this.aiDraft
1706
1717
  ? html`<section>
@@ -1708,7 +1719,6 @@ export class FigureEditor extends LitElement {
1708
1719
  <button start-button start-ai @click=${() => this.dispatchEvent(new CustomEvent('ask-ai-draft', { bubbles: true, composed: true }))}>
1709
1720
  ${i18next.t('figure.button.start-ai')}
1710
1721
  </button>
1711
- <small>${i18next.t('figure.text.start-ai-optional')}</small>
1712
1722
  </section>`
1713
1723
  : nothing}
1714
1724
  </div>
@@ -1789,8 +1799,8 @@ export class FigureEditor extends LitElement {
1789
1799
  return html`<ox-canvas-bar
1790
1800
  history
1791
1801
  fit
1792
- undo-shortcut="Ctrl+Z"
1793
- redo-shortcut="Ctrl+Y"
1802
+ undo-shortcut=${isMacOS() ? '⌘Z' : 'Ctrl+Z'}
1803
+ redo-shortcut=${isMacOS() ? '⇧⌘Z' : 'Ctrl+Y'}
1794
1804
  ?can-undo=${!unavailable && view.canUndo}
1795
1805
  ?can-redo=${!unavailable && view.canRedo}
1796
1806
  .pane=${this.panes}
@@ -1822,7 +1832,7 @@ export class FigureEditor extends LitElement {
1822
1832
  >
1823
1833
  <md-icon>publish</md-icon>${i18next.t('figure.button.release')}
1824
1834
  </button>
1825
- <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}><md-icon>save</md-icon>${i18next.t('figure.button.save')}</button>
1835
+ <button slot="commit" save title=${!view.id && !view.geometry.length ? i18next.t('figure.text.save-needs-a-part') : ''} ?disabled=${unavailable || (!view.dirty && !!view.id) || (!view.id && !view.geometry.length)} @click=${this.save}><md-icon>save</md-icon>${i18next.t('figure.button.save')}</button>
1826
1836
  </ox-canvas-bar>
1827
1837
  ${this.blockersOpen && blockers.length ? this.renderReleaseBlockers(blockers, asset) : nothing}
1828
1838
  ${this.catalogOpen
@@ -1914,7 +1924,7 @@ export class FigureEditor extends LitElement {
1914
1924
  ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
1915
1925
  ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
1916
1926
  ${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
1917
- ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'))}
1927
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.tab-verdict'))}
1918
1928
  </div>
1919
1929
  <slot name="notice"></slot>
1920
1930
  <div panel ?off=${tab !== 'properties'}>
@@ -1943,7 +1953,9 @@ export class FigureEditor extends LitElement {
1943
1953
  @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
1944
1954
  ></figure-material-panel>
1945
1955
  ${this.renderMore(part, view.palette ?? {}, unavailable)}`
1946
- : view.geometry.length === 0
1956
+ : view.loading || !view.context
1957
+ ? nothing
1958
+ : view.geometry.length === 0
1947
1959
  ? html`${this.renderSubject()}<section empty-figure>
1948
1960
  <p>${i18next.t('figure.text.empty-figure-next')}</p>
1949
1961
  <details>
@@ -2039,18 +2051,19 @@ export class FigureEditor extends LitElement {
2039
2051
  return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10)
2040
2052
  }
2041
2053
  return html`<div vector-row>
2042
- <label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html` <span unit>${unit}</span>`}</label>
2054
+ <label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html` <span unit>(${unit})</span>`}</label>
2043
2055
  <div triple>
2044
2056
  ${(['x', 'y', 'z'] as const).map(
2045
2057
  axis => !group.cells[axis]
2046
2058
  ? html`<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
2047
2059
  <span axis>${axis.toUpperCase()}</span>
2048
- <input type="text" readonly .value=${given(axis)} aria-label=${`${group.name}.${axis} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
2060
+ <input type="text" readonly .value=${given(axis)} data-field=${`${group.name}.${axis}`} aria-label=${`${i18next.t('figure.label.group-' + group.name)} ${axis.toUpperCase()} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
2049
2061
  </div>`
2050
2062
  : html`<div cell>
2051
2063
  <span axis ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}>${axis.toUpperCase()}</span>
2052
2064
  <input
2053
- aria-label=${group.cells[axis]!.id}
2065
+ data-field=${group.cells[axis]!.id}
2066
+ aria-label=${`${i18next.t('figure.label.group-' + group.name)} ${axis.toUpperCase()}`}
2054
2067
  type="number"
2055
2068
  step="any"
2056
2069
  .value=${String(group.cells[axis]!.value)}
@@ -2085,9 +2098,10 @@ export class FigureEditor extends LitElement {
2085
2098
  }
2086
2099
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
2087
2100
  const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
2088
- return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
2101
+ return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${unitWord(field.unit)})</span
2089
2102
  ><input
2090
- aria-label=${field.id}
2103
+ data-field=${field.id}
2104
+ aria-label=${words.join(' · ')}
2091
2105
  type="number"
2092
2106
  step="any"
2093
2107
  .value=${String(field.value)}
@@ -3,6 +3,7 @@ import { customElement, property } from 'lit/decorators.js'
3
3
  import { live } from 'lit/directives/live.js'
4
4
 
5
5
  import { i18next, localize } from '@operato/i18n'
6
+ import { unitWord } from './unit-words.js'
6
7
  import { figureBindingsOf } from '@hatiolab/figure-model'
7
8
  import type { FigureAsset } from '@hatiolab/figure-model'
8
9
  import { RUNGS, rungOf, type Rung } from '@things-factory/figure-viewer/dist-client/capability-rung.js'
@@ -15,6 +16,7 @@ import {
15
16
  inputTableAt,
16
17
  namesWith,
17
18
  needsMissing,
19
+ needsOffered,
18
20
  partsAt,
19
21
  settingsIssues,
20
22
  settingsOf,
@@ -50,6 +52,12 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
50
52
  border-radius: 8px;
51
53
  cursor: pointer;
52
54
  }
55
+ /* The box keeps its size beside a long note: squeezed by the flex row it read as a dot (sweep 2026-10-27 U1). */
56
+ label[rung] > input,
57
+ label[item] > input {
58
+ flex: none;
59
+ margin: 2px 0 0;
60
+ }
53
61
  label[rung][on],
54
62
  label[item][on] {
55
63
  border-color: var(--md-sys-color-primary);
@@ -135,7 +143,8 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
135
143
  const names = asset.document.capabilities.names ?? []
136
144
  const rung = rungOf(names)
137
145
  const places = figureBindingsOf(asset)
138
- return html`<section>
146
+ return html`${this.renderItem(asset)}
147
+ <section>
139
148
  <h3>${i18next.t('figure.label.what-this-does')}</h3>
140
149
  ${(['none', 'hold', 'flow'] as const).map(
141
150
  which => html`<label rung ?on=${rung === which}>
@@ -147,10 +156,18 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
147
156
  </label>`
148
157
  )}
149
158
  ${rung === null ? html`<p hint>${i18next.t('figure.text.capability-hand-written')}</p>` : nothing}
150
- ${names.length ? html`<code>${names.join(' · ')}</code>` : nothing}
151
159
  </section>
152
160
  ${this.renderConsumers(asset)}
153
- <section>
161
+ ${places.length || !this.selected ? this.renderPlaces(places, rung) : nothing}
162
+ `
163
+ }
164
+
165
+ /*
166
+ The figure's places. None yet, with a part picked: nothing here -- the part's own place form stands above, and 「pick a
167
+ part and add one」 under it told the author what they were doing (sweep 2026-10-27 U20).
168
+ */
169
+ private renderPlaces(places: ReturnType<typeof figureBindingsOf>, rung: Rung | null) {
170
+ return html`<section>
154
171
  <h3>${i18next.t('figure.label.places')}</h3>
155
172
  ${places.length
156
173
  ? places.map(
@@ -164,8 +181,12 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
164
181
  )
165
182
  : html`<p hint>${i18next.t('figure.text.no-places')}</p>`}
166
183
  ${places.length && rung === 'none' ? html`<p hint>${i18next.t('figure.text.place-needs-holding')}</p>` : nothing}
167
- </section>
168
- <section>
184
+ </section>`
185
+ }
186
+
187
+ /** Whether the figure is an item: what a product capability may need first, so it stands first (sweep 2026-10-27 J4). */
188
+ private renderItem(asset: FigureAsset) {
189
+ return html`<section>
169
190
  <h3>${i18next.t('figure.label.item')}</h3>
170
191
  <label item ?on=${!!asset.item}>
171
192
  <input type="checkbox" .checked=${live(!!asset.item)} ?disabled=${this.locked} @change=${(e: Event) => this.setItem((e.target as HTMLInputElement).checked)} />
@@ -174,8 +195,7 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
174
195
  <em>${i18next.t('figure.text.is-item-note')}</em>
175
196
  </span>
176
197
  </label>
177
- </section>
178
- `
198
+ </section>`
179
199
  }
180
200
 
181
201
  /** The products' capabilities: those this modeller knows, each on or off with its settings, and those it does not know. */
@@ -189,15 +209,22 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
189
209
  ${known.map(d => {
190
210
  const on = names.includes(d.name)
191
211
  const missing = needsMissing(d, asset)
212
+ const offered = needsOffered(d, asset)
213
+ const needs = [
214
+ ...(offered.item ? [i18next.t('figure.label.is-item')] : []),
215
+ ...(offered.rungs.length
216
+ ? [i18next.t('figure.text.product-capability-needs-rungs', { rungs: offered.rungs.map(r => i18next.t(`figure.label.rung-${r}`)).join(i18next.t('figure.label.or-between')) })]
217
+ : []),
218
+ ...offered.names
219
+ ]
192
220
  return html`<label rung ?on=${on}>
193
221
  <input type="checkbox" .checked=${live(on)} ?disabled=${this.locked || (!on && missing.length > 0)} @change=${(e: Event) => this.setConsumer(asset, d.name, (e.target as HTMLInputElement).checked)} />
194
222
  <span>
195
223
  <strong>${d.label}</strong>
196
224
  <em>${d.note}</em>
197
- <code>${d.name} · ${d.version}</code>
225
+ ${needs.length ? html`<small needs>${i18next.t('figure.text.product-capability-needs', { needs: needs.join(', ') })}</small>` : nothing}
198
226
  </span>
199
227
  </label>
200
- ${missing.length ? html`<p hint>${i18next.t('figure.text.product-capability-needs', { needs: missing.map(m => (m === 'item' ? i18next.t('figure.label.is-item') : m)).join(', ') })}</p>` : nothing}
201
228
  ${on ? this.renderSettings(asset, d) : nothing}`
202
229
  })}
203
230
  ${unknown.map(
@@ -235,7 +262,7 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
235
262
  return html`<span>${setting.label}</span>
236
263
  <select ?disabled=${this.locked} @change=${(e: Event) => write(withInputTable(settings, setting, (e.target as HTMLSelectElement).value || undefined, table.values))}>
237
264
  <option value="" ?selected=${!table.input}>${i18next.t('figure.label.data-none')}</option>
238
- ${inputs.map(i => html`<option value=${i.id} ?selected=${i.id === table.input}>${asset.stateInputs?.[i.id]?.label ?? i.id}${i.unit ? ` (${i.unit} ${i.min ?? ''}~${i.max ?? ''})` : ''}</option>`)}
265
+ ${inputs.map(i => html`<option value=${i.id} ?selected=${i.id === table.input}>${asset.stateInputs?.[i.id]?.label ?? i.id}${i.unit ? ` (${unitWord(i.unit)} ${i.min ?? ''}~${i.max ?? ''})` : ''}</option>`)}
239
266
  </select>
240
267
  ${table.input
241
268
  ? html`<span></span>
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from 'lit'
4
4
  import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
+ import { unitWord } from './unit-words.js'
7
8
  import type { FigureAsset } from '@hatiolab/figure-model'
8
9
 
9
10
  import { clipGroupsOf, clipsOf, freeGroupIdOf, groupEdit, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipGroup, type ClipTrack } from './clips.js'
@@ -118,7 +119,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
118
119
  <h3>${i18next.t('figure.label.clips')}${this.help.button('figure.text.clips-explained')}</h3>
119
120
  ${this.help.hint('figure.text.clips-explained')}
120
121
  ${clips.map(clip => this.renderClip(clip))}
121
- ${empty ? html`<p hint>${i18next.t('figure.text.no-controls')}</p>` : nothing}
122
+ ${empty ? html`<p hint>${i18next.t('figure.text.no-clips')}</p>` : nothing}
122
123
  </section>
123
124
  ${empty ? nothing : this.renderAdd(asset, clips)} ${this.renderGroups(asset)}
124
125
  `
@@ -156,7 +157,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
156
157
  const current = this.values[track.state]
157
158
  return html`<div track>
158
159
  <div track-head>
159
- <strong>${track.label} <small>(${track.unit})</small></strong>
160
+ <strong>${track.label} <small>(${unitWord(track.unit)})</small></strong>
160
161
  <select
161
162
  aria-label=${i18next.t('figure.label.clip-loop')}
162
163
  ?disabled=${this.locked}
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from 'lit'
4
4
  import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
+ import { unitWord } from './unit-words.js'
7
8
  import type { FigureAsset } from '@hatiolab/figure-model'
8
9
 
9
10
  import { controlsOf, poseAsDefault, poseDiffers, type Control } from '@things-factory/figure-viewer/dist-client/controls.js'
@@ -125,7 +126,7 @@ export class FigureControls extends localize(i18next)(LitElement) {
125
126
  const c = this.live[control.id] === undefined ? control : { ...control, value: this.live[control.id]! }
126
127
  const open = this.open === c.id
127
128
  return html`<div control>
128
- <span name title=${c.id}>${c.label} <em>${c.unit}</em></span>
129
+ <span name title=${c.id}>${c.label} <em>${unitWord(c.unit)}</em></span>
129
130
  ${c.unit === 'deg' ? this.renderDial(c) : html`<span></span>`}
130
131
  <input
131
132
  type="range"
@@ -177,7 +178,7 @@ export class FigureControls extends localize(i18next)(LitElement) {
177
178
  <div form>
178
179
  <span>${i18next.t('figure.label.control-name')}</span>
179
180
  <input .value=${c.named ? c.label : ''} placeholder=${c.id} ?disabled=${this.locked} @change=${(e: Event) => policy({ label: (e.target as HTMLInputElement).value })} />
180
- <span>${i18next.t('figure.label.control-range')} (${c.unit})</span>
181
+ <span>${i18next.t('figure.label.control-range')} (${unitWord(c.unit)})</span>
181
182
  <div pair>
182
183
  <input type="number" step="any" .value=${String(c.min)} ?disabled=${this.locked} @change=${(e: Event) => policy({ min: number(e) })} />
183
184
  <input type="number" step="any" .value=${String(c.max)} ?disabled=${this.locked} @change=${(e: Event) => policy({ max: number(e) })} />
@@ -185,7 +186,7 @@ export class FigureControls extends localize(i18next)(LitElement) {
185
186
  <span>${i18next.t('figure.label.control-sweep')}</span>
186
187
  <input type="number" min="0" step="any" .value=${String(c.sweep)} ?disabled=${this.locked} @change=${(e: Event) => policy({ sweep: number(e) })} />
187
188
  </div>
188
- <small>${i18next.t('figure.text.control-starts-at', { value: c.start, unit: c.unit })}</small>
189
+ <small>${i18next.t('figure.text.control-starts-at', { value: c.start, unit: unitWord(c.unit) })}</small>
189
190
  </div>`
190
191
  }
191
192
 
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from 'lit'
4
4
  import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
+ import { unitWord } from './unit-words.js'
7
8
  import { FIGURE_UNITS, figureDataExample, inferFigureDataSchema } from '@hatiolab/figure-model'
8
9
  import type {
9
10
  FigureAsset,
@@ -137,6 +138,17 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
137
138
  overflow-wrap: anywhere;
138
139
  cursor: pointer;
139
140
  }
141
+ /* What it reads and where on the first line, its method under it: a method wrapped alone read 「· 배율」 (sweep 2026-10-27 U11). */
142
+ div[head] button[line] small {
143
+ display: block;
144
+ color: var(--md-sys-color-on-surface-variant);
145
+ }
146
+ /* A binding a product capability takes over is marked where the list is read, folded or not (sweep 2026-10-27 U18). */
147
+ div[head] button[line] span[rests] {
148
+ padding: 0 6px;
149
+ border: 1px solid var(--md-sys-color-outline-variant);
150
+ border-radius: 999px;
151
+ }
140
152
  div[head] button[act] {
141
153
  width: 30px;
142
154
  min-height: var(--figure-control-height);
@@ -307,7 +319,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
307
319
  const kind = i18next.t('figure.label.data-kind-' + node.kind)
308
320
  if (node.kind === 'number') {
309
321
  const range = node.min !== undefined || node.max !== undefined ? ` ${node.min ?? ''}~${node.max ?? ''}` : ''
310
- return `${kind}${node.unit ? ` ${node.unit}` : ''}${range}`
322
+ return `${kind}${node.unit ? ` ${unitWord(node.unit)}` : ''}${range}`
311
323
  }
312
324
  if (node.kind === 'text' && node.values) return `${kind} (${node.values.join(', ')})`
313
325
  if (node.kind === 'list') return `${kind} · ${i18next.t('figure.label.data-kind-' + node.item.kind)}`
@@ -346,7 +358,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
346
358
  this.edit({ kind: 'set-data-schema', schema: schemaWithoutField(schema, row.path) })
347
359
  }}
348
360
  >
349
- <md-icon>close</md-icon>
361
+ <md-icon>delete</md-icon>
350
362
  </button>
351
363
  </div>
352
364
  <div form>
@@ -361,8 +373,8 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
361
373
  ${field.kind === 'number'
362
374
  ? html`<span>${i18next.t('figure.label.data-unit')}</span>
363
375
  <select ?disabled=${this.locked} @change=${(e: Event) => set({ unit: value(e) || undefined })}>
364
- <option value="" ?selected=${!field.unit}>-</option>
365
- ${FIGURE_UNITS.map(u => html`<option value=${u} ?selected=${u === field.unit}>${u}</option>`)}
376
+ <option value="" ?selected=${!field.unit}>${i18next.t('figure.label.unit-none')}</option>
377
+ ${FIGURE_UNITS.map(u => html`<option value=${u} ?selected=${u === field.unit}>${unitWord(u)}</option>`)}
366
378
  </select>
367
379
  <span>${i18next.t('figure.label.data-min')}</span>
368
380
  <input type="number" step="any" .value=${field.min === undefined ? '' : String(field.min)} ?disabled=${this.locked} @change=${(e: Event) => set({ min: number(e) })} />
@@ -412,22 +424,25 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
412
424
  private renderAddField(schema: readonly FigureDataField[], under: string) {
413
425
  const a = this.adding
414
426
  const named = a.name.trim()
427
+ const add = () => {
428
+ if (this.locked || !named) return
429
+ this.edit({ kind: 'set-data-schema', schema: schemaWithField(schema, under, { name: named, ...nodeOfKind(a.kind) } as FigureDataField) })
430
+ this.adding = { ...a, name: '' }
431
+ }
415
432
  return html`<div form>
416
433
  <span>${i18next.t('figure.label.data-name')}</span>
417
- <input .value=${a.name} @input=${(e: Event) => (this.adding = { ...a, name: (e.target as HTMLInputElement).value })} />
434
+ <input
435
+ aria-label=${i18next.t('figure.label.data-name')}
436
+ .value=${a.name}
437
+ @input=${(e: Event) => (this.adding = { ...a, name: (e.target as HTMLInputElement).value })}
438
+ @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && !e.isComposing && add()}
439
+ />
418
440
  <span>${i18next.t('figure.label.data-kind')}</span>
419
441
  <select @change=${(e: Event) => (this.adding = { ...a, kind: (e.target as HTMLSelectElement).value as FigureDataNode['kind'] })}>
420
442
  ${KINDS.map(k => html`<option value=${k} ?selected=${k === a.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
421
443
  </select>
422
444
  </div>
423
- <button
424
- act
425
- ?disabled=${this.locked || !named}
426
- @click=${() => {
427
- this.edit({ kind: 'set-data-schema', schema: schemaWithField(schema, under, { name: named, ...nodeOfKind(a.kind) } as FigureDataField) })
428
- this.adding = { ...a, name: '' }
429
- }}
430
- >
445
+ <button act ?disabled=${this.locked || !named} @click=${add}>
431
446
  <md-icon>add</md-icon>${i18next.t(under ? 'figure.button.add-data-child' : 'figure.button.add-data-field', { parent: under })}
432
447
  </button>`
433
448
  }
@@ -499,13 +514,15 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
499
514
  const open = this.opened === b.id
500
515
  const set = (next: FigureDataBinding) => this.edit({ kind: 'set-data-binding', binding: next })
501
516
  const what = b.rule === 'table' ? this.targetsName(asset, b) : this.behaviorName(asset, choices, b.behavior)
517
+ const rests = resting.some(r => r.id === b.id)
502
518
  return html`<section item>
503
519
  <div head>
504
520
  <button line aria-expanded=${open ? 'true' : 'false'} @click=${() => (this.opened = open ? '' : b.id)}>
505
- <strong>${b.label ?? b.accessor}</strong> → ${what} <small>· ${i18next.t('figure.label.data-method-' + methodOf(b))}</small>
521
+ <strong>${b.label ?? b.accessor}</strong> → ${what}
522
+ <small>${i18next.t('figure.label.data-method-' + methodOf(b))}${rests ? html` <span rests>${i18next.t('figure.label.data-binding-resting')}</span>` : nothing}</small>
506
523
  </button>
507
524
  <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-binding')} @click=${() => this.edit({ kind: 'remove-data-binding', id: b.id })}>
508
- <md-icon>close</md-icon>
525
+ <md-icon>delete</md-icon>
509
526
  </button>
510
527
  </div>
511
528
  ${resting.filter(r => r.id === b.id).map(r => this.renderResting(r))}
@@ -700,7 +717,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
700
717
  return html`<div looks style="grid-template-columns: ${gridOf(keyCells, columns.length)}">
701
718
  <span style="grid-column: span ${keyCells}">${i18next.t('figure.label.data-otherwise')}</span>
702
719
  ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, 'default', l)))}
703
- ${needed ? html`<span></span>` : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => set({ ...b, table: rest })}><md-icon>close</md-icon></button>`}
720
+ ${needed ? html`<span></span>` : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => set({ ...b, table: rest })}><md-icon>delete</md-icon></button>`}
704
721
  </div>`
705
722
  }
706
723
 
@@ -736,7 +753,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
736
753
  ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
737
754
  ${boolean || rows.length < 2 && domain.length > 0
738
755
  ? html`<span></span>`
739
- : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => rekeyed(rows.filter((_, j) => j !== i))}><md-icon>close</md-icon></button>`}`
756
+ : html`<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-case')} @click=${() => rekeyed(rows.filter((_, j) => j !== i))}><md-icon>delete</md-icon></button>`}`
740
757
  )}
741
758
  </div>
742
759
  ${this.renderOtherwise(asset, b, columns, domain.length > 0 && !covered, set, 1)}
@@ -779,7 +796,7 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
779
796
  return html`<input type="number" step="any" .value=${e.from} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'from', (ev.target as HTMLInputElement).value)} />
780
797
  <input type="number" step="any" .value=${e.to} ?disabled=${this.locked} @change=${(ev: Event) => rekey(i, 'to', (ev.target as HTMLInputElement).value)} />
781
798
  ${this.lookCells(asset, columns, look, l => set(tableWithLook(b, k, l)))}
782
- <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-range')} @click=${() => set(tableWithKeys(b, rows.filter((_, j) => j !== i)))}><md-icon>close</md-icon></button>`
799
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-range')} @click=${() => set(tableWithKeys(b, rows.filter((_, j) => j !== i)))}><md-icon>delete</md-icon></button>`
783
800
  })}
784
801
  </div>
785
802
  ${this.renderOtherwise(asset, b, columns, false, set, 2)}
@@ -868,9 +885,12 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
868
885
  <input .value=${a.name} @input=${(e: Event) => set({ name: typed(e) })} />`
869
886
  : nothing}
870
887
  <span>${i18next.t('figure.label.data-method')}</span>
871
- <select ?disabled=${!methods.length} @change=${(e: Event) => set({ method: typed(e) as DataMethod })}>
872
- ${methods.map(m => html`<option value=${m} ?selected=${m === method}>${i18next.t('figure.label.data-method-' + m)}</option>`)}
873
- </select>
888
+ ${methods.length === 1
889
+ ? /* One way only (a text is read by a code table): said, not offered as a choice of one (sweep 2026-10-27 U10). */
890
+ html`<span method>${i18next.t('figure.label.data-method-' + methods[0])}</span>`
891
+ : html`<select ?disabled=${!methods.length} @change=${(e: Event) => set({ method: typed(e) as DataMethod })}>
892
+ ${methods.map(m => html`<option value=${m} ?selected=${m === method}>${i18next.t('figure.label.data-method-' + m)}</option>`)}
893
+ </select>`}
874
894
  ${!table && fitting.length
875
895
  ? html`<span>${i18next.t('figure.label.data-behavior')}</span>
876
896
  <select @change=${(e: Event) => set({ behavior: typed(e) })}>
@@ -7,6 +7,7 @@ import './figure-size-editor.js'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
 
9
9
  import { i18next, localize } from '@operato/i18n'
10
+ import { unitWord } from './unit-words.js'
10
11
  import { figureFaceOverhangOf, figureMotionsOf, motionIdOf } from '@hatiolab/figure-model'
11
12
  import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureFace } from '@hatiolab/figure-model'
12
13
 
@@ -624,7 +625,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
624
625
  const axes = followableAxes(asset, part)
625
626
  if (!axes.length) return nothing
626
627
  const word = (axis: Axis) => `${axis.toUpperCase()}${measureFactor(asset, part, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
627
- const unitWord = (unit: string) => i18next.t(`figure.label.state-unit-${unit}`)
628
628
  const sized = axes.flatMap(axis => {
629
629
  const f = followOf(asset, part, axis)
630
630
  return f.of === 'state' ? [{ axis, f }] : []
@@ -687,9 +687,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
687
687
  <input type="number" step="any" .value=${String(form.min)} ?disabled=${this.locked} @change=${number('min')} />
688
688
  <input type="number" step="any" .value=${String(form.max)} ?disabled=${this.locked} @change=${number('max')} />
689
689
  </div>
690
- <span>${i18next.t('figure.label.state-size-at-min')} (mm)</span>
690
+ <span>${i18next.t('figure.label.state-size-at-min', { at: String(form.min) })} (mm)</span>
691
691
  <input type="number" step="any" min="0" state-size-at-min .value=${Number.isFinite(form.atMin) ? String(form.atMin) : ''} ?disabled=${this.locked} @change=${number('atMin')} />
692
- <span>${i18next.t('figure.label.state-size-at-max')} (mm)</span>
692
+ <span>${i18next.t('figure.label.state-size-at-max', { at: String(form.max) })} (mm)</span>
693
693
  <input type="number" step="any" min="0" state-size-at-max .value=${Number.isFinite(atMax) ? String(atMax) : ''} ?disabled=${this.locked} @change=${number('atMax')} />
694
694
  </div>
695
695
  <button act add-state-size ?disabled=${this.locked || !command} @click=${() => {
@@ -768,7 +768,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
768
768
  : it.follows
769
769
  ? html`<small>${i18next.t('figure.label.motion-follow')}: ${it.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${it.follows.offset}</small>`
770
770
  : it.control
771
- ? html`<small>${it.control.id} (${it.control.unit}) ${it.control.min} … ${it.control.max}</small>`
771
+ ? html`<small>${it.control.id} (${unitWord(it.control.unit)}) ${it.control.min} … ${it.control.max}</small>`
772
772
  : nothing}
773
773
  </span>
774
774
  ${count > 1
@@ -792,7 +792,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
792
792
  })}
793
793
  </p>
794
794
  ${ends
795
- ? html`<p hint motion-ends>${i18next.t('figure.text.motion-ends-shown', { min: String(ends.min), max: String(ends.max), unit: ends.unit })}</p>`
795
+ ? html`<p hint motion-ends>${i18next.t('figure.text.motion-ends-shown', { min: String(ends.min), max: String(ends.max), unit: unitWord(ends.unit) })}</p>`
796
796
  : nothing}
797
797
  ${Array.from({ length: count }, (_, k) => row(k))}
798
798
  <div pair>