@things-factory/figure-ui 10.1.68 → 10.1.70

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 (68) hide show
  1. package/client/graphql/index.ts +89 -0
  2. package/client/modeller/figure-v3-links.ts +73 -7
  3. package/client/modeller/figure-v3-parts.ts +153 -2
  4. package/client/modeller/v3-editor.ts +133 -5
  5. package/client/modeller/v3-gizmo.ts +18 -2
  6. package/client/modeller/v3-links.ts +71 -2
  7. package/client/modeller/v3-palette.ts +2 -1
  8. package/client/modeller/v3-product-session.ts +8 -0
  9. package/client/modeller/v3-recipes.ts +69 -0
  10. package/client/pages/figure-recipe-page.ts +338 -0
  11. package/client/pages/figure-v3-modeller-page.ts +25 -2
  12. package/client/route.ts +4 -0
  13. package/dist-client/graphql/index.d.ts +21 -0
  14. package/dist-client/graphql/index.js +75 -0
  15. package/dist-client/graphql/index.js.map +1 -1
  16. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  17. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  18. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  19. package/dist-client/modeller/figure-v3-links.d.ts +11 -1
  20. package/dist-client/modeller/figure-v3-links.js +69 -8
  21. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  22. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  23. package/dist-client/modeller/figure-v3-parts.d.ts +15 -1
  24. package/dist-client/modeller/figure-v3-parts.js +166 -1
  25. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  26. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  27. package/dist-client/modeller/v3-clips.d.ts +1 -1
  28. package/dist-client/modeller/v3-editor.d.ts +26 -0
  29. package/dist-client/modeller/v3-editor.js +147 -4
  30. package/dist-client/modeller/v3-editor.js.map +1 -1
  31. package/dist-client/modeller/v3-gizmo.d.ts +6 -0
  32. package/dist-client/modeller/v3-gizmo.js +20 -2
  33. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  34. package/dist-client/modeller/v3-links.d.ts +27 -0
  35. package/dist-client/modeller/v3-links.js +68 -2
  36. package/dist-client/modeller/v3-links.js.map +1 -1
  37. package/dist-client/modeller/v3-palette.js +2 -1
  38. package/dist-client/modeller/v3-palette.js.map +1 -1
  39. package/dist-client/modeller/v3-product-session.d.ts +34 -0
  40. package/dist-client/modeller/v3-product-session.js +8 -0
  41. package/dist-client/modeller/v3-product-session.js.map +1 -1
  42. package/dist-client/modeller/v3-recipes.d.ts +26 -0
  43. package/dist-client/modeller/v3-recipes.js +69 -0
  44. package/dist-client/modeller/v3-recipes.js.map +1 -0
  45. package/dist-client/pages/figure-recipe-page.d.ts +41 -0
  46. package/dist-client/pages/figure-recipe-page.js +352 -0
  47. package/dist-client/pages/figure-recipe-page.js.map +1 -0
  48. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  49. package/dist-client/pages/figure-v3-modeller-page.js +29 -2
  50. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  51. package/dist-client/route.d.ts +1 -1
  52. package/dist-client/route.js +3 -0
  53. package/dist-client/route.js.map +1 -1
  54. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +5 -5
  57. package/test/browser/modeller-page.js +15 -1
  58. package/test/browser/modeller.check.mjs +217 -2
  59. package/test/modeller-route.test.ts +9 -0
  60. package/test/v3-gizmo.test.ts +44 -0
  61. package/test/v3-links.test.ts +91 -1
  62. package/test/v3-recipes.test.ts +60 -0
  63. package/things-factory.config.js +1 -0
  64. package/translations/en.json +40 -8
  65. package/translations/ja.json +40 -8
  66. package/translations/ko.json +40 -8
  67. package/translations/ms.json +40 -8
  68. package/translations/zh.json +40 -8
@@ -29,8 +29,8 @@ export interface GizmoReach {
29
29
  round?: { across: string[]; along: string[] }
30
30
  }
31
31
 
32
- /** Shapes whose first three arguments are the extent along X, Y and Z. */
33
- const BOXES = new Set(['box-shape@1', 'rounded-box@1'])
32
+ /** Shapes whose first three arguments are the extent along X, Y and Z: a door frame's too (its width, height, depth). */
33
+ const BOXES = new Set(['box-shape@1', 'rounded-box@1', 'portal-profile@2'])
34
34
 
35
35
  interface Node {
36
36
  id: string
@@ -138,3 +138,19 @@ export function gizmoChanges(part: string, reach: GizmoReach, result: GizmoResul
138
138
  }
139
139
  return changes
140
140
  }
141
+
142
+ /**
143
+ * A height typed into its field keeps the part's bottom where it is, as the scale gizmo does (it anchors the opposite
144
+ * face): the part's own Y position moves by half the change. A post typed 2200 tall sank 933 mm under the floor
145
+ * (2026-09-26). Only for a part placed by its own numbers and standing unturned; otherwise its height is not up and down.
146
+ */
147
+ export function keepBottomChanges(part: string, reach: GizmoReach, input: string, value: number, values: Record<string, number>): V3InputChange[] {
148
+ if (!reach.translate) return []
149
+ const height = reach.sizes?.[1] ?? reach.ownSizes?.[1] ?? reach.round?.along[0]
150
+ if (!height || height !== input) return []
151
+ if (AXES.some(axis => (values[`${part}.rotation.${axis}`] ?? 0) % 360 !== 0)) return []
152
+ const before = values[input],
153
+ y = values[`${part}.position.y`]
154
+ if (before === undefined || y === undefined || !Number.isFinite(value)) return []
155
+ return [{ kind: 'set-design', input: `${part}.position.y`, unit: 'mm', value: Math.round((y + (value - before) / 2) * 10) / 10 } as V3InputChange]
156
+ }
@@ -1,4 +1,4 @@
1
- import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
1
+ import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Asset, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
2
  import type { V3SizeRange } from '@hatiolab/figure-model'
3
3
  import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
4
4
 
@@ -122,7 +122,8 @@ export function canFollow(asset: V3Asset, part: string): boolean {
122
122
  export function followableAxes(asset: V3Asset, part: string): Axis[] {
123
123
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
124
124
  if (!shape) return []
125
- if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1') return [...AXES]
125
+ /* A door frame's first three measures are its width, height and depth, as a box's (figure-model portal-profile@2). */
126
+ if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1' || shape.op === 'portal-profile@2') return [...AXES]
126
127
  if (shape.op === 'cylinder-shape@1') return ['x', 'y']
127
128
  return []
128
129
  }
@@ -210,6 +211,74 @@ export function alignAxesOf(asset: V3Asset, part: string, target: { part: string
210
211
  return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: !twisted && OUTER[axis].every(f => theirs.has(f) && mine.has(f)) }))
211
212
  }
212
213
 
214
+ /** The controls a part's showing can follow: the figure's state inputs, with their range and label. */
215
+ export function visibilityControlsOf(asset: V3Asset): Array<{ id: string; unit: string; min: number; max: number; label: string }> {
216
+ const labels = (asset as unknown as { stateInputs?: Record<string, { label?: string }> }).stateInputs ?? {}
217
+ return modelOf(asset)
218
+ .inputs.filter(input => input.role === 'state')
219
+ .map(input => ({ id: input.id, unit: input.unit ?? '', min: input.min ?? 0, max: input.max ?? 1, label: labels[input.id]?.label || input.id }))
220
+ }
221
+
222
+ /** When the part is drawn (figure-model `set-visibility`): always (null), or while a control is at least a value. */
223
+ export function shownWhenOf(asset: V3Asset, part: string): { input: string; atLeast: number } | null {
224
+ const look = asset.appearance.find(one => one.target === part) as { visibility?: string } | undefined
225
+ if (!look?.visibility) return null
226
+ const m = modelOf(asset)
227
+ const node = m.nodes.find(n => n.op === 'at-least@1' && Object.values(n.outputs).includes(look.visibility!))
228
+ const at = node && m.constants.find(c => c.id === node.args[1])
229
+ return node && at ? { input: node.args[0]!, atLeast: at.value } : null
230
+ }
231
+
232
+ /** Whether `part` hangs from `from`, through any number of fastenings. */
233
+ export function hangsFrom(asset: V3Asset, part: string, from: string): boolean {
234
+ const seen = new Set<string>()
235
+ for (let at: string | undefined = part; at && !seen.has(at); ) {
236
+ seen.add(at)
237
+ const j = ((asset.joints ?? []) as V3Joint[]).find(o => o.body1.part === at)
238
+ at = j && 'part' in j.body0 ? (j.body0 as { part: string }).part : undefined
239
+ if (at === from) return true
240
+ }
241
+ return false
242
+ }
243
+
244
+ /**
245
+ * The alignment that keeps the part where it stands when it is fastened: on each axis across the face, its distance
246
+ * from the target's centre (the plane's is the origin). Fastening used to centre it -- a door set 100 mm in front of
247
+ * its frame slid into it (2026-09-26). Left centred where it already is, and where the target stands turned.
248
+ */
249
+ export function placeKeptOf(
250
+ asset: V3Asset,
251
+ part: string,
252
+ target: { part: string; face: string } | 'plane',
253
+ twisted = false
254
+ ): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>> } {
255
+ const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>> }
256
+ let geometry
257
+ try {
258
+ geometry = compileV3Asset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
259
+ } catch {
260
+ return kept
261
+ }
262
+ const poseOf = (id: string) => geometry.find(g => g.id === id)?.pose
263
+ const mine = poseOf(part)
264
+ if (!mine) return kept
265
+ let centre = [0, 0, 0]
266
+ if (target !== 'plane') {
267
+ const theirs = poseOf(target.part)
268
+ const upright = theirs?.r.every((row, i) => row.every((v, j) => Math.abs(v - (i === j ? 1 : 0)) < 1e-9))
269
+ if (!theirs || !upright) return kept
270
+ centre = theirs.t
271
+ }
272
+ for (const { axis } of alignAxesOf(asset, part, target, twisted)) {
273
+ const k = AXES.indexOf(axis)
274
+ const off = Math.round((mine.t[k]! - centre[k]!) * 10) / 10
275
+ if (Math.abs(off) < 0.5) continue
276
+ kept.align[axis] = 'mm'
277
+ kept.alignMm[axis] = off
278
+ }
279
+ return kept
280
+ }
281
+
213
282
  /** The `align` of an attach command from the form's choices: only the axes across that face, and centred ones left out. */
214
283
  export function alignOf(
215
284
  asset: V3Asset,
@@ -39,7 +39,8 @@ export function paletteRecipe(kind: V3PaletteKind, id: string): V3PrimitiveCreat
39
39
  id,
40
40
  position: { x: 0, y: 0, z: 0 },
41
41
  kind: 'portal',
42
- dimensions: { left: -100, right: 100, bottom: 0, top: 200, openingLeft: -70, openingRight: 70, openingTop: 170, depth: 30 }
42
+ /* A door frame at a door's size, as a wall is a wall's: its width, height, depth and the jambs' and head's thickness. */
43
+ dimensions: { width: 1000, height: 2200, depth: 150, jamb: 100, head: 100 }
43
44
  }
44
45
  }
45
46
  }
@@ -120,6 +120,14 @@ export function createV3ProductSession(store: V3ProductStore) {
120
120
  writable()
121
121
  return workspace.applyProposal({ origin: 'ai', context, expectedRevision, steps })
122
122
  },
123
+ /**
124
+ * A recipe put in by the person (ADR-0095): its resolved commands, as one step of the history, on the version open
125
+ * now. The same path as a proposal; the person is its origin.
126
+ */
127
+ applyRecipe(steps: readonly V3ProposalStep[]) {
128
+ const at = writable()
129
+ return workspace.applyProposal({ origin: 'ui', context: at.context, expectedRevision: at.expectedRevision, steps })
130
+ },
123
131
  add(part: V3PrimitiveCreation) {
124
132
  return workspace.addPrimitive({ ...writable(), part })
125
133
  },
@@ -0,0 +1,69 @@
1
+ import type { V3Recipe, V3RecipeParam, V3RecipeValue } from '@hatiolab/figure-model'
2
+
3
+ /*
4
+ * The recipe section's rules (ADR-0095), kept apart from the element so they can be tested without a browser.
5
+ */
6
+
7
+ /** A recipe's words in the language on screen: that language, else English, else the first it has. */
8
+ export function recipeWord(words: Record<string, string> | undefined, language: string): string {
9
+ if (!words) return ''
10
+ return words[language] ?? words[language.split('-')[0]!] ?? words.en ?? Object.values(words)[0] ?? ''
11
+ }
12
+
13
+ /** The recipes as the server gave them, parsed; a row that is not a recipe is left out rather than breaking the list. */
14
+ export function recipesOf(rows: ReadonlyArray<{ source: string }>): V3Recipe[] {
15
+ const out: V3Recipe[] = []
16
+ for (const row of rows) {
17
+ try {
18
+ const recipe = JSON.parse(row.source)
19
+ if (recipe && typeof recipe.name === 'string' && Array.isArray(recipe.steps)) out.push(recipe)
20
+ } catch {
21
+ /* not a recipe */
22
+ }
23
+ }
24
+ return out
25
+ }
26
+
27
+ /** Whether a recipe can be put in now: one for a part needs a part chosen. */
28
+ export function recipeReady(recipe: Pick<V3Recipe, 'target'>, selected: string): boolean {
29
+ return recipe.target === 'figure' || !!selected
30
+ }
31
+
32
+ /**
33
+ * A field's value as the recipe takes it. A ratio is shown and typed as a percentage (35 for 0.35), since that is how a
34
+ * person says a share; the rest are taken as they are. A number field left empty or not a number keeps the default.
35
+ */
36
+ export function recipeValueOf(param: V3RecipeParam, typed: string | boolean): V3RecipeValue {
37
+ if (param.kind === 'flag') return typed === true || typed === 'true'
38
+ if (param.kind === 'ratio' || param.kind === 'mm') {
39
+ const n = typeof typed === 'string' && typed.trim() !== '' ? Number(typed) : NaN
40
+ if (!Number.isFinite(n)) return param.default
41
+ return param.kind === 'ratio' ? Math.round(n * 1e4) / 1e6 : n
42
+ }
43
+ const option = (param.options ?? []).find(o => String(o) === String(typed))
44
+ return option ?? param.default
45
+ }
46
+
47
+ /** What a field shows for a value: a ratio as a percentage. */
48
+ export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): string {
49
+ if (param.kind === 'ratio' && typeof value === 'number') return String(Math.round(value * 1e6) / 1e4)
50
+ return String(value)
51
+ }
52
+
53
+ /** The domain on screen, from the address (`/domain/<subdomain>/…`); '' when the address names none. */
54
+ export function subdomainOf(pathname: string): string {
55
+ return /^\/domain\/([^/]+)/.exec(pathname)?.[1] ?? ''
56
+ }
57
+
58
+ /** Whose a recipe row is: this domain's own (it may change it) or a parent's (copied in to be changed). */
59
+ export function recipeIsOwn(row: { domain?: { subdomain?: string } }, subdomain: string): boolean {
60
+ return !!subdomain && row.domain?.subdomain === subdomain
61
+ }
62
+
63
+ /** What a parameter takes, in a few words: a range, a percentage range, or its options. */
64
+ export function paramRangeOf(param: V3RecipeParam): string {
65
+ if (param.kind === 'ratio') return `${recipeFieldOf(param, param.min ?? 0)} – ${recipeFieldOf(param, param.max ?? 1)} %`
66
+ if (param.kind === 'mm') return `${param.min ?? ''} – ${param.max ?? ''} mm`
67
+ if (param.kind === 'flag') return ''
68
+ return (param.options ?? []).join(' / ')
69
+ }
@@ -0,0 +1,338 @@
1
+ import '@material/web/icon/icon.js'
2
+ import '../modeller/figure-v3-preview.js'
3
+
4
+ import { css, html, nothing } from 'lit'
5
+ import { customElement, state } from 'lit/decorators.js'
6
+
7
+ import { i18next, localize } from '@operato/i18n'
8
+ import { PageView } from '@operato/shell'
9
+ import { OxPrompt } from '@operato/popup'
10
+ import { ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
11
+ import { applyV3Recipe, parseV3Recipe, v3RecipeTestRoom } from '@hatiolab/figure-model'
12
+ import type { V3Asset, V3Recipe } from '@hatiolab/figure-model'
13
+
14
+ import { deleteFigureRecipe, fetchFigureRecipeRows, saveFigureRecipe, setFigureRecipeState, type FigureRecipeRow } from '../graphql/index.js'
15
+ import { paramRangeOf, recipeFieldOf, recipeIsOwn, recipesOf, recipeWord, subdomainOf } from '../modeller/v3-recipes.js'
16
+
17
+ /**
18
+ * The recipes (ADR-0095): what each one puts in, shown put into a room at several sizes, and whose it is.
19
+ *
20
+ * A recipe of this domain's own can be released, taken back to draft, changed or deleted. A parent's -- the ones the
21
+ * system domain ships -- is copied in under its name to be changed, and the copy stands in for it. Figures a recipe was
22
+ * put into keep their parts whatever happens here.
23
+ */
24
+ @customElement('figure-recipe-page')
25
+ export class FigureRecipePage extends localize(i18next)(PageView) {
26
+ static styles = [
27
+ WorkSurfacePageStyles,
28
+ ScrollbarStyles,
29
+ css`
30
+ :host {
31
+ display: grid;
32
+ grid-template-columns: minmax(220px, 280px) 1fr;
33
+ background-color: var(--md-sys-color-surface);
34
+ color: var(--md-sys-color-on-surface);
35
+ font-size: 0.85rem;
36
+ }
37
+ @container (max-width: 640px) {
38
+ :host {
39
+ grid-template-columns: 1fr;
40
+ }
41
+ }
42
+ nav {
43
+ overflow: auto;
44
+ border-right: 1px solid var(--md-sys-color-outline-variant);
45
+ padding: 8px;
46
+ }
47
+ nav p[intro] {
48
+ margin: 4px 4px 10px;
49
+ color: var(--md-sys-color-on-surface-variant);
50
+ line-height: 1.5;
51
+ }
52
+ button[row] {
53
+ display: grid;
54
+ gap: 2px;
55
+ width: 100%;
56
+ padding: 8px 10px;
57
+ text-align: left;
58
+ font: inherit;
59
+ color: inherit;
60
+ background: none;
61
+ border: none;
62
+ border-radius: 8px;
63
+ cursor: pointer;
64
+ }
65
+ button[row][on] {
66
+ background: var(--md-sys-color-secondary-container);
67
+ color: var(--md-sys-color-on-secondary-container);
68
+ }
69
+ button[row] small {
70
+ display: flex;
71
+ gap: 6px;
72
+ color: var(--md-sys-color-on-surface-variant);
73
+ }
74
+ span[chip] {
75
+ padding: 0 6px;
76
+ border-radius: 999em;
77
+ border: 1px solid var(--md-sys-color-outline-variant);
78
+ font-size: 0.7rem;
79
+ }
80
+ span[chip][draft] {
81
+ color: var(--md-sys-color-tertiary);
82
+ border-color: var(--md-sys-color-tertiary);
83
+ }
84
+ main {
85
+ overflow: auto;
86
+ padding: 12px 16px;
87
+ display: grid;
88
+ gap: 12px;
89
+ align-content: start;
90
+ }
91
+ main h2 {
92
+ margin: 0;
93
+ font-size: var(--ui-t-title, 16px);
94
+ }
95
+ main p {
96
+ margin: 0;
97
+ line-height: 1.5;
98
+ }
99
+ p[muted] {
100
+ color: var(--md-sys-color-on-surface-variant);
101
+ }
102
+ table {
103
+ border-collapse: collapse;
104
+ width: 100%;
105
+ max-width: 720px;
106
+ }
107
+ th,
108
+ td {
109
+ text-align: left;
110
+ padding: 4px 8px;
111
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
112
+ }
113
+ figure-v3-preview {
114
+ height: 420px;
115
+ border: 1px solid var(--md-sys-color-outline-variant);
116
+ border-radius: 8px;
117
+ overflow: hidden;
118
+ }
119
+ div[actions] {
120
+ display: flex;
121
+ flex-wrap: wrap;
122
+ gap: 8px;
123
+ }
124
+ div[actions] button {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ gap: 4px;
128
+ padding: 6px 12px;
129
+ font: inherit;
130
+ color: var(--md-sys-color-on-surface);
131
+ background: none;
132
+ border: 1px solid var(--md-sys-color-outline-variant);
133
+ border-radius: 999em;
134
+ cursor: pointer;
135
+ }
136
+ div[actions] button[primary] {
137
+ color: var(--md-sys-color-on-primary);
138
+ background: var(--md-sys-color-primary);
139
+ border-color: transparent;
140
+ }
141
+ div[actions] button[danger] {
142
+ color: var(--md-sys-color-error);
143
+ border-color: var(--md-sys-color-error);
144
+ }
145
+ p[role='alert'] {
146
+ padding: 8px;
147
+ white-space: pre-wrap;
148
+ color: var(--md-sys-color-on-error-container);
149
+ background: var(--md-sys-color-error-container);
150
+ border-radius: 8px;
151
+ }
152
+ textarea {
153
+ width: 100%;
154
+ min-height: 320px;
155
+ font: 0.78rem/1.4 monospace;
156
+ box-sizing: border-box;
157
+ }
158
+ `
159
+ ]
160
+
161
+ @state() private rows: FigureRecipeRow[] = []
162
+ @state() private chosen = ''
163
+ @state() private error = ''
164
+ @state() private editing = false
165
+ @state() private text = ''
166
+
167
+ get context() {
168
+ return { title: i18next.t('figure.title.recipes') }
169
+ }
170
+
171
+ async pageUpdated(_changes: Record<string, unknown>, _lifecycle: unknown, before: { active?: boolean }) {
172
+ if (this.active && !before?.active) await this.read()
173
+ }
174
+
175
+ private async read() {
176
+ try {
177
+ this.rows = await fetchFigureRecipeRows()
178
+ if (!this.rows.some(r => r.name === this.chosen)) this.chosen = this.rows[0]?.name ?? ''
179
+ this.error = ''
180
+ } catch (error) {
181
+ this.error = error instanceof Error ? error.message : String(error)
182
+ }
183
+ }
184
+
185
+ private get subdomain() {
186
+ return subdomainOf(location.pathname)
187
+ }
188
+
189
+ render() {
190
+ const language = i18next.language || 'en'
191
+ const recipes = recipesOf(this.rows)
192
+ const row = this.rows.find(r => r.name === this.chosen)
193
+ const recipe = recipes.find(r => r.name === this.chosen)
194
+ return html`
195
+ <nav>
196
+ <p intro>${i18next.t('figure.text.recipes-intro')}</p>
197
+ ${this.rows.map(one => {
198
+ const r = recipes.find(x => x.name === one.name)
199
+ return html`<button row ?on=${one.name === this.chosen} @click=${() => this.choose(one.name)}>
200
+ <span>${r ? recipeWord(r.title, language) : one.name}</span>
201
+ <small>
202
+ <span>${one.name}</span>
203
+ ${one.state === 'draft' ? html`<span chip draft>${i18next.t('figure.label.recipe-draft')}</span>` : nothing}
204
+ ${recipeIsOwn(one, this.subdomain) ? nothing : html`<span chip>${i18next.t('figure.label.recipe-inherited')}</span>`}
205
+ </small>
206
+ </button>`
207
+ })}
208
+ </nav>
209
+ <main>
210
+ ${this.error ? html`<p role="alert">${this.error}</p>` : nothing}
211
+ ${row && recipe ? this.renderRecipe(row, recipe, language) : html`<p muted>${i18next.t('figure.text.recipes-empty')}</p>`}
212
+ </main>
213
+ `
214
+ }
215
+
216
+ private renderRecipe(row: FigureRecipeRow, recipe: V3Recipe, language: string) {
217
+ const own = recipeIsOwn(row, this.subdomain)
218
+ return html`
219
+ <h2>${recipeWord(recipe.title, language)}</h2>
220
+ <p>${recipeWord(recipe.description, language)}</p>
221
+ <p muted>
222
+ ${i18next.t(recipe.target === 'part' ? 'figure.text.recipe-target-part' : 'figure.text.recipe-target-figure')} ·
223
+ ${i18next.t(own ? 'figure.text.recipe-own' : 'figure.text.recipe-inherited', { domain: row.domain?.subdomain ?? '' })}
224
+ </p>
225
+ <table>
226
+ <thead>
227
+ <tr>
228
+ <th>${i18next.t('figure.label.recipe-param')}</th>
229
+ <th>${i18next.t('figure.label.recipe-default')}</th>
230
+ <th>${i18next.t('figure.label.recipe-range')}</th>
231
+ </tr>
232
+ </thead>
233
+ <tbody>
234
+ ${recipe.params.map(
235
+ p => html`<tr>
236
+ <td>${recipeWord(p.label, language) || p.name}</td>
237
+ <td>${p.kind === 'flag' ? i18next.t(p.default ? 'figure.label.yes' : 'figure.label.no') : recipeFieldOf(p, p.default)}${p.kind === 'ratio' ? ' %' : ''}</td>
238
+ <td>${paramRangeOf(p)}</td>
239
+ </tr>`
240
+ )}
241
+ </tbody>
242
+ </table>
243
+ ${this.renderPreview(recipe)}
244
+ <div actions>
245
+ ${own
246
+ ? html`${row.state === 'draft'
247
+ ? html`<button primary @click=${() => this.setState(row.name, 'released')}>${i18next.t('figure.button.recipe-release')}</button>`
248
+ : html`<button @click=${() => this.setState(row.name, 'draft')}>${i18next.t('figure.button.recipe-to-draft')}</button>`}
249
+ <button danger @click=${() => this.deleteRecipe(row.name)}><md-icon>delete</md-icon>${i18next.t('figure.button.recipe-delete')}</button>`
250
+ : html`<button primary @click=${() => this.copyIn(row)}>${i18next.t('figure.button.recipe-copy-in')}</button>`}
251
+ <button @click=${() => this.edit(row)}><md-icon>data_object</md-icon>${i18next.t('figure.button.recipe-edit-source')}</button>
252
+ </div>
253
+ ${this.editing
254
+ ? html`<p muted>${i18next.t('figure.text.recipe-edit-source')}</p>
255
+ <textarea .value=${this.text} @input=${(e: Event) => (this.text = (e.target as HTMLTextAreaElement).value)}></textarea>
256
+ <div actions>
257
+ <button primary @click=${() => this.saveText(own ? row.state : 'draft')}>${i18next.t('figure.button.save')}</button>
258
+ <button @click=${() => (this.editing = false)}>${i18next.t('figure.button.close')}</button>
259
+ </div>`
260
+ : nothing}
261
+ `
262
+ }
263
+
264
+ /** The recipe put into the room it is checked in, with its defaults: what it makes, at several sizes. */
265
+ private renderPreview(recipe: V3Recipe) {
266
+ let asset: V3Asset
267
+ try {
268
+ const room = v3RecipeTestRoom()
269
+ asset = applyV3Recipe(room.asset, parseV3Recipe(recipe), recipe.target === 'part' ? { target: room.target } : {}).asset
270
+ } catch (error) {
271
+ return html`<p role="alert">${error instanceof Error ? error.message : String(error)}</p>`
272
+ }
273
+ return html`<p muted>${i18next.t('figure.text.recipe-preview')}</p>
274
+ <figure-v3-preview .asset=${asset} .context=${`recipe:${recipe.name}`}></figure-v3-preview>`
275
+ }
276
+
277
+ private choose(name: string) {
278
+ this.chosen = name
279
+ this.editing = false
280
+ this.error = ''
281
+ }
282
+
283
+ private edit(row: FigureRecipeRow) {
284
+ this.editing = !this.editing
285
+ try {
286
+ this.text = JSON.stringify(JSON.parse(row.source), null, 2)
287
+ } catch {
288
+ this.text = row.source
289
+ }
290
+ }
291
+
292
+ private async run(action: () => Promise<unknown>) {
293
+ try {
294
+ await action()
295
+ this.error = ''
296
+ await this.read()
297
+ } catch (error) {
298
+ this.error = error instanceof Error ? error.message : String(error)
299
+ }
300
+ }
301
+
302
+ private setState(name: string, state: 'draft' | 'released') {
303
+ return this.run(() => setFigureRecipeState(name, state))
304
+ }
305
+
306
+ /* The app's own prompt, as every operato screen asks -- not the browser's confirm box (user, 2026-09-24). */
307
+ private async deleteRecipe(name: string) {
308
+ const sure = await OxPrompt.open({
309
+ type: 'warning',
310
+ title: i18next.t('figure.button.recipe-delete'),
311
+ text: i18next.t('figure.text.recipe-delete-confirm', { name }),
312
+ confirmButton: { text: i18next.t('figure.button.recipe-delete') },
313
+ cancelButton: { text: i18next.t('figure.button.close') }
314
+ })
315
+ if (sure) await this.run(() => deleteFigureRecipe(name))
316
+ }
317
+
318
+ /** A parent's recipe copied into this domain as a draft, to be changed here. */
319
+ private copyIn(row: FigureRecipeRow) {
320
+ return this.run(() => saveFigureRecipe(row.source, 'draft'))
321
+ }
322
+
323
+ /** The source as typed: checked by the server in the test room; a new name is a new recipe. */
324
+ private async saveText(state: 'draft' | 'released') {
325
+ let name = ''
326
+ try {
327
+ name = JSON.parse(this.text).name ?? ''
328
+ } catch (error) {
329
+ this.error = error instanceof Error ? error.message : String(error)
330
+ return
331
+ }
332
+ await this.run(async () => {
333
+ await saveFigureRecipe(this.text, state)
334
+ this.chosen = name
335
+ this.editing = false
336
+ })
337
+ }
338
+ }
@@ -5,7 +5,9 @@ import { modellerPathOf } from '../modeller/modeller-route.js'
5
5
  import { createV3ProductSession } from '../modeller/v3-product-session.js'
6
6
  import { v3ProductStore } from '../graphql/v3-store.js'
7
7
  import { i18next } from '@operato/i18n'
8
- import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
8
+ import { fetchFigure, fetchFigureRecipes, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
9
+ import { recipesOf } from '../modeller/v3-recipes.js'
10
+ import type { V3Recipe } from '@hatiolab/figure-model'
9
11
  import { occupancyFromParts } from '../modeller/v3-occupancy.js'
10
12
  import type { V3AuthoringAction } from '@hatiolab/figure-model'
11
13
  import '../modeller/v3-editor.js'
@@ -93,6 +95,9 @@ export class FigureV3ModellerPage extends PageView {
93
95
  private routeId: string | undefined
94
96
  private discardApproved = false
95
97
  @state() private revision = 0
98
+ /** The recipes this domain sees (ADR-0095), read once when the page is first shown. */
99
+ @state() private recipes: V3Recipe[] = []
100
+ private recipesRead = false
96
101
  @state() private error = ''
97
102
  /**
98
103
  * 발행 패널이 읽는 행의 사실 — 상태와 발행 판 번호. 원본은 세션이 들고, 이 둘은 행에 있다.
@@ -328,13 +333,24 @@ export class FigureV3ModellerPage extends PageView {
328
333
  detail.error = error instanceof Error ? error.message : String(error)
329
334
  }
330
335
  }
336
+ /*
337
+ Reloading or closing the tab is not a move the shell asks `canDeactivate` about: a new figure not yet saved was lost
338
+ without a word (2026-09-26). The browser's own question is the only one it allows there.
339
+ */
340
+ private holdUnsaved = (event: BeforeUnloadEvent) => {
341
+ if (!this.active || !this.session.read().dirty) return
342
+ event.preventDefault()
343
+ event.returnValue = ''
344
+ }
331
345
  connectedCallback() {
332
346
  super.connectedCallback()
333
347
  window.addEventListener('figure-v3-apply', this.applyAI)
348
+ window.addEventListener('beforeunload', this.holdUnsaved)
334
349
  }
335
350
  disconnectedCallback() {
336
351
  this.withdrawAssistant()
337
352
  window.removeEventListener('figure-v3-apply', this.applyAI)
353
+ window.removeEventListener('beforeunload', this.holdUnsaved)
338
354
  super.disconnectedCallback()
339
355
  }
340
356
  async canDeactivate() {
@@ -360,6 +376,13 @@ export class FigureV3ModellerPage extends PageView {
360
376
  this.withdrawAssistant()
361
377
  return
362
378
  }
379
+ if (!this.recipesRead) {
380
+ this.recipesRead = true
381
+ /* A recipe list that cannot be read leaves the section out; the modeller works without it. */
382
+ fetchFigureRecipes()
383
+ .then(rows => (this.recipes = recipesOf(rows)))
384
+ .catch(() => (this.recipesRead = false))
385
+ }
363
386
  const id = lifecycle?.resourceId
364
387
  if (this.session.read().context && id === this.routeId && !changes.active) return
365
388
  try {
@@ -424,7 +447,7 @@ export class FigureV3ModellerPage extends PageView {
424
447
  render() {
425
448
  if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
426
449
  const view = this.session.read()
427
- return html`<figure-v3-editor .session=${this.session} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
450
+ return html`<figure-v3-editor .session=${this.session} .recipes=${this.recipes} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
428
451
  ${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
429
452
  <figure-v3-properties
430
453
  slot="figure"
package/client/route.ts CHANGED
@@ -4,6 +4,10 @@ export default function route(page: string) {
4
4
  import('./pages/figure-list-page')
5
5
  return page
6
6
 
7
+ case 'figure-recipes':
8
+ import('./pages/figure-recipe-page')
9
+ return page
10
+
7
11
  /*
8
12
  One modeller since ADR-0087 step 5. `figure-v3-modeller` is kept as an alias for links made before the switch.
9
13
  */
@@ -65,3 +65,24 @@ export declare function inspectFigureDraft(source: string, id?: string): Promise
65
65
  */
66
66
  export declare function fetchReleasedSource(id: string): Promise<string | undefined>;
67
67
  export declare function fetchFigureVersions(id: string): Promise<FigureVersion[]>;
68
+ /** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */
69
+ export declare function fetchFigureRecipes(): Promise<Array<{
70
+ name: string;
71
+ source: string;
72
+ }>>;
73
+ /** A recipe row as the management screen reads it: whose it is and whether it is released. */
74
+ export interface FigureRecipeRow {
75
+ name: string;
76
+ source: string;
77
+ state: 'draft' | 'released';
78
+ updatedAt?: string;
79
+ domain?: {
80
+ id: string;
81
+ subdomain?: string;
82
+ };
83
+ }
84
+ /** Every recipe this domain sees, drafts too (ADR-0095). */
85
+ export declare function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]>;
86
+ export declare function saveFigureRecipe(source: string, state: 'draft' | 'released'): Promise<FigureRecipeRow>;
87
+ export declare function setFigureRecipeState(name: string, state: 'draft' | 'released'): Promise<void>;
88
+ export declare function deleteFigureRecipe(name: string): Promise<void>;