@things-factory/figure-ui 10.1.115 → 10.1.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/client/graphql/index.ts +6 -1
  2. package/client/modeller/editor.ts +5 -11
  3. package/client/modeller/fasten-geometry.ts +1 -1
  4. package/client/modeller/figure-links.ts +52 -17
  5. package/client/modeller/joint-axis.ts +36 -0
  6. package/client/modeller/joint-drag.ts +30 -10
  7. package/client/modeller/joint-fan.ts +2 -1
  8. package/client/modeller/links.ts +70 -20
  9. package/client/modeller/recipes.ts +6 -1
  10. package/client/pages/figure-list-page.ts +4 -4
  11. package/client/types.ts +1 -1
  12. package/client/viewparts/detail-motion.ts +11 -0
  13. package/client/viewparts/figure-asset-viewer.ts +177 -0
  14. package/client/viewparts/figure-detail-dock.ts +187 -0
  15. package/dist-client/graphql/index.d.ts +5 -0
  16. package/dist-client/graphql/index.js +5 -1
  17. package/dist-client/graphql/index.js.map +1 -1
  18. package/dist-client/modeller/clips.d.ts +1 -1
  19. package/dist-client/modeller/editor.d.ts +3 -2
  20. package/dist-client/modeller/editor.js +5 -11
  21. package/dist-client/modeller/editor.js.map +1 -1
  22. package/dist-client/modeller/fasten-geometry.js +1 -1
  23. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  24. package/dist-client/modeller/figure-links.d.ts +1 -0
  25. package/dist-client/modeller/figure-links.js +65 -33
  26. package/dist-client/modeller/figure-links.js.map +1 -1
  27. package/dist-client/modeller/joint-axis.d.ts +13 -0
  28. package/dist-client/modeller/joint-axis.js +31 -0
  29. package/dist-client/modeller/joint-axis.js.map +1 -0
  30. package/dist-client/modeller/joint-drag.js +29 -9
  31. package/dist-client/modeller/joint-drag.js.map +1 -1
  32. package/dist-client/modeller/joint-fan.js +2 -1
  33. package/dist-client/modeller/joint-fan.js.map +1 -1
  34. package/dist-client/modeller/links.d.ts +34 -4
  35. package/dist-client/modeller/links.js +59 -18
  36. package/dist-client/modeller/links.js.map +1 -1
  37. package/dist-client/modeller/recipes.js +5 -1
  38. package/dist-client/modeller/recipes.js.map +1 -1
  39. package/dist-client/pages/figure-list-page.d.ts +2 -3
  40. package/dist-client/pages/figure-list-page.js +4 -4
  41. package/dist-client/pages/figure-list-page.js.map +1 -1
  42. package/dist-client/route.d.ts +1 -1
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/types.d.ts +1 -1
  45. package/dist-client/types.js.map +1 -1
  46. package/dist-client/viewparts/detail-motion.d.ts +6 -0
  47. package/dist-client/viewparts/detail-motion.js +9 -0
  48. package/dist-client/viewparts/detail-motion.js.map +1 -0
  49. package/dist-client/viewparts/figure-asset-viewer.d.ts +31 -0
  50. package/dist-client/viewparts/figure-asset-viewer.js +198 -0
  51. package/dist-client/viewparts/figure-asset-viewer.js.map +1 -0
  52. package/dist-client/viewparts/figure-detail-dock.d.ts +36 -0
  53. package/dist-client/viewparts/figure-detail-dock.js +224 -0
  54. package/dist-client/viewparts/figure-detail-dock.js.map +1 -0
  55. package/package.json +6 -6
  56. package/test/browser/detail-page.js +34 -0
  57. package/test/browser/serve-detail.mjs +10 -0
  58. package/test/command-preview.test.ts +2 -2
  59. package/test/detail-motion.test.ts +26 -0
  60. package/test/joint-frames.test.ts +293 -0
  61. package/test/links.test.ts +20 -1
  62. package/test/modeller-route.test.ts +4 -2
  63. package/test/verdict.test.ts +3 -1
  64. package/translations/en.json +20 -2
  65. package/translations/ja.json +20 -2
  66. package/translations/ko.json +20 -2
  67. package/translations/ms.json +20 -2
  68. package/translations/zh.json +20 -2
@@ -16,6 +16,7 @@ import { capacityFrom } from '../modeller/scene-budget.js'
16
16
  import { deleteFigure, fetchFigureList } from '../graphql/index.js'
17
17
  import type { Figure } from '../types.js'
18
18
  import { modellerPathOf } from '../modeller/modeller-route.js'
19
+ import { openFigureDetail } from '../viewparts/figure-detail-dock.js'
19
20
 
20
21
  /**
21
22
  * 이 값 아래면 점수를 색으로 눈에 띄게 한다.
@@ -62,9 +63,8 @@ const FigureListPageBase = p13n(localize(i18next)(PageView))
62
63
  /**
63
64
  * Figure 카탈로그.
64
65
  *
65
- * 이 화면은 저작면으로 가는 문이다. 여기서 오래 머물 화면이 아니므로 꾸미지 않는다. 카드에는
66
- * **고를 때 쓰는 것만** 싣는다 — 그림과 이름, 그리고 아직 초안이라는 것. 배치 한도·성숙도·삼각형
67
- * 수는 고른 뒤에 따지는 값이라 표 모드의 칸으로 남기고 저작면에서 본다.
66
+ * 카드를 고르면 상세 패널에서 저장본과 발행판을 살펴본다. 크기와 움직임을 시험한 뒤
67
+ * 「편집 화면 열기」로 저작면에 들어간다. 목록 질의는 자산 원본을 싣지 않는다.
68
68
  *
69
69
  * ## 왜 손으로 만든 카드가 아니라 격자인가
70
70
  *
@@ -531,7 +531,7 @@ export class FigureListPage extends FigureListPageBase {
531
531
  handlers: {
532
532
  click: (_columns: any, _data: any, _column: any, record: Figure) => {
533
533
  if (record?.id) {
534
- this.openModeller(record)
534
+ openFigureDetail(record)
535
535
  }
536
536
  }
537
537
  }
package/client/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * 화면이 다루는 모양. 도형 자산의 타입은 `@hatiolab/figure-model` 이 정한다 — 여기서 다시 적지 않는다.
3
3
  */
4
4
 
5
- /** 서버가 돌려주는 Figure 한 벌. `source` 는 저작면에서만 받는다. */
5
+ /** 서버가 돌려주는 Figure 한 벌. `source` 는 상세 또는 저작면을 열 때만 받는다. */
6
6
  export interface Figure {
7
7
  id: string
8
8
  /** 컴포넌트 타입 이름. **만든 뒤에 고칠 수 없다.** */
@@ -0,0 +1,11 @@
1
+ import type { FigureAsset } from '@hatiolab/figure-model'
2
+
3
+ /** External values win over drivers; release only the controls owned by the chosen clip. */
4
+ export function parametersForClip(asset: FigureAsset, values: Record<string, number>, clip: string) {
5
+ const driven = new Set((asset.drivers ?? []).filter(d => d.clip === clip).map(d => d.state))
6
+ return Object.fromEntries(Object.entries(values).filter(([id]) => !driven.has(id)))
7
+ }
8
+
9
+ export function clipForControl(asset: FigureAsset, id: string) {
10
+ return asset.drivers?.find(d => d.state === id)?.clip
11
+ }
@@ -0,0 +1,177 @@
1
+ import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
2
+ import { customElement, property, query, state } from 'lit/decorators.js'
3
+ import { i18next, localize } from '@operato/i18n'
4
+ import { compileFigureAsset, FIGURE_INSTANCE_SIZE_INPUTS, type FigureAsset } from '@hatiolab/figure-model'
5
+ import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
6
+ import { authoredSizeOf } from '../modeller/preview-model.js'
7
+ import { controlsOf } from '../modeller/controls.js'
8
+ import { DEFAULT_VIEW, previewBoardDepth, sceneLook } from '../modeller/figure-view.js'
9
+ import { clipForControl, parametersForClip } from './detail-motion.js'
10
+
11
+ type Axis = 'x' | 'y' | 'z'
12
+ interface Scene {
13
+ dispose?(): void
14
+ resize?(): void
15
+ fit?(type?: string): void
16
+ root?: {
17
+ fitCameraToModel?(): void
18
+ components?: Array<{ set(key: string, value: unknown): void }>
19
+ }
20
+ }
21
+
22
+ /** A single board instance. Sizes and poses are transient, and never change the asset or call a mutation. */
23
+ @customElement('figure-asset-viewer')
24
+ export class FigureAssetViewer extends localize(i18next)(LitElement) {
25
+ static styles = css`
26
+ :host { display: block; min-width: 0; }
27
+ [viewport] { height: 300px; position: relative; background: #858585; }
28
+ [stage] { position: absolute; inset: 0; width: 100%; height: 100%; }
29
+ section { padding: 12px 16px; border-bottom: 1px solid var(--md-sys-color-outline-variant); }
30
+ h3 { margin: 0 0 10px; font-size: var(--ui-t-body, 13px); }
31
+ label { display: grid; grid-template-columns: 72px minmax(0, 1fr) 90px; align-items: center; gap: 8px; margin: 8px 0; }
32
+ input { min-width: 0; width: 100%; box-sizing: border-box; }
33
+ input[type=number] { padding: 6px; }
34
+ input, button { font: inherit; color: inherit; }
35
+ button { padding: 8px 12px; border: 1px solid var(--md-sys-color-outline); border-radius: 8px; background: var(--md-sys-color-surface); cursor: pointer; }
36
+ [clips] { display: flex; flex-wrap: wrap; gap: 8px; }
37
+ p { font-size: 12px; line-height: 1.5; }
38
+ [error] { color: var(--md-sys-color-error); }
39
+ `
40
+ @property({ attribute: false }) asset?: FigureAsset
41
+ @query('[stage]') private stage!: HTMLElement
42
+ @state() private sizes: Record<Axis, number> = { x: 1, y: 1, z: 1 }
43
+ @state() private values: Record<string, number> = {}
44
+ @state() private playing = ''
45
+ @state() private error = ''
46
+ private scene?: Scene
47
+ private registered?: string
48
+ private observer?: ResizeObserver
49
+ private needsScene = false
50
+ private played = new Set<string>()
51
+
52
+ connectedCallback() {
53
+ super.connectedCallback()
54
+ this.needsScene = true
55
+ if (this.hasUpdated) this.observer?.observe(this.stage)
56
+ this.requestUpdate()
57
+ }
58
+
59
+ protected updated(changed: PropertyValues) {
60
+ // Reconnection and a new asset can arrive in the same update. Build exactly once.
61
+ if (changed.has('asset') || this.needsScene) {
62
+ this.needsScene = false
63
+ this.standUp()
64
+ }
65
+ }
66
+ firstUpdated() {
67
+ this.observer = new ResizeObserver(() => this.scene?.resize?.())
68
+ this.observer.observe(this.stage)
69
+ }
70
+ disconnectedCallback() {
71
+ super.disconnectedCallback()
72
+ this.observer?.disconnect()
73
+ this.teardown()
74
+ }
75
+
76
+ render() {
77
+ const asset = this.asset
78
+ return html`<div viewport><div stage></div></div>
79
+ ${this.error ? html`<p error role="alert">${this.error}</p>` : nothing}
80
+ ${asset ? html`<section>
81
+ <h3>${i18next.t('figure.label.detail-size')}</h3>
82
+ ${(['x', 'y', 'z'] as const).map(axis => {
83
+ const input = asset.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
84
+ return html`<label><span>${axis.toUpperCase()} · mm</span>
85
+ <input type="range" aria-label=${`${axis.toUpperCase()} mm`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
86
+ .value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
87
+ @input=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
88
+ <input type="number" aria-label=${`${axis.toUpperCase()} mm value`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
89
+ .value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
90
+ @change=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
91
+ </label>`
92
+ })}
93
+ <button @click=${() => this.reset()}>${i18next.t('figure.button.detail-reset')}</button>
94
+ <p>${i18next.t('figure.text.detail-transient')}</p>
95
+ </section>
96
+ <section><h3>${i18next.t('figure.label.motion')}</h3>
97
+ ${controlsOf(asset, this.values).map(c => html`<label><span>${c.label} · ${c.unit}</span>
98
+ <input type="range" aria-label=${c.label} min=${c.min} max=${c.max} step=${c.step || 'any'} .value=${String(c.value)}
99
+ ?disabled=${!!this.playing && clipForControl(asset, c.id) === this.playing}
100
+ @input=${(e: Event) => this.pose(c.id, (e.target as HTMLInputElement).valueAsNumber)}>
101
+ <output>${this.playing && clipForControl(asset, c.id) === this.playing ? i18next.t('figure.text.detail-playing')
102
+ : this.played.has(clipForControl(asset, c.id) ?? '') && !(c.id in this.values) ? i18next.t('figure.text.detail-paused') : Math.round(c.value * 100) / 100}</output></label>`)}
103
+ <div clips>${[...new Set((asset.drivers ?? []).map(d => d.clip))].map(name => html`<button aria-pressed=${String(this.playing === name)}
104
+ @click=${() => this.play(name)}>${this.playing === name ? '⏸' : '▶'} ${name}</button>`)}</div>
105
+ ${!controlsOf(asset).length && ![...new Set((asset.drivers ?? []).map(d => d.clip))].length ? html`<p>${i18next.t('figure.text.detail-no-motion')}</p>` : nothing}
106
+ </section>` : nothing}`
107
+ }
108
+
109
+ private tell(key: string, value: unknown) {
110
+ this.scene?.root?.components?.[0]?.set(key, value)
111
+ }
112
+ private resize(axis: Axis, value: number) {
113
+ const input = this.asset?.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
114
+ if (!input || !Number.isFinite(value)) { this.requestUpdate(); return }
115
+ const used = Math.min(input.max, Math.max(input.min, value))
116
+ this.sizes = { ...this.sizes, [axis]: used }
117
+ this.tell(axis === 'x' ? 'width' : axis === 'y' ? 'depth' : 'height', used)
118
+ this.scene?.root?.fitCameraToModel?.()
119
+ }
120
+ private pose(id: string, value: number) {
121
+ if (!Number.isFinite(value)) return
122
+ this.values = { ...this.values, [id]: value }
123
+ this.tell('parameters', this.values)
124
+ }
125
+ private play(name: string) {
126
+ this.playing = this.playing === name ? '' : name
127
+ if (this.playing && this.asset) {
128
+ this.played.add(name)
129
+ this.values = parametersForClip(this.asset, this.values, name)
130
+ this.tell('parameters', this.values)
131
+ }
132
+ this.tell('clips', Object.fromEntries([...new Set((this.asset?.drivers ?? []).map(d => d.clip))].map(key => [key, key === this.playing ? 1 : 0])))
133
+ }
134
+ private reset() {
135
+ if (!this.asset) return
136
+ // A new instance also resets driver clocks. Clearing speeds alone freezes the last pose.
137
+ this.standUp()
138
+ }
139
+ private standUp() {
140
+ this.teardown()
141
+ this.error = ''
142
+ const asset = this.asset
143
+ if (!asset) return
144
+ this.values = {}
145
+ this.playing = ''
146
+ this.played.clear()
147
+ try {
148
+ compileFigureAsset(asset)
149
+ if (!asset.appearance.length) throw Error(i18next.t('figure.text.preview-needs-a-part'))
150
+ this.sizes = authoredSizeOf(asset)
151
+ const type = `figure-detail-${crypto.randomUUID()}`
152
+ registerFigure(type, asset)
153
+ this.registered = type
154
+ const size = this.sizes
155
+ this.scene = create({ target: this.stage, mode: 0, model: {
156
+ type: 'model-layer', width: size.x * 2, height: size.z * 2,
157
+ depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, DEFAULT_VIEW.ceilingHeight, 1),
158
+ threed: true, sky: 'color', ...sceneLook(DEFAULT_VIEW),
159
+ placement: DEFAULT_VIEW.placement, heights: { floor: 0, ceiling: DEFAULT_VIEW.ceilingHeight },
160
+ pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
161
+ components: [{ type, id: 'detail-instance', left: size.x / 2, top: size.z / 2, width: size.x, height: size.z, depth: size.y }]
162
+ } }) as Scene
163
+ this.scene.resize?.()
164
+ this.scene.fit?.('ratio')
165
+ this.scene.root?.fitCameraToModel?.()
166
+ } catch (error) {
167
+ this.error = (error as Error).message
168
+ this.teardown()
169
+ }
170
+ }
171
+ private teardown() {
172
+ this.scene?.dispose?.()
173
+ this.scene = undefined
174
+ if (this.registered) unregisterFigure(this.registered)
175
+ this.registered = undefined
176
+ }
177
+ }
@@ -0,0 +1,187 @@
1
+ import { css, html, LitElement, nothing } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import { i18next, localize } from '@operato/i18n'
4
+ import { appendViewpart, UPDATE_VIEWPART, VIEWPART_POSITION, VIEWPART_LEVEL } from '@operato/layout'
5
+ import { navigate, store } from '@operato/shell'
6
+ import { ASSISTANT_DOCK_VIEWPART } from '@things-factory/ai-assistant/dist-client/components/assistant-dock.js'
7
+ import { figureCostOf, parseFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
8
+ import { capacityFrom } from '../modeller/scene-budget.js'
9
+ import { fetchFigure, fetchReleasedFigure } from '../graphql/index.js'
10
+ import { modellerPathOf } from '../modeller/modeller-route.js'
11
+ import type { Figure } from '../types.js'
12
+ import './figure-asset-viewer.js'
13
+
14
+ const VIEWPART = 'figure-detail'
15
+ export interface FigureDetailRecord {
16
+ saved: Figure
17
+ released?: { version: number; source: string }
18
+ }
19
+
20
+ /** Catalogue details outlive the page; choosing a size or a pose has no write path. */
21
+ @customElement('figure-detail-dock')
22
+ export class FigureDetailDock extends localize(i18next)(LitElement) {
23
+ static styles = css`
24
+ :host { display: flex; flex-direction: column; width: 440px; max-width: 100vw; height: 100%; min-height: 0;
25
+ color: var(--md-sys-color-on-surface); background: var(--md-sys-color-surface); border-left: 1px solid var(--md-sys-color-outline-variant); font-size: 13px; }
26
+ header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--md-sys-color-outline-variant); }
27
+ h2 { margin: 0; font-size: var(--ui-t-title, 16px); }
28
+ button, select { font: inherit; color: inherit; padding: 8px 12px; border: 1px solid var(--md-sys-color-outline); border-radius: 8px; background: var(--md-sys-color-surface); cursor: pointer; }
29
+ [body] { flex: 1; overflow: auto; min-height: 0; }
30
+ [meta], [status] { padding: 12px 16px; }
31
+ [meta] { display: grid; gap: 8px; }
32
+ [summary] { padding: 12px 16px; border-bottom: 1px solid var(--md-sys-color-outline-variant); }
33
+ h3 { margin: 0 0 8px; font-size: var(--ui-t-body, 13px); }
34
+ dl { display: grid; grid-template-columns: 1fr auto; gap: 6px 12px; margin: 0; }
35
+ dd { margin: 0; font-variant-numeric: tabular-nums; }
36
+ p { margin: 0; line-height: 1.5; }
37
+ code { color: var(--md-sys-color-on-surface-variant); }
38
+ footer { padding: 12px 16px; border-top: 1px solid var(--md-sys-color-outline-variant); }
39
+ footer button { width: 100%; background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); }
40
+ @media (max-width: 1439.98px) {
41
+ :host { position: fixed; inset: var(--figure-detail-top, 44px) 0 0 auto; height: calc(100dvh - var(--figure-detail-top, 44px)); z-index: 11; box-shadow: -4px 0 16px rgb(0 0 0 / 20%); }
42
+ }
43
+ `
44
+ @property({ attribute: false }) load: (id: string) => Promise<FigureDetailRecord> = async id => {
45
+ const [saved, released] = await Promise.all([fetchFigure(id), fetchReleasedFigure(id)])
46
+ if (!saved?.source) throw Error(i18next.t('figure.text.detail-no-source'))
47
+ return { saved, released }
48
+ }
49
+ @state() private figureId = ''
50
+ @state() private record?: FigureDetailRecord
51
+ @state() private asset?: FigureAsset
52
+ @state() private sourceKind: 'saved' | 'released' = 'saved'
53
+ @state() private loading = false
54
+ @state() private error = ''
55
+ @state() private cost?: ReturnType<typeof figureCostOf>
56
+ private ticket = 0
57
+ private unsubscribe?: () => void
58
+ private boundary?: Element
59
+ private boundaryObserver?: ResizeObserver
60
+
61
+ async show(id: string) {
62
+ const ticket = ++this.ticket
63
+ this.figureId = id
64
+ this.record = undefined
65
+ this.asset = undefined
66
+ this.cost = undefined
67
+ this.loading = true
68
+ this.error = ''
69
+ try {
70
+ const record = await this.load(id)
71
+ if (ticket !== this.ticket) return
72
+ this.record = record
73
+ this.choose(record.released ? 'released' : 'saved')
74
+ } catch {
75
+ if (ticket === this.ticket) this.error = i18next.t('figure.text.detail-load-failed')
76
+ } finally {
77
+ if (ticket === this.ticket) this.loading = false
78
+ }
79
+ }
80
+ private choose(kind: 'saved' | 'released') {
81
+ this.sourceKind = kind
82
+ this.asset = undefined
83
+ this.cost = undefined
84
+ this.error = ''
85
+ try {
86
+ const source = kind === 'released' ? this.record?.released?.source : this.record?.saved.source
87
+ if (!source) throw Error('missing source')
88
+ const asset = parseFigureAsset(source)
89
+ this.cost = figureCostOf(asset)
90
+ this.asset = asset
91
+ } catch {
92
+ this.error = i18next.t('figure.text.detail-invalid-source')
93
+ }
94
+ }
95
+ connectedCallback() {
96
+ super.connectedCallback()
97
+ window.addEventListener('resize', this.place)
98
+ window.addEventListener('popstate', this.place)
99
+ this.unsubscribe = store.subscribe(() => {
100
+ const state = store.getState() as { layout?: { viewparts?: Record<string, { show?: boolean }> } }
101
+ const parts = state.layout?.viewparts
102
+ if (parts?.[ASSISTANT_DOCK_VIEWPART]?.show && parts?.[VIEWPART]?.show) hideDetail()
103
+ else requestAnimationFrame(this.place)
104
+ })
105
+ this.place()
106
+ window.addEventListener('keydown', this.escape)
107
+ }
108
+ disconnectedCallback() {
109
+ super.disconnectedCallback()
110
+ window.removeEventListener('resize', this.place)
111
+ window.removeEventListener('popstate', this.place)
112
+ window.removeEventListener('keydown', this.escape)
113
+ this.unsubscribe?.()
114
+ this.boundaryObserver?.disconnect()
115
+ this.boundary = undefined
116
+ }
117
+ private place = () => {
118
+ const bar = visibleBar(document)
119
+ if (bar !== this.boundary) {
120
+ this.boundaryObserver?.disconnect()
121
+ this.boundary = bar
122
+ if (bar) {
123
+ this.boundaryObserver = new ResizeObserver(this.place)
124
+ this.boundaryObserver.observe(bar)
125
+ }
126
+ }
127
+ this.style.setProperty('--figure-detail-top', `${bar?.getBoundingClientRect().bottom ?? 44}px`)
128
+ }
129
+ private escape = (e: KeyboardEvent) => {
130
+ if (e.key === 'Escape') hideDetail()
131
+ }
132
+ render() {
133
+ const saved = this.record?.saved
134
+ const cost = this.cost
135
+ const capacity = cost ? capacityFrom(cost.groups, cost.triangles) : undefined
136
+ return html`<header><h2>${saved?.name || i18next.t('figure.title.detail')}</h2>
137
+ <button aria-label=${i18next.t('figure.button.close')} @click=${() => hideDetail()}>×</button></header>
138
+ <div body>
139
+ ${this.loading ? html`<p status role="status">${i18next.t('figure.text.detail-loading')}</p>` : nothing}
140
+ ${this.error ? html`<div status role="alert"><p>${this.error}</p><button @click=${() => this.show(this.figureId)}>${i18next.t('figure.button.detail-retry')}</button></div>` : nothing}
141
+ ${saved ? html`<section meta><code>${saved.type}</code><p>${saved.description || ''}</p>
142
+ <label>${i18next.t('figure.label.detail-source')}
143
+ <select aria-label=${i18next.t('figure.label.detail-source')} .value=${this.sourceKind}
144
+ @change=${(e: Event) => this.choose((e.target as HTMLSelectElement).value as 'saved' | 'released')}>
145
+ ${this.record?.released ? html`<option value="released">${i18next.t('figure.label.detail-released', { version: this.record.released.version })}</option>` : nothing}
146
+ <option value="saved">${i18next.t('figure.label.detail-saved')} · ${i18next.t(saved.state === 'released' ? 'figure.label.figure-released' : 'figure.label.draft')}</option>
147
+ </select></label>
148
+ ${!this.record?.released ? html`<p>${i18next.t('figure.text.detail-unreleased')}</p>` : nothing}
149
+ </section>` : nothing}
150
+ ${this.asset ? html`<figure-asset-viewer .asset=${this.asset}></figure-asset-viewer>
151
+ <section summary><h3>${i18next.t('figure.label.detail-summary')}</h3><dl>
152
+ <dt>${i18next.t('figure.label.budget-parts')}</dt><dd>${this.asset.appearance.length}</dd>
153
+ <dt>${i18next.t('figure.label.budget-groups')}</dt><dd>${cost?.groups ?? '—'}</dd>
154
+ <dt>${i18next.t('figure.label.triangles')}</dt><dd>${cost?.triangles ?? '—'}</dd>
155
+ </dl>${capacity ? html`<p>${i18next.t('figure.text.parts-budget', { triangles: cost!.triangles, groups: cost!.groups, instances: capacity.instances })}</p>` : nothing}
156
+ </section>` : nothing}
157
+ </div>
158
+ <footer><button ?disabled=${!saved} @click=${() => {
159
+ navigate(modellerPathOf(saved))
160
+ requestAnimationFrame(() => requestAnimationFrame(this.place))
161
+ }}>${i18next.t('figure.button.detail-edit')}</button></footer>`
162
+ }
163
+ }
164
+
165
+ function visibleBar(root: Document | ShadowRoot): Element | undefined {
166
+ for (const element of root.querySelectorAll('*')) {
167
+ if (element.tagName === 'OX-CANVAS-BAR' && element.getBoundingClientRect().height > 0) return element
168
+ if (element.shadowRoot) {
169
+ const found = visibleBar(element.shadowRoot)
170
+ if (found) return found
171
+ }
172
+ }
173
+ }
174
+
175
+ let dock: FigureDetailDock | undefined
176
+ function hideDetail() {
177
+ store.dispatch({ type: UPDATE_VIEWPART, name: VIEWPART, override: { show: false } })
178
+ }
179
+ export function openFigureDetail(figure: Figure) {
180
+ if (!dock) {
181
+ dock = document.createElement('figure-detail-dock') as FigureDetailDock
182
+ }
183
+ store.dispatch({ type: UPDATE_VIEWPART, name: ASSISTANT_DOCK_VIEWPART, override: { show: false } })
184
+ appendViewpart({ name: VIEWPART, position: VIEWPART_POSITION.ASIDEBAR,
185
+ viewpart: { show: true, persistent: true, level: VIEWPART_LEVEL.TOPMOST, template: html`${dock}` } })
186
+ void dock.show(figure.id)
187
+ }
@@ -101,6 +101,11 @@ export declare function inspectFigureDraft(source: string, id?: string): Promise
101
101
  * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
102
102
  */
103
103
  export declare function fetchReleasedSource(id: string): Promise<string | undefined>;
104
+ /** Read-only catalogue preview: source and version come from the same released snapshot. */
105
+ export declare function fetchReleasedFigure(id: string): Promise<{
106
+ version: number;
107
+ source: string;
108
+ } | undefined>;
104
109
  export declare function fetchFigureVersions(id: string): Promise<FigureVersion[]>;
105
110
  /** A recipe template as the server gives it (ADR-0095, amended): words in the language asked, the recipe as JSON. */
106
111
  export interface RecipeTemplateRow {
@@ -302,6 +302,10 @@ export async function inspectFigureDraft(source, id) {
302
302
  * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
303
303
  */
304
304
  export async function fetchReleasedSource(id) {
305
+ return (await fetchReleasedFigure(id))?.source;
306
+ }
307
+ /** Read-only catalogue preview: source and version come from the same released snapshot. */
308
+ export async function fetchReleasedFigure(id) {
305
309
  const response = await client.query({
306
310
  query: gql `
307
311
  query FigureReleased($id: String!) {
@@ -314,7 +318,7 @@ export async function fetchReleasedSource(id) {
314
318
  variables: { id },
315
319
  fetchPolicy: 'network-only'
316
320
  });
317
- return unwrap(response, 'figureReleased')?.source ?? undefined;
321
+ return unwrap(response, 'figureReleased') ?? undefined;
318
322
  }
319
323
  export async function fetchFigureVersions(id) {
320
324
  const response = await client.query({
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,aAAa,CAAA;AAC7B,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAEzC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAYjD;;;;;;;;;;GAUG;AACH,SAAS,MAAM,CAAI,QAAqF,EAAE,KAAa;IACrH,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,CAAA;IAC1C,IAAI,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,CAAA;IAE/B,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;QACnB,oGAAoG;QACpG,OAAO,CAAC,KAAK,CAAC,+CAA+C,KAAK,EAAE,CAAC,CAAA;QACrE,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,qCAAqC,CAAC,CAAC,CAAA;IACnE,CAAC;IAED,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAM,CAAA;AAClC,CAAC;AAGD;;;;;;;GAOG;AACH,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;CAe1B,CAAA;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,MAKrC;IACC,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;oBAGM,kBAAkB;;;;KAIjC;QACD,SAAS,EAAE;YACT,OAAO,EAAE,MAAM,CAAC,OAAO,IAAI,EAAE;YAC7B,UAAU,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,EAAE,EAAE;YACjE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;SAC1F;QACD,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAmB,QAAQ,EAAE,SAAS,CAAC,CAAA;AACtD,CAAC;AAED,qCAAqC;AACrC,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,EAAU;IAC1C,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;YAGF,kBAAkB;;;;;KAKzB;QACD,SAAS,EAAE,EAAE,EAAE,EAAE;QACjB,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAS,QAAQ,EAAE,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB;IACxC,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;KAIT;QACD,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAkB,QAAQ,EAAE,iBAAiB,CAAC,IAAI,EAAE,CAAA;AACnE,CAAC;AAQD,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,MAAiB;IAClD,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC;QACnC,QAAQ,EAAE,GAAG,CAAA;;;qBAGI,kBAAkB;;;;KAIlC;QACD,SAAS,EAAE,EAAE,MAAM,EAAE;KACtB,CAAC,CAAA;IAEF,OAAO,MAAM,CAAa,QAAQ,EAAE,cAAc,CAAC,CAAA;AACrD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,EAAU,EAAE,KAAkB;IAC/D,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC;QACnC,QAAQ,EAAE,GAAG,CAAA;;;qBAGI,kBAAkB;;;;KAIlC;QACD,SAAS,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE;KACzB,CAAC,CAAA;IAEF,OAAO,MAAM,CAAa,QAAQ,EAAE,cAAc,CAAC,CAAA;AACrD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,EAAU;IAC3C,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC;QACnC,QAAQ,EAAE,GAAG,CAAA;;;;KAIZ;QACD,SAAS,EAAE,EAAE,EAAE,EAAE;KAClB,CAAC,CAAA;IAEF,OAAO,MAAM,CAAU,QAAQ,EAAE,cAAc,CAAC,CAAA;AAClD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,EAAU,EAAE,OAAgB;IAC9D,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC;QACnC,QAAQ,EAAE,GAAG,CAAA;;;;;;;;;KASZ;QACD,SAAS,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE;KAC3B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAS,QAAQ,EAAE,eAAe,CAAC,CAAA;AAClD,CAAC;AAcD,MAAM,WAAW,GAAG,+EAA+E,CAAA;AAEnG;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,EAAU,EACV,OAAe,EACf,SAAiB,EACjB,cAAsB;IAEtB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,MAAM,CAAC,MAAM,CAAC;QACZ,QAAQ,EAAE,GAAG,CAAA;;;uBAGI,kBAAkB;;qBAEpB,WAAW;;;OAGzB;QACD,SAAS,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE;KACtD,CAAC,EACF,oBAAoB,CACrB,CAAA;IACD,SAAS,CAAC,QAAQ,CAAC,CAAA;IACnB,OAAO,MAAM,CAAmC,QAAQ,EAAE,qBAAqB,CAAC,CAAA;AAClF,CAAC;AAED,kEAAkE;AAClE,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,MAAc,EAAE,SAAiB;IACtE,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,MAAM,CAAC,MAAM,CAAC;QACZ,QAAQ,EAAE,GAAG,CAAA;;;uBAGI,kBAAkB;;qBAEpB,WAAW;;;OAGzB;QACD,SAAS,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE;KACjC,CAAC,EACF,oBAAoB,CACrB,CAAA;IACD,SAAS,CAAC,QAAQ,CAAC,CAAA;IACnB,OAAO,MAAM,CAAmC,QAAQ,EAAE,kBAAkB,CAAC,CAAA;AAC/E,CAAC;AAUD;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,WAAmB;IAC5D,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;KAIT;QACD,SAAS,EAAE,EAAE,WAAW,EAAE;QAC1B,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IACF,OAAO,MAAM,CAAoB,QAAQ,EAAE,uBAAuB,CAAC,IAAI,EAAE,CAAA;AAC3E,CAAC;AAED,8CAA8C;AAC9C,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,EAAU;IAClD,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;0CAE4B,WAAW;;KAEhD;QACD,SAAS,EAAE,EAAE,EAAE,EAAE;QACjB,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IACF,OAAO,MAAM,CAAc,QAAQ,EAAE,sBAAsB,CAAC,CAAA;AAC9D,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,EAAU;IAC5C,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;;;;;;KAcT;QACD,SAAS,EAAE,EAAE,EAAE,EAAE;QACjB,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAmB,QAAQ,EAAE,eAAe,CAAC,CAAA;AAC5D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,MAAc,EAAE,EAAW;IAClE,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;;;;;;KAcT;QACD,SAAS,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE;QACzB,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAmB,QAAQ,EAAE,oBAAoB,CAAC,CAAA;AACjE,CAAC;AAED,2BAA2B;AAC3B;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,EAAU;IAClD,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;KAOT;QACD,SAAS,EAAE,EAAE,EAAE,EAAE;QACjB,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAsB,QAAQ,EAAE,gBAAgB,CAAC,EAAE,MAAM,IAAI,SAAS,CAAA;AACrF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,EAAU;IAClD,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;;;;;KAaT;QACD,SAAS,EAAE,EAAE,EAAE,EAAE;QACjB,WAAW,EAAE,cAAc;KAC5B,CAAC,CAAA;IAEF,OAAO,MAAM,CAAkB,QAAQ,EAAE,gBAAgB,CAAC,IAAI,EAAE,CAAA;AAClE,CAAC;AAcD,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,IAAY;IACrD,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;;;;;KAaT;QACD,SAAS,EAAE,EAAE,IAAI,EAAE;KACpB,CAAC,CAAA;IACF,OAAO,MAAM,CAA6B,QAAQ,EAAE,uBAAuB,CAAC,IAAI,EAAE,CAAA;AACpF,CAAC;AAED,+EAA+E;AAC/E,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,IAAY,EAAE,IAAY;IAC/D,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;;;KAMT;QACD,SAAS,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE;KAC1B,CAAC,CAAA;IACF,OAAO,MAAM,CAA4B,QAAQ,EAAE,sBAAsB,CAAC,EAAE,KAAK,IAAI,EAAE,CAAA;AACzF,CAAC","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport { reached, refusedIn } from './reached.js'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** A draft a revert kept when it wrote over it (ADR-0097 §5.6). Not a released version. */\nexport interface KeptDraft {\n id: string\n kind: 'revert' | 'undo'\n state?: string\n version?: number\n revertedToVersion?: number\n restoredAt?: string\n createdAt?: string\n creator?: { name?: string }\n}\n\nconst KEPT_FIELDS = `id kind state version revertedToVersion restoredAt createdAt creator { name }`\n\n/**\n * 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 덮이는 초안은 같은 트랜잭션에서 서버가\n * 보관한다. `expectedSource` 는 서버가 들고 있다고 확인된 정본 -- 그 사이 바뀌었으면 거절된다. 답을 못 받았으면\n * (REVERT_UNREACHABLE) 같은 `requestId` 로 다시 묻는다: 서버는 같은 되돌리기로 답하고 두 번 하지 않는다.\n */\nexport async function revertFigureVersion(\n id: string,\n version: number,\n requestId: string,\n expectedSource: string\n): Promise<SaveResult & { kept: KeptDraft }> {\n const response = await reached(\n client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!, $requestId: String!, $expectedSource: String!) {\n revertFigureVersion(id: $id, version: $version, requestId: $requestId, expectedSource: $expectedSource) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n kept { ${KEPT_FIELDS} }\n }\n }\n `,\n variables: { id, version, requestId, expectedSource }\n }),\n 'REVERT_UNREACHABLE'\n )\n refusedIn(response)\n return unwrap<SaveResult & { kept: KeptDraft }>(response, 'revertFigureVersion')\n}\n\n/** 되돌리기 취소 -- 보관한 초안을 되살린다. 되돌린 뒤 모델이 바뀌었으면 서버가 거절하고 보관본은 남는다. */\nexport async function undoFigureRevert(keptId: string, requestId: string): Promise<SaveResult & { kept: KeptDraft }> {\n const response = await reached(\n client.mutate({\n mutation: gql`\n mutation UndoFigureRevert($keptId: String!, $requestId: String!) {\n undoFigureRevert(keptId: $keptId, requestId: $requestId) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n kept { ${KEPT_FIELDS} }\n }\n }\n `,\n variables: { keptId, requestId }\n }),\n 'REVERT_UNREACHABLE'\n )\n refusedIn(response)\n return unwrap<SaveResult & { kept: KeptDraft }>(response, 'undoFigureRevert')\n}\n\n/** A figure an AI proposal was saved into (ADR-0097 §5.6 item 5). */\nexport interface ProposalOutcome {\n figureId: string\n type?: string\n name?: string\n recordedAt?: string\n}\n\n/**\n * 제안이 무엇이 됐나 -- 그 제안을 담은 저장이 들어간 도형들. 비었으면 저장되지 않았다(적용만 하고 저장 전이거나, 되돌렸거나,\n * 적용하지 않았다). 도크의 「결과 확인」이 이것을 묻는다: 도크의 기억이 아니라 서버의 기록이다.\n */\nexport async function fetchProposalOutcome(proposalKey: string): Promise<ProposalOutcome[]> {\n const response = await client.query({\n query: gql`\n query FigureProposalOutcome($proposalKey: String!) {\n figureProposalOutcome(proposalKey: $proposalKey) { figureId type name recordedAt }\n }\n `,\n variables: { proposalKey },\n fetchPolicy: 'network-only'\n })\n return unwrap<ProposalOutcome[]>(response, 'figureProposalOutcome') ?? []\n}\n\n/** 되돌리기가 보관한 초안들, 새것부터 -- 다시 접속해도 여기서 찾는다. */\nexport async function fetchReplacedDrafts(id: string): Promise<KeptDraft[]> {\n const response = await client.query({\n query: gql`\n query FigureReplacedDrafts($id: String!) {\n figureReplacedDrafts(id: $id) { ${KEPT_FIELDS} }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n return unwrap<KeptDraft[]>(response, 'figureReplacedDrafts')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/**\n * 편집 중인 원본의 발행 판정 — 저장하지 않은 것을 잰다.\n *\n * 발행 패널의 `inspectFigure` 는 저장된 원본을 재므로 고친 결과를 저장한 뒤에야 보여 준다. 편집하면서 막히는\n * 것을 보려면 편집 중인 원본을 보내야 한다. 판정은 서버의 같은 문(`inspectAnySource`)이 하고 문장도 같다 —\n * 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행판을 읽는다.\n */\nexport async function inspectFigureDraft(source: string, id?: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigureDraft($source: String!, $id: String) {\n inspectFigureDraft(source: $source, id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { source, id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigureDraft')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\n/**\n * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.\n *\n * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장\n * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.\n */\nexport async function fetchReleasedSource(id: string): Promise<string | undefined> {\n const response = await client.query({\n query: gql`\n query FigureReleased($id: String!) {\n figureReleased(id: $id) {\n version\n source\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined\n}\n\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n\n/** A recipe template as the server gives it (ADR-0095, amended): words in the language asked, the recipe as JSON. */\nexport interface RecipeTemplateRow {\n name: string\n category: string\n tags: string[]\n target: 'part' | 'figure'\n title: string\n description: string\n source: string\n hasThumbnail: boolean\n}\n\nexport async function fetchRecipeTemplates(lang: string): Promise<RecipeTemplateRow[]> {\n const response = await client.query({\n query: gql`\n query ($lang: String) {\n figureRecipeTemplates(lang: $lang) {\n name\n category\n tags\n target\n title\n description\n source\n hasThumbnail\n }\n }\n `,\n variables: { lang }\n })\n return unwrap<RecipeTemplateRow[] | null>(response, 'figureRecipeTemplates') ?? []\n}\n\n/** One template's guide in the language asked, or '' while none is written. */\nexport async function fetchRecipeGuide(name: string, lang: string): Promise<string> {\n const response = await client.query({\n query: gql`\n query ($name: String!, $lang: String) {\n figureRecipeTemplate(name: $name, lang: $lang) {\n guide\n }\n }\n `,\n variables: { name, lang }\n })\n return unwrap<{ guide?: string } | null>(response, 'figureRecipeTemplate')?.guide ?? ''\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { i18next } from '@operato/i18n'\nimport { client } from '@operato/graphql'\n\nimport { reached, refusedIn } from './reached.js'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n /* The field name stays off the screen because the user cannot act on it; it goes to the console. */\n console.error(`[figure-ui] the server returned no data for ${field}`)\n throw new Error(i18next.t('figure.text.server-returned-no-data'))\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** A draft a revert kept when it wrote over it (ADR-0097 §5.6). Not a released version. */\nexport interface KeptDraft {\n id: string\n kind: 'revert' | 'undo'\n state?: string\n version?: number\n revertedToVersion?: number\n restoredAt?: string\n createdAt?: string\n creator?: { name?: string }\n}\n\nconst KEPT_FIELDS = `id kind state version revertedToVersion restoredAt createdAt creator { name }`\n\n/**\n * 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. 덮이는 초안은 같은 트랜잭션에서 서버가\n * 보관한다. `expectedSource` 는 서버가 들고 있다고 확인된 정본 -- 그 사이 바뀌었으면 거절된다. 답을 못 받았으면\n * (REVERT_UNREACHABLE) 같은 `requestId` 로 다시 묻는다: 서버는 같은 되돌리기로 답하고 두 번 하지 않는다.\n */\nexport async function revertFigureVersion(\n id: string,\n version: number,\n requestId: string,\n expectedSource: string\n): Promise<SaveResult & { kept: KeptDraft }> {\n const response = await reached(\n client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!, $requestId: String!, $expectedSource: String!) {\n revertFigureVersion(id: $id, version: $version, requestId: $requestId, expectedSource: $expectedSource) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n kept { ${KEPT_FIELDS} }\n }\n }\n `,\n variables: { id, version, requestId, expectedSource }\n }),\n 'REVERT_UNREACHABLE'\n )\n refusedIn(response)\n return unwrap<SaveResult & { kept: KeptDraft }>(response, 'revertFigureVersion')\n}\n\n/** 되돌리기 취소 -- 보관한 초안을 되살린다. 되돌린 뒤 모델이 바뀌었으면 서버가 거절하고 보관본은 남는다. */\nexport async function undoFigureRevert(keptId: string, requestId: string): Promise<SaveResult & { kept: KeptDraft }> {\n const response = await reached(\n client.mutate({\n mutation: gql`\n mutation UndoFigureRevert($keptId: String!, $requestId: String!) {\n undoFigureRevert(keptId: $keptId, requestId: $requestId) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n kept { ${KEPT_FIELDS} }\n }\n }\n `,\n variables: { keptId, requestId }\n }),\n 'REVERT_UNREACHABLE'\n )\n refusedIn(response)\n return unwrap<SaveResult & { kept: KeptDraft }>(response, 'undoFigureRevert')\n}\n\n/** A figure an AI proposal was saved into (ADR-0097 §5.6 item 5). */\nexport interface ProposalOutcome {\n figureId: string\n type?: string\n name?: string\n recordedAt?: string\n}\n\n/**\n * 제안이 무엇이 됐나 -- 그 제안을 담은 저장이 들어간 도형들. 비었으면 저장되지 않았다(적용만 하고 저장 전이거나, 되돌렸거나,\n * 적용하지 않았다). 도크의 「결과 확인」이 이것을 묻는다: 도크의 기억이 아니라 서버의 기록이다.\n */\nexport async function fetchProposalOutcome(proposalKey: string): Promise<ProposalOutcome[]> {\n const response = await client.query({\n query: gql`\n query FigureProposalOutcome($proposalKey: String!) {\n figureProposalOutcome(proposalKey: $proposalKey) { figureId type name recordedAt }\n }\n `,\n variables: { proposalKey },\n fetchPolicy: 'network-only'\n })\n return unwrap<ProposalOutcome[]>(response, 'figureProposalOutcome') ?? []\n}\n\n/** 되돌리기가 보관한 초안들, 새것부터 -- 다시 접속해도 여기서 찾는다. */\nexport async function fetchReplacedDrafts(id: string): Promise<KeptDraft[]> {\n const response = await client.query({\n query: gql`\n query FigureReplacedDrafts($id: String!) {\n figureReplacedDrafts(id: $id) { ${KEPT_FIELDS} }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n return unwrap<KeptDraft[]>(response, 'figureReplacedDrafts')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/**\n * 편집 중인 원본의 발행 판정 — 저장하지 않은 것을 잰다.\n *\n * 발행 패널의 `inspectFigure` 는 저장된 원본을 재므로 고친 결과를 저장한 뒤에야 보여 준다. 편집하면서 막히는\n * 것을 보려면 편집 중인 원본을 보내야 한다. 판정은 서버의 같은 문(`inspectAnySource`)이 하고 문장도 같다 —\n * 화면이 제 판정을 따로 가지면 두 자리가 다른 말을 한다. `id` 가 있으면 서버가 그 행의 타입과 직전 발행판을 읽는다.\n */\nexport async function inspectFigureDraft(source: string, id?: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigureDraft($source: String!, $id: String) {\n inspectFigureDraft(source: $source, id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { source, id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigureDraft')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\n/**\n * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.\n *\n * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장\n * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.\n */\nexport async function fetchReleasedSource(id: string): Promise<string | undefined> {\n return (await fetchReleasedFigure(id))?.source\n}\n\n/** Read-only catalogue preview: source and version come from the same released snapshot. */\nexport async function fetchReleasedFigure(id: string): Promise<{ version: number; source: string } | undefined> {\n const response = await client.query({\n query: gql`\n query FigureReleased($id: String!) {\n figureReleased(id: $id) {\n version\n source\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<{ version: number; source: string } | undefined>(response, 'figureReleased') ?? undefined\n}\n\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n\n/** A recipe template as the server gives it (ADR-0095, amended): words in the language asked, the recipe as JSON. */\nexport interface RecipeTemplateRow {\n name: string\n category: string\n tags: string[]\n target: 'part' | 'figure'\n title: string\n description: string\n source: string\n hasThumbnail: boolean\n}\n\nexport async function fetchRecipeTemplates(lang: string): Promise<RecipeTemplateRow[]> {\n const response = await client.query({\n query: gql`\n query ($lang: String) {\n figureRecipeTemplates(lang: $lang) {\n name\n category\n tags\n target\n title\n description\n source\n hasThumbnail\n }\n }\n `,\n variables: { lang }\n })\n return unwrap<RecipeTemplateRow[] | null>(response, 'figureRecipeTemplates') ?? []\n}\n\n/** One template's guide in the language asked, or '' while none is written. */\nexport async function fetchRecipeGuide(name: string, lang: string): Promise<string> {\n const response = await client.query({\n query: gql`\n query ($name: String!, $lang: String) {\n figureRecipeTemplate(name: $name, lang: $lang) {\n guide\n }\n }\n `,\n variables: { name, lang }\n })\n return unwrap<{ guide?: string } | null>(response, 'figureRecipeTemplate')?.guide ?? ''\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
  };
@@ -299,8 +299,9 @@ export declare class FigureEditor extends LitElement {
299
299
  private renderTab;
300
300
  updated(changed: PropertyValues<this>): void;
301
301
  /**
302
- * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
303
- * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
302
+ * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape
303
+ * or an AI draft when the page offers one. Recipes require a host part and are reached from the parts panel after
304
+ * that part exists. Nothing is added until one is chosen; the type, the space, the
304
305
  * mounting face and the tags are not asked for first.
305
306
  */
306
307
  /** The section editor over the canvas, the figure behind it drawing what the working points make. */
@@ -37,7 +37,7 @@ import { END_FACE } from './section-controls.js';
37
37
  import { nudgeOf, placementOf } from './stage-model.js';
38
38
  import { roomRefitRowsOf } from './occupancy.js';
39
39
  import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js';
40
- import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
40
+ import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
41
41
  import { jointFanOf } from './joint-fan.js';
42
42
  import { jointDragOf } from './joint-drag.js';
43
43
  import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './live.js';
@@ -1411,7 +1411,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1411
1411
  /* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
1412
1412
  const fastened = new Map((asset?.joints ?? []).map(j => [
1413
1413
  j.body1.part,
1414
- 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
1414
+ 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`
1415
1415
  ]));
1416
1416
  const rows = new Map();
1417
1417
  for (const g of geometry) {
@@ -1843,8 +1843,9 @@ let FigureEditor = class FigureEditor extends LitElement {
1843
1843
  }
1844
1844
  }
1845
1845
  /**
1846
- * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
1847
- * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
1846
+ * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape
1847
+ * or an AI draft when the page offers one. Recipes require a host part and are reached from the parts panel after
1848
+ * that part exists. Nothing is added until one is chosen; the type, the space, the
1848
1849
  * mounting face and the tags are not asked for first.
1849
1850
  */
1850
1851
  /** The section editor over the canvas, the figure behind it drawing what the working points make. */
@@ -1918,13 +1919,6 @@ let FigureEditor = class FigureEditor extends LitElement {
1918
1919
  </div>
1919
1920
  <small>${i18next.t('figure.text.start-shape-more')}</small>
1920
1921
  </section>
1921
- <section>
1922
- <h3>${i18next.t('figure.label.start-recipe')}</h3>
1923
- <button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
1924
- ${i18next.t('figure.button.start-recipe')}
1925
- </button>
1926
- ${this.templates.length ? nothing : html `<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
1927
- </section>
1928
1922
  ${this.aiDraft
1929
1923
  ? html `<section>
1930
1924
  <h3>${i18next.t('figure.label.start-ai')}</h3>