@things-factory/figure-ui 10.1.69 → 10.1.71

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 (58) hide show
  1. package/client/graphql/index.ts +38 -6
  2. package/client/modeller/figure-v3-parts.ts +16 -136
  3. package/client/modeller/figure-v3-recipe-catalog.ts +348 -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 +53 -12
  11. package/client/pages/figure-v3-modeller-page.ts +16 -13
  12. package/dist-client/graphql/index.d.ts +12 -3
  13. package/dist-client/graphql/index.js +25 -6
  14. package/dist-client/graphql/index.js.map +1 -1
  15. package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
  16. package/dist-client/modeller/figure-v3-parts.js +17 -147
  17. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  18. package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +36 -0
  19. package/dist-client/modeller/figure-v3-recipe-catalog.js +382 -0
  20. package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
  21. package/dist-client/modeller/v3-editor.d.ts +8 -3
  22. package/dist-client/modeller/v3-editor.js +35 -6
  23. package/dist-client/modeller/v3-editor.js.map +1 -1
  24. package/dist-client/modeller/v3-fields.d.ts +2 -2
  25. package/dist-client/modeller/v3-fields.js +20 -18
  26. package/dist-client/modeller/v3-fields.js.map +1 -1
  27. package/dist-client/modeller/v3-gizmo.js +1 -1
  28. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  29. package/dist-client/modeller/v3-links.d.ts +8 -25
  30. package/dist-client/modeller/v3-links.js +4 -66
  31. package/dist-client/modeller/v3-links.js.map +1 -1
  32. package/dist-client/modeller/v3-palette.js +3 -0
  33. package/dist-client/modeller/v3-palette.js.map +1 -1
  34. package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
  35. package/dist-client/modeller/v3-recipe-source.js +14 -0
  36. package/dist-client/modeller/v3-recipe-source.js.map +1 -0
  37. package/dist-client/modeller/v3-recipes.d.ts +25 -4
  38. package/dist-client/modeller/v3-recipes.js +43 -11
  39. package/dist-client/modeller/v3-recipes.js.map +1 -1
  40. package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
  41. package/dist-client/pages/figure-v3-modeller-page.js +16 -13
  42. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  43. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  44. package/dist-client/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +5 -4
  46. package/scripts/template-thumbnails.mjs +53 -0
  47. package/test/browser/build.mjs +3 -3
  48. package/test/browser/modeller-page.js +25 -3
  49. package/test/browser/modeller.check.mjs +57 -47
  50. package/test/browser/thumbnail-page.js +14 -0
  51. package/test/v3-fields.test.ts +5 -1
  52. package/test/v3-links.test.ts +12 -0
  53. package/test/v3-recipes.test.ts +44 -11
  54. package/translations/en.json +38 -1
  55. package/translations/ja.json +38 -1
  56. package/translations/ko.json +38 -1
  57. package/translations/ms.json +38 -1
  58. package/translations/zh.json +38 -1
@@ -333,18 +333,50 @@ export async function fetchFigureVersions(id: string): Promise<FigureVersion[]>
333
333
  return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []
334
334
  }
335
335
 
336
- /** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */
337
- export async function fetchFigureRecipes(): Promise<Array<{ name: string; source: string }>> {
336
+ /** A recipe template as the server gives it (ADR-0095, amended): words in the language asked, the recipe as JSON. */
337
+ export interface RecipeTemplateRow {
338
+ name: string
339
+ category: string
340
+ tags: string[]
341
+ target: 'part' | 'figure'
342
+ title: string
343
+ description: string
344
+ source: string
345
+ hasThumbnail: boolean
346
+ }
347
+
348
+ export async function fetchRecipeTemplates(lang: string): Promise<RecipeTemplateRow[]> {
338
349
  const response = await client.query({
339
350
  query: gql`
340
- query {
341
- figureRecipes {
351
+ query ($lang: String) {
352
+ figureRecipeTemplates(lang: $lang) {
342
353
  name
354
+ category
355
+ tags
356
+ target
357
+ title
358
+ description
343
359
  source
360
+ hasThumbnail
344
361
  }
345
362
  }
346
363
  `,
347
- fetchPolicy: 'network-only'
364
+ variables: { lang }
365
+ })
366
+ return unwrap<RecipeTemplateRow[] | null>(response, 'figureRecipeTemplates') ?? []
367
+ }
368
+
369
+ /** One template's guide in the language asked, or '' while none is written. */
370
+ export async function fetchRecipeGuide(name: string, lang: string): Promise<string> {
371
+ const response = await client.query({
372
+ query: gql`
373
+ query ($name: String!, $lang: String) {
374
+ figureRecipeTemplate(name: $name, lang: $lang) {
375
+ guide
376
+ }
377
+ }
378
+ `,
379
+ variables: { name, lang }
348
380
  })
349
- return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []
381
+ return unwrap<{ guide?: string } | null>(response, 'figureRecipeTemplate')?.guide ?? ''
350
382
  }
@@ -1,11 +1,10 @@
1
1
  import '@material/web/icon/icon.js'
2
2
  import { css, html, LitElement, nothing } from 'lit'
3
- import { customElement, property, state } from 'lit/decorators.js'
3
+ import { customElement, property } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { ScrollbarStyles } from '@operato/styles'
6
6
  import { PART_LIMIT } from '@hatiolab/figure-model'
7
- import type { V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
8
- import { recipeFieldOf, recipeReady, recipeValueOf, recipeWord } from './v3-recipes.js'
7
+ import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
9
8
  import type { V3BudgetLine } from './v3-maturity.js'
10
9
  import { partLabelOf } from './v3-fields.js'
11
10
 
@@ -61,7 +60,8 @@ const RECIPES: Array<{ kind: V3PaletteKind; icon: string; label: string }> = [
61
60
  { kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
62
61
  { kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
63
62
  { kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
64
- { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
63
+ { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' },
64
+ { kind: 'hollow-box', icon: 'inventory_2', label: 'figure.label.primitive-hollow-box' }
65
65
  ]
66
66
 
67
67
  @customElement('figure-v3-parts')
@@ -209,83 +209,19 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
209
209
  border-radius: 6px;
210
210
  cursor: pointer;
211
211
  }
212
- div[recipe-list] {
213
- display: flex;
214
- flex-wrap: wrap;
215
- gap: 3px;
216
- }
217
- button[recipe] {
218
- padding: 4px 8px;
219
- font: inherit;
220
- font-size: 0.75rem;
221
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
222
- background: none;
223
- border: 1px solid var(--md-sys-color-outline-variant);
224
- border-radius: 999em;
225
- cursor: pointer;
226
- }
227
- button[recipe][on] {
228
- color: var(--md-sys-color-on-secondary-container);
229
- background: var(--md-sys-color-secondary-container);
230
- border-color: transparent;
231
- }
232
- button[recipe]:disabled {
233
- opacity: 0.38;
234
- cursor: default;
235
- }
236
- div[recipe-form] {
237
- display: grid;
238
- gap: 6px;
239
- margin-top: 8px;
240
- padding: 8px;
241
- border: 1px solid var(--md-sys-color-outline-variant);
242
- border-radius: 8px;
243
- font-size: 0.75rem;
244
- }
245
- div[recipe-form] label[field] {
246
- display: grid;
247
- grid-template-columns: 1fr 5.5em;
248
- align-items: center;
249
- gap: 6px;
250
- }
251
- div[recipe-form] input[type='number'],
252
- div[recipe-form] select {
253
- min-width: 0;
254
- font: inherit;
255
- }
256
- div[recipe-form] p[hint] {
257
- margin: 0;
258
- color: var(--md-sys-color-on-surface-variant);
259
- }
260
- div[recipe-form] p[error] {
261
- margin: 0;
262
- padding: 6px;
263
- white-space: pre-wrap;
264
- color: var(--md-sys-color-on-error-container);
265
- background: var(--md-sys-color-error-container);
266
- border-radius: 6px;
267
- }
268
- div[recipe-actions] {
269
- display: flex;
270
- gap: 6px;
271
- }
272
- div[recipe-actions] button {
212
+ button[recipe-catalog] {
273
213
  display: inline-flex;
274
214
  align-items: center;
275
215
  gap: 4px;
276
216
  padding: 4px 10px;
277
217
  font: inherit;
218
+ font-size: 0.75rem;
219
+ color: var(--md-sys-color-on-surface);
220
+ background: none;
278
221
  border: 1px solid var(--md-sys-color-outline-variant);
279
222
  border-radius: 999em;
280
- background: none;
281
- color: var(--md-sys-color-on-surface);
282
223
  cursor: pointer;
283
224
  }
284
- div[recipe-actions] button[insert] {
285
- border-color: transparent;
286
- color: var(--md-sys-color-on-primary);
287
- background: var(--md-sys-color-primary);
288
- }
289
225
  button[shape]:hover:not([disabled]) {
290
226
  color: var(--md-sys-color-on-primary-container);
291
227
  background-color: var(--md-sys-color-primary-container);
@@ -442,13 +378,8 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
442
378
  * 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
443
379
  */
444
380
  @property({ attribute: false }) partBudget?: { level: 'S' | 'M' | 'L' | null; used: number; limit: number | null }
445
- /** The recipes this domain sees (ADR-0095). */
446
- @property({ attribute: false }) recipes: V3Recipe[] = []
447
- /** Why the last recipe put in was refused -- the kernel's sentence, as it is (ADR-0095 condition 3). */
448
- @property({ type: String }) recipeError = ''
449
- /** The recipe whose form is open, and the values in it. */
450
- @state() private openRecipe = ''
451
- @state() private recipeValues: Record<string, V3RecipeValue> = {}
381
+ /** How many recipe templates the catalog holds; none, and the section is not drawn. */
382
+ @property({ type: Number }) recipeCount = 0
452
383
 
453
384
  render() {
454
385
  const b = this.partBudget
@@ -626,70 +557,19 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
626
557
  }
627
558
 
628
559
  /**
629
- * Recipes (ADR-0095): a door, a roller row -- parts made in one step, with a few numbers and choices. A recipe for a
630
- * part needs one chosen; its form opens under it, and putting it in is one step of the history.
560
+ * Recipes (ADR-0095): the catalog of templates -- a door, a roller row -- opened over the modeller, put onto the chosen
561
+ * part in one step.
631
562
  */
632
563
  private renderRecipes(full: boolean) {
633
- if (!this.recipes.length) return nothing
634
- const language = i18next.language || 'en'
635
- const open = this.recipes.find(r => r.name === this.openRecipe)
564
+ if (!this.recipeCount) return nothing
636
565
  return html`<section recipes>
637
566
  <h3>${i18next.t('figure.label.recipes')}</h3>
638
- <div recipe-list>
639
- ${this.recipes.map(recipe => {
640
- const ready = recipeReady(recipe, this.selected)
641
- return html`<button
642
- recipe=${recipe.name}
643
- ?on=${recipe.name === this.openRecipe}
644
- ?disabled=${this.locked || full || !ready}
645
- title=${ready ? recipeWord(recipe.description, language) : i18next.t('figure.text.recipe-needs-a-part')}
646
- @click=${() => this.openFor(recipe)}
647
- >
648
- ${recipeWord(recipe.title, language)}
649
- </button>`
650
- })}
651
- </div>
652
- ${this.selected || !this.recipes.some(r => r.target === 'part') ? nothing : html`<p hint>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}
653
- ${open && recipeReady(open, this.selected) ? this.renderRecipeForm(open, language) : nothing}
567
+ <button recipe-catalog ?disabled=${this.locked || full} @click=${() => this.ask('open-recipe-catalog', {})}>
568
+ <md-icon>widgets</md-icon>${i18next.t('figure.button.open-recipe-catalog', { count: this.recipeCount })}
569
+ </button>
654
570
  </section>`
655
571
  }
656
572
 
657
- private openFor(recipe: V3Recipe) {
658
- if (this.openRecipe === recipe.name) {
659
- this.openRecipe = ''
660
- return
661
- }
662
- this.openRecipe = recipe.name
663
- this.recipeValues = Object.fromEntries(recipe.params.map(p => [p.name, p.default]))
664
- }
665
-
666
- private renderRecipeForm(recipe: V3Recipe, language: string) {
667
- const set = (name: string, value: V3RecipeValue) => (this.recipeValues = { ...this.recipeValues, [name]: value })
668
- return html`<div recipe-form>
669
- ${recipe.target === 'part' ? html`<p hint>${i18next.t('figure.text.recipe-onto', { part: this.selected })}</p>` : nothing}
670
- ${recipe.params.map(param => {
671
- const label = recipeWord(param.label, language) || param.name
672
- const value = this.recipeValues[param.name] ?? param.default
673
- if (param.kind === 'flag')
674
- return html`<label toggle><input type="checkbox" .checked=${value === true} @change=${(e: Event) => set(param.name, (e.target as HTMLInputElement).checked)} />${label}</label>`
675
- if (param.kind === 'choice' || param.kind === 'face')
676
- return html`<label field><span>${label}</span>
677
- <select @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLSelectElement).value))}>
678
- ${(param.options ?? []).map(o => html`<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}
679
- </select></label>`
680
- return html`<label field><span>${label}${param.kind === 'ratio' ? ' (%)' : ''}</span>
681
- <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>`
682
- })}
683
- ${this.recipeError ? html`<p role="alert" error>${this.recipeError}</p>` : nothing}
684
- <div recipe-actions>
685
- <button act insert ?disabled=${this.locked} @click=${() => this.ask('insert-recipe', { recipe, values: this.recipeValues })}>
686
- <md-icon>add</md-icon>${i18next.t('figure.button.insert-recipe')}
687
- </button>
688
- <button act @click=${() => (this.openRecipe = '')}>${i18next.t('figure.button.close')}</button>
689
- </div>
690
- </div>`
691
- }
692
-
693
573
  /** 정본을 고치지 않는다 — 무엇을 하겠다는 것만 알린다. */
694
574
  private ask(what: string, detail: object) {
695
575
  this.dispatchEvent(new CustomEvent(what, { detail, bubbles: true, composed: true }))
@@ -0,0 +1,348 @@
1
+ import '@material/web/icon/icon.js'
2
+ import '@operato/markdown'
3
+
4
+ import { css, html, LitElement, nothing } from 'lit'
5
+ import { customElement, property, state } from 'lit/decorators.js'
6
+
7
+ import { i18next, localize } from '@operato/i18n'
8
+ import { ScrollbarStyles } from '@operato/styles'
9
+ import { describeV3Recipe } from '@hatiolab/figure-model'
10
+ import type { V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
11
+
12
+ import { recipeFieldOf, recipeReady, recipeValueOf, recipeWord, recipeWorkings, recipesMatching, type RecipeTemplate } from './v3-recipes.js'
13
+
14
+ /**
15
+ * The recipe catalog (ADR-0095): the templates the product ships, by category, found by words, each told in full --
16
+ * what it puts in, how many parts, how they move, what each value does, and how to use it -- and put onto the chosen
17
+ * part in one step. The numbers and the ways of moving are worked out from the recipe (`describeV3Recipe`), so they say
18
+ * what it does; only the descriptions and the guide are written.
19
+ */
20
+ @customElement('figure-v3-recipe-catalog')
21
+ export class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {
22
+ static styles = [
23
+ ScrollbarStyles,
24
+ css`
25
+ :host {
26
+ position: fixed;
27
+ inset: 0;
28
+ z-index: 20;
29
+ display: grid;
30
+ place-items: center;
31
+ background: color-mix(in srgb, var(--md-sys-color-scrim, #000) 32%, transparent);
32
+ font-size: 0.82rem;
33
+ color: var(--md-sys-color-on-surface);
34
+ }
35
+ div[panel] {
36
+ display: grid;
37
+ grid-template-rows: auto 1fr;
38
+ width: min(1100px, calc(100vw - 32px));
39
+ height: min(720px, calc(100vh - 32px));
40
+ background: var(--md-sys-color-surface);
41
+ border-radius: 12px;
42
+ overflow: hidden;
43
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
44
+ }
45
+ header {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 8px;
49
+ padding: 8px 12px;
50
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
51
+ }
52
+ header h2 {
53
+ margin: 0;
54
+ font-size: var(--ui-t-title, 16px);
55
+ flex: 1;
56
+ }
57
+ header input {
58
+ width: 220px;
59
+ padding: 4px 8px;
60
+ font: inherit;
61
+ }
62
+ button[close] {
63
+ display: inline-grid;
64
+ place-items: center;
65
+ width: 32px;
66
+ height: 32px;
67
+ border: none;
68
+ border-radius: 8px;
69
+ background: none;
70
+ color: var(--md-sys-color-on-surface-variant);
71
+ cursor: pointer;
72
+ }
73
+ div[body] {
74
+ display: grid;
75
+ grid-template-columns: 170px minmax(0, 1fr) minmax(300px, 380px);
76
+ min-height: 0;
77
+ }
78
+ @media (max-width: 760px) {
79
+ div[body] {
80
+ grid-template-columns: 1fr;
81
+ overflow: auto;
82
+ }
83
+ }
84
+ nav,
85
+ section[list],
86
+ section[detail] {
87
+ overflow: auto;
88
+ min-height: 0;
89
+ padding: 8px;
90
+ }
91
+ nav {
92
+ border-right: 1px solid var(--md-sys-color-outline-variant);
93
+ }
94
+ section[detail] {
95
+ border-left: 1px solid var(--md-sys-color-outline-variant);
96
+ display: grid;
97
+ gap: 10px;
98
+ align-content: start;
99
+ }
100
+ button[category] {
101
+ display: flex;
102
+ justify-content: space-between;
103
+ width: 100%;
104
+ padding: 6px 8px;
105
+ font: inherit;
106
+ color: inherit;
107
+ background: none;
108
+ border: none;
109
+ border-radius: 6px;
110
+ text-align: left;
111
+ cursor: pointer;
112
+ }
113
+ button[category][on],
114
+ button[card][on] {
115
+ background: var(--md-sys-color-secondary-container);
116
+ color: var(--md-sys-color-on-secondary-container);
117
+ }
118
+ section[list] {
119
+ display: grid;
120
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
121
+ gap: 8px;
122
+ align-content: start;
123
+ }
124
+ button[card] {
125
+ display: grid;
126
+ gap: 4px;
127
+ padding: 8px;
128
+ font: inherit;
129
+ color: inherit;
130
+ text-align: left;
131
+ background: none;
132
+ border: 1px solid var(--md-sys-color-outline-variant);
133
+ border-radius: 8px;
134
+ cursor: pointer;
135
+ }
136
+ button[card] img {
137
+ width: 100%;
138
+ aspect-ratio: 4 / 3;
139
+ object-fit: contain;
140
+ background: var(--md-sys-color-surface-container);
141
+ border-radius: 4px;
142
+ }
143
+ button[card] small,
144
+ p[muted] {
145
+ color: var(--md-sys-color-on-surface-variant);
146
+ }
147
+ h3 {
148
+ margin: 0;
149
+ font-size: var(--ui-t-body, 14px);
150
+ }
151
+ p {
152
+ margin: 0;
153
+ line-height: 1.5;
154
+ }
155
+ ul {
156
+ margin: 0;
157
+ padding-left: 18px;
158
+ line-height: 1.6;
159
+ }
160
+ table {
161
+ border-collapse: collapse;
162
+ width: 100%;
163
+ }
164
+ th,
165
+ td {
166
+ text-align: left;
167
+ vertical-align: top;
168
+ padding: 3px 4px;
169
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
170
+ }
171
+ td small {
172
+ display: block;
173
+ color: var(--md-sys-color-on-surface-variant);
174
+ }
175
+ div[form] {
176
+ display: grid;
177
+ gap: 6px;
178
+ padding: 8px;
179
+ border: 1px solid var(--md-sys-color-outline-variant);
180
+ border-radius: 8px;
181
+ }
182
+ div[form] label {
183
+ display: grid;
184
+ gap: 2px;
185
+ }
186
+ div[form] label[toggle] {
187
+ display: flex;
188
+ align-items: center;
189
+ gap: 6px;
190
+ }
191
+ div[form] input,
192
+ div[form] select {
193
+ font: inherit;
194
+ min-width: 0;
195
+ }
196
+ button[insert] {
197
+ justify-self: start;
198
+ display: inline-flex;
199
+ align-items: center;
200
+ gap: 4px;
201
+ padding: 6px 14px;
202
+ font: inherit;
203
+ white-space: nowrap;
204
+ color: var(--md-sys-color-on-primary);
205
+ background: var(--md-sys-color-primary);
206
+ border: none;
207
+ border-radius: 999em;
208
+ cursor: pointer;
209
+ }
210
+ button[insert]:disabled {
211
+ opacity: 0.38;
212
+ cursor: default;
213
+ }
214
+ p[role='alert'] {
215
+ padding: 6px;
216
+ white-space: pre-wrap;
217
+ color: var(--md-sys-color-on-error-container);
218
+ background: var(--md-sys-color-error-container);
219
+ border-radius: 6px;
220
+ }
221
+ ox-markdown {
222
+ display: block;
223
+ }
224
+ `
225
+ ]
226
+
227
+ @property({ attribute: false }) templates: RecipeTemplate[] = []
228
+ /** The part it would be put onto. */
229
+ @property({ type: String }) selected = ''
230
+ @property({ type: Boolean }) locked = false
231
+ /** Why the last one put in was refused, in the kernel's words. */
232
+ @property({ type: String }) error = ''
233
+ /** Reads a template's guide; absent, no guide is shown. */
234
+ @property({ attribute: false }) guideOf?: (name: string) => Promise<string>
235
+ /** Where a template's thumbnail is read from; absent, cards show no picture. */
236
+ @property({ attribute: false }) thumbnailOf?: (name: string) => string
237
+
238
+ @state() private category = ''
239
+ @state() private query = ''
240
+ @state() private chosen = ''
241
+ @state() private values: Record<string, V3RecipeValue> = {}
242
+ @state() private guide = ''
243
+
244
+ render() {
245
+ const language = i18next.language || 'en'
246
+ const categories = [...new Set(this.templates.map(t => t.category))].sort()
247
+ const shown = recipesMatching(this.templates, this.category, this.query)
248
+ const chosen = this.templates.find(t => t.name === this.chosen) ?? shown[0]
249
+ return html`<div panel role="dialog" aria-modal="true" aria-label=${i18next.t('figure.label.recipe-catalog')}>
250
+ <header>
251
+ <h2>${i18next.t('figure.label.recipe-catalog')}</h2>
252
+ <input search type="search" placeholder=${i18next.t('figure.text.recipe-search')} .value=${this.query} @input=${(e: Event) => (this.query = (e.target as HTMLInputElement).value)} />
253
+ <button close aria-label=${i18next.t('figure.button.close')} @click=${() => this.dispatchEvent(new CustomEvent('close-catalog', { bubbles: true, composed: true }))}>
254
+ <md-icon>close</md-icon>
255
+ </button>
256
+ </header>
257
+ <div body>
258
+ <nav>
259
+ <button category ?on=${!this.category} @click=${() => (this.category = '')}>
260
+ <span>${i18next.t('figure.label.recipe-all')}</span><small>${this.templates.length}</small>
261
+ </button>
262
+ ${categories.map(
263
+ c => html`<button category=${c} ?on=${this.category === c} @click=${() => (this.category = c)}>
264
+ <span>${i18next.t('figure.label.recipe-category-' + c)}</span><small>${this.templates.filter(t => t.category === c).length}</small>
265
+ </button>`
266
+ )}
267
+ </nav>
268
+ <section list>
269
+ ${shown.map(
270
+ t => html`<button card=${t.name} ?on=${t.name === chosen?.name} @click=${() => this.choose(t)}>
271
+ ${t.hasThumbnail && this.thumbnailOf ? html`<img alt="" loading="lazy" src=${this.thumbnailOf(t.name)} />` : nothing}
272
+ <b>${t.title}</b>
273
+ <small>${t.description}</small>
274
+ </button>`
275
+ )}
276
+ </section>
277
+ <section detail>${chosen ? this.renderDetail(chosen, language) : nothing}</section>
278
+ </div>
279
+ </div>`
280
+ }
281
+
282
+ private choose(t: RecipeTemplate) {
283
+ if (this.chosen === t.name) return
284
+ this.chosen = t.name
285
+ this.values = Object.fromEntries(t.recipe.params.map(p => [p.name, p.default]))
286
+ this.guide = ''
287
+ this.guideOf?.(t.name)
288
+ .then(text => this.chosen === t.name && (this.guide = text))
289
+ .catch(() => {})
290
+ }
291
+
292
+ protected willUpdate() {
293
+ /* The first card is chosen while nothing is, so the detail is never empty. */
294
+ const shown = recipesMatching(this.templates, this.category, this.query)
295
+ if (!this.templates.some(t => t.name === this.chosen) && shown[0]) this.choose(shown[0])
296
+ }
297
+
298
+ private renderDetail(t: RecipeTemplate, language: string) {
299
+ const d = describeV3Recipe(t.recipe)
300
+ const count = d.parts.min === d.parts.max ? String(d.parts.min) : `${d.parts.min}–${d.parts.max}`
301
+ const ready = recipeReady(t.recipe, this.selected)
302
+ return html`
303
+ <h3>${t.title}</h3>
304
+ <p>${t.description}</p>
305
+ <div>
306
+ <h3>${i18next.t('figure.label.recipe-adds')}</h3>
307
+ <ul>
308
+ <li parts=${count}>${i18next.t("figure.text.recipe-parts", { parts: count })}</li>
309
+ ${recipeWorkings(t.recipe).map(line => html`<li>${i18next.t(line.key, line.words)}</li>`)}
310
+ <li>${i18next.t(t.recipe.target === 'part' ? 'figure.text.recipe-onto-chosen' : 'figure.text.recipe-onto-figure')}</li>
311
+ </ul>
312
+ </div>
313
+ ${t.recipe.params.length ? this.renderForm(t.recipe, language) : nothing}
314
+ ${this.error ? html`<p role="alert">${this.error}</p>` : nothing}
315
+ ${ready ? nothing : html`<p muted>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}
316
+ <button insert ?disabled=${this.locked || !ready} @click=${() => this.dispatchEvent(new CustomEvent('insert-recipe', { detail: { recipe: t.recipe, values: this.values }, bubbles: true, composed: true }))}>
317
+ <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')}
318
+ </button>
319
+ ${this.guide ? html`<ox-markdown .src=${this.guide}></ox-markdown>` : nothing}
320
+ `
321
+ }
322
+
323
+ /** Each value: its name, what it does, and the field to set it -- a share typed as a percentage. */
324
+ private renderForm(recipe: V3Recipe, language: string) {
325
+ const set = (name: string, value: V3RecipeValue) => (this.values = { ...this.values, [name]: value })
326
+ return html`<div form>
327
+ <h3>${i18next.t('figure.label.recipe-values')}</h3>
328
+ ${recipe.params.map(param => {
329
+ const label = recipeWord(param.label, language) || param.name
330
+ const hint = recipeWord(param.hint, language)
331
+ const value = this.values[param.name] ?? param.default
332
+ const help = hint ? html`<small>${hint}</small>` : nothing
333
+ if (param.kind === 'flag')
334
+ 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}`
335
+ if (param.kind === 'choice' || param.kind === 'face')
336
+ return html`<label><span>${label}</span>
337
+ <select param=${param.name} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLSelectElement).value))}>
338
+ ${(param.options ?? []).map(o => html`<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}
339
+ </select></label
340
+ >${help}`
341
+ const range = param.kind === 'ratio' ? `${recipeFieldOf(param, param.min ?? 0)}–${recipeFieldOf(param, param.max ?? 1)} %` : `${param.min ?? ''}–${param.max ?? ''} mm`
342
+ return html`<label><span>${label} (${range})</span>
343
+ <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
344
+ >${help}`
345
+ })}
346
+ </div>`
347
+ }
348
+ }
@@ -5,6 +5,8 @@ import '@operato/layout'
5
5
  import { customElement, property, state } from 'lit/decorators.js'
6
6
  import { live } from 'lit/directives/live.js'
7
7
  import { PART_LIMIT, parseV3Recipe, resolveV3Recipe, v3PartCountOf, v3RecipeIdFor } from '@hatiolab/figure-model'
8
+ import type { RecipeTemplate } from './v3-recipes.js'
9
+ import './figure-v3-recipe-catalog.js'
8
10
  import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
9
11
  import type { V3ProductSession } from './v3-product-session.js'
10
12
  import { captureV3Thumbnail } from './v3-thumbnail.js'
@@ -262,8 +264,12 @@ export class FigureV3Editor extends LitElement {
262
264
  }
263
265
  `]
264
266
  @property({ attribute: false }) session?: V3ProductSession
265
- /** The recipes this domain sees (ADR-0095), given by the page. */
266
- @property({ attribute: false }) recipes: V3Recipe[] = []
267
+ /** The recipe templates (ADR-0095), read by the page from the server. */
268
+ @property({ attribute: false }) templates: RecipeTemplate[] = []
269
+ /** Reads a template's guide; given by the page. */
270
+ @property({ attribute: false }) guideOf?: (name: string) => Promise<string>
271
+ @property({ attribute: false }) thumbnailOf?: (name: string) => string
272
+ @state() private catalogOpen = false
267
273
  /** Why the last recipe was refused, in the kernel's words. */
268
274
  @state() private recipeError = ''
269
275
  @property({ type: Number }) revision = 0
@@ -534,6 +540,7 @@ export class FigureV3Editor extends LitElement {
534
540
  const steps = resolveV3Recipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
535
541
  this.session!.applyRecipe(steps)
536
542
  this.recipeError = ''
543
+ this.catalogOpen = false
537
544
  this.error = ''
538
545
  this.changed()
539
546
  } catch (error) {
@@ -989,6 +996,8 @@ export class FigureV3Editor extends LitElement {
989
996
  private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
990
997
  const repeat = repeatOf(this.selected, geometry)
991
998
  if (!asset || !repeat || repeat.index) return nothing
999
+ /* The row is set on its part; what hangs from that part is copied with it and has no row of its own. */
1000
+ if (!asset.document.model.nodes.some(n => n.id === `${this.selected}.repeat`)) return nothing
992
1001
  const model = asset.document.model as unknown as {
993
1002
  nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
994
1003
  constants: { id: string; value: number }[]
@@ -1143,6 +1152,18 @@ export class FigureV3Editor extends LitElement {
1143
1152
  </div>`
1144
1153
  : null}
1145
1154
  ${this.notice ? html`<div role="status" class="notice">${this.notice}</div>` : null}
1155
+ ${this.catalogOpen
1156
+ ? html`<figure-v3-recipe-catalog
1157
+ .templates=${this.templates}
1158
+ .selected=${this.selected}
1159
+ .error=${this.recipeError}
1160
+ .guideOf=${this.guideOf}
1161
+ .thumbnailOf=${this.thumbnailOf}
1162
+ ?locked=${unavailable}
1163
+ @insert-recipe=${(e: CustomEvent) => this.insertRecipe(e.detail.recipe, e.detail.values)}
1164
+ @close-catalog=${() => (this.catalogOpen = false)}
1165
+ ></figure-v3-recipe-catalog>`
1166
+ : null}
1146
1167
  <ox-three-pane>
1147
1168
  <figure-v3-parts
1148
1169
  slot="start"
@@ -1154,9 +1175,8 @@ export class FigureV3Editor extends LitElement {
1154
1175
  .selected=${this.selected}
1155
1176
  ?locked=${unavailable}
1156
1177
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
1157
- .recipes=${this.recipes}
1158
- .recipeError=${this.recipeError}
1159
- @insert-recipe=${(e: CustomEvent) => this.insertRecipe(e.detail.recipe, e.detail.values)}
1178
+ .recipeCount=${this.templates.length}
1179
+ @open-recipe-catalog=${() => ((this.catalogOpen = true), (this.recipeError = ''))}
1160
1180
  @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
1161
1181
  @toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
1162
1182
  @set-all-parts-hidden=${(e: CustomEvent) =>