@things-factory/figure-ui 10.1.73 → 10.1.75

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.
@@ -7,7 +7,7 @@ import { live } from 'lit/directives/live.js'
7
7
  import { PART_LIMIT, parseV3Recipe, resolveV3Recipe, v3PartCountOf, v3RecipeIdFor } from '@hatiolab/figure-model'
8
8
  import type { RecipeTemplate } from './v3-recipes.js'
9
9
  import './figure-v3-recipe-catalog.js'
10
- import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
10
+ import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3ProposalStep, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
11
11
  import type { V3ProductSession } from './v3-product-session.js'
12
12
  import { captureV3Thumbnail } from './v3-thumbnail.js'
13
13
  import { drawableV3Parts } from './v3-renderer.js'
@@ -31,7 +31,7 @@ import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
31
31
  import { paletteRecipe } from './v3-palette.js'
32
32
  import type { FigureV3Canvas } from './v3-canvas.js'
33
33
  import { nudgeOf } from './v3-stage-model.js'
34
- import { inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, sizeRangeRefusalOf } from './v3-refusals.js'
34
+ import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './v3-refusals.js'
35
35
  import type { V3RenderPart } from './v3-renderer.js'
36
36
  import type { V3BudgetLine } from './v3-maturity.js'
37
37
  import { instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './v3-links.js'
@@ -269,6 +269,8 @@ export class FigureV3Editor extends LitElement {
269
269
  /** Reads a template's guide; given by the page. */
270
270
  @property({ attribute: false }) guideOf?: (name: string) => Promise<string>
271
271
  @property({ attribute: false }) thumbnailOf?: (name: string) => string
272
+ /** The 「보드에서 바꿀 수 있음」 form being filled for a row, before it is published (ADR-0096). */
273
+ @state() private publishing?: { part: string; label: string; min: number; max: number }
272
274
  @state() private catalogOpen = false
273
275
  /** Why the last recipe was refused, in the kernel's words. */
274
276
  @state() private recipeError = ''
@@ -400,14 +402,23 @@ export class FigureV3Editor extends LitElement {
400
402
  this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')?.gizmo(mode)
401
403
  this.gizmoMode = this.gizmoMode === mode ? undefined : mode
402
404
  }
405
+ /**
406
+ * A refusal no particular sentence knows: figure-model's code said as a refusal, never its English; any other error
407
+ * (the network, the server) as it came.
408
+ */
409
+ private said(error: unknown): string {
410
+ return (error as { code?: string })?.code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error)
411
+ }
403
412
  private run(action: () => unknown) {
404
413
  try {
405
414
  action()
406
415
  this.error = ''
416
+ /* The next step stands on its own: what the last removal let go of is no longer the news. */
417
+ this.notice = ''
407
418
  this.changed()
408
419
  } catch (error) {
409
420
  const say = (key: string, params: Record<string, string>) => i18next.t(key, params)
410
- this.error = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? (error instanceof Error ? error.message : String(error))
421
+ this.error = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? this.said(error)
411
422
  } finally {
412
423
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
413
424
  this.live = NOTHING_LIVE
@@ -473,7 +484,7 @@ export class FigureV3Editor extends LitElement {
473
484
  await promise
474
485
  this.error = ''
475
486
  } catch (error) {
476
- this.error = error instanceof Error ? error.message : String(error)
487
+ this.error = this.said(error)
477
488
  } finally {
478
489
  this.changed()
479
490
  }
@@ -506,13 +517,15 @@ export class FigureV3Editor extends LitElement {
506
517
  private removePart(id: string, refitRoom = false) {
507
518
  this.retry = undefined
508
519
  try {
509
- this.session!.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) })
520
+ const view = this.session!.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) }) as { removal?: Parameters<typeof removalNoticeOf>[0] }
510
521
  this.error = ''
522
+ /* What else the removal let go of, named (chief architect, 2026-09-27); undo brings it back. */
523
+ this.notice = removalNoticeOf(view.removal, (key, params) => i18next.t(key, params)) ?? ''
511
524
  if (this.selected === id) this.selected = ''
