@things-factory/figure-ui 10.1.225 → 10.1.227

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 (94) hide show
  1. package/client/graphql/index.ts +6 -6
  2. package/client/modeller/consumer-capabilities.ts +15 -0
  3. package/client/modeller/editor-styles.ts +18 -1
  4. package/client/modeller/editor.ts +38 -26
  5. package/client/modeller/figure-capability-panel.ts +31 -10
  6. package/client/modeller/figure-clips.ts +2 -1
  7. package/client/modeller/figure-controls.ts +4 -3
  8. package/client/modeller/figure-data-panel.ts +24 -7
  9. package/client/modeller/figure-history-panel.ts +7 -7
  10. package/client/modeller/figure-links.ts +5 -5
  11. package/client/modeller/figure-material-panel.ts +2 -0
  12. package/client/modeller/figure-preview.ts +69 -2
  13. package/client/modeller/figure-recipe-catalog.ts +4 -0
  14. package/client/modeller/figure-slot-layout-panel.ts +25 -8
  15. package/client/modeller/figure-verdict.ts +4 -1
  16. package/client/modeller/unit-words.ts +11 -0
  17. package/client/pages/figure-list-page.ts +2 -2
  18. package/client/pages/figure-modeller-page.ts +7 -6
  19. package/client/types.ts +2 -2
  20. package/client/viewparts/figure-thumb.ts +1 -1
  21. package/dist-client/graphql/index.d.ts +5 -5
  22. package/dist-client/graphql/index.js +6 -6
  23. package/dist-client/graphql/index.js.map +1 -1
  24. package/dist-client/modeller/consumer-capabilities.d.ts +11 -0
  25. package/dist-client/modeller/consumer-capabilities.js +14 -0
  26. package/dist-client/modeller/consumer-capabilities.js.map +1 -1
  27. package/dist-client/modeller/editor-styles.js +18 -1
  28. package/dist-client/modeller/editor-styles.js.map +1 -1
  29. package/dist-client/modeller/editor.js +38 -26
  30. package/dist-client/modeller/editor.js.map +1 -1
  31. package/dist-client/modeller/figure-capability-panel.d.ts +3 -0
  32. package/dist-client/modeller/figure-capability-panel.js +29 -11
  33. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  34. package/dist-client/modeller/figure-clips.js +2 -1
  35. package/dist-client/modeller/figure-clips.js.map +1 -1
  36. package/dist-client/modeller/figure-controls.js +4 -3
  37. package/dist-client/modeller/figure-controls.js.map +1 -1
  38. package/dist-client/modeller/figure-data-panel.js +24 -7
  39. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  40. package/dist-client/modeller/figure-history-panel.d.ts +5 -5
  41. package/dist-client/modeller/figure-history-panel.js +7 -7
  42. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  43. package/dist-client/modeller/figure-links.js +5 -5
  44. package/dist-client/modeller/figure-links.js.map +1 -1
  45. package/dist-client/modeller/figure-material-panel.js +2 -0
  46. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  47. package/dist-client/modeller/figure-preview.d.ts +3 -0
  48. package/dist-client/modeller/figure-preview.js +78 -2
  49. package/dist-client/modeller/figure-preview.js.map +1 -1
  50. package/dist-client/modeller/figure-recipe-catalog.js +4 -0
  51. package/dist-client/modeller/figure-recipe-catalog.js.map +1 -1
  52. package/dist-client/modeller/figure-slot-layout-panel.d.ts +1 -1
  53. package/dist-client/modeller/figure-slot-layout-panel.js +25 -8
  54. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  55. package/dist-client/modeller/figure-verdict.js +4 -1
  56. package/dist-client/modeller/figure-verdict.js.map +1 -1
  57. package/dist-client/modeller/unit-words.d.ts +5 -0
  58. package/dist-client/modeller/unit-words.js +12 -0
  59. package/dist-client/modeller/unit-words.js.map +1 -0
  60. package/dist-client/pages/figure-list-page.d.ts +1 -1
  61. package/dist-client/pages/figure-list-page.js +2 -2
  62. package/dist-client/pages/figure-list-page.js.map +1 -1
  63. package/dist-client/pages/figure-modeller-page.d.ts +3 -3
  64. package/dist-client/pages/figure-modeller-page.js +7 -6
  65. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  66. package/dist-client/tsconfig.tsbuildinfo +1 -1
  67. package/dist-client/types.d.ts +2 -2
  68. package/dist-client/types.js.map +1 -1
  69. package/dist-client/viewparts/figure-thumb.js +1 -1
  70. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  71. package/package.json +9 -9
  72. package/test/browser/detail-page.js +1 -1
  73. package/test/browser/editing-page.js +3 -3
  74. package/test/browser/editing.check.mjs +5 -5
  75. package/test/browser/links-page.js +1 -1
  76. package/test/browser/links.check.mjs +2 -3
  77. package/test/browser/modeller-page.js +4 -4
  78. package/test/browser/modeller-revision.check.mjs +5 -5
  79. package/test/browser/modeller.check.mjs +5 -5
  80. package/test/browser/parts.check.mjs +2 -2
  81. package/test/browser/product-page.check.mjs +6 -0
  82. package/test/browser/props.check.mjs +3 -4
  83. package/test/browser/row-preview.check.mjs +3 -3
  84. package/test/browser/screen.check.mjs +13 -11
  85. package/test/consumer-capabilities.test.ts +7 -0
  86. package/test/links.test.ts +1 -1
  87. package/test/refusals.test.ts +1 -1
  88. package/test/resolve-ts-siblings.mjs +3 -3
  89. package/translations/en.json +23 -12
  90. package/translations/es.json +23 -12
  91. package/translations/ja.json +24 -13
  92. package/translations/ko.json +83 -72
  93. package/translations/ms.json +23 -12
  94. package/translations/zh.json +23 -12
@@ -45,7 +45,7 @@ function unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly
45
45
  * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림
46
46
  * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로
47
47
  * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를
48
- * 그것으로 알고, 판을 가리는 값으로도 쓴다.
48
+ * 그것으로 알고, 버전을 가리는 값으로도 쓴다.
49
49
  */
50
50
  const FIGURE_LIST_FIELDS = `
51
51
  id
@@ -226,7 +226,7 @@ export async function deleteFigure(id: string): Promise<boolean> {
226
226
  }
227
227
 
228
228
  /**
229
- * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.
229
+ * 발행한다 — 버전 번호가 오르고, 그 순간이 버전으로 남는다.
230
230
  *
231
231
  * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은
232
232
  * 발행을 푼다(초안으로 돌아온다).
@@ -340,7 +340,7 @@ export interface KeptDraft {
340
340
  const KEPT_FIELDS = `id kind state version revertedToVersion restoredAt createdAt creator { name }`
341
341
 
342
342
  /**
343
- * 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 덮이는 초안은 같은 트랜잭션에서 서버가
343
+ * 옛 버전을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 덮이는 초안은 같은 트랜잭션에서 서버가
344
344
  * 보관한다. `expectedSource` 는 서버가 들고 있다고 확인된 정본 -- 그 사이 바뀌었으면 거절된다. 답을 못 받았으면
345
345
  * (REVERT_UNREACHABLE) 같은 `requestId` 로 다시 묻는다: 서버는 같은 되돌리기로 답하고 두 번 하지 않는다.
346
346
  */
@@ -464,7 +464,7 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
464
464
  *
465
465
  * 발행 패널의 `inspectFigure` 는 저장된 원본을 재므로 고친 결과를 저장한 뒤에야 보여 준다. 편집하면서 막히는
466
466
  * 것을 보려면 편집 중인 원본을 보내야 한다. 판정은 서버의 같은 문(`inspectAnySource`)이 하고 문장도 같다 —
467
- * 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행판을 읽는다.
467
+ * 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행 버전을 읽는다.
468
468
  */
469
469
  export async function inspectFigureDraft(source: string, id?: string): Promise<FigureInspection> {
470
470
  const response = await client.query({
@@ -490,9 +490,9 @@ export async function inspectFigureDraft(source: string, id?: string): Promise<F
490
490
  return unwrap<FigureInspection>(response, 'inspectFigureDraft')
491
491
  }
492
492
 
493
- /** 발행된 판들 — 최근 것부터 열 개. */
493
+ /** 발행된 버전들 — 최근 것부터 열 개. */
494
494
  /**
495
- * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.
495
+ * 직전 발행 버전의 정본. 한 번도 발행 안 했으면 `undefined`.
496
496
  *
497
497
  * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장
498
498
  * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
@@ -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) })}>
@@ -20,13 +20,13 @@ import type { FigureGateFinding, FigureInspection, FigureVersion } from '../type
20
20
  import { OxPrompt } from '@operato/popup'
21
21
 
22
22
  /**
23
- * 이력 — **발행하는 자리이자, 발행된 판들을 보는 자리.**
23
+ * 이력 — **발행하는 자리이자, 발행된 버전들을 보는 자리.**
24
24
  *
25
25
  * ## 왜 저장 옆이 아니라 여기인가
26
26
  *
27
27
  * 저장은 하루에 수십 번이고 발행은 드물다. 두 단추를 나란히 두면 저장하려다 발행을 누른다.
28
- * 그리고 발행은 **소비처에게 하는 약속**이라, 무엇을 약속하는지(지금 몇 판인가 · 저장 안 한 것이
29
- * 있나 · 지난 판이 무엇이었나)를 함께 보여 주는 자리에 있어야 한다.
28
+ * 그리고 발행은 **소비처에게 하는 약속**이라, 무엇을 약속하는지(지금 몇 버전인가 · 저장 안 한 것이
29
+ * 있나 · 지난 버전이 무엇이었나)를 함께 보여 주는 자리에 있어야 한다.
30
30
  *
31
31
  * ## 저장하지 않은 것이 있으면 발행하지 않는다
32
32
  *
@@ -35,11 +35,11 @@ import { OxPrompt } from '@operato/popup'
35
35
  *
36
36
  * ## 되돌리기는 초안으로 돌아온다
37
37
  *
38
- * 옛 판의 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 되살렸다고 그 판이 다시 발행되지는
38
+ * 옛 버전의 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 되살렸다고 그 버전이 다시 발행되지는
39
39
  * 않는다 — 다시 내보내려면 발행을 한 번 더 하고, 그때 번호는 이미 쓴 다음 번호를 받는다.
40
40
  *
41
41
  * 덮이는 초안은 서버가 같은 트랜잭션에서 보관하고, 「되돌리기 전 초안」에 남는다(ADR-0097 §5.6, 디자이너 2026-09-27).
42
- * 발행 판과 섞지 않는다. 다시 접속해도 거기서 찾고, 가장 최근 것은 「되돌리기 취소」로 되살린다 — 되돌린 뒤 모델이
42
+ * 발행 버전과 섞지 않는다. 다시 접속해도 거기서 찾고, 가장 최근 것은 「되돌리기 취소」로 되살린다 — 되돌린 뒤 모델이
43
43
  * 바뀌었으면 서버가 거절하고 보관본은 남는다.
44
44
  */
45
45
  @customElement('figure-history-panel')
@@ -501,7 +501,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
501
501
  if (this.dirty) {
502
502
  if (!(await this.settleUnsaved('revert', version))) return
503
503
  } else {
504
- /* 지금 초안은 서버가 보관하지만, 화면의 것이 그 판으로 바뀐다 — 이름으로 확인한다. */
504
+ /* 지금 초안은 서버가 보관하지만, 화면의 것이 그 버전으로 바뀐다 — 이름으로 확인한다. */
505
505
  const agreed = await OxPrompt.open({
506
506
  type: 'warning',
507
507
  title: i18next.t('figure.button.revert'),
@@ -542,7 +542,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
542
542
  this.dispatchEvent(new CustomEvent('figure-reverted', { detail: result, bubbles: true, composed: true }))
543
543
  await this.reload()
544
544
  /*
545
- **되돌리면 정본이 바뀌므로 판정도 바뀐다.** 이것을 안 하면 옛 판의 사유가 새 정본 위에
545
+ **되돌리면 정본이 바뀌므로 판정도 바뀐다.** 이것을 안 하면 옛 버전의 사유가 새 정본 위에
546
546
  남아 화면이 거짓말한다 — 되돌리기는 `dirty` 도 `figureVersion` 도 건드리지 않으므로
547
547
  `updated` 의 감시로는 다시 묻지 않는다.
548
548
  */
@@ -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"