@things-factory/figure-ui 10.1.208 → 10.1.211

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 (92) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +97 -88
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +87 -157
  5. package/client/modeller/figure-size-editor.ts +233 -0
  6. package/client/modeller/joint-drag.ts +25 -21
  7. package/client/modeller/joint-fan.ts +4 -3
  8. package/client/modeller/links.ts +87 -36
  9. package/client/modeller/live.ts +33 -0
  10. package/client/modeller/motion-ends.ts +10 -6
  11. package/client/modeller/size-response.ts +95 -0
  12. package/dist-client/modeller/data-interface.d.ts +1 -1
  13. package/dist-client/modeller/data-interface.js +5 -5
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +14 -1
  16. package/dist-client/modeller/editor.js +103 -89
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/fasten-geometry.js +1 -1
  19. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  20. package/dist-client/modeller/figure-links.d.ts +3 -4
  21. package/dist-client/modeller/figure-links.js +91 -145
  22. package/dist-client/modeller/figure-links.js.map +1 -1
  23. package/dist-client/modeller/figure-size-editor.d.ts +29 -0
  24. package/dist-client/modeller/figure-size-editor.js +262 -0
  25. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  26. package/dist-client/modeller/joint-drag.d.ts +1 -1
  27. package/dist-client/modeller/joint-drag.js +27 -22
  28. package/dist-client/modeller/joint-drag.js.map +1 -1
  29. package/dist-client/modeller/joint-fan.js +4 -3
  30. package/dist-client/modeller/joint-fan.js.map +1 -1
  31. package/dist-client/modeller/links.d.ts +21 -9
  32. package/dist-client/modeller/links.js +85 -35
  33. package/dist-client/modeller/links.js.map +1 -1
  34. package/dist-client/modeller/live.d.ts +14 -0
  35. package/dist-client/modeller/live.js +31 -0
  36. package/dist-client/modeller/live.js.map +1 -1
  37. package/dist-client/modeller/motion-ends.d.ts +4 -1
  38. package/dist-client/modeller/motion-ends.js +10 -6
  39. package/dist-client/modeller/motion-ends.js.map +1 -1
  40. package/dist-client/modeller/size-response.d.ts +29 -0
  41. package/dist-client/modeller/size-response.js +99 -0
  42. package/dist-client/modeller/size-response.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  45. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  46. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  47. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  48. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  49. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  50. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  51. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  52. package/docs/multipart-response-review-2026-10-24.md +77 -0
  53. package/docs/response-authoring-2026-10-24.md +46 -0
  54. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  55. package/package.json +7 -7
  56. package/test/attachment-size-contract.test.ts +47 -0
  57. package/test/browser/acceptance-page.js +1 -1
  58. package/test/browser/detail-page.js +1 -1
  59. package/test/browser/detail-tabs-page.js +1 -1
  60. package/test/browser/editing-page.js +1 -1
  61. package/test/browser/editing.check.mjs +66 -14
  62. package/test/browser/links.check.mjs +41 -22
  63. package/test/browser/modeller-page.js +1 -1
  64. package/test/browser/modeller-revision.check.mjs +21 -7
  65. package/test/browser/modeller.check.mjs +106 -39
  66. package/test/browser/multipart-response-page.js +8 -0
  67. package/test/browser/parts.check.mjs +2 -1
  68. package/test/browser/product-page.js +1 -1
  69. package/test/browser/props.check.mjs +17 -14
  70. package/test/browser/response-page.js +12 -0
  71. package/test/browser/row-preview.check.mjs +3 -3
  72. package/test/browser/screen.check.mjs +57 -13
  73. package/test/browser/serve-response.mjs +24 -0
  74. package/test/clip-ai-proposal.test.ts +1 -1
  75. package/test/clip-consent.test.ts +2 -2
  76. package/test/command-preview.test.ts +7 -7
  77. package/test/joint-drag.test.ts +6 -6
  78. package/test/joint-frames.test.ts +33 -32
  79. package/test/joints.ts +1 -6
  80. package/test/links.test.ts +60 -20
  81. package/test/multipart-response-fixture.js +17 -0
  82. package/test/multipart-response.test.ts +37 -0
  83. package/test/refusals.test.ts +2 -2
  84. package/test/resize-with-placement.test.ts +23 -1
  85. package/test/size-response.test.ts +49 -0
  86. package/test/size-source-pairing.test.ts +66 -0
  87. package/translations/en.json +47 -10
  88. package/translations/es.json +47 -10
  89. package/translations/ja.json +47 -10
  90. package/translations/ko.json +52 -15
  91. package/translations/ms.json +47 -10
  92. package/translations/zh.json +47 -10
@@ -6,7 +6,7 @@ import '@operato/layout';
6
6
  import { openPopup } from '@operato/layout';
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, figureRowOf, figureFaceOverhangOf } from '@hatiolab/figure-model';
9
+ import { figureFastenAxesOf, figurePartFaceOf, PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditOf, figureSizeEditorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figureRowOf, figureFaceOverhangOf } from '@hatiolab/figure-model';
10
10
  import './figure-recipe-catalog.js';
11
11
  import { captureFigureThumbnail } from './thumbnail.js';
12
12
  import { drawableFigureParts } from './renderer.js';
@@ -50,13 +50,14 @@ import { nudgeOf, placementOf } from './stage-model.js';
50
50
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js';
51
51
  import { editRefusalOf, partNameRefusalOf, sizeRangeFitsOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js';
52
52
  import { HelpToggles } from './help.js';
53
- import { endPlaceKeyOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
53
+ import { sizeInputOf } from './size-response.js';
54
+ import { canFollow, followableAxes, endPlaceKeyOf, followOf, sizeFollowersOf, measureNow, partSummaryOf, instanceRangeOf, instanceSizeToDeclare, proportionToFollow, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
54
55
  import { jointFanOf } from './joint-fan.js';
55
56
  import { derivedOf } from './revision.js';
56
57
  import { jointDragOf } from './joint-drag.js';
57
- import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './live.js';
58
+ import { liveOverSession, NOTHING_LIVE, scrubLabel } from './live.js';
58
59
  import { startingShapeKindOfBody } from './starting-shape.js';
59
- import { groupMenu, GroupMenuStyles } from './group-menu.js';
60
+ import { GroupMenuStyles } from './group-menu.js';
60
61
  /** The person's own fold of the properties' 「더 보기」, kept in their browser. */
61
62
  const MORE_OPEN_KEY = 'figure-properties-more-open';
62
63
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
@@ -330,10 +331,16 @@ let FigureEditor = class FigureEditor extends LitElement {
330
331
  }
331
332
  /**
332
333
  * A refusal no particular sentence knows: figure-model's code said as a refusal, never its English; any other error
333
- * (the network, the server) as it came.
334
+ * (the network, the server) as it came. A loop is said as a loop whichever way it was made: a command, or the size
335
+ * editor's steps (a measure made to follow what follows it, 2026-10-24 -- said as a bare refused step before).
334
336
  */
335
337
  said(error) {
336
- return error?.code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error);
338
+ const code = error?.code;
339
+ if (code === 'DIMENSION_CYCLE')
340
+ return i18next.t('figure.text.follow-would-loop');
341
+ if (code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE')
342
+ return i18next.t('figure.text.fasten-would-loop');
343
+ return code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error);
337
344
  }
338
345
  run(action) {
339
346
  try {
@@ -824,6 +831,10 @@ let FigureEditor = class FigureEditor extends LitElement {
824
831
  .partId=${this.selected}
825
832
  ?locked=${unavailable}
826
833
  @command=${(e) => this.linkCommand(e.detail.action)}
834
+ @size-recipe=${(e) => this.applySizeSteps(e.detail.steps)}
835
+ @size-input=${(e) => this.writeField({ id: e.detail.input, unit: 'mm' }, e.detail.value)}
836
+ @size-typing=${(e) => this.hold(this.heldOf({ id: e.detail.input, unit: 'mm' }, e.detail.value))}
837
+ @size-let-go=${() => (this.live = NOTHING_LIVE)}
827
838
  @fasten-open=${() => this.openFastening()}
828
839
  .previewWords=${this.commandPreview.words()}
829
840
  .detachWords=${this.commandPreview.detachWords(shown)}
@@ -843,18 +854,22 @@ let FigureEditor = class FigureEditor extends LitElement {
843
854
  */
844
855
  renderPartGroups(shown, geometry, fields, unavailable, held) {
845
856
  const groups = this.withSizeRow(groupFields(fields, this.selected), held);
846
- const size = groups.vectors.filter(g => g.kind === 'vector' && g.name === 'size');
857
+ const integrated = !!shown && canFollow(shown.asset, this.selected);
858
+ const dimensionInputs = new Set(integrated ? followableAxes(shown.asset, this.selected).map(axis => sizeInputOf(shown.asset, this.selected, axis)) : []);
859
+ const singles = groups.singles.filter(g => g.kind !== 'single' || !dimensionInputs.has(g.field.id));
860
+ const size = integrated ? [] : groups.vectors.filter(g => g.kind === 'vector' && g.name === 'size');
847
861
  const place = groups.vectors.filter(g => g.kind === 'vector' && g.name !== 'size');
848
862
  const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(this.selected));
849
863
  const placeLocked = fastened && place.every(g => g.kind === 'vector' && ['x', 'y', 'z'].every(axis => !g.cells[axis]));
850
- return this.renderLinks(unavailable, 'shape', html `${size.length || groups.singles.length
851
- ? html `<div slot="size" group-fields>
864
+ return this.renderLinks(unavailable, 'shape', html `${size.length || singles.length
865
+ ? html `<section slot="size" group-fields>
866
+ <h3>${i18next.t(integrated ? 'figure.label.group-shape' : 'figure.label.group-size')}</h3>
852
867
  ${size.map(g => this.renderVector(g, unavailable, held))}
853
- ${groups.singles.length
854
- ? html `<div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
868
+ ${singles.length
869
+ ? html `<div form>${singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
855
870
  : nothing}
856
- ${this.renderSizeKeeps()}
857
- </div>`
871
+ ${!integrated ? this.renderSizeKeeps() : nothing}
872
+ </section>`
858
873
  : nothing}
859
874
  ${place.length && !placeLocked
860
875
  ? html `<div slot="place" group-fields>${place.map(g => this.renderVector(g, unavailable, held))}${this.renderHeldAxes(place)}</div>`
@@ -862,7 +877,7 @@ let FigureEditor = class FigureEditor extends LitElement {
862
877
  }
863
878
  /** The 「반응형」 tab of a part: the links' response groups, its row's own section put in by slot. */
864
879
  renderResponse(shown, geometry, unavailable) {
865
- return this.renderLinks(unavailable, 'response', this.renderRepeat(shown, geometry, unavailable));
880
+ return this.renderLinks(unavailable, 'response', html `<div slot="size-note">${this.renderSizeKeeps()}</div>${this.renderRepeat(shown, geometry, unavailable)}`);
866
881
  }
867
882
  /**
868
883
  * 「더 보기」: what few parts need, folded by default and left as this person last left it (chief architect
@@ -970,6 +985,25 @@ let FigureEditor = class FigureEditor extends LitElement {
970
985
  if (done && held.from === 'section-editor')
971
986
  this.closeSectionEditor();
972
987
  }
988
+ /** The figure's size, declared for a part to follow, in one line (ADR ruling Q1). */
989
+ sizeDeclaredWords() {
990
+ const size = this.designValues();
991
+ return i18next.t('figure.text.size-declared-to-follow', {
992
+ x: Math.round(size['size.x'] ?? 0).toLocaleString(),
993
+ y: Math.round(size['size.y'] ?? 0).toLocaleString(),
994
+ z: Math.round(size['size.z'] ?? 0).toLocaleString()
995
+ });
996
+ }
997
+ /**
998
+ * The size editor's steps, one step of the history. A measure made to follow the figure on a figure with no size
999
+ * declares it on the way; that is said in one line, as follow-size said it (ADR ruling Q1, 2026-10-24).
1000
+ */
1001
+ applySizeSteps(steps) {
1002
+ const hadSize = this.hasSize();
1003
+ this.run(() => this.session.applyRecipe(steps));
1004
+ if (!hadSize && !this.error && this.hasSize())
1005
+ this.notice = this.sizeDeclaredWords();
1006
+ }
973
1007
  /** Runs a command; true when it was taken. */
974
1008
  linkCommand(action, from = 'panel') {
975
1009
  /* Committed or refused, a command in hand is let go of: the figure as it stands is drawn again. */
@@ -981,14 +1015,8 @@ let FigureEditor = class FigureEditor extends LitElement {
981
1015
  this.error = '';
982
1016
  this.faceMapping = undefined;
983
1017
  /* What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1). */
984
- if (action.kind === 'follow-size' && !hadSize) {
985
- const size = this.designValues();
986
- this.notice = i18next.t('figure.text.size-declared-to-follow', {
987
- x: Math.round(size['size.x'] ?? 0).toLocaleString(),
988
- y: Math.round(size['size.y'] ?? 0).toLocaleString(),
989
- z: Math.round(size['size.z'] ?? 0).toLocaleString()
990
- });
991
- }
1018
+ if (action.kind === 'follow-size' && !hadSize)
1019
+ this.notice = this.sizeDeclaredWords();
992
1020
  this.changed();
993
1021
  return true;
994
1022
  }
@@ -1003,18 +1031,20 @@ let FigureEditor = class FigureEditor extends LitElement {
1003
1031
  this.error = '';
1004
1032
  return false;
1005
1033
  }
1006
- this.error =
1007
- code === 'DIMENSION_CYCLE'
1008
- ? i18next.t('figure.text.follow-would-loop')
1009
- : code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
1010
- ? i18next.t('figure.text.fasten-would-loop')
1011
- : this.said(error);
1034
+ this.error = this.said(error);
1012
1035
  return false;
1013
1036
  }
1014
1037
  }
1015
1038
  changeField(field, event) {
1016
- const input = event.target, value = input.valueAsNumber;
1017
- /* A number typed keeps the part's middle, or its fastening -- figure-model's one size function (ADR-0140 4.1). */
1039
+ const input = event.target;
1040
+ // Rejected edits must also restore the displayed numeric value.
1041
+ input.value = String(this.writeField(field, input.valueAsNumber));
1042
+ }
1043
+ /**
1044
+ * A number written to an input, from a field, a scrub or the size editor's rows: a size keeps the part's middle, or its
1045
+ * fastening -- figure-model's one size function (ADR-0140 4.1). Gives the value the input holds after, refused or not.
1046
+ */
1047
+ writeField(field, value) {
1018
1048
  const part = this.selected;
1019
1049
  const change = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value };
1020
1050
  const asset = this.shown()?.asset;
@@ -1024,8 +1054,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1024
1054
  const after = asset && part && field.role !== 'state' ? throughPlaneOf(this.shown().asset, drawableFigureParts(this.session.read().geometry ?? []), part) : 0;
1025
1055
  if (after > before + 0.5)
1026
1056
  this.tellThroughPlane(part, after);
1027
- // Rejected edits must also restore the displayed numeric value.
1028
- input.value = String(this.session.read().fields.find(i => i.id === field.id).value);
1057
+ return this.session.read().fields.find(i => i.id === field.id).value;
1029
1058
  }
1030
1059
  /** Typing or a held spinner: each number on the way is drawn; the field's change records it. */
1031
1060
  typing(field, event) {
@@ -1056,16 +1085,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1056
1085
  * pointer, and the release records one step. The field's number follows too, so the value in hand is always read.
1057
1086
  */
1058
1087
  scrub(field, down, unavailable) {
1059
- if (unavailable || down.button !== 0)
1088
+ if (unavailable)
1060
1089
  return;
1061
1090
  const label = down.currentTarget;
1062
1091
  const input = (label.closest('div[cell]')?.querySelector('input') ?? label.nextElementSibling);
1063
1092
  if (!input)
1064
1093
  return;
1065
- down.preventDefault();
1066
- label.setPointerCapture(down.pointerId);
1067
- const start = field.value;
1068
- let value = start;
1069
1094
  /* Prepared once for the scrub: each move is a size through the same function its release writes with. */
1070
1095
  const asset = this.shown()?.asset;
1071
1096
  const sized = asset && this.selected && field.role !== 'state' ? (() => { try {
@@ -1074,29 +1099,17 @@ let FigureEditor = class FigureEditor extends LitElement {
1074
1099
  catch {
1075
1100
  return undefined;
1076
1101
  } })() : undefined;
1077
- const move = (e) => {
1078
- value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey));
1079
- input.value = String(value);
1080
- this.hold(this.heldOf(field, value, sized));
1081
- };
1082
- const up = (e) => {
1083
- label.removeEventListener('pointermove', move);
1084
- label.removeEventListener('pointerup', up);
1085
- label.removeEventListener('pointercancel', up);
1086
- /* A drag the system took away is not the author's value: the field goes back to where it was. */
1087
- if (e.type === 'pointercancel')
1088
- value = start;
1089
- if (value === start) {
1090
- input.value = String(start);
1102
+ scrubLabel(down, field, {
1103
+ move: value => {
1104
+ input.value = String(value);
1105
+ this.hold(this.heldOf(field, value, sized));
1106
+ },
1107
+ release: value => (input.value = String(this.writeField(field, value))),
1108
+ cancel: () => {
1109
+ input.value = String(field.value);
1091
1110
  this.live = NOTHING_LIVE;
1092
- return;
1093
1111
  }
1094
- input.value = String(value);
1095
- this.changeField(field, { target: input });
1096
- };
1097
- label.addEventListener('pointermove', move);
1098
- label.addEventListener('pointerup', up);
1099
- label.addEventListener('pointercancel', up);
1112
+ });
1100
1113
  }
1101
1114
  /** The sizes a board may give the figure: the ends the author typed, and the rest as far as its parts allow. */
1102
1115
  renderInstanceRange(shown, unavailable) {
@@ -1256,45 +1269,46 @@ let FigureEditor = class FigureEditor extends LitElement {
1256
1269
  const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?';
1257
1270
  const own = `${this.selected}.repeat.pitch`;
1258
1271
  /* The pitch as it stands: the part's own number, a constant, or what a pitch that follows a length comes to. */
1259
- const pitch = asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(shown, layout?.args[4]);
1260
1272
  const fits = layout?.op === 'fit-pitch@1';
1261
- /* What the pitch gives, in numbers: this many copies at the length it is authored, and how long it may be placed. */
1262
- const lengthRef = layout?.args[0] ?? '';
1263
- const length = asset.designInputs[lengthRef];
1264
- const longest = model.inputs.find(i => i.id === lengthRef)?.max;
1265
- const limit = model.constants.find(c => c.id === layout?.args[6])?.value;
1273
+ const pitch = fits ? asset.designInputs[own] ?? model.constants.find(c => c.id === layout?.args[4])?.value ?? valueNow(shown, layout?.args[4]) : this.rowNow(shown)?.pitch;
1266
1274
  const field = { id: own, unit: 'mm', role: 'design', value: pitch ?? 0 };
1267
- const removable = !!asset.joints?.some(j => j.body1.part === this.selected && j.repeat);
1275
+ const record = asset.joints?.find(j => j.body1.part === this.selected && j.repeat);
1276
+ const parent = record && 'part' in record.body0 ? record.body0.part : undefined;
1277
+ const removable = !!record;
1278
+ const axes = record && 'part' in record.body0 && 'face' in record.body0 ? figureFastenAxesOf(asset, record.body0) : [];
1279
+ const update = (change) => {
1280
+ if (!record?.repeat || unavailable)
1281
+ return;
1282
+ this.run(() => this.session.command({ kind: 'set-joint', joint: { ...record, repeat: { ...record.repeat, ...change } } }));
1283
+ };
1268
1284
  return html `<section slot="row" row>
1269
1285
  <h3>
1270
1286
  ${i18next.t('figure.label.repeat')}
1271
- ${groupMenu([{ label: i18next.t('figure.button.remove-repeat'), icon: 'close', disabled: unavailable || !removable, run: () => this.run(() => this.session.command({ kind: 'remove-repeat', part: this.selected })) }])}
1287
+ <span section-state active>${i18next.t('figure.label.repeat-active')}</span>
1272
1288
  </h3>
1273
- <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
1274
- <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
1289
+ <div choice-row role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
1290
+ <label toggle><input type="radio" name="applied-repeat-mode" .checked=${live(fits)} ?disabled=${unavailable || !record} @change=${() => update({ count: undefined, pitch: pitch && pitch > 0 ? pitch : 200 })}>${i18next.t('figure.label.repeat-fills')}</label>
1291
+ <label toggle><input type="radio" name="applied-repeat-mode" .checked=${live(!fits)} ?disabled=${unavailable || !record} @change=${() => update({ count: repeat.count })}>${i18next.t('figure.label.row-by-count')}</label>
1292
+ </div>
1293
+ <p hint>${i18next.t(fits ? 'figure.text.repeat-mode-pitch' : 'figure.text.repeat-mode-count')}</p>
1294
+ <div inline-field><label>${i18next.t('figure.label.size-source')}</label><span value>${parent ?? '—'}</span></div>
1295
+ <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) => update({ axis: e.target.value.toUpperCase() })}>${axes.map(a => html `<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}</select>` : html `<span value>${axis.toUpperCase()}</span>`}</div>
1296
+ <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) => update({ count: e.target.valueAsNumber })}>` : html `<span value>${repeat.count}</span>`}</div>
1275
1297
  <div inline-field>
1276
1298
  <label>${i18next.t('figure.label.repeat-gap')} <span unit>mm</span></label>
1277
- <input
1299
+ ${fits ? html `<input
1278
1300
  type="number"
1279
1301
  step="any"
1280
- aria-label=${own}
1281
- .value=${String(pitch ?? '')}
1282
- ?disabled=${unavailable || !fits}
1302
+ aria-label=${i18next.t('figure.label.repeat-gap')}
1303
+ .value=${live(String(pitch ?? ''))}
1304
+ ?disabled=${unavailable}
1283
1305
  @change=${(e) => this.setPitch(field, e)}
1284
- />
1306
+ />` : html `<span value>${pitch === undefined ? '—' : Number(pitch.toFixed(3))}</span>`}
1285
1307
  </div>
1286
- <ul row-reading>${rowWordsOf(this.rowNow(shown), (key, params) => i18next.t(key, params)).map(line => html `<li>${line}</li>`)}</ul>
1287
- <p hint>
1288
- ${fits && length !== undefined && longest !== undefined && limit !== undefined
1289
- ? i18next.t('figure.text.repeat-what-pitch-gives', {
1290
- length: length.toLocaleString(),
1291
- count: repeat.count,
1292
- longest: longest.toLocaleString(),
1293
- limit
1294
- })
1295
- : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
1296
- </p>
1297
- ${this.renderPublicPitch(asset, pitch ?? 0, unavailable)}
1308
+ <ul row-reading>${rowWordsOf(this.rowNow(shown), (key, params) => i18next.t(key, params)).slice(1).map(line => html `<li>${line}</li>`)}</ul>
1309
+ <button quiet ?disabled=${unavailable || !removable} @click=${() => this.run(() => this.session.command({ kind: 'remove-repeat', part: this.selected }))}>${i18next.t('figure.button.remove-repeat')}</button>
1310
+ <p hint>${i18next.t('figure.text.repeat-remove-explained')}</p>
1311
+ ${fits ? this.renderPublicPitch(asset, pitch ?? 0, unavailable) : nothing}
1298
1312
  </section>`;
1299
1313
  }
1300
1314
  /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
@@ -1610,8 +1624,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1610
1624
  /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1611
1625
  renderTab(tab, icon, label) {
1612
1626
  /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1613
- const name = i18next.t('figure.label.tab-short-' + tab);
1614
- return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1627
+ const name = i18next.t(tab === 'response' && this.selected ? 'figure.label.tab-arrangement' : 'figure.label.tab-short-' + tab);
1628
+ return html `<button tab ?on=${this.tab === tab} title=${tab === 'response' && this.selected ? name : label} aria-label=${name} @click=${() => (this.tab = tab)}>
1615
1629
  <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>
1616
1630
  </button>`;
1617
1631
  }
@@ -1741,8 +1755,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1741
1755
  return nothing;
1742
1756
  return html `<div subject>
1743
1757
  <div subject-summary>
1744
- ${summary.kind ? html `<b>${summary.kind}</b>` : nothing}
1745
- ${summary.size ? html `<small>${summary.size}</small>` : nothing}
1758
+ <b>${ownerOf(this.selected)}</b>
1759
+ ${summary.kind ? html `<small>${summary.kind}</small>` : nothing}
1746
1760
  </div>
1747
1761
  </div>`;
1748
1762
  }