@things-factory/figure-ui 10.1.70 → 10.1.72

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 (69) hide show
  1. package/client/graphql/index.ts +28 -69
  2. package/client/modeller/figure-v3-parts.ts +16 -136
  3. package/client/modeller/figure-v3-recipe-catalog.ts +366 -0
  4. package/client/modeller/v3-editor.ts +25 -5
  5. package/client/modeller/v3-fields.ts +21 -16
  6. package/client/modeller/v3-gizmo.ts +1 -1
  7. package/client/modeller/v3-links.ts +7 -66
  8. package/client/modeller/v3-palette.ts +3 -0
  9. package/client/modeller/v3-recipe-source.ts +15 -0
  10. package/client/modeller/v3-recipes.ts +50 -27
  11. package/client/pages/figure-v3-modeller-page.ts +16 -13
  12. package/client/route.ts +0 -4
  13. package/dist-client/graphql/index.d.ts +11 -18
  14. package/dist-client/graphql/index.js +18 -59
  15. package/dist-client/graphql/index.js.map +1 -1
  16. package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
  17. package/dist-client/modeller/figure-v3-parts.js +17 -147
  18. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  19. package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +38 -0
  20. package/dist-client/modeller/figure-v3-recipe-catalog.js +399 -0
  21. package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
  22. package/dist-client/modeller/v3-clips.d.ts +1 -1
  23. package/dist-client/modeller/v3-editor.d.ts +8 -3
  24. package/dist-client/modeller/v3-editor.js +35 -6
  25. package/dist-client/modeller/v3-editor.js.map +1 -1
  26. package/dist-client/modeller/v3-fields.d.ts +2 -2
  27. package/dist-client/modeller/v3-fields.js +20 -18
  28. package/dist-client/modeller/v3-fields.js.map +1 -1
  29. package/dist-client/modeller/v3-gizmo.js +1 -1
  30. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  31. package/dist-client/modeller/v3-links.d.ts +8 -25
  32. package/dist-client/modeller/v3-links.js +4 -66
  33. package/dist-client/modeller/v3-links.js.map +1 -1
  34. package/dist-client/modeller/v3-palette.js +3 -0
  35. package/dist-client/modeller/v3-palette.js.map +1 -1
  36. package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
  37. package/dist-client/modeller/v3-recipe-source.js +14 -0
  38. package/dist-client/modeller/v3-recipe-source.js.map +1 -0
  39. package/dist-client/modeller/v3-recipes.d.ts +25 -14
  40. package/dist-client/modeller/v3-recipes.js +42 -28
  41. package/dist-client/modeller/v3-recipes.js.map +1 -1
  42. package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
  43. package/dist-client/pages/figure-v3-modeller-page.js +16 -13
  44. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  45. package/dist-client/route.d.ts +1 -1
  46. package/dist-client/route.js +0 -3
  47. package/dist-client/route.js.map +1 -1
  48. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  49. package/dist-client/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +5 -4
  51. package/scripts/template-thumbnails.mjs +53 -0
  52. package/test/browser/build.mjs +15 -3
  53. package/test/browser/modeller-page.js +26 -3
  54. package/test/browser/modeller.check.mjs +73 -46
  55. package/test/browser/serve.mjs +7 -1
  56. package/test/browser/thumbnail-page.js +14 -0
  57. package/test/v3-fields.test.ts +5 -1
  58. package/test/v3-links.test.ts +12 -0
  59. package/test/v3-recipes.test.ts +47 -25
  60. package/things-factory.config.js +0 -1
  61. package/translations/en.json +38 -23
  62. package/translations/ja.json +38 -23
  63. package/translations/ko.json +38 -23
  64. package/translations/ms.json +38 -23
  65. package/translations/zh.json +38 -23
  66. package/client/pages/figure-recipe-page.ts +0 -338
  67. package/dist-client/pages/figure-recipe-page.d.ts +0 -41
  68. package/dist-client/pages/figure-recipe-page.js +0 -352
  69. package/dist-client/pages/figure-recipe-page.js.map +0 -1
@@ -0,0 +1,399 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import '@material/web/icon/icon.js';
3
+ import '@operato/markdown';
4
+ import { css, html, LitElement, nothing } from 'lit';
5
+ import { customElement, property, state } from 'lit/decorators.js';
6
+ import { i18next, localize } from '@operato/i18n';
7
+ import { ScrollbarStyles } from '@operato/styles';
8
+ import { describeV3Recipe } from '@hatiolab/figure-model';
9
+ import { recipeFieldOf, recipeReady, recipeValueOf, recipeWord, recipeWorkings, recipesMatching } from './v3-recipes.js';
10
+ /**
11
+ * The recipe catalog (ADR-0095): the templates the product ships, by category, found by words, each told in full --
12
+ * what it puts in, how many parts, how they move, what each value does, and how to use it -- and put onto the chosen
13
+ * part in one step. The numbers and the ways of moving are worked out from the recipe (`describeV3Recipe`), so they say
14
+ * what it does; only the descriptions and the guide are written.
15
+ */
16
+ let FigureV3RecipeCatalog = class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {
17
+ constructor() {
18
+ super(...arguments);
19
+ this.templates = [];
20
+ /** The part it would be put onto. */
21
+ this.selected = '';
22
+ this.locked = false;
23
+ /** Why the last one put in was refused, in the kernel's words. */
24
+ this.error = '';
25
+ this.category = '';
26
+ this.query = '';
27
+ this.chosen = '';
28
+ this.values = {};
29
+ /*
30
+ The guide as a page \`ox-markdown\` can read: it renders what its src points to, not text, and given the text it
31
+ fetched it as a path and drew the app's own page in its place (seen on :3300, 2026-09-26). A blob of type
32
+ text/markdown is read as markdown.
33
+ */
34
+ this.guide = '';
35
+ }
36
+ static { this.styles = [
37
+ ScrollbarStyles,
38
+ css `
39
+ :host {
40
+ position: fixed;
41
+ inset: 0;
42
+ z-index: 20;
43
+ display: grid;
44
+ place-items: center;
45
+ background: color-mix(in srgb, var(--md-sys-color-scrim, #000) 32%, transparent);
46
+ font-size: 0.82rem;
47
+ color: var(--md-sys-color-on-surface);
48
+ }
49
+ div[panel] {
50
+ display: grid;
51
+ grid-template-rows: auto 1fr;
52
+ width: min(1100px, calc(100vw - 32px));
53
+ height: min(720px, calc(100vh - 32px));
54
+ background: var(--md-sys-color-surface);
55
+ border-radius: 12px;
56
+ overflow: hidden;
57
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
58
+ }
59
+ header {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 8px;
63
+ padding: 8px 12px;
64
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
65
+ }
66
+ header h2 {
67
+ margin: 0;
68
+ font-size: var(--ui-t-title, 16px);
69
+ flex: 1;
70
+ }
71
+ header input {
72
+ width: 220px;
73
+ padding: 4px 8px;
74
+ font: inherit;
75
+ }
76
+ button[close] {
77
+ display: inline-grid;
78
+ place-items: center;
79
+ width: 32px;
80
+ height: 32px;
81
+ border: none;
82
+ border-radius: 8px;
83
+ background: none;
84
+ color: var(--md-sys-color-on-surface-variant);
85
+ cursor: pointer;
86
+ }
87
+ div[body] {
88
+ display: grid;
89
+ grid-template-columns: 170px minmax(0, 1fr) minmax(300px, 380px);
90
+ min-height: 0;
91
+ }
92
+ @media (max-width: 760px) {
93
+ div[body] {
94
+ grid-template-columns: 1fr;
95
+ overflow: auto;
96
+ }
97
+ }
98
+ nav,
99
+ section[list],
100
+ section[detail] {
101
+ overflow: auto;
102
+ min-height: 0;
103
+ padding: 8px;
104
+ }
105
+ nav {
106
+ border-right: 1px solid var(--md-sys-color-outline-variant);
107
+ }
108
+ section[detail] {
109
+ border-left: 1px solid var(--md-sys-color-outline-variant);
110
+ display: grid;
111
+ gap: 10px;
112
+ align-content: start;
113
+ }
114
+ button[category] {
115
+ display: flex;
116
+ justify-content: space-between;
117
+ width: 100%;
118
+ padding: 6px 8px;
119
+ font: inherit;
120
+ color: inherit;
121
+ background: none;
122
+ border: none;
123
+ border-radius: 6px;
124
+ text-align: left;
125
+ cursor: pointer;
126
+ }
127
+ button[category][on],
128
+ button[card][on] {
129
+ background: var(--md-sys-color-secondary-container);
130
+ color: var(--md-sys-color-on-secondary-container);
131
+ }
132
+ section[list] {
133
+ display: grid;
134
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
135
+ gap: 8px;
136
+ align-content: start;
137
+ }
138
+ button[card] {
139
+ display: grid;
140
+ gap: 4px;
141
+ padding: 8px;
142
+ font: inherit;
143
+ color: inherit;
144
+ text-align: left;
145
+ background: none;
146
+ border: 1px solid var(--md-sys-color-outline-variant);
147
+ border-radius: 8px;
148
+ cursor: pointer;
149
+ }
150
+ button[card] img {
151
+ width: 100%;
152
+ aspect-ratio: 4 / 3;
153
+ object-fit: contain;
154
+ background: var(--md-sys-color-surface-container);
155
+ border-radius: 4px;
156
+ }
157
+ button[card] small,
158
+ p[muted] {
159
+ color: var(--md-sys-color-on-surface-variant);
160
+ }
161
+ h3 {
162
+ margin: 0;
163
+ font-size: var(--ui-t-body, 14px);
164
+ }
165
+ p {
166
+ margin: 0;
167
+ line-height: 1.5;
168
+ }
169
+ ul {
170
+ margin: 0;
171
+ padding-left: 18px;
172
+ line-height: 1.6;
173
+ }
174
+ table {
175
+ border-collapse: collapse;
176
+ width: 100%;
177
+ }
178
+ th,
179
+ td {
180
+ text-align: left;
181
+ vertical-align: top;
182
+ padding: 3px 4px;
183
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
184
+ }
185
+ td small {
186
+ display: block;
187
+ color: var(--md-sys-color-on-surface-variant);
188
+ }
189
+ div[form] {
190
+ display: grid;
191
+ gap: 6px;
192
+ padding: 8px;
193
+ border: 1px solid var(--md-sys-color-outline-variant);
194
+ border-radius: 8px;
195
+ }
196
+ div[form] label {
197
+ display: grid;
198
+ gap: 2px;
199
+ }
200
+ div[form] label[toggle] {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 6px;
204
+ }
205
+ div[form] input,
206
+ div[form] select {
207
+ font: inherit;
208
+ min-width: 0;
209
+ }
210
+ button[insert] {
211
+ justify-self: start;
212
+ display: inline-flex;
213
+ align-items: center;
214
+ gap: 4px;
215
+ padding: 6px 14px;
216
+ font: inherit;
217
+ white-space: nowrap;
218
+ color: var(--md-sys-color-on-primary);
219
+ background: var(--md-sys-color-primary);
220
+ border: none;
221
+ border-radius: 999em;
222
+ cursor: pointer;
223
+ }
224
+ button[insert]:disabled {
225
+ opacity: 0.38;
226
+ cursor: default;
227
+ }
228
+ p[role='alert'] {
229
+ padding: 6px;
230
+ white-space: pre-wrap;
231
+ color: var(--md-sys-color-on-error-container);
232
+ background: var(--md-sys-color-error-container);
233
+ border-radius: 6px;
234
+ }
235
+ ox-markdown {
236
+ display: block;
237
+ }
238
+ `
239
+ ]; }
240
+ set guideText(text) {
241
+ if (this.guide)
242
+ URL.revokeObjectURL(this.guide);
243
+ this.guide = text ? URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) : '';
244
+ }
245
+ disconnectedCallback() {
246
+ super.disconnectedCallback();
247
+ this.guideText = '';
248
+ }
249
+ render() {
250
+ const language = i18next.language || 'en';
251
+ const categories = [...new Set(this.templates.map(t => t.category))].sort();
252
+ const shown = recipesMatching(this.templates, this.category, this.query);
253
+ const chosen = this.templates.find(t => t.name === this.chosen) ?? shown[0];
254
+ return html `<div panel role="dialog" aria-modal="true" aria-label=${i18next.t('figure.label.recipe-catalog')}>
255
+ <header>
256
+ <h2>${i18next.t('figure.label.recipe-catalog')}</h2>
257
+ <input search type="search" placeholder=${i18next.t('figure.text.recipe-search')} .value=${this.query} @input=${(e) => (this.query = e.target.value)} />
258
+ <button close aria-label=${i18next.t('figure.button.close')} @click=${() => this.dispatchEvent(new CustomEvent('close-catalog', { bubbles: true, composed: true }))}>
259
+ <md-icon>close</md-icon>
260
+ </button>
261
+ </header>
262
+ <div body>
263
+ <nav>
264
+ <button category ?on=${!this.category} @click=${() => (this.category = '')}>
265
+ <span>${i18next.t('figure.label.recipe-all')}</span><small>${this.templates.length}</small>
266
+ </button>
267
+ ${categories.map(c => html `<button category=${c} ?on=${this.category === c} @click=${() => (this.category = c)}>
268
+ <span>${i18next.t('figure.label.recipe-category-' + c)}</span><small>${this.templates.filter(t => t.category === c).length}</small>
269
+ </button>`)}
270
+ </nav>
271
+ <section list>
272
+ ${shown.map(t => html `<button card=${t.name} ?on=${t.name === chosen?.name} @click=${() => this.choose(t)}>
273
+ ${t.hasThumbnail && this.thumbnailOf ? html `<img alt="" loading="lazy" src=${this.thumbnailOf(t.name)} />` : nothing}
274
+ <b>${t.title}</b>
275
+ <small>${t.description}</small>
276
+ </button>`)}
277
+ </section>
278
+ <section detail>${chosen ? this.renderDetail(chosen, language) : nothing}</section>
279
+ </div>
280
+ </div>`;
281
+ }
282
+ choose(t) {
283
+ if (this.chosen === t.name)
284
+ return;
285
+ this.chosen = t.name;
286
+ this.values = Object.fromEntries(t.recipe.params.map(p => [p.name, p.default]));
287
+ this.guideText = '';
288
+ this.guideOf?.(t.name)
289
+ .then(text => this.chosen === t.name && (this.guideText = text))
290
+ .catch(() => { });
291
+ }
292
+ willUpdate() {
293
+ /*
294
+ The first card shown is chosen while none of those shown is, so the detail is never empty and never of a card the
295
+ search or the category has taken away (the air cylinder stayed told under a search for rollers, :3300, 2026-09-26).
296
+ */
297
+ const shown = recipesMatching(this.templates, this.category, this.query);
298
+ if (!shown.some(t => t.name === this.chosen) && shown[0])
299
+ this.choose(shown[0]);
300
+ }
301
+ renderDetail(t, language) {
302
+ const d = describeV3Recipe(t.recipe);
303
+ const count = d.parts.min === d.parts.max ? String(d.parts.min) : `${d.parts.min}–${d.parts.max}`;
304
+ const ready = recipeReady(t.recipe, this.selected);
305
+ return html `
306
+ <h3>${t.title}</h3>
307
+ <p>${t.description}</p>
308
+ <div>
309
+ <h3>${i18next.t('figure.label.recipe-adds')}</h3>
310
+ <ul>
311
+ <li parts=${count}>${i18next.t("figure.text.recipe-parts", { parts: count })}</li>
312
+ ${recipeWorkings(t.recipe).map(line => html `<li>${i18next.t(line.key, line.words)}</li>`)}
313
+ <li>${i18next.t(t.recipe.target === 'part' ? 'figure.text.recipe-onto-chosen' : 'figure.text.recipe-onto-figure')}</li>
314
+ </ul>
315
+ </div>
316
+ ${t.recipe.params.length ? this.renderForm(t.recipe, language) : nothing}
317
+ ${this.error ? html `<p role="alert">${this.error}</p>` : nothing}
318
+ ${ready ? nothing : html `<p muted>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}
319
+ <button insert ?disabled=${this.locked || !ready} @click=${() => this.dispatchEvent(new CustomEvent('insert-recipe', { detail: { recipe: t.recipe, values: this.values }, bubbles: true, composed: true }))}>
320
+ <md-icon>add</md-icon>${this.selected && t.recipe.target === 'part' ? i18next.t('figure.button.insert-recipe-onto', { part: this.selected }) : i18next.t('figure.button.insert-recipe')}
321
+ </button>
322
+ ${this.guide ? html `<ox-markdown .src=${this.guide}></ox-markdown>` : nothing}
323
+ `;
324
+ }
325
+ /** Each value: its name, what it does, and the field to set it -- a share typed as a percentage. */
326
+ renderForm(recipe, language) {
327
+ const set = (name, value) => (this.values = { ...this.values, [name]: value });
328
+ return html `<div form>
329
+ <h3>${i18next.t('figure.label.recipe-values')}</h3>
330
+ ${recipe.params.map(param => {
331
+ const label = recipeWord(param.label, language) || param.name;
332
+ const hint = recipeWord(param.hint, language);
333
+ const value = this.values[param.name] ?? param.default;
334
+ const help = hint ? html `<small>${hint}</small>` : nothing;
335
+ if (param.kind === 'flag')
336
+ return html `<label toggle><input type="checkbox" param=${param.name} .checked=${value === true} @change=${(e) => set(param.name, e.target.checked)} />${label}</label>${help}`;
337
+ if (param.kind === 'choice' || param.kind === 'face')
338
+ return html `<label><span>${label}</span>
339
+ <select param=${param.name} @change=${(e) => set(param.name, recipeValueOf(param, e.target.value))}>
340
+ ${(param.options ?? []).map(o => html `<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}
341
+ </select></label
342
+ >${help}`;
343
+ const range = param.kind === 'ratio' ? `${recipeFieldOf(param, param.min ?? 0)}–${recipeFieldOf(param, param.max ?? 1)} %` : `${param.min ?? ''}–${param.max ?? ''} mm`;
344
+ return html `<label><span>${label} (${range})</span>
345
+ <input type="number" step="any" param=${param.name} .value=${recipeFieldOf(param, value)} @change=${(e) => set(param.name, recipeValueOf(param, e.target.value))} /></label
346
+ >${help}`;
347
+ })}
348
+ </div>`;
349
+ }
350
+ };
351
+ __decorate([
352
+ property({ attribute: false }),
353
+ __metadata("design:type", Array)
354
+ ], FigureV3RecipeCatalog.prototype, "templates", void 0);
355
+ __decorate([
356
+ property({ type: String }),
357
+ __metadata("design:type", Object)
358
+ ], FigureV3RecipeCatalog.prototype, "selected", void 0);
359
+ __decorate([
360
+ property({ type: Boolean }),
361
+ __metadata("design:type", Object)
362
+ ], FigureV3RecipeCatalog.prototype, "locked", void 0);
363
+ __decorate([
364
+ property({ type: String }),
365
+ __metadata("design:type", Object)
366
+ ], FigureV3RecipeCatalog.prototype, "error", void 0);
367
+ __decorate([
368
+ property({ attribute: false }),
369
+ __metadata("design:type", Function)
370
+ ], FigureV3RecipeCatalog.prototype, "guideOf", void 0);
371
+ __decorate([
372
+ property({ attribute: false }),
373
+ __metadata("design:type", Function)
374
+ ], FigureV3RecipeCatalog.prototype, "thumbnailOf", void 0);
375
+ __decorate([
376
+ state(),
377
+ __metadata("design:type", Object)
378
+ ], FigureV3RecipeCatalog.prototype, "category", void 0);
379
+ __decorate([
380
+ state(),
381
+ __metadata("design:type", Object)
382
+ ], FigureV3RecipeCatalog.prototype, "query", void 0);
383
+ __decorate([
384
+ state(),
385
+ __metadata("design:type", Object)
386
+ ], FigureV3RecipeCatalog.prototype, "chosen", void 0);
387
+ __decorate([
388
+ state(),
389
+ __metadata("design:type", Object)
390
+ ], FigureV3RecipeCatalog.prototype, "values", void 0);
391
+ __decorate([
392
+ state(),
393
+ __metadata("design:type", Object)
394
+ ], FigureV3RecipeCatalog.prototype, "guide", void 0);
395
+ FigureV3RecipeCatalog = __decorate([
396
+ customElement('figure-v3-recipe-catalog')
397
+ ], FigureV3RecipeCatalog);
398
+ export { FigureV3RecipeCatalog };
399
+ //# sourceMappingURL=figure-v3-recipe-catalog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-v3-recipe-catalog.js","sourceRoot":"","sources":["../../client/modeller/figure-v3-recipe-catalog.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\nimport '@operato/markdown'\n\nimport { css, html, LitElement, nothing } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\n\nimport { i18next, localize } from '@operato/i18n'\nimport { ScrollbarStyles } from '@operato/styles'\nimport { describeV3Recipe } from '@hatiolab/figure-model'\nimport type { V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'\n\nimport { recipeFieldOf, recipeReady, recipeValueOf, recipeWord, recipeWorkings, recipesMatching, type RecipeTemplate } from './v3-recipes.js'\n\n/**\n * The recipe catalog (ADR-0095): the templates the product ships, by category, found by words, each told in full --\n * what it puts in, how many parts, how they move, what each value does, and how to use it -- and put onto the chosen\n * part in one step. The numbers and the ways of moving are worked out from the recipe (`describeV3Recipe`), so they say\n * what it does; only the descriptions and the guide are written.\n */\n@customElement('figure-v3-recipe-catalog')\nexport class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {\n static styles = [\n ScrollbarStyles,\n css`\n :host {\n position: fixed;\n inset: 0;\n z-index: 20;\n display: grid;\n place-items: center;\n background: color-mix(in srgb, var(--md-sys-color-scrim, #000) 32%, transparent);\n font-size: 0.82rem;\n color: var(--md-sys-color-on-surface);\n }\n div[panel] {\n display: grid;\n grid-template-rows: auto 1fr;\n width: min(1100px, calc(100vw - 32px));\n height: min(720px, calc(100vh - 32px));\n background: var(--md-sys-color-surface);\n border-radius: 12px;\n overflow: hidden;\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);\n }\n header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n }\n header h2 {\n margin: 0;\n font-size: var(--ui-t-title, 16px);\n flex: 1;\n }\n header input {\n width: 220px;\n padding: 4px 8px;\n font: inherit;\n }\n button[close] {\n display: inline-grid;\n place-items: center;\n width: 32px;\n height: 32px;\n border: none;\n border-radius: 8px;\n background: none;\n color: var(--md-sys-color-on-surface-variant);\n cursor: pointer;\n }\n div[body] {\n display: grid;\n grid-template-columns: 170px minmax(0, 1fr) minmax(300px, 380px);\n min-height: 0;\n }\n @media (max-width: 760px) {\n div[body] {\n grid-template-columns: 1fr;\n overflow: auto;\n }\n }\n nav,\n section[list],\n section[detail] {\n overflow: auto;\n min-height: 0;\n padding: 8px;\n }\n nav {\n border-right: 1px solid var(--md-sys-color-outline-variant);\n }\n section[detail] {\n border-left: 1px solid var(--md-sys-color-outline-variant);\n display: grid;\n gap: 10px;\n align-content: start;\n }\n button[category] {\n display: flex;\n justify-content: space-between;\n width: 100%;\n padding: 6px 8px;\n font: inherit;\n color: inherit;\n background: none;\n border: none;\n border-radius: 6px;\n text-align: left;\n cursor: pointer;\n }\n button[category][on],\n button[card][on] {\n background: var(--md-sys-color-secondary-container);\n color: var(--md-sys-color-on-secondary-container);\n }\n section[list] {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));\n gap: 8px;\n align-content: start;\n }\n button[card] {\n display: grid;\n gap: 4px;\n padding: 8px;\n font: inherit;\n color: inherit;\n text-align: left;\n background: none;\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 8px;\n cursor: pointer;\n }\n button[card] img {\n width: 100%;\n aspect-ratio: 4 / 3;\n object-fit: contain;\n background: var(--md-sys-color-surface-container);\n border-radius: 4px;\n }\n button[card] small,\n p[muted] {\n color: var(--md-sys-color-on-surface-variant);\n }\n h3 {\n margin: 0;\n font-size: var(--ui-t-body, 14px);\n }\n p {\n margin: 0;\n line-height: 1.5;\n }\n ul {\n margin: 0;\n padding-left: 18px;\n line-height: 1.6;\n }\n table {\n border-collapse: collapse;\n width: 100%;\n }\n th,\n td {\n text-align: left;\n vertical-align: top;\n padding: 3px 4px;\n border-bottom: 1px solid var(--md-sys-color-outline-variant);\n }\n td small {\n display: block;\n color: var(--md-sys-color-on-surface-variant);\n }\n div[form] {\n display: grid;\n gap: 6px;\n padding: 8px;\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 8px;\n }\n div[form] label {\n display: grid;\n gap: 2px;\n }\n div[form] label[toggle] {\n display: flex;\n align-items: center;\n gap: 6px;\n }\n div[form] input,\n div[form] select {\n font: inherit;\n min-width: 0;\n }\n button[insert] {\n justify-self: start;\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 6px 14px;\n font: inherit;\n white-space: nowrap;\n color: var(--md-sys-color-on-primary);\n background: var(--md-sys-color-primary);\n border: none;\n border-radius: 999em;\n cursor: pointer;\n }\n button[insert]:disabled {\n opacity: 0.38;\n cursor: default;\n }\n p[role='alert'] {\n padding: 6px;\n white-space: pre-wrap;\n color: var(--md-sys-color-on-error-container);\n background: var(--md-sys-color-error-container);\n border-radius: 6px;\n }\n ox-markdown {\n display: block;\n }\n `\n ]\n\n @property({ attribute: false }) templates: RecipeTemplate[] = []\n /** The part it would be put onto. */\n @property({ type: String }) selected = ''\n @property({ type: Boolean }) locked = false\n /** Why the last one put in was refused, in the kernel's words. */\n @property({ type: String }) error = ''\n /** Reads a template's guide; absent, no guide is shown. */\n @property({ attribute: false }) guideOf?: (name: string) => Promise<string>\n /** Where a template's thumbnail is read from; absent, cards show no picture. */\n @property({ attribute: false }) thumbnailOf?: (name: string) => string\n\n @state() private category = ''\n @state() private query = ''\n @state() private chosen = ''\n @state() private values: Record<string, V3RecipeValue> = {}\n /*\n The guide as a page \\`ox-markdown\\` can read: it renders what its src points to, not text, and given the text it\n fetched it as a path and drew the app's own page in its place (seen on :3300, 2026-09-26). A blob of type\n text/markdown is read as markdown.\n */\n @state() private guide = ''\n\n private set guideText(text: string) {\n if (this.guide) URL.revokeObjectURL(this.guide)\n this.guide = text ? URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) : ''\n }\n\n disconnectedCallback() {\n super.disconnectedCallback()\n this.guideText = ''\n }\n\n render() {\n const language = i18next.language || 'en'\n const categories = [...new Set(this.templates.map(t => t.category))].sort()\n const shown = recipesMatching(this.templates, this.category, this.query)\n const chosen = this.templates.find(t => t.name === this.chosen) ?? shown[0]\n return html`<div panel role=\"dialog\" aria-modal=\"true\" aria-label=${i18next.t('figure.label.recipe-catalog')}>\n <header>\n <h2>${i18next.t('figure.label.recipe-catalog')}</h2>\n <input search type=\"search\" placeholder=${i18next.t('figure.text.recipe-search')} .value=${this.query} @input=${(e: Event) => (this.query = (e.target as HTMLInputElement).value)} />\n <button close aria-label=${i18next.t('figure.button.close')} @click=${() => this.dispatchEvent(new CustomEvent('close-catalog', { bubbles: true, composed: true }))}>\n <md-icon>close</md-icon>\n </button>\n </header>\n <div body>\n <nav>\n <button category ?on=${!this.category} @click=${() => (this.category = '')}>\n <span>${i18next.t('figure.label.recipe-all')}</span><small>${this.templates.length}</small>\n </button>\n ${categories.map(\n c => html`<button category=${c} ?on=${this.category === c} @click=${() => (this.category = c)}>\n <span>${i18next.t('figure.label.recipe-category-' + c)}</span><small>${this.templates.filter(t => t.category === c).length}</small>\n </button>`\n )}\n </nav>\n <section list>\n ${shown.map(\n t => html`<button card=${t.name} ?on=${t.name === chosen?.name} @click=${() => this.choose(t)}>\n ${t.hasThumbnail && this.thumbnailOf ? html`<img alt=\"\" loading=\"lazy\" src=${this.thumbnailOf(t.name)} />` : nothing}\n <b>${t.title}</b>\n <small>${t.description}</small>\n </button>`\n )}\n </section>\n <section detail>${chosen ? this.renderDetail(chosen, language) : nothing}</section>\n </div>\n </div>`\n }\n\n private choose(t: RecipeTemplate) {\n if (this.chosen === t.name) return\n this.chosen = t.name\n this.values = Object.fromEntries(t.recipe.params.map(p => [p.name, p.default]))\n this.guideText = ''\n this.guideOf?.(t.name)\n .then(text => this.chosen === t.name && (this.guideText = text))\n .catch(() => {})\n }\n\n protected willUpdate() {\n /*\n The first card shown is chosen while none of those shown is, so the detail is never empty and never of a card the\n search or the category has taken away (the air cylinder stayed told under a search for rollers, :3300, 2026-09-26).\n */\n const shown = recipesMatching(this.templates, this.category, this.query)\n if (!shown.some(t => t.name === this.chosen) && shown[0]) this.choose(shown[0])\n }\n\n private renderDetail(t: RecipeTemplate, language: string) {\n const d = describeV3Recipe(t.recipe)\n const count = d.parts.min === d.parts.max ? String(d.parts.min) : `${d.parts.min}–${d.parts.max}`\n const ready = recipeReady(t.recipe, this.selected)\n return html`\n <h3>${t.title}</h3>\n <p>${t.description}</p>\n <div>\n <h3>${i18next.t('figure.label.recipe-adds')}</h3>\n <ul>\n <li parts=${count}>${i18next.t(\"figure.text.recipe-parts\", { parts: count })}</li>\n ${recipeWorkings(t.recipe).map(line => html`<li>${i18next.t(line.key, line.words)}</li>`)}\n <li>${i18next.t(t.recipe.target === 'part' ? 'figure.text.recipe-onto-chosen' : 'figure.text.recipe-onto-figure')}</li>\n </ul>\n </div>\n ${t.recipe.params.length ? this.renderForm(t.recipe, language) : nothing}\n ${this.error ? html`<p role=\"alert\">${this.error}</p>` : nothing}\n ${ready ? nothing : html`<p muted>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}\n <button insert ?disabled=${this.locked || !ready} @click=${() => this.dispatchEvent(new CustomEvent('insert-recipe', { detail: { recipe: t.recipe, values: this.values }, bubbles: true, composed: true }))}>\n <md-icon>add</md-icon>${this.selected && t.recipe.target === 'part' ? i18next.t('figure.button.insert-recipe-onto', { part: this.selected }) : i18next.t('figure.button.insert-recipe')}\n </button>\n ${this.guide ? html`<ox-markdown .src=${this.guide}></ox-markdown>` : nothing}\n `\n }\n\n /** Each value: its name, what it does, and the field to set it -- a share typed as a percentage. */\n private renderForm(recipe: V3Recipe, language: string) {\n const set = (name: string, value: V3RecipeValue) => (this.values = { ...this.values, [name]: value })\n return html`<div form>\n <h3>${i18next.t('figure.label.recipe-values')}</h3>\n ${recipe.params.map(param => {\n const label = recipeWord(param.label, language) || param.name\n const hint = recipeWord(param.hint, language)\n const value = this.values[param.name] ?? param.default\n const help = hint ? html`<small>${hint}</small>` : nothing\n if (param.kind === 'flag')\n return html`<label toggle><input type=\"checkbox\" param=${param.name} .checked=${value === true} @change=${(e: Event) => set(param.name, (e.target as HTMLInputElement).checked)} />${label}</label>${help}`\n if (param.kind === 'choice' || param.kind === 'face')\n return html`<label><span>${label}</span>\n <select param=${param.name} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLSelectElement).value))}>\n ${(param.options ?? []).map(o => html`<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}\n </select></label\n >${help}`\n const range = param.kind === 'ratio' ? `${recipeFieldOf(param, param.min ?? 0)}–${recipeFieldOf(param, param.max ?? 1)} %` : `${param.min ?? ''}–${param.max ?? ''} mm`\n return html`<label><span>${label} (${range})</span>\n <input type=\"number\" step=\"any\" param=${param.name} .value=${recipeFieldOf(param, value)} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLInputElement).value))} /></label\n >${help}`\n })}\n </div>`\n }\n}\n"]}
@@ -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
  };
@@ -1,6 +1,7 @@
1
1
  import { LitElement, type PropertyValues } from 'lit';
2
2
  import '@operato/layout';
3
- import type { V3Recipe } from '@hatiolab/figure-model';
3
+ import type { RecipeTemplate } from './v3-recipes.js';
4
+ import './figure-v3-recipe-catalog.js';
4
5
  import type { V3ProductSession } from './v3-product-session.js';
5
6
  import './v3-canvas.js';
6
7
  import './figure-v3-parts.js';
@@ -17,8 +18,12 @@ import type { FirstSave } from './v3-first-save.js';
17
18
  export declare class FigureV3Editor extends LitElement {
18
19
  static styles: import("lit").CSSResult[];
19
20
  session?: V3ProductSession;
20
- /** The recipes this domain sees (ADR-0095), given by the page. */
21
- recipes: V3Recipe[];
21
+ /** The recipe templates (ADR-0095), read by the page from the server. */
22
+ templates: RecipeTemplate[];
23
+ /** Reads a template's guide; given by the page. */
24
+ guideOf?: (name: string) => Promise<string>;
25
+ thumbnailOf?: (name: string) => string;
26
+ private catalogOpen;
22
27
  /** Why the last recipe was refused, in the kernel's words. */
23
28
  private recipeError;
24
29
  revision: number;
@@ -6,6 +6,7 @@ import '@operato/layout';
6
6
  import { customElement, property, state } from 'lit/decorators.js';
7
7
  import { live } from 'lit/directives/live.js';
8
8
  import { PART_LIMIT, parseV3Recipe, resolveV3Recipe, v3PartCountOf, v3RecipeIdFor } from '@hatiolab/figure-model';
9
+ import './figure-v3-recipe-catalog.js';
9
10
  import { captureV3Thumbnail } from './v3-thumbnail.js';
10
11
  import { drawableV3Parts } from './v3-renderer.js';
11
12
  import './v3-canvas.js';
@@ -35,8 +36,9 @@ import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './v3-live.js
35
36
  let FigureV3Editor = class FigureV3Editor extends LitElement {
36
37
  constructor() {
37
38
  super(...arguments);
38
- /** The recipes this domain sees (ADR-0095), given by the page. */
39
- this.recipes = [];
39
+ /** The recipe templates (ADR-0095), read by the page from the server. */
40
+ this.templates = [];
41
+ this.catalogOpen = false;
40
42
  /** Why the last recipe was refused, in the kernel's words. */
41
43
  this.recipeError = '';
42
44
  this.revision = 0;
@@ -529,6 +531,7 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
529
531
  const steps = resolveV3Recipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values });
530
532
  this.session.applyRecipe(steps);
531
533
  this.recipeError = '';
534
+ this.catalogOpen = false;
532
535
  this.error = '';
533
536
  this.changed();
534
537
  }
@@ -1005,6 +1008,9 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
1005
1008
  const repeat = repeatOf(this.selected, geometry);
1006
1009
  if (!asset || !repeat || repeat.index)
1007
1010
  return nothing;
1011
+ /* The row is set on its part; what hangs from that part is copied with it and has no row of its own. */
1012
+ if (!asset.document.model.nodes.some(n => n.id === `${this.selected}.repeat`))
1013
+ return nothing;
1008
1014
  const model = asset.document.model;
1009
1015
  const layout = model.nodes.find(n => n.id === `${this.selected}.layout`);
1010
1016
  const axis = model.nodes.find(n => n.id === `${this.selected}.repeat`)?.params?.axis ?? '?';
@@ -1160,6 +1166,18 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
1160
1166
  </div>`
1161
1167
  : null}
1162
1168
  ${this.notice ? html `<div role="status" class="notice">${this.notice}</div>` : null}
1169
+ ${this.catalogOpen
1170
+ ? html `<figure-v3-recipe-catalog
1171
+ .templates=${this.templates}
1172
+ .selected=${this.selected}
1173
+ .error=${this.recipeError}
1174
+ .guideOf=${this.guideOf}
1175
+ .thumbnailOf=${this.thumbnailOf}
1176
+ ?locked=${unavailable}
1177
+ @insert-recipe=${(e) => this.insertRecipe(e.detail.recipe, e.detail.values)}
1178
+ @close-catalog=${() => (this.catalogOpen = false)}
1179
+ ></figure-v3-recipe-catalog>`
1180
+ : null}
1163
1181
  <ox-three-pane>
1164
1182
  <figure-v3-parts
1165
1183
  slot="start"
@@ -1171,9 +1189,8 @@ let FigureV3Editor = class FigureV3Editor extends LitElement {
1171
1189
  .selected=${this.selected}
1172
1190
  ?locked=${unavailable}
1173
1191
  @add-part=${(e) => this.add(e.detail.kind)}
1174
- .recipes=${this.recipes}
1175
- .recipeError=${this.recipeError}
1176
- @insert-recipe=${(e) => this.insertRecipe(e.detail.recipe, e.detail.values)}
1192
+ .recipeCount=${this.templates.length}
1193
+ @open-recipe-catalog=${() => ((this.catalogOpen = true), (this.recipeError = ''))}
1177
1194
  @select-part=${(e) => this.choose(e.detail.id)}
1178
1195
  @toggle-part-hidden=${(e) => this.toggleHiddenPart(e.detail.id)}
1179
1196
  @set-all-parts-hidden=${(e) => (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => ownerOf(g.id))) : new Set())}
@@ -1327,7 +1344,19 @@ __decorate([
1327
1344
  __decorate([
1328
1345
  property({ attribute: false }),
1329
1346
  __metadata("design:type", Array)
1330
- ], FigureV3Editor.prototype, "recipes", void 0);
1347
+ ], FigureV3Editor.prototype, "templates", void 0);
1348
+ __decorate([
1349
+ property({ attribute: false }),
1350
+ __metadata("design:type", Function)
1351
+ ], FigureV3Editor.prototype, "guideOf", void 0);
1352
+ __decorate([
1353
+ property({ attribute: false }),
1354
+ __metadata("design:type", Function)
1355
+ ], FigureV3Editor.prototype, "thumbnailOf", void 0);
1356
+ __decorate([
1357
+ state(),
1358
+ __metadata("design:type", Object)
1359
+ ], FigureV3Editor.prototype, "catalogOpen", void 0);
1331
1360
  __decorate([
1332
1361
  state(),
1333
1362
  __metadata("design:type", Object)