512
525
  this.changed()
513
526
  } catch (error) {
514
527
  /* The refusal a person meets most: another part stands on this one or follows its size. Said in their words. */
515
- this.error = partRefusalOf(error, id, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error))
528
+ this.error = partRefusalOf(error, id, (key, params) => i18next.t(key, params)) ?? this.said(error)
516
529
  /* In the declared room only: the way on is to delete it and fit the room to the parts left, in one step. */
517
530
  if (!refitRoom && inDeclaredRoom(error)) this.retry = { label: i18next.t('figure.button.remove-and-refit-room', { part: id }), run: () => this.removePart(id, true) }
518
531
  }
@@ -544,7 +557,7 @@ export class FigureV3Editor extends LitElement {
544
557
  this.error = ''
545
558
  this.changed()
546
559
  } catch (error) {
547
- this.recipeError = error instanceof Error ? error.message : String(error)
560
+ this.recipeError = recipeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? this.said(error)
548
561
  }
549
562
  }
550
563
  /** The design values of the asset being edited. */
@@ -628,7 +641,7 @@ export class FigureV3Editor extends LitElement {
628
641
  ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
629
642
  : /is fastened to/.test(message)
630
643
  ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
631
- : message
644
+ : this.said(error)
632
645
  this.requestUpdate()
633
646
  }
634
647
  }
@@ -801,7 +814,7 @@ export class FigureV3Editor extends LitElement {
801
814
  ? i18next.t('figure.text.fasten-would-loop')
802
815
  : sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ??
803
816
  placementRefusalOf(error, (key, params) => i18next.t(key, params)) ??
804
- (error instanceof Error ? error.message : String(error))
817
+ this.said(error)
805
818
  }
806
819
  }
807
820
  private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
@@ -1040,6 +1053,7 @@ export class FigureV3Editor extends LitElement {
1040
1053
  })
1041
1054
  : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
1042
1055
  </p>
1056
+ ${this.renderPublicPitch(asset, pitch ?? 0, unavailable)}
1043
1057
  ${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
1044
1058
  ? html`<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>
1045
1059
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
@@ -1047,6 +1061,80 @@ export class FigureV3Editor extends LitElement {
1047
1061
  : nothing}
1048
1062
  </section>`
1049
1063
  }
1064
+ /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1065
+ private pitchInputOf(asset: V3Asset, part: string): string | undefined {
1066
+ const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch as { from?: { of?: string; input?: string } } | number | undefined
1067
+ return typeof pitch === 'object' && pitch?.from?.of === 'input' ? pitch.from.input : undefined
1068
+ }
1069
+ /**
1070
+ * 「보드에서 바꿀 수 있음」 (ADR-0096): the row's pitch made a design input the figure publishes -- a name on the board and
1071
+ * a range the board's value is held to -- in one step; changed or withdrawn the same way. Only a row with a record.
1072
+ */
1073
+ private renderPublicPitch(asset: V3Asset, pitch: number, unavailable: boolean) {
1074
+ const part = this.selected
1075
+ if (!asset.joints?.some(j => j.body1.part === part && j.repeat)) return nothing
1076
+ const from = this.pitchInputOf(asset, part)
1077
+ const listed = from ? asset.publicInputs?.find(p => p.input === from) : undefined
1078
+ const input = from ? asset.document.model.inputs.find(i => i.id === from) : undefined
1079
+ const form =
1080
+ this.publishing?.part === part
1081
+ ? this.publishing
1082
+ : listed && input
1083
+ ? { part, label: listed.label, min: input.min, max: input.max }
1084
+ : undefined
1085
+ const open = !!form
1086
+ const edit = (key: 'label' | 'min' | 'max') => (e: Event) => {
1087
+ const target = e.target as HTMLInputElement
1088
+ const base = form ?? { part, label: '', min: 0, max: 0 }
1089
+ this.publishing = { ...base, [key]: key === 'label' ? target.value : target.valueAsNumber }
1090
+ }
1091
+ const steps = (): V3ProposalStep[] => {
1092
+ const f = form!
1093
+ const id = from ?? `${part}.pitch`
1094
+ if (listed) return [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }, { kind: 'publish-input', input: id, label: f.label }] as V3ProposalStep[]
1095
+ const exists = asset.document.model.inputs.some(i => i.id === id)
1096
+ return [
1097
+ ...(exists ? [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }] : [{ kind: 'add-design-input', id, unit: 'mm', min: f.min, max: f.max, value: pitch }]),
1098
+ ...(from ? [] : [{ kind: 'set-repeat', part, pitch: { from: { of: 'input', input: id } } }]),
1099
+ { kind: 'publish-input', input: id, label: f.label }
1100
+ ] as V3ProposalStep[]
1101
+ }
1102
+ const run = (list: V3ProposalStep[]) => {
1103
+ try {
1104
+ this.session!.applyRecipe(list)
1105
+ this.publishing = undefined
1106
+ this.error = ''
1107
+ this.changed()
1108
+ } catch (error) {
1109
+ this.error = this.said(error)
1110
+ }
1111
+ }
1112
+ return html`<div public-pitch>
1113
+ <label toggle>
1114
+ <input
1115
+ type="checkbox"
1116
+ aria-label="figure.label.board-sets"
1117
+ .checked=${open}
1118
+ ?disabled=${unavailable}
1119
+ @change=${(e: Event) =>
1120
+ (e.target as HTMLInputElement).checked
1121
+ ? (this.publishing = { part, label: i18next.t('figure.label.repeat-gap'), min: Math.max(1, Math.round(pitch / 2)), max: Math.round(pitch * 2) })
1122
+ : listed
1123
+ ? run([{ kind: 'unpublish-input', input: from }, { kind: 'set-repeat', part, pitch }] as V3ProposalStep[])
1124
+ : (this.publishing = undefined)}
1125
+ />
1126
+ ${i18next.t('figure.label.board-sets')}
1127
+ </label>
1128
+ ${open
1129
+ ? 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>
1130
+ <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>
1131
+ <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>
1132
+ <button act publish-pitch ?disabled=${unavailable || !form!.label.trim()} @click=${() => run(steps())}>
1133
+ <md-icon>${listed ? 'check' : 'ios_share'}</md-icon>${i18next.t(listed ? 'figure.button.apply' : 'figure.button.publish-input')}
1134
+ </button>`
1135
+ : nothing}
1136
+ </div>`
1137
+ }
1050
1138
  /**
1051
1139
  * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
1052
1140
  * which a plain input edit would not. Refused only when the authored length would already hold more copies than
@@ -1056,6 +1144,13 @@ export class FigureV3Editor extends LitElement {
1056
1144
  const input = event.target as HTMLInputElement
1057
1145
  const pitch = input.valueAsNumber
1058
1146
  if (!Number.isFinite(pitch) || pitch === field.value) return
1147
+ /* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
1148
+ const from = this.pitchInputOf(JSON.parse(this.session!.source()) as V3Asset, this.selected)
1149
+ if (from) {
1150
+ this.run(() => this.session!.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]))
1151
+ input.value = String(this.session!.read().fields.find(i => i.id === from)?.value ?? field.value)
1152
+ return
1153
+ }
1059
1154
  try {
1060
1155
  this.session!.command({ kind: 'set-repeat', part: this.selected, pitch })
1061
1156
  this.error = ''
@@ -1068,7 +1163,7 @@ export class FigureV3Editor extends LitElement {
1068
1163
  this.error =
1069
1164
  code === 'INPUT_RANGE' && found
1070
1165
  ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
1071
- : message
1166
+ : this.said(error)
1072
1167
  }
1073
1168
  }
1074
1169
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
@@ -39,11 +39,6 @@ export function narrowingRetryOf<A extends { kind: string }>(action: A, error: u
39
39
  }
40
40
  }
41
41
 
42
- /**
43
- * Why a part cannot be deleted, in the author's words -- figure-model names four reasons (PART_REFERENCED): parts hang
44
- * from it, the declared space holds it, another part follows its size or place, or a joint moves with its joint. One
45
- * sentence for all of them told the author to undo a link that was not there (the architect's workbench, 2026-09-25).
46
- */
47
42
  /**
48
43
  * A placement refused because a part is fastened to the mounting plane by a face the new plane does not take (a bottom
49
44
  * on the floor, when the figure is to hang on a wall), said in the author's words with the parts it names.
@@ -61,20 +56,74 @@ export function inDeclaredRoom(error: unknown): boolean {
61
56
  return (error as { code?: string })?.code === 'PART_REFERENCED' && /is in the space the figure declares/.test(error instanceof Error ? error.message : String(error))
62
57
  }
63
58
 
59
+ /**
60
+ * Why a part cannot be deleted, in the author's words. Since the chief architect's ruling of 2026-09-27 a part that
61
+ * holds others, leads a motion or is followed goes, and what depended on it is let go of (`removalNoticeOf`); what is
62
+ * left is the declared space (with a way on: fit it again) and a row -- one laid along the part, or hanging in its row.
63
+ */
64
64
  export function partRefusalOf(error: unknown, part: string, say: Say): string | null {
65
- if ((error as { code?: string })?.code !== 'PART_REFERENCED') return null
66
- /* figure-model's sentence after its code and path: 「PART_REFERENCED: top: top holds leg-2, lamp; …」. */
65
+ const code = (error as { code?: string })?.code
66
+ if (code !== 'PART_REFERENCED' && code !== 'PART_ROW') return null
67
+ /* figure-model's sentence after its code and path: 「PART_ROW: bed: roller is a row laid along bed; …」. */
67
68
  const message = (error instanceof Error ? error.message : String(error)).replace(/^[A-Z_]+: [^:]+: /, '')
68
- const holds = / holds (.+?); take (?:them|it) off first/.exec(message)
69
- if (holds) return say('figure.text.part-holds-others', { part, others: holds[1]! })
70
69
  if (/is in the space the figure declares/.test(message)) return say('figure.text.part-in-declared-space', { part })
71
- const moves = /^(.+?) (?:moves|move) with /.exec(message)
72
- if (moves) return say('figure.text.part-is-led', { part, others: moves[1]! })
73
- const follows = /^(.+?) (?:follows|follow) .+'s size or place/.exec(message)
74
- if (follows) return say('figure.text.part-is-followed', { part, others: follows[1]! })
70
+ const along = /^(.+?) (?:is a row|are rows) laid along /.exec(message)
71
+ if (along) return say('figure.text.part-row-along', { part, rows: along[1]! })
72
+ const carried = /^(.+?) hangs? in the row /.exec(message)
73
+ if (carried) return say('figure.text.part-row-carries', { part, others: carried[1]! })
75
74
  return say('figure.text.part-is-referenced', { part })
76
75
  }
77
76
 
77
+ /** What removing a part let go of (figure-model's `RemovalNotice`), said in one line, or nothing when it let go of nothing. */
78
+ export function removalNoticeOf(
79
+ notice:
80
+ | {
81
+ released: Array<{ part: string; control?: string }>
82
+ fixed: Array<{ part: string }>
83
+ controls: Array<{ part: string; control: string; min: number; max: number }>
84
+ mountingFace?: boolean
85
+ bindings?: Array<{ id: string; label?: string }>
86
+ }
87
+ | undefined,
88
+ say: Say
89
+ ): string | null {
90
+ if (!notice) return null
91
+ const number = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 3 })
92
+ const lines: string[] = []
93
+ const still = notice.released.filter(r => !r.control).map(r => r.part)
94
+ if (still.length) lines.push(say('figure.text.removal-released', { parts: still.join(', ') }))
95
+ for (const r of notice.released.filter(r => r.control)) lines.push(say('figure.text.removal-released-motion', { part: r.part, control: r.control! }))
96
+ for (const c of notice.controls) lines.push(say('figure.text.removal-own-control', { part: c.part, control: c.control, min: number(c.min), max: number(c.max) }))
97
+ const fixed = [...new Set(notice.fixed.map(f => f.part))]
98
+ if (fixed.length) lines.push(say('figure.text.removal-fixed', { parts: fixed.join(', ') }))
99
+ if (notice.bindings?.length) lines.push(say('figure.text.removal-bindings', { places: notice.bindings.map(b => b.label ?? b.id).join(', ') }))
100
+ if (notice.mountingFace) lines.push(say('figure.text.removal-mounting-face', {}))
101
+ return lines.length ? lines.join(' ') : null
102
+ }
103
+
104
+ /**
105
+ * A recipe refused at one of its steps: which step, and why -- the particular sentence when there is one (a size
106
+ * range, a value's range, a placement), otherwise the kernel's code. The kernel says 「steps[2]: attach: …」.
107
+ */
108
+ export function recipeRefusalOf(error: unknown, say: Say): string | null {
109
+ const step = /^steps\[(\d+)\]$/.exec((error as { path?: string })?.path ?? '')
110
+ if (!step) return null
111
+ const why = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? placementRefusalOf(error, say)
112
+ return say('figure.text.recipe-step-refused', { step: String(Number(step[1]) + 1), why: why ?? '', code: (error as { code?: string })?.code ?? '' })
113
+ }
114
+
115
+ /**
116
+ * A refusal no sentence above knows, said as a refusal with its code -- never the kernel's English on the screen (the
117
+ * duplicate of a row's part showed 「EDIT_TARGET: … not a repeated template」, 2026-09-27). The figure's node limit
118
+ * is said with its number.
119
+ */
120
+ export function editRefusalOf(error: unknown, say: Say): string {
121
+ const code = (error as { code?: string })?.code
122
+ const message = error instanceof Error ? error.message : String(error)
123
+ if (code === 'DOCUMENT_LIMIT') return say('figure.text.edit-refused-node-limit', { max: /maximum (\d+) nodes/.exec(message)?.[1] ?? '' })
124
+ return say('figure.text.edit-refused', { code: code ?? '' })
125
+ }
126
+
78
127
  /**
79
128
  * A value outside the range its input declares, with the range and the value given -- the kernel says
80
129
  * 「size.x: 6021100 outside [300,1200]」. The figure's own size is named by its axis; any other input by its range only.
@@ -34,6 +34,6 @@ export declare const trackEdit: (clip: Clip, track: ClipTrack, patch?: Partial<P
34
34
  state: string;
35
35
  clip: string;
36
36
  keys: ClipKey[];
37
- loop: "hold" | "wrap";
37
+ loop: "wrap" | "hold";
38
38
  interpolation: "linear" | "step";
39
39
  };
@@ -23,6 +23,8 @@ export declare class FigureV3Editor extends LitElement {
23
23
  /** Reads a template's guide; given by the page. */
24
24
  guideOf?: (name: string) => Promise<string>;
25
25
  thumbnailOf?: (name: string) => string;
26
+ /** The 「보드에서 바꿀 수 있음」 form being filled for a row, before it is published (ADR-0096). */
27
+ private publishing?;
26
28
  private catalogOpen;
27
29
  /** Why the last recipe was refused, in the kernel's words. */
28
30
  private recipeError;
@@ -77,6 +79,11 @@ export declare class FigureV3Editor extends LitElement {
77
79
  protected willUpdate(_changed: PropertyValues): void;
78
80
  /** The scene toggles: the mode already shown takes the gizmo away. */
79
81
  private useGizmo;
82
+ /**
83
+ * A refusal no particular sentence knows: figure-model's code said as a refusal, never its English; any other error
84
+ * (the network, the server) as it came.
85
+ */
86
+ private said;
80
87
  private run;
81
88
  /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
82
89
  private drawnParts;
@@ -168,6 +175,13 @@ export declare class FigureV3Editor extends LitElement {
168
175
  * time); a row of a fixed count says its pitch cannot be set yet (figure-model refuses it the same way).
169
176
  */
170
177
  private renderRepeat;
178
+ /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
179
+ private pitchInputOf;
180
+ /**
181
+ * 「보드에서 바꿀 수 있음」 (ADR-0096): the row's pitch made a design input the figure publishes -- a name on the board and
182
+ * a range the board's value is held to -- in one step; changed or withdrawn the same way. Only a row with a record.
183
+ */
184
+ private renderPublicPitch;
171
185
  /**
172
186
  * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
173
187
  * which a plain input edit would not. Refused only when the authored length would already hold more copies than
@@ -27,7 +27,7 @@ import { ScrollbarStyles } from '@operato/styles';
27
27
  import { alignChanges, boxOf, canMirror, mirrorChanges } from './v3-align.js';
28
28
  import { paletteRecipe } from './v3-palette.js';
29
29
  import { nudgeOf } from './v3-stage-model.js';
30
- import { inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, sizeRangeRefusalOf } from './v3-refusals.js';
30
+ import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './v3-refusals.js';
31
31
  import { instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './v3-links.js';
32
32
  import { jointFanOf } from './v3-joint-fan.js';
33
33
  import { jointDragOf } from './v3-joint-drag.js';
@@ -376,15 +376,24 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
376
376
  this.renderRoot.querySelector('figure-v3-canvas')?.gizmo(mode);
377
377
  this.gizmoMode = this.gizmoMode === mode ? undefined : mode;
378
378
  }
379
+ /**
380
+ * A refusal no particular sentence knows: figure-model's code said as a refusal, never its English; any other error
381
+ * (the network, the server) as it came.
382
+ */
383
+ said(error) {
384
+ return error?.code ? editRefusalOf(error, (key, params) => i18next.t(key, params)) : error instanceof Error ? error.message : String(error);
385
+ }
379
386
  run(action) {
380
387
  try {
381
388
  action();
382
389
  this.error = '';
390
+ /* The next step stands on its own: what the last removal let go of is no longer the news. */
391
+ this.notice = '';
383
392
  this.changed();
384
393
  }
385
394
  catch (error) {
386
395
  const say = (key, params) => i18next.t(key, params);
387
- this.error = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? (error instanceof Error ? error.message : String(error));
396
+ this.error = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? this.said(error);
388
397
  }
389
398
  finally {
390
399
  /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
@@ -456,7 +465,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
456
465
  this.error = '';
457
466
  }
458
467
  catch (error) {
459
- this.error = error instanceof Error ? error.message : String(error);
468
+ this.error = this.said(error);
460
469
  }
461
470
  finally {
462
471
  this.changed();
@@ -492,15 +501,17 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
492
501
  removePart(id, refitRoom = false) {
493
502
  this.retry = undefined;
494
503
  try {
495
- this.session.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) });
504
+ const view = this.session.edit({ kind: 'remove-part', target: id, ...(refitRoom ? { refitRoom: true } : {}) });
496
505
  this.error = '';
506
+ /* What else the removal let go of, named (chief architect, 2026-09-27); undo brings it back. */
507
+ this.notice = removalNoticeOf(view.removal, (key, params) => i18next.t(key, params)) ?? '';
497
508
  if (this.selected === id)
498
509
  this.selected = '';
499
510
  this.changed();
500
511
  }
501
512
  catch (error) {
502
513
  /* The refusal a person meets most: another part stands on this one or follows its size. Said in their words. */
503
- this.error = partRefusalOf(error, id, (key, params) => i18next.t(key, params)) ?? (error instanceof Error ? error.message : String(error));
514
+ this.error = partRefusalOf(error, id, (key, params) => i18next.t(key, params)) ?? this.said(error);
504
515
  /* In the declared room only: the way on is to delete it and fit the room to the parts left, in one step. */
505
516
  if (!refitRoom && inDeclaredRoom(error))
506
517
  this.retry = { label: i18next.t('figure.button.remove-and-refit-room', { part: id }), run: () => this.removePart(id, true) };
@@ -536,7 +547,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
536
547
  this.changed();
537
548
  }
538
549
  catch (error) {
539
- this.recipeError = error instanceof Error ? error.message : String(error);
550
+ this.recipeError = recipeRefusalOf(error, (key, params) => i18next.t(key, params)) ?? this.said(error);
540
551
  }
541
552
  }
542
553
  /** The design values of the asset being edited. */
@@ -629,7 +640,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
629
640
  ? i18next.t('figure.text.gizmo-joint-places', { part: partLabelOf(id) })
630
641
  : /is fastened to/.test(message)
631
642
  ? i18next.t('figure.text.gizmo-fastening-places', { part: partLabelOf(id) })
632
- : message;
643
+ : this.said(error);
633
644
  this.requestUpdate();
634
645
  }
635
646
  }
@@ -810,7 +821,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
810
821
  ? i18next.t('figure.text.fasten-would-loop')
811
822
  : sizeRangeRefusalOf(error, (key, params) => i18next.t(key, params)) ??
812
823
  placementRefusalOf(error, (key, params) => i18next.t(key, params)) ??
813
- (error instanceof Error ? error.message : String(error));
824
+ this.said(error);
814
825
  }
815
826
  }
816
827
  changeField(field, event) {
@@ -1049,6 +1060,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
1049
1060
  })
1050
1061
  : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
1051
1062
  </p>
1063
+ ${this.renderPublicPitch(asset, pitch ?? 0, unavailable)}
1052
1064
  ${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
1053
1065
  ? html `<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session.command({ kind: 'remove-repeat', part: this.selected }))}>
1054
1066
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
@@ -1056,6 +1068,81 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
1056
1068
  : nothing}
1057
1069
  </section>`;
1058
1070
  }
1071
+ /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1072
+ pitchInputOf(asset, part) {
1073
+ const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch;
1074
+ return typeof pitch === 'object' && pitch?.from?.of === 'input' ? pitch.from.input : undefined;
1075
+ }
1076
+ /**
1077
+ * 「보드에서 바꿀 수 있음」 (ADR-0096): the row's pitch made a design input the figure publishes -- a name on the board and
1078
+ * a range the board's value is held to -- in one step; changed or withdrawn the same way. Only a row with a record.
1079
+ */
1080
+ renderPublicPitch(asset, pitch, unavailable) {
1081
+ const part = this.selected;
1082
+ if (!asset.joints?.some(j => j.body1.part === part && j.repeat))
1083
+ return nothing;
1084
+ const from = this.pitchInputOf(asset, part);
1085
+ const listed = from ? asset.publicInputs?.find(p => p.input === from) : undefined;
1086
+ const input = from ? asset.document.model.inputs.find(i => i.id === from) : undefined;
1087
+ const form = this.publishing?.part === part
1088
+ ? this.publishing
1089
+ : listed && input
1090
+ ? { part, label: listed.label, min: input.min, max: input.max }
1091
+ : undefined;
1092
+ const open = !!form;
1093
+ const edit = (key) => (e) => {
1094
+ const target = e.target;
1095
+ const base = form ?? { part, label: '', min: 0, max: 0 };
1096
+ this.publishing = { ...base, [key]: key === 'label' ? target.value : target.valueAsNumber };
1097
+ };
1098
+ const steps = () => {
1099
+ const f = form;
1100
+ const id = from ?? `${part}.pitch`;
1101
+ if (listed)
1102
+ return [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }, { kind: 'publish-input', input: id, label: f.label }];
1103
+ const exists = asset.document.model.inputs.some(i => i.id === id);
1104
+ return [
1105
+ ...(exists ? [{ kind: 'set-input-range', input: id, min: f.min, max: f.max }] : [{ kind: 'add-design-input', id, unit: 'mm', min: f.min, max: f.max, value: pitch }]),
1106
+ ...(from ? [] : [{ kind: 'set-repeat', part, pitch: { from: { of: 'input', input: id } } }]),
1107
+ { kind: 'publish-input', input: id, label: f.label }
1108
+ ];
1109
+ };
1110
+ const run = (list) => {
1111
+ try {
1112
+ this.session.applyRecipe(list);
1113
+ this.publishing = undefined;
1114
+ this.error = '';
1115
+ this.changed();
1116
+ }
1117
+ catch (error) {
1118
+ this.error = this.said(error);
1119
+ }
1120
+ };
1121
+ return html `<div public-pitch>
1122
+ <label toggle>
1123
+ <input
1124
+ type="checkbox"
1125
+ aria-label="figure.label.board-sets"
1126
+ .checked=${open}
1127
+ ?disabled=${unavailable}
1128
+ @change=${(e) => e.target.checked
1129
+ ? (this.publishing = { part, label: i18next.t('figure.label.repeat-gap'), min: Math.max(1, Math.round(pitch / 2)), max: Math.round(pitch * 2) })
1130
+ : listed
1131
+ ? run([{ kind: 'unpublish-input', input: from }, { kind: 'set-repeat', part, pitch }])
1132
+ : (this.publishing = undefined)}
1133
+ />
1134
+ ${i18next.t('figure.label.board-sets')}
1135
+ </label>
1136
+ ${open
1137
+ ? 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>
1138
+ <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>
1139
+ <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>
1140
+ <button act publish-pitch ?disabled=${unavailable || !form.label.trim()} @click=${() => run(steps())}>
1141
+ <md-icon>${listed ? 'check' : 'ios_share'}</md-icon>${i18next.t(listed ? 'figure.button.apply' : 'figure.button.publish-input')}
1142
+ </button>`
1143
+ : nothing}
1144
+ </div>`;
1145
+ }
1059
1146
  /**
1060
1147
  * A pitch always goes through `set-repeat`: the command moves the longest length with it (at most `limit` copies),
1061
1148
  * which a plain input edit would not. Refused only when the authored length would already hold more copies than
@@ -1066,6 +1153,13 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
1066
1153
  const pitch = input.valueAsNumber;
1067
1154
  if (!Number.isFinite(pitch) || pitch === field.value)
1068
1155
  return;
1156
+ /* Read from a design input the board may set: the input's value changes, the row keeps reading it (ADR-0096). */
1157
+ const from = this.pitchInputOf(JSON.parse(this.session.source()), this.selected);
1158
+ if (from) {
1159
+ this.run(() => this.session.apply([{ kind: 'set-design', input: from, unit: 'mm', value: pitch }]));
1160
+ input.value = String(this.session.read().fields.find(i => i.id === from)?.value ?? field.value);
1161
+ return;
1162
+ }
1069
1163
  try {
1070
1164
  this.session.command({ kind: 'set-repeat', part: this.selected, pitch });
1071
1165
  this.error = '';
@@ -1079,7 +1173,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
1079
1173
  this.error =
1080
1174
  code === 'INPUT_RANGE' && found
1081
1175
  ? i18next.t('figure.text.repeat-pitch-too-small', { length: Number(found[1]).toLocaleString(), min: found[2], limit: found[3] })
1082
- : message;
1176
+ : this.said(error);
1083
1177
  }
1084
1178
  }
1085
1179
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
@@ -1353,6 +1447,10 @@ __decorate([
1353
1447
  property({ attribute: false }),
1354
1448
  __metadata("design:type", Function)
1355
1449
  ], FigureV3Editor.prototype, "thumbnailOf", void 0);
1450
+ __decorate([
1451
+ state(),
1452
+ __metadata("design:type", Object)
1453
+ ], FigureV3Editor.prototype, "publishing", void 0);
1356
1454
  __decorate([
1357
1455
  state(),
1358
1456
  __metadata("design:type", Object)