@things-factory/figure-ui 10.1.205 → 10.1.207

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 (105) hide show
  1. package/client/modeller/editor-styles.ts +16 -0
  2. package/client/modeller/editor.ts +53 -137
  3. package/client/modeller/fasten-form.ts +50 -11
  4. package/client/modeller/figure-facts.ts +1 -1
  5. package/client/modeller/figure-fasten-dialog.ts +3 -2
  6. package/client/modeller/figure-history-panel.ts +1 -29
  7. package/client/modeller/figure-links.ts +2 -2
  8. package/client/modeller/figure-preview.ts +10 -12
  9. package/client/modeller/figure-verdict.ts +2 -17
  10. package/client/modeller/gizmo.ts +10 -0
  11. package/client/modeller/keys.ts +10 -1
  12. package/client/modeller/links.ts +32 -41
  13. package/client/modeller/maturity.ts +11 -27
  14. package/client/modeller/palette.ts +1 -1
  15. package/client/modeller/product-session.ts +16 -4
  16. package/client/modeller/refusal-teller.ts +3 -25
  17. package/client/modeller/refusals.ts +20 -33
  18. package/client/pages/figure-modeller-page.ts +7 -22
  19. package/dist-client/modeller/editor-styles.js +16 -0
  20. package/dist-client/modeller/editor-styles.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +8 -11
  22. package/dist-client/modeller/editor.js +57 -138
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/fasten-form.d.ts +16 -7
  25. package/dist-client/modeller/fasten-form.js +45 -11
  26. package/dist-client/modeller/fasten-form.js.map +1 -1
  27. package/dist-client/modeller/figure-facts.js +1 -1
  28. package/dist-client/modeller/figure-facts.js.map +1 -1
  29. package/dist-client/modeller/figure-fasten-dialog.js +3 -3
  30. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  31. package/dist-client/modeller/figure-history-panel.d.ts +0 -16
  32. package/dist-client/modeller/figure-history-panel.js +1 -27
  33. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  34. package/dist-client/modeller/figure-links.js +2 -2
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/figure-preview.js +8 -11
  37. package/dist-client/modeller/figure-preview.js.map +1 -1
  38. package/dist-client/modeller/figure-verdict.d.ts +0 -5
  39. package/dist-client/modeller/figure-verdict.js +2 -21
  40. package/dist-client/modeller/figure-verdict.js.map +1 -1
  41. package/dist-client/modeller/gizmo.d.ts +6 -0
  42. package/dist-client/modeller/gizmo.js +11 -0
  43. package/dist-client/modeller/gizmo.js.map +1 -1
  44. package/dist-client/modeller/keys.d.ts +1 -1
  45. package/dist-client/modeller/keys.js +6 -0
  46. package/dist-client/modeller/keys.js.map +1 -1
  47. package/dist-client/modeller/links.d.ts +16 -22
  48. package/dist-client/modeller/links.js +26 -39
  49. package/dist-client/modeller/links.js.map +1 -1
  50. package/dist-client/modeller/maturity.d.ts +2 -4
  51. package/dist-client/modeller/maturity.js +12 -26
  52. package/dist-client/modeller/maturity.js.map +1 -1
  53. package/dist-client/modeller/palette.js +1 -1
  54. package/dist-client/modeller/palette.js.map +1 -1
  55. package/dist-client/modeller/product-session.d.ts +15 -13
  56. package/dist-client/modeller/product-session.js +15 -5
  57. package/dist-client/modeller/product-session.js.map +1 -1
  58. package/dist-client/modeller/refusal-teller.d.ts +1 -12
  59. package/dist-client/modeller/refusal-teller.js +3 -22
  60. package/dist-client/modeller/refusal-teller.js.map +1 -1
  61. package/dist-client/modeller/refusals.d.ts +7 -17
  62. package/dist-client/modeller/refusals.js +17 -34
  63. package/dist-client/modeller/refusals.js.map +1 -1
  64. package/dist-client/pages/figure-modeller-page.d.ts +1 -3
  65. package/dist-client/pages/figure-modeller-page.js +7 -23
  66. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  67. package/dist-client/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +7 -7
  69. package/test/browser/acceptance-page.js +4 -4
  70. package/test/browser/detail-page.js +1 -3
  71. package/test/browser/detail-tabs-page.js +1 -2
  72. package/test/browser/links.check.mjs +32 -50
  73. package/test/browser/modeller.check.mjs +46 -19
  74. package/test/browser/parts-page.js +2 -2
  75. package/test/browser/parts.check.mjs +51 -12
  76. package/test/browser/product-page.check.mjs +6 -9
  77. package/test/browser/product-page.js +1 -2
  78. package/test/browser/screen-page.js +2 -6
  79. package/test/browser/screen.check.mjs +8 -65
  80. package/test/browser/section.check.mjs +27 -0
  81. package/test/fasten-form.test.ts +42 -2
  82. package/test/first-minute.test.ts +2 -3
  83. package/test/fixtures/joint-column.json +1 -66
  84. package/test/fixtures/joint-roller.json +1 -66
  85. package/test/fixtures/robot-arm-6dof.json +2 -67
  86. package/test/gizmo.test.ts +12 -1
  87. package/test/keys.test.ts +11 -0
  88. package/test/links.test.ts +39 -34
  89. package/test/parts-wiring.test.ts +1 -1
  90. package/test/properties.test.ts +9 -12
  91. package/test/refusals.test.ts +20 -42
  92. package/test/resize-with-placement.test.ts +13 -15
  93. package/test/revision.test.ts +1 -2
  94. package/test/verdict.test.ts +1 -15
  95. package/translations/en.json +8 -32
  96. package/translations/es.json +8 -32
  97. package/translations/ja.json +8 -32
  98. package/translations/ko.json +8 -32
  99. package/translations/ms.json +8 -32
  100. package/translations/zh.json +8 -32
  101. package/client/modeller/occupancy.ts +0 -51
  102. package/dist-client/modeller/occupancy.d.ts +0 -31
  103. package/dist-client/modeller/occupancy.js +0 -36
  104. package/dist-client/modeller/occupancy.js.map +0 -1
  105. package/test/release-panel.test.ts +0 -53
@@ -131,6 +131,22 @@ export const EditorStyles = css`
131
131
  min-width: 0;
132
132
  min-height: 0;
133
133
  }
134
+ /* Which size handles change one diameter (ADR-0140 4.1): a quiet line over the canvas, out of the gizmo's way. */
135
+ p[same-measure] {
136
+ position: absolute;
137
+ bottom: 12px;
138
+ left: 50%;
139
+ z-index: 5;
140
+ transform: translateX(-50%);
141
+ margin: 0;
142
+ padding: 4px 10px;
143
+ border-radius: 999em;
144
+ background: var(--md-sys-color-surface-container-high);
145
+ color: var(--md-sys-color-on-surface-variant);
146
+ font-size: var(--ui-t-small, 11.5px);
147
+ pointer-events: none;
148
+ white-space: nowrap;
149
+ }
134
150
  /* A new figure's first question, over the empty canvas. */
135
151
  div[start] {
136
152
  position: absolute;
@@ -6,7 +6,7 @@ import { openPopup } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
9
+ import { figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, type FigurePlaneContact, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
12
  import type { FigureAsset, FigureAuthoringAction, FigureEvaluatedShape, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement, FigureFaceMappingNeed, FigureFaceRemap } from '@hatiolab/figure-model'
@@ -30,13 +30,13 @@ import './figure-preview.js'
30
30
  import './figure-settings.js'
31
31
  import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
32
32
  import './figure-capability-panel.js'
33
- import { gizmoChanges, gizmoReachOf, sizeKeepOf, type GizmoReach, type GizmoResult } from './gizmo.js'
33
+ import { gizmoChanges, gizmoReachOf, sameMeasureAxesOf, sizeKeepOf, type GizmoReach, type GizmoResult } from './gizmo.js'
34
34
  import { keptBySizeOf, partsAtFrame, SIZE_KEEPS_WORDS, startSizeDrag, throughPlaneOf, type SizeDrag, type SizeDragFrame } from './size-drag.js'
35
35
  import type { KeyAction } from './keys.js'
36
36
  import { ModellerKeys } from './modeller-keys.js'
37
37
  import { PaneFold } from './pane-fold.js'
38
38
  import { PanelMemory } from './panel-memory.js'
39
- import { RefusalTeller, type Retry } from './refusal-teller.js'
39
+ import { RefusalTeller } from './refusal-teller.js'
40
40
  import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './fields.js'
41
41
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
42
42
  import { EditorStyles } from './editor-styles.js'
@@ -57,14 +57,13 @@ import { nudgeOf, placementOf } from './stage-model.js'
57
57
 
58
58
  /** The numbers a rule gives a part's position, turn and size, per axis, for the cells that have no field. */
59
59
  type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
60
- import { occupancyFromParts, roomRefitRowsOf } from './occupancy.js'
61
60
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
62
61
  import type { FigureGateFinding } from '../types.js'
63
- import { editRefusalOf, inDeclaredRoom, narrowingRetryOf, partNameRefusalOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
62
+ import { editRefusalOf, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
64
63
  import type { FigureRenderPart } from './renderer.js'
65
64
  import type { FigureBudgetLine } from './maturity.js'
66
65
  import { HelpToggles } from './help.js'
67
- import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
66
+ import { endPlaceKeyOf, fieldWordsOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
68
67
  import { jointFanOf } from './joint-fan.js'
69
68
  import { derivedOf, type FigureRevision } from './revision.js'
70
69
  import { jointDragOf } from './joint-drag.js'
@@ -135,8 +134,6 @@ export class FigureEditor extends LitElement {
135
134
  * edit was made (`from`).
136
135
  */
137
136
  @state() private faceMapping?: { action: FigureAuthoringAction; need: FigureFaceMappingNeed; from: 'section-editor' | 'panel' }
138
- /** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
139
- @state() private retry?: Retry
140
137
  @state() private tab: SideTab = 'properties'
141
138
  /** The view settings or the release history, open over the work from the bar. */
142
139
  @state() private overlay?: BarOverlay
@@ -268,6 +265,14 @@ export class FigureEditor extends LitElement {
268
265
  selected: () => this.selected,
269
266
  act: action => this.keyAction(action)
270
267
  })
268
+ /** The part Ctrl+C took: pasted as the kernel's duplicate. Not kept past the editor, nor written anywhere. */
269
+ private copied = ''
270
+ /** A copied part still in the figure is duplicated -- a part of its own, named by figure-model; one gone, nothing. */
271
+ private pasteCopied() {
272
+ const asset = this.session?.revisionNow()?.asset
273
+ if (!this.copied || !asset?.document.model.nodes.some(n => n.op === 'place@1' && n.id === this.copied)) return
274
+ this.duplicatePart(this.copied)
275
+ }
271
276
  private keyAction(action: KeyAction) {
272
277
  const view = this.session!.read()
273
278
  const canvas = this.renderRoot.querySelector<FigureCanvas>('figure-canvas')
@@ -275,6 +280,9 @@ export class FigureEditor extends LitElement {
275
280
  else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
276
281
  else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
277
282
  else if (action === 'duplicate') this.duplicatePart(this.selected)
283
+ else if (action === 'copy') this.copied = this.selected
284
+ else if (action === 'paste') this.pasteCopied()
285
+ else if (action === 'hold') return
278
286
  else if (action === 'remove') this.removePart(this.selected)
279
287
  else this.selected = ''
280
288
  }
@@ -283,9 +291,7 @@ export class FigureEditor extends LitElement {
283
291
  session: () => this.session,
284
292
  error: () => this.error,
285
293
  notice: () => this.notice,
286
- retry: () => this.retry,
287
- refuse: message => (this.error = message),
288
- command: action => this.linkCommand(action)
294
+ refuse: message => (this.error = message)
289
295
  })
290
296
  private panelMemory = new PanelMemory(() => this.renderRoot)
291
297
  /** The preview is made when first asked for, and kept (hidden) after that; it is taken down with the editor. */
@@ -331,6 +337,14 @@ export class FigureEditor extends LitElement {
331
337
  if (closed && this.selected === editing) this.selected = next
332
338
  })
333
339
  }
340
+ /** While the size tool is on a round part: which of its handles change the one diameter (ADR-0140 4.1). */
341
+ private renderSameMeasure(unavailable: boolean) {
342
+ if (unavailable || this.gizmoMode !== 'scale' || !this.selected) return nothing
343
+ const axes = sameMeasureAxesOf(this.reachOf(this.selected))
344
+ return axes
345
+ ? html`<p same-measure role="status">${i18next.t('figure.text.gizmo-same-measure', { axes: axes.map(a => a.toUpperCase()).join(' · ') })}</p>`
346
+ : nothing
347
+ }
334
348
  /** The scene toggles: the mode already shown takes the gizmo away. */
335
349
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
336
350
  /*
@@ -412,6 +426,9 @@ export class FigureEditor extends LitElement {
412
426
  }
413
427
 
414
428
  private changed() {
429
+ /* What the step did to the size ends the author typed, said with whatever else it said. */
430
+ const fits = this.session?.takeSizeRangeFits() ?? []
431
+ if (fits.length) this.notice = [this.notice, sizeRangeFitsOf(fits, (key, params) => i18next.t(key, params))].filter(Boolean).join(' ')
415
432
  this.requestUpdate()
416
433
  this.dispatchEvent(new CustomEvent('changed', { bubbles: true, composed: true }))
417
434
  }
@@ -506,27 +523,16 @@ export class FigureEditor extends LitElement {
506
523
  private movePart(id: string, to: number) {
507
524
  this.run(() => this.session!.edit({ kind: 'move-part', target: id, to }))
508
525
  }
509
- private removePart(id: string, refitRoom = false) {
510
- this.retry = undefined
526
+ private removePart(id: string) {
511
527
  try {
512
- const view = this.session!.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) }) as { removal?: Parameters<typeof removalNoticeOf>[0] }
528
+ const view = this.session!.edit({ kind: 'remove-part', target: id }) as { removal?: Parameters<typeof removalNoticeOf>[0] }
513
529
  this.error = ''
514
530
  /* What else the removal let go of, named (chief architect, 2026-09-27); undo brings it back. */
515
531
  this.notice = removalNoticeOf(view.removal, (key, params) => i18next.t(key, params)) ?? ''
516
532
  if (this.selected === id) this.selected = ''
517
533
  this.changed()
518
534
  } catch (error) {
519
- if (this.refusals.askClipLoss(error, () => this.removePart(id, refitRoom))) return
520
- /*
521
- A part in the declared space goes, and the space is fitted to the parts left, in the same click: every space
522
- this product declares is fitted to the parts (「부품에 맞추기」, the AI's fit-room -- no padding), so fitting it
523
- again keeps what it says, and there is nothing to ask (user, 2026-09-27: 「이 확인이 꼭 필요한가 ?」).
524
- */
525
- if (!refitRoom && inDeclaredRoom(error)) {
526
- this.removePart(id, true)
527
- if (!this.error) this.notice = [this.notice, i18next.t('figure.text.removal-space-refitted')].filter(Boolean).join(' ')
528
- return
529
- }
535
+ if (this.refusals.askClipLoss(error, () => this.removePart(id))) return
530
536
  this.error = partRefusalOf(error, id, (key, params) => i18next.t(key, params)) ?? this.said(error)
531
537
  }
532
538
  }
@@ -965,10 +971,8 @@ export class FigureEditor extends LitElement {
965
971
  private linkCommand(action: FigureAuthoringAction, from: 'section-editor' | 'panel' = 'panel'): boolean {
966
972
  /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
967
973
  this.holdAction(null)
968
- this.retry = undefined
969
974
  this.notice = ''
970
975
  const hadSize = this.hasSize()
971
- const hadSpace = !!this.shown()?.asset.occupancy
972
976
  try {
973
977
  this.session!.command(action)
974
978
  this.error = ''
@@ -976,9 +980,7 @@ export class FigureEditor extends LitElement {
976
980
  /* What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1). */
977
981
  if (action.kind === 'follow-size' && !hadSize) {
978
982
  const size = this.designValues()
979
- /* The same step may have fitted the space the figure takes to its parts (figure-model, ruling 2026-10-16 Q1 A): one line says both. */
980
- const spaceToo = !hadSpace && !!this.shown()?.asset.occupancy
981
- this.notice = i18next.t(spaceToo ? 'figure.text.size-and-space-declared-to-follow' : 'figure.text.size-declared-to-follow', {
983
+ this.notice = i18next.t('figure.text.size-declared-to-follow', {
982
984
  x: Math.round(size['size.x'] ?? 0).toLocaleString(),
983
985
  y: Math.round(size['size.y'] ?? 0).toLocaleString(),
984
986
  z: Math.round(size['size.z'] ?? 0).toLocaleString()
@@ -996,12 +998,6 @@ export class FigureEditor extends LitElement {
996
998
  this.error = ''
997
999
  return false
998
1000
  }
999
- const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
1000
- if (narrowing) {
1001
- this.error = narrowing.message
1002
- this.retry = { label: narrowing.label, action: narrowing.retry as FigureAuthoringAction }
1003
- return false
1004
- }
1005
1001
  this.error =
1006
1002
  code === 'DIMENSION_CYCLE'
1007
1003
  ? i18next.t('figure.text.follow-would-loop')
@@ -1086,21 +1082,21 @@ export class FigureEditor extends LitElement {
1086
1082
  label.addEventListener('pointerup', up)
1087
1083
  label.addEventListener('pointercancel', up)
1088
1084
  }
1089
- /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
1085
+ /** The sizes a board may give the figure: the ends the author typed, and the rest as far as its parts allow. */
1090
1086
  private renderInstanceRange(shown: FigureRevision | undefined, unavailable: boolean) {
1091
1087
  if (!shown) return nothing
1092
1088
  const asset = shown.asset
1093
- /* Measured once per version: measuring evaluates the figure at its edges. */
1089
+ /* Computed once per version: figure-model evaluates the figure at its edges. */
1094
1090
  const measured = derivedOf(shown, 'instance-range', () => {
1095
1091
  let of: ReturnType<typeof instanceRangeOf>
1096
- let toDeclare: ReturnType<typeof instanceRangeToDeclare>
1092
+ let toDeclare: ReturnType<typeof instanceSizeToDeclare>
1097
1093
  try {
1098
1094
  of = instanceRangeOf(asset)
1099
1095
  } catch {
1100
1096
  of = undefined
1101
1097
  }
1102
1098
  try {
1103
- toDeclare = of ? undefined : instanceRangeToDeclare(asset)
1099
+ toDeclare = of ? undefined : instanceSizeToDeclare(asset)
1104
1100
  } catch {
1105
1101
  toDeclare = undefined
1106
1102
  }
@@ -1121,13 +1117,17 @@ export class FigureEditor extends LitElement {
1121
1117
  return html`<section board-size>
1122
1118
  ${title('figure.text.board-size-fixed-explained')}
1123
1119
  <p fixed-size>${i18next.t('figure.text.board-size-fixed', { x: mm(toDeclare.at.x), y: mm(toDeclare.at.y), z: mm(toDeclare.at.z) })}</p>
1124
- <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: toDeclare.range, at: toDeclare.at })}>
1120
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', at: toDeclare.at })}>
1125
1121
  <md-icon>open_in_full</md-icon>${i18next.t('figure.button.declare-instance-size')}
1126
1122
  </button>
1127
1123
  </section>`
1128
1124
  if (!range) return nothing
1129
- const differs = rangeDiffers(range.declared, range.measured)
1130
1125
  const followers = sizeFollowersOf(asset)
1126
+ /*
1127
+ An end the author typed shows its number; an open end is empty, with what the parts allow written faintly in it
1128
+ (ADR-0087 decision 6). Typing narrows that end, emptying the field opens it again.
1129
+ */
1130
+ const shownEnd = (r: (typeof range)['x'], bound: 'min' | 'max') => (bound === 'max' && !Number.isFinite(r.max) ? '∞' : mm(r[bound]))
1131
1131
  return html`<section board-size>
1132
1132
  ${title('figure.text.board-size-range-explained')}
1133
1133
  ${(['x', 'y', 'z'] as const).map(
@@ -1139,11 +1139,12 @@ export class FigureEditor extends LitElement {
1139
1139
  aria-label=${`size.${axis}.${bound}`}
1140
1140
  type="number"
1141
1141
  step="any"
1142
- .value=${live(String(Math.round(range.declared[axis][bound])))}
1142
+ .value=${live(range[axis].narrowed[bound] ? String(Math.round(range[axis][bound])) : '')}
1143
+ placeholder=${shownEnd(range[axis], bound)}
1143
1144
  ?disabled=${unavailable}
1144
1145
  @change=${(e: Event) => {
1145
- const next = rangeWith(range.declared, axis, bound, (e.target as HTMLInputElement).valueAsNumber)
1146
- if (next) this.linkCommand({ kind: 'declare-instance-size', range: next, replace: true })
1146
+ const typed = (e.target as HTMLInputElement).valueAsNumber
1147
+ this.linkCommand({ kind: 'declare-instance-size', range: rangeWith(range, axis, bound, typed), replace: true })
1147
1148
  }}
1148
1149
  />`
1149
1150
  )}
@@ -1157,19 +1158,6 @@ export class FigureEditor extends LitElement {
1157
1158
  })
1158
1159
  : i18next.t('figure.text.board-size-no-followers')}
1159
1160
  </p>
1160
- ${differs
1161
- ? html`<p hint>
1162
- ${i18next.t('figure.text.instance-range-can-widen', {
1163
- ranges: (['x', 'y', 'z'] as const)
1164
- .filter(axis => rangeDiffers(range.declared, { ...range.declared, [axis]: range.measured[axis] }))
1165
- .map(axis => `${axis.toUpperCase()} ${mm(range.measured[axis].min)} – ${mm(range.measured[axis].max)}`)
1166
- .join(' · ')
1167
- })}
1168
- </p>
1169
- <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: range.measured, replace: true })}>
1170
- <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
1171
- </button>`
1172
- : nothing}
1173
1161
  ${this.renderSizingRule(asset, unavailable)}
1174
1162
  </section>`
1175
1163
  }
@@ -1325,7 +1313,7 @@ export class FigureEditor extends LitElement {
1325
1313
  this.publishing?.part === part
1326
1314
  ? this.publishing
1327
1315
  : listed && input
1328
- ? { part, label: listed.label, min: input.min, max: input.max }
1316
+ ? /* A published input has both ends (figure-model checkPublicInput). */ { part, label: listed.label, min: input.min!, max: input.max! }
1329
1317
  : undefined
1330
1318
  const open = !!form
1331
1319
  const edit = (key: 'label' | 'min' | 'max') => (e: Event) => {
@@ -1417,19 +1405,13 @@ export class FigureEditor extends LitElement {
1417
1405
  if (state === 'released') return i18next.t('figure.text.release-state-released', { version: String(version) })
1418
1406
  return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new')
1419
1407
  }
1420
- /** The findings that block release, under the bar beside 「발행」, each with the way out the verdict tab offers for it. */
1421
- private renderReleaseBlockers(blockers: FigureGateFinding[], asset: FigureAsset | undefined, unavailable: boolean) {
1408
+ /** The findings that block release, under the bar beside 「발행」. */
1409
+ private renderReleaseBlockers(blockers: FigureGateFinding[], asset: FigureAsset | undefined) {
1422
1410
  const parts = (asset?.appearance ?? []).map(look => look.target)
1423
- const way = (code: string) =>
1424
- (code === 'occupancy-undeclared' || code === 'part-outside-occupancy') && asset && parts.length
1425
- ? html`<button act ?disabled=${unavailable} @click=${() => this.linkCommand(occupancyFromParts(asset))}>
1426
- <md-icon>fit_screen</md-icon>${i18next.t('figure.button.fit-base-to-parts')}
1427
- </button>`
1428
- : nothing
1429
1411
  return html`<div release-blockers role="dialog" aria-label=${i18next.t('figure.text.must-fix-to-release')} @keydown=${(e: KeyboardEvent) => e.key === 'Escape' && (this.blockersOpen = false)}>
1430
1412
  <ul gate>
1431
1413
  <li head>${i18next.t('figure.text.must-fix-to-release')}</li>
1432
- ${blockers.map(finding => renderGateFinding(finding, true, way(finding.code), parts))}
1414
+ ${blockers.map(finding => renderGateFinding(finding, true, nothing, parts))}
1433
1415
  </ul>
1434
1416
  </div>`
1435
1417
  }
@@ -1758,19 +1740,9 @@ export class FigureEditor extends LitElement {
1758
1740
  /* A repeat's own row values (its pitch) have their own section, not the shape's. */
1759
1741
  const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
1760
1742
  const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
1761
- /*
1762
- A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
1763
- nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
1764
- */
1765
1743
  const shown = this.shown()
1766
1744
  const asset = shown?.asset
1767
- let roomOnly: string[] = []
1768
- try {
1769
- roomOnly = this.selected && asset ? figureRoomOnlyInputsOf(asset, this.selected) : []
1770
- } catch {
1771
- roomOnly = []
1772
- }
1773
- const partFields = own.filter(i => !roomOnly.includes(i.id))
1745
+ const partFields = own
1774
1746
  /* The numbers a rule gives the axes that have no field of their own -- shown read-only, never as a blank. */
1775
1747
  let held: HeldValues | undefined
1776
1748
  try {
@@ -1787,7 +1759,6 @@ export class FigureEditor extends LitElement {
1787
1759
  } catch {
1788
1760
  held = undefined
1789
1761
  }
1790
- const roomFields = own.filter(i => roomOnly.includes(i.id))
1791
1762
  /*
1792
1763
  Only what is under way is said. Saved or not is the save button's: filled and pressable when there is something
1793
1764
  to save, dimmed when not -- the saved and unsaved words beside it said the same thing again in the primary
@@ -1840,7 +1811,7 @@ export class FigureEditor extends LitElement {
1840
1811
  </button>
1841
1812
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}><md-icon>save</md-icon>${i18next.t('figure.button.save')}</button>
1842
1813
  </ox-canvas-bar>
1843
- ${this.blockersOpen && blockers.length ? this.renderReleaseBlockers(blockers, asset, unavailable) : nothing}
1814
+ ${this.blockersOpen && blockers.length ? this.renderReleaseBlockers(blockers, asset) : nothing}
1844
1815
  ${this.catalogOpen
1845
1816
  ? html`<figure-recipe-catalog
1846
1817
  .templates=${this.templates}
@@ -1921,7 +1892,7 @@ export class FigureEditor extends LitElement {
1921
1892
  @render-error=${(e: CustomEvent<Error>) => {
1922
1893
  this.error = e.detail.message
1923
1894
  }}
1924
- ></figure-canvas></div>
1895
+ ></figure-canvas>${this.renderSameMeasure(unavailable)}</div>
1925
1896
  <aside slot="end">
1926
1897
  <div tabs>
1927
1898
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1950,7 +1921,6 @@ export class FigureEditor extends LitElement {
1950
1921
  ${this.renderRepeatNote(view.geometry)}
1951
1922
  </section>
1952
1923
  ${this.renderPartGroups(shown, view.geometry, partFields, unavailable, held)}
1953
- ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
1954
1924
  <figure-material-panel
1955
1925
  .body=${{ ...part, id: this.selected }}
1956
1926
  .palette=${view.palette ?? {}}
@@ -2096,60 +2066,6 @@ export class FigureEditor extends LitElement {
2096
2066
  if (fastened && placed) return html`<p hint held-axes>${i18next.t('figure.text.held-by-fastening')}</p>`
2097
2067
  return html`<p hint held-axes>${i18next.t('figure.text.held-worked-out', { group: held.map(g => i18next.t('figure.label.group-' + g.name)).join(' · ') })}</p>`
2098
2068
  }
2099
- /**
2100
- * A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
2101
- * typed into -- they move nothing. Declaring the room again is offered; nothing is deleted or widened on the way.
2102
- */
2103
- private renderRoomOnly(fields: FieldLike[], unavailable: boolean) {
2104
- return html`<section room-only>
2105
- <h3>${i18next.t('figure.label.room-only-inputs')}</h3>
2106
- <p hint>${i18next.t('figure.text.room-only-inputs')}</p>
2107
- <div form>
2108
- ${fields.map(
2109
- f => html`<span>${fieldWordsOf(f.id, key => i18next.t(key))} (${f.unit})</span><input aria-label=${f.id} type="number" .value=${String(f.value)} disabled />`
2110
- )}
2111
- </div>
2112
- <button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}><md-icon>fit_screen</md-icon>${i18next.t('figure.button.room-refit')}</button>
2113
- </section>`
2114
- }
2115
- /** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
2116
- private async askRoomRefit() {
2117
- let refit
2118
- try {
2119
- refit = figureRoomRefitOf(this.shown()!.asset)
2120
- } catch (error) {
2121
- this.error = this.said(error)
2122
- return
2123
- }
2124
- if (!refit) return
2125
- const rows = roomRefitRowsOf(refit)
2126
- const outside = (list: typeof refit.outside.declared) =>
2127
- list.length
2128
- ? list.map(o => i18next.t('figure.text.room-part-outside', { part: partLabelOf(o.part), axis: o.axis.toUpperCase(), mm: String(o.mm) })).join(' · ')
2129
- : i18next.t('figure.text.room-nothing-outside')
2130
- const agreed = await OxPrompt.open({
2131
- type: 'question',
2132
- title: i18next.t('figure.label.room-refit'),
2133
- template: html`<table room-refit>
2134
- <tr><th></th><th>${i18next.t('figure.label.room-declared')}</th><th>${i18next.t('figure.label.room-proposed')}</th></tr>
2135
- ${rows.map(
2136
- r => html`<tr>
2137
- <td>${i18next.t(`figure.label.room-at-${r.at}`)} ${r.axis.toUpperCase()}</td>
2138
- <td>${r.declared}</td>
2139
- <td>${r.same ? i18next.t('figure.text.room-same') : r.proposed}</td>
2140
- </tr>`
2141
- )}
2142
- </table>
2143
- <p>${i18next.t('figure.label.room-declared')}: ${outside(refit.outside.declared)}</p>
2144
- <p>${i18next.t('figure.label.room-proposed')}: ${outside(refit.outside.proposed)}</p>
2145
- ${refit.drops.length
2146
- ? html`<p>${i18next.t('figure.text.room-drops', { fields: [...new Set(refit.drops.map(id => `${partLabelOf(id.split('.')[0]!)} ${fieldWordsOf(id, key => i18next.t(key))}`))].join(' · ') })}</p>`
2147
- : nothing}`,
2148
- confirmButton: { text: i18next.t('figure.button.room-refit-confirm') },
2149
- cancelButton: { text: i18next.t('figure.button.keep-editing') }
2150
- })
2151
- if (agreed) this.linkCommand(refit.step)
2152
- }
2153
2069
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
2154
2070
  const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
2155
2071
  return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
@@ -1,4 +1,4 @@
1
- import { FIGURE_FACES, figureFastenTargetAt, figurePlaneFacingOf, figureSurfaceKeptPlaceOf } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureFastenKeptGapOf, figureFastenTargetAt, figurePlaneFacingOf, figureSurfaceKeptPlaceOf } from '@hatiolab/figure-model'
2
2
  import type { FigureAuthoringAction, FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
3
3
 
4
4
  import { faceViewOf, type FaceView, type FastenMode, type FastenTarget } from './fasten-geometry.js'
@@ -38,6 +38,12 @@ export interface FastenForm {
38
38
  gap: number
39
39
  /** The record's gap when it is worked out (a length that follows something): kept until a number is typed. */
40
40
  gapKept: unknown
41
+ /**
42
+ * Where the gap came from (ADR-0140 stage 4, 「현재 상태로 붙이기」): `kept` -- the distance the part stands at, worked
43
+ * out again whenever where or how it is fastened changes, so fastening does not move it; `typed` -- the author's
44
+ * number, left as it is; `pressed` -- 0, the part put where the canvas was pressed.
45
+ */
46
+ gapFrom: 'kept' | 'typed' | 'pressed'
41
47
  align: Partial<Record<Axis, Alignment>>
42
48
  alignMm: Partial<Record<Axis, number>>
43
49
  alignInput: Partial<Record<Axis, string>>
@@ -52,6 +58,7 @@ export const freshFasten = (): FastenForm => ({
52
58
  facing: 'meet',
53
59
  gap: 0,
54
60
  gapKept: undefined,
61
+ gapFrom: 'kept',
55
62
  align: {},
56
63
  alignMm: {},
57
64
  alignInput: {}
@@ -60,7 +67,9 @@ export const freshFasten = (): FastenForm => ({
60
67
  /** The part's own fastening as a form, or a fresh one: a window opens on what the part has (walk 1, 2026-09-26). */
61
68
  export function fastenFormOf(revision: FigureRevision, part: string): FastenForm {
62
69
  const own = fasteningOf(revision, part)
63
- return own && !own.atOrigin ? ({ ...freshFasten(), ...own } as FastenForm) : freshFasten()
70
+ if (!own || own.atOrigin) return freshFasten()
71
+ /* A gap the record works out is the author's; a number is where the part stands, so aiming again keeps it there. */
72
+ return { ...freshFasten(), ...own, gapFrom: own.gapKept !== undefined ? 'typed' : 'kept' } as FastenForm
64
73
  }
65
74
 
66
75
  /** What a part may be fastened to: every other part that can be, each said whether it would close a loop. */
@@ -133,11 +142,11 @@ export function fastenChanged(
133
142
  change: FastenChange
134
143
  ): FastenForm {
135
144
  const f = form
136
- if ('targetFace' in change) return aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace })
137
- if ('onSurface' in change) return aimFasten(revision, part, f, { surface: true })
138
- if ('surface' in change) return { ...f, surface: change.surface }
139
- if ('mode' in change) return { ...f, facing: change.mode }
140
- if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined }
145
+ if ('targetFace' in change) return keptGapOf(revision, part, aimFasten(revision, part, f, { targetFace: change.targetFace as FigureFace }))
146
+ if ('onSurface' in change) return keptGapOf(revision, part, aimFasten(revision, part, f, { surface: true }))
147
+ if ('surface' in change) return keptGapOf(revision, part, { ...f, surface: change.surface })
148
+ if ('mode' in change) return keptGapOf(revision, part, { ...f, facing: change.mode })
149
+ if ('gap' in change) return { ...f, gap: change.gap, gapKept: undefined, gapFrom: 'typed' }
141
150
  return {
142
151
  ...f,
143
152
  align: { ...f.align, [change.axis]: change.how },
@@ -145,6 +154,29 @@ export function fastenChanged(
145
154
  }
146
155
  }
147
156
 
157
+ /**
158
+ * The form with the gap that keeps the part where it stands (ADR-0140 stage 4, user 2026-10-20 「현재 상태로 붙이기」):
159
+ * figure-model's `figureFastenKeptGapOf` for what it is aimed at and how it meets it. A gap the author typed, or the 0 a
160
+ * canvas press puts, is left as it is. Nothing aimed at yet, the form is as it was.
161
+ */
162
+ export function keptGapOf(revision: FigureRevision, part: string, form: FastenForm): FastenForm {
163
+ if (form.gapFrom !== 'kept') return form
164
+ const asset = revision.asset
165
+ const theirFaces = form.target ? facesOf(asset, form.target) : FACES
166
+ const targetFace = (theirFaces.includes(form.targetFace) ? form.targetFace : theirFaces[0]) as FigureFace
167
+ const planeWay = figurePlaneFacingOf(asset)
168
+ const mode: FastenMode = form.toPlane ? (mountingPlaneOf(asset).facings.includes(form.facing) ? form.facing : (planeWay ?? 'meet')) : form.facing
169
+ const to = form.toPlane
170
+ ? ('plane' as const)
171
+ : form.target && form.surface && surfaceOf(asset, form.target)
172
+ ? { part: form.target, surface: form.surface }
173
+ : form.target
174
+ ? { part: form.target, face: targetFace }
175
+ : undefined
176
+ if (!to) return form
177
+ return { ...form, gap: figureFastenKeptGapOf(asset, part, to, mode, { ...revision.inputs }), gapKept: undefined }
178
+ }
179
+
148
180
  /** What a form comes to: the face aimed at, the way, the drawing of the face and the command. */
149
181
  export interface FastenPlan {
150
182
  /** Nothing is aimed at until a part or the plane is chosen. */
@@ -226,12 +258,16 @@ export function fastenSentenceOf(
226
258
  return say(`figure.text.fasten-sentence-${plan.mode}`, { to })
227
259
  }
228
260
 
229
- /** What the folded details hold that is not their default, said while folded; nothing for the default. */
261
+ /**
262
+ * What the folded details hold that is not their default, said while folded; nothing for the default. The gap's default
263
+ * is the one it starts at -- the distance the part stands at, or 0 where the canvas was pressed -- so only a number typed
264
+ * is said.
265
+ */
230
266
  export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
231
267
  return [
232
268
  ...(form.gapKept !== undefined
233
269
  ? [say('figure.text.fasten-summary-gap-worked-out')]
234
- : form.gap
270
+ : form.gapFrom === 'typed' && form.gap
235
271
  ? [say('figure.text.fasten-summary-gap', { mm: String(form.gap) })]
236
272
  : [])
237
273
  ]
@@ -244,6 +280,9 @@ export function fastenDetailWordsOf(form: FastenForm, say: Say): string[] {
244
280
  * nothing to fasten to is not aimed at; a press on a curve there is no fastening to aims at the part alone. Each says
245
281
  * why, by its words' key.
246
282
  */
283
+ /* Put where the canvas was pressed: on the face or the surface there, with no gap (ADR-0140 stage 4). */
284
+ const PRESSED = { gap: 0, gapKept: undefined, gapFrom: 'pressed' as const }
285
+
247
286
  export function aimFastenAt(
248
287
  revision: FigureRevision,
249
288
  part: string,
@@ -265,8 +304,8 @@ export function aimFastenAt(
265
304
  /* On the face where it was pressed, as a point of a curved surface is: its middle there. */
266
305
  const onFace = aimFasten(revision, part, { ...aimed, surface: undefined }, { targetFace: lands.face as FigureFace })
267
306
  const at = lands.at as Partial<Record<Axis, number>>
268
- return { form: { ...onFace, align: Object.fromEntries(Object.keys(at).map(a => [a, 'mm'])) as FastenForm['align'], alignMm: { ...at }, alignInput: {} }, part: target }
307
+ return { form: { ...onFace, ...PRESSED, align: Object.fromEntries(Object.keys(at).map(a => [a, 'mm'])) as FastenForm['align'], alignMm: { ...at }, alignInput: {} }, part: target }
269
308
  }
270
- if ('surface' in lands) return { form: { ...aimed, toPlane: false, target, surface: lands.surface, align: {}, alignMm: {}, alignInput: {} }, part: target }
309
+ if ('surface' in lands) return { form: { ...aimed, ...PRESSED, toPlane: false, target, surface: lands.surface, align: {}, alignMm: {}, alignInput: {} }, part: target }
271
310
  return { form: aimed, note: 'aim-not-here', part: target }
272
311
  }
@@ -27,7 +27,7 @@ export function figureFigureFacts(revision: FigureRevision): FigureFigureFacts {
27
27
  }
28
28
  function figureFactsOf(revision: FigureRevision): FigureFigureFacts {
29
29
  const asset = revision.asset as FigureAsset
30
- const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
30
+ const feature = asset.mountingFace?.surface.feature
31
31
  const found = feature?.match(/^(.+)\.mount\.(left|right|bottom|top|back|front)\.value$/)
32
32
  return {
33
33
  name: asset.name,
@@ -16,6 +16,7 @@ import { turnsInPlace, type FastenMode } from './fasten-geometry.js'
16
16
  import {
17
17
  aimFasten,
18
18
  aimFastenAt,
19
+ keptGapOf,
19
20
  fastenChanged,
20
21
  fastenDetailWordsOf,
21
22
  fastenFormOf,
@@ -260,7 +261,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
260
261
  step="any"
261
262
  .value=${f.gapKept !== undefined ? '' : String(f.gap)}
262
263
  placeholder=${f.gapKept !== undefined ? i18next.t('figure.label.follows-expression') : ''}
263
- @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined })}
264
+ @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined, gapFrom: 'typed' })}
264
265
  />
265
266
  </div>
266
267
  </details>
@@ -330,7 +331,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
330
331
  this.aimNote = ''
331
332
  const chosen = this.form.toPlane ? PLANE : this.form.target
332
333
  if (part === chosen) return
333
- this.form = aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part })
334
+ this.form = keptGapOf(this.revision, this.partId, aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part }))
334
335
  }
335
336
 
336
337
  /**
@@ -401,35 +401,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
401
401
  * 준다 — 화면이 문장을 지으면 서버가 거절할 때와 다른 말을 하게 된다.
402
402
  */
403
403
  private renderFinding(finding: FigureGateFinding, blocking: boolean) {
404
- return renderGateFinding(finding, blocking, html`
405
- ${this.offersFit(finding.code)
406
- ? html`<button fit-base @click=${() => this.fitBase()}>
407
- ${i18next.t('figure.button.fit-base-to-parts')}
408
- </button>`
409
- : nothing}
410
- `)
411
- }
412
-
413
- /**
414
- * 넘친 것을 담는 길 하나를 여기서도 내민다 — **같은 동작이 속성 패널에도 있다**(「부품에 맞추기」).
415
- *
416
- * 그 단추를 아는 저작자는 거기서 누른다. 발행이 막혔다는 말을 여기서 읽은 사람은 그 단추가 있는
417
- * 것을 모르고, 문구는 그것을 가리킬 수 없다 — 문장은 형식이 쓰고 형식은 화면의 단추를 모른다.
418
- * 그래서 화면이 자기 자리에서 같은 동작을 낸다. 누르면 상자만 커지고 부품은 제자리이며, 저장은
419
- * 여전히 저작자가 따로 한다.
420
- */
421
- /**
422
- * 「부품에 맞추기」를 내미는 판정: 공간을 부품에서 다시 선언한다(`declare-occupancy`) — 부품은 그대로 두고 선언만
423
- * 바꾼다. 공간을 아직 선언하지 않은 도형도 같은 단추로 선언한다: 새로 만든 도형은 전부 그 판정으로 막히고, 화면에
424
- * 다른 길이 없다. 장착면이 떠 있는 것은 여기 없다 — 부품 하나를 평면에 붙여도 거기 붙지 않은 것은 따라오지 않는다.
425
- * 판정의 「어떻게」가 할 일을 말한다.
426
- */
427
- private offersFit(code: string): boolean {
428
- return code === 'part-outside-occupancy' || code === 'occupancy-undeclared'
429
- }
430
-
431
- private fitBase() {
432
- this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
404
+ return renderGateFinding(finding, blocking, nothing)
433
405
  }
434
406
 
435
407
  /** Where one released version downloads as a package file, or undefined before the figure has a full name. */
@@ -278,7 +278,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
278
278
  <section size-response>
279
279
  <h3>${i18next.t('figure.label.group-size')}</h3>
280
280
  <p group-question>${i18next.t('figure.label.resize-intent')}</p>
281
- ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
281
+ ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-follow')}</p>`}
282
282
  </section>
283
283
  ${positions.length
284
284
  ? html`<section position-response>
@@ -302,7 +302,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
302
302
  ${groupMenu([{ label: i18next.t('figure.button.detach'), icon: 'link_off', disabled: this.locked || !held, run: () => this.command({ kind: 'detach', part: this.partId }) }])}
303
303
  </h3>${this.help.hint('figure.text.fastening-explained')}
304
304
  <slot name="place"></slot>
305
- ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
305
+ ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-fasten')}</p>`}
306
306
  </section>
307
307
  `
308
308
  this.candidate = this.fastenCandidate