@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
@@ -333,91 +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 }>> {
338
- const response = await client.query({
339
- query: gql`
340
- query {
341
- figureRecipes {
342
- name
343
- source
344
- }
345
- }
346
- `,
347
- fetchPolicy: 'network-only'
348
- })
349
- return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []
350
- }
351
-
352
- /** A recipe row as the management screen reads it: whose it is and whether it is released. */
353
- export interface FigureRecipeRow {
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 {
354
338
  name: string
339
+ category: string
340
+ tags: string[]
341
+ target: 'part' | 'figure'
342
+ title: string
343
+ description: string
355
344
  source: string
356
- state: 'draft' | 'released'
357
- updatedAt?: string
358
- domain?: { id: string; subdomain?: string }
345
+ hasThumbnail: boolean
359
346
  }
360
347
 
361
- /** Every recipe this domain sees, drafts too (ADR-0095). */
362
- export async function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]> {
348
+ export async function fetchRecipeTemplates(lang: string): Promise<RecipeTemplateRow[]> {
363
349
  const response = await client.query({
364
350
  query: gql`
365
- query {
366
- figureRecipes(all: true) {
367
- name
368
- source
369
- state
370
- updatedAt
371
- domain {
372
- id
373
- subdomain
374
- }
375
- }
376
- }
377
- `,
378
- fetchPolicy: 'network-only'
379
- })
380
- return unwrap<FigureRecipeRow[] | null>(response, 'figureRecipes') ?? []
381
- }
382
-
383
- export async function saveFigureRecipe(source: string, state: 'draft' | 'released'): Promise<FigureRecipeRow> {
384
- const response = await client.mutate({
385
- mutation: gql`
386
- mutation ($source: String!, $state: String) {
387
- saveFigureRecipe(source: $source, state: $state) {
351
+ query ($lang: String) {
352
+ figureRecipeTemplates(lang: $lang) {
388
353
  name
354
+ category
355
+ tags
356
+ target
357
+ title
358
+ description
389
359
  source
390
- state
360
+ hasThumbnail
391
361
  }
392
362
  }
393
363
  `,
394
- variables: { source, state }
364
+ variables: { lang }
395
365
  })
396
- return unwrap<FigureRecipeRow>(response, 'saveFigureRecipe')
366
+ return unwrap<RecipeTemplateRow[] | null>(response, 'figureRecipeTemplates') ?? []
397
367
  }
398
368
 
399
- export async function setFigureRecipeState(name: string, state: 'draft' | 'released'): Promise<void> {
400
- const response = await client.mutate({
401
- mutation: gql`
402
- mutation ($name: String!, $state: String!) {
403
- setFigureRecipeState(name: $name, state: $state) {
404
- name
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
405
376
  }
406
377
  }
407
378
  `,
408
- variables: { name, state }
409
- })
410
- unwrap(response, 'setFigureRecipeState')
411
- }
412
-
413
- export async function deleteFigureRecipe(name: string): Promise<void> {
414
- const response = await client.mutate({
415
- mutation: gql`
416
- mutation ($name: String!) {
417
- deleteFigureRecipe(name: $name)
418
- }
419
- `,
420
- variables: { name }
379
+ variables: { name, lang }
421
380
  })
422
- unwrap(response, 'deleteFigureRecipe')
381
+ return unwrap<{ guide?: string } | null>(response, 'figureRecipeTemplate')?.guide ?? ''
423
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
- /* The side pane is narrow (200 px, 160 under 860): the name above its field, not beside it. */
246
- div[recipe-form] label[field] {
247
- display: grid;
248
- gap: 2px;
249
- }
250
- div[recipe-form] input[type='number'],
251
- div[recipe-form] select {
252
- min-width: 0;
253
- font: inherit;
254
- }
255
- div[recipe-form] p[hint] {
256
- margin: 0;
257
- color: var(--md-sys-color-on-surface-variant);
258
- }
259
- div[recipe-form] p[error] {
260
- margin: 0;
261
- padding: 6px;
262
- white-space: pre-wrap;
263
- color: var(--md-sys-color-on-error-container);
264
- background: var(--md-sys-color-error-container);
265
- border-radius: 6px;
266
- }
267
- div[recipe-actions] {
268
- display: flex;
269
- gap: 6px;
270
- }
271
- div[recipe-actions] button {
272
- white-space: nowrap;
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,366 @@
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
+ /*
243
+ The guide as a page \`ox-markdown\` can read: it renders what its src points to, not text, and given the text it
244
+ 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
245
+ text/markdown is read as markdown.
246
+ */
247
+ @state() private guide = ''
248
+
249
+ private set guideText(text: string) {
250
+ if (this.guide) URL.revokeObjectURL(this.guide)
251
+ this.guide = text ? URL.createObjectURL(new Blob([text], { type: 'text/markdown' })) : ''
252
+ }
253
+
254
+ disconnectedCallback() {
255
+ super.disconnectedCallback()
256
+ this.guideText = ''
257
+ }
258
+
259
+ render() {
260
+ const language = i18next.language || 'en'
261
+ const categories = [...new Set(this.templates.map(t => t.category))].sort()
262
+ const shown = recipesMatching(this.templates, this.category, this.query)
263
+ const chosen = this.templates.find(t => t.name === this.chosen) ?? shown[0]
264
+ return html`<div panel role="dialog" aria-modal="true" aria-label=${i18next.t('figure.label.recipe-catalog')}>
265
+ <header>
266
+ <h2>${i18next.t('figure.label.recipe-catalog')}</h2>
267
+ <input search type="search" placeholder=${i18next.t('figure.text.recipe-search')} .value=${this.query} @input=${(e: Event) => (this.query = (e.target as HTMLInputElement).value)} />
268
+ <button close aria-label=${i18next.t('figure.button.close')} @click=${() => this.dispatchEvent(new CustomEvent('close-catalog', { bubbles: true, composed: true }))}>
269
+ <md-icon>close</md-icon>
270
+ </button>
271
+ </header>
272
+ <div body>
273
+ <nav>
274
+ <button category ?on=${!this.category} @click=${() => (this.category = '')}>
275
+ <span>${i18next.t('figure.label.recipe-all')}</span><small>${this.templates.length}</small>
276
+ </button>
277
+ ${categories.map(
278
+ c => html`<button category=${c} ?on=${this.category === c} @click=${() => (this.category = c)}>
279
+ <span>${i18next.t('figure.label.recipe-category-' + c)}</span><small>${this.templates.filter(t => t.category === c).length}</small>
280
+ </button>`
281
+ )}
282
+ </nav>
283
+ <section list>
284
+ ${shown.map(
285
+ t => html`<button card=${t.name} ?on=${t.name === chosen?.name} @click=${() => this.choose(t)}>
286
+ ${t.hasThumbnail && this.thumbnailOf ? html`<img alt="" loading="lazy" src=${this.thumbnailOf(t.name)} />` : nothing}
287
+ <b>${t.title}</b>
288
+ <small>${t.description}</small>
289
+ </button>`
290
+ )}
291
+ </section>
292
+ <section detail>${chosen ? this.renderDetail(chosen, language) : nothing}</section>
293
+ </div>
294
+ </div>`
295
+ }
296
+
297
+ private choose(t: RecipeTemplate) {
298
+ if (this.chosen === t.name) return
299
+ this.chosen = t.name
300
+ this.values = Object.fromEntries(t.recipe.params.map(p => [p.name, p.default]))
301
+ this.guideText = ''
302
+ this.guideOf?.(t.name)
303
+ .then(text => this.chosen === t.name && (this.guideText = text))
304
+ .catch(() => {})
305
+ }
306
+
307
+ protected willUpdate() {
308
+ /*
309
+ The first card shown is chosen while none of those shown is, so the detail is never empty and never of a card the
310
+ search or the category has taken away (the air cylinder stayed told under a search for rollers, :3300, 2026-09-26).
311
+ */
312
+ const shown = recipesMatching(this.templates, this.category, this.query)
313
+ if (!shown.some(t => t.name === this.chosen) && shown[0]) this.choose(shown[0])
314
+ }
315
+
316
+ private renderDetail(t: RecipeTemplate, language: string) {
317
+ const d = describeV3Recipe(t.recipe)
318
+ const count = d.parts.min === d.parts.max ? String(d.parts.min) : `${d.parts.min}–${d.parts.max}`
319
+ const ready = recipeReady(t.recipe, this.selected)
320
+ return html`
321
+ <h3>${t.title}</h3>
322
+ <p>${t.description}</p>
323
+ <div>
324
+ <h3>${i18next.t('figure.label.recipe-adds')}</h3>
325
+ <ul>
326
+ <li parts=${count}>${i18next.t("figure.text.recipe-parts", { parts: count })}</li>
327
+ ${recipeWorkings(t.recipe).map(line => html`<li>${i18next.t(line.key, line.words)}</li>`)}
328
+ <li>${i18next.t(t.recipe.target === 'part' ? 'figure.text.recipe-onto-chosen' : 'figure.text.recipe-onto-figure')}</li>
329
+ </ul>
330
+ </div>
331
+ ${t.recipe.params.length ? this.renderForm(t.recipe, language) : nothing}
332
+ ${this.error ? html`<p role="alert">${this.error}</p>` : nothing}
333
+ ${ready ? nothing : html`<p muted>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}
334
+ <button insert ?disabled=${this.locked || !ready} @click=${() => this.dispatchEvent(new CustomEvent('insert-recipe', { detail: { recipe: t.recipe, values: this.values }, bubbles: true, composed: true }))}>
335
+ <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')}
336
+ </button>
337
+ ${this.guide ? html`<ox-markdown .src=${this.guide}></ox-markdown>` : nothing}
338
+ `
339
+ }
340
+
341
+ /** Each value: its name, what it does, and the field to set it -- a share typed as a percentage. */
342
+ private renderForm(recipe: V3Recipe, language: string) {
343
+ const set = (name: string, value: V3RecipeValue) => (this.values = { ...this.values, [name]: value })
344
+ return html`<div form>
345
+ <h3>${i18next.t('figure.label.recipe-values')}</h3>
346
+ ${recipe.params.map(param => {
347
+ const label = recipeWord(param.label, language) || param.name
348
+ const hint = recipeWord(param.hint, language)
349
+ const value = this.values[param.name] ?? param.default
350
+ const help = hint ? html`<small>${hint}</small>` : nothing
351
+ if (param.kind === 'flag')
352
+ 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}`
353
+ if (param.kind === 'choice' || param.kind === 'face')
354
+ return html`<label><span>${label}</span>
355
+ <select param=${param.name} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLSelectElement).value))}>
356
+ ${(param.options ?? []).map(o => html`<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}
357
+ </select></label
358
+ >${help}`
359
+ const range = param.kind === 'ratio' ? `${recipeFieldOf(param, param.min ?? 0)}–${recipeFieldOf(param, param.max ?? 1)} %` : `${param.min ?? ''}–${param.max ?? ''} mm`
360
+ return html`<label><span>${label} (${range})</span>
361
+ <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
362
+ >${help}`
363
+ })}
364
+ </div>`
365
+ }
366
+ }