@things-factory/figure-ui 10.1.225 → 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 (73) hide show
  1. package/client/modeller/consumer-capabilities.ts +15 -0
  2. package/client/modeller/editor-styles.ts +18 -1
  3. package/client/modeller/editor.ts +38 -26
  4. package/client/modeller/figure-capability-panel.ts +31 -10
  5. package/client/modeller/figure-clips.ts +2 -1
  6. package/client/modeller/figure-controls.ts +4 -3
  7. package/client/modeller/figure-data-panel.ts +24 -7
  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 +69 -2
  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/unit-words.ts +11 -0
  15. package/client/pages/figure-modeller-page.ts +3 -2
  16. package/dist-client/modeller/consumer-capabilities.d.ts +11 -0
  17. package/dist-client/modeller/consumer-capabilities.js +14 -0
  18. package/dist-client/modeller/consumer-capabilities.js.map +1 -1
  19. package/dist-client/modeller/editor-styles.js +18 -1
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.js +38 -26
  22. package/dist-client/modeller/editor.js.map +1 -1
  23. package/dist-client/modeller/figure-capability-panel.d.ts +3 -0
  24. package/dist-client/modeller/figure-capability-panel.js +29 -11
  25. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  26. package/dist-client/modeller/figure-clips.js +2 -1
  27. package/dist-client/modeller/figure-clips.js.map +1 -1
  28. package/dist-client/modeller/figure-controls.js +4 -3
  29. package/dist-client/modeller/figure-controls.js.map +1 -1
  30. package/dist-client/modeller/figure-data-panel.js +24 -7
  31. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  32. package/dist-client/modeller/figure-links.js +5 -5
  33. package/dist-client/modeller/figure-links.js.map +1 -1
  34. package/dist-client/modeller/figure-material-panel.js +2 -0
  35. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  36. package/dist-client/modeller/figure-preview.d.ts +3 -0
  37. package/dist-client/modeller/figure-preview.js +78 -2
  38. package/dist-client/modeller/figure-preview.js.map +1 -1
  39. package/dist-client/modeller/figure-recipe-catalog.js +4 -0
  40. package/dist-client/modeller/figure-recipe-catalog.js.map +1 -1
  41. package/dist-client/modeller/figure-slot-layout-panel.d.ts +1 -1
  42. package/dist-client/modeller/figure-slot-layout-panel.js +25 -8
  43. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  44. package/dist-client/modeller/figure-verdict.js +4 -1
  45. package/dist-client/modeller/figure-verdict.js.map +1 -1
  46. package/dist-client/modeller/unit-words.d.ts +5 -0
  47. package/dist-client/modeller/unit-words.js +12 -0
  48. package/dist-client/modeller/unit-words.js.map +1 -0
  49. package/dist-client/pages/figure-modeller-page.js +3 -2
  50. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  51. package/dist-client/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +9 -9
  53. package/test/browser/editing-page.js +3 -3
  54. package/test/browser/editing.check.mjs +5 -5
  55. package/test/browser/links-page.js +1 -1
  56. package/test/browser/links.check.mjs +2 -3
  57. package/test/browser/modeller-page.js +4 -4
  58. package/test/browser/modeller-revision.check.mjs +5 -5
  59. package/test/browser/modeller.check.mjs +5 -5
  60. package/test/browser/parts.check.mjs +2 -2
  61. package/test/browser/product-page.check.mjs +6 -0
  62. package/test/browser/props.check.mjs +3 -4
  63. package/test/browser/row-preview.check.mjs +3 -3
  64. package/test/browser/screen.check.mjs +13 -11
  65. package/test/consumer-capabilities.test.ts +7 -0
  66. package/test/links.test.ts +1 -1
  67. package/test/refusals.test.ts +1 -1
  68. package/translations/en.json +23 -12
  69. package/translations/es.json +23 -12
  70. package/translations/ja.json +24 -13
  71. package/translations/ko.json +82 -71
  72. package/translations/ms.json +23 -12
  73. package/translations/zh.json +23 -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
@@ -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'}>
@@ -2041,18 +2051,19 @@ export class FigureEditor extends LitElement {
2041
2051
  return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10)
2042
2052
  }
2043
2053
  return html`<div vector-row>
2044
- <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>
2045
2055
  <div triple>
2046
2056
  ${(['x', 'y', 'z'] as const).map(
2047
2057
  axis => !group.cells[axis]
2048
2058
  ? html`<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
2049
2059
  <span axis>${axis.toUpperCase()}</span>
2050
- <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')}`} />
2051
2061
  </div>`
2052
2062
  : html`<div cell>
2053
2063
  <span axis ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}>${axis.toUpperCase()}</span>
2054
2064
  <input
2055
- 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()}`}
2056
2067
  type="number"
2057
2068
  step="any"
2058
2069
  .value=${String(group.cells[axis]!.value)}
@@ -2087,9 +2098,10 @@ export class FigureEditor extends LitElement {
2087
2098
  }
2088
2099
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
2089
2100
  const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
2090
- 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
2091
2102
  ><input
2092
- aria-label=${field.id}
2103
+ data-field=${field.id}
2104
+ aria-label=${words.join(' · ')}
2093
2105
  type="number"
2094
2106
  step="any"
2095
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,
@@ -141,7 +143,8 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
141
143
  const names = asset.document.capabilities.names ?? []
142
144
  const rung = rungOf(names)
143
145
  const places = figureBindingsOf(asset)
144
- return html`<section>
146
+ return html`${this.renderItem(asset)}
147
+ <section>
145
148
  <h3>${i18next.t('figure.label.what-this-does')}</h3>
146
149
  ${(['none', 'hold', 'flow'] as const).map(
147
150
  which => html`<label rung ?on=${rung === which}>
@@ -153,10 +156,18 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
153
156
  </label>`
154
157
  )}
155
158
  ${rung === null ? html`<p hint>${i18next.t('figure.text.capability-hand-written')}</p>` : nothing}
156
- ${names.length ? html`<code>${names.join(' · ')}</code>` : nothing}
157
159
  </section>
158
160
  ${this.renderConsumers(asset)}
159
- <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>
160
171
  <h3>${i18next.t('figure.label.places')}</h3>
161
172
  ${places.length
162
173
  ? places.map(
@@ -170,8 +181,12 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
170
181
  )
171
182
  : html`<p hint>${i18next.t('figure.text.no-places')}</p>`}
172
183
  ${places.length && rung === 'none' ? html`<p hint>${i18next.t('figure.text.place-needs-holding')}</p>` : nothing}
173
- </section>
174
- <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>
175
190
  <h3>${i18next.t('figure.label.item')}</h3>
176
191
  <label item ?on=${!!asset.item}>
177
192
  <input type="checkbox" .checked=${live(!!asset.item)} ?disabled=${this.locked} @change=${(e: Event) => this.setItem((e.target as HTMLInputElement).checked)} />
@@ -180,8 +195,7 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
180
195
  <em>${i18next.t('figure.text.is-item-note')}</em>
181
196
  </span>
182
197
  </label>
183
- </section>
184
- `
198
+ </section>`
185
199
  }
186
200
 
187
201
  /** The products' capabilities: those this modeller knows, each on or off with its settings, and those it does not know. */
@@ -195,15 +209,22 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
195
209
  ${known.map(d => {
196
210
  const on = names.includes(d.name)
197
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
+ ]
198
220
  return html`<label rung ?on=${on}>
199
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)} />
200
222
  <span>
201
223
  <strong>${d.label}</strong>
202
224
  <em>${d.note}</em>
203
- <code>${d.name} · ${d.version}</code>
225
+ ${needs.length ? html`<small needs>${i18next.t('figure.text.product-capability-needs', { needs: needs.join(', ') })}</small>` : nothing}
204
226
  </span>
205
227
  </label>
206
- ${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}
207
228
  ${on ? this.renderSettings(asset, d) : nothing}`
208
229
  })}
209
230
  ${unknown.map(
@@ -241,7 +262,7 @@ export class FigureCapabilityPanel extends localize(i18next)(LitElement) {
241
262
  return html`<span>${setting.label}</span>
242
263
  <select ?disabled=${this.locked} @change=${(e: Event) => write(withInputTable(settings, setting, (e.target as HTMLSelectElement).value || undefined, table.values))}>
243
264
  <option value="" ?selected=${!table.input}>${i18next.t('figure.label.data-none')}</option>
244
- ${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>`)}
245
266
  </select>
246
267
  ${table.input
247
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'
@@ -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)}`
@@ -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) })} />
@@ -502,10 +514,12 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
502
514
  const open = this.opened === b.id
503
515
  const set = (next: FigureDataBinding) => this.edit({ kind: 'set-data-binding', binding: next })
504
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)
505
518
  return html`<section item>
506
519
  <div head>
507
520
  <button line aria-expanded=${open ? 'true' : 'false'} @click=${() => (this.opened = open ? '' : b.id)}>
508
- <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>
509
523
  </button>
510
524
  <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-binding')} @click=${() => this.edit({ kind: 'remove-data-binding', id: b.id })}>
511
525
  <md-icon>delete</md-icon>
@@ -871,9 +885,12 @@ export class FigureDataPanel extends localize(i18next)(LitElement) {
871
885
  <input .value=${a.name} @input=${(e: Event) => set({ name: typed(e) })} />`
872
886
  : nothing}
873
887
  <span>${i18next.t('figure.label.data-method')}</span>
874
- <select ?disabled=${!methods.length} @change=${(e: Event) => set({ method: typed(e) as DataMethod })}>
875
- ${methods.map(m => html`<option value=${m} ?selected=${m === method}>${i18next.t('figure.label.data-method-' + m)}</option>`)}
876
- </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>`}
877
894
  ${!table && fitting.length
878
895
  ? html`<span>${i18next.t('figure.label.data-behavior')}</span>
879
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>
@@ -219,7 +219,9 @@ export class FigureMaterialPanel extends localize(i18next)(LitElement) {
219
219
  private renderMore(part: FigureAssetGeometry & { id: string }, painted: [string, string][], lit: [string, string][]) {
220
220
  const material = part.material
221
221
  const glow = material.emissive
222
+ /* Under its own title: below 「단면」 without one, transparency and glow read as the section's (sweep 2026-10-27 U21). */
222
223
  return html`<div more>
224
+ <h3>${i18next.t('figure.label.part-material')}</h3>
223
225
  <label toggle>
224
226
  <input
225
227
  type="checkbox"