@things-factory/figure-ui 10.1.69 → 10.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -348,3 +348,76 @@ export async function fetchFigureRecipes(): Promise<Array<{ name: string; source
348
348
  })
349
349
  return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []
350
350
  }
351
+
352
+ /** A recipe row as the management screen reads it: whose it is and whether it is released. */
353
+ export interface FigureRecipeRow {
354
+ name: string
355
+ source: string
356
+ state: 'draft' | 'released'
357
+ updatedAt?: string
358
+ domain?: { id: string; subdomain?: string }
359
+ }
360
+
361
+ /** Every recipe this domain sees, drafts too (ADR-0095). */
362
+ export async function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]> {
363
+ const response = await client.query({
364
+ 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) {
388
+ name
389
+ source
390
+ state
391
+ }
392
+ }
393
+ `,
394
+ variables: { source, state }
395
+ })
396
+ return unwrap<FigureRecipeRow>(response, 'saveFigureRecipe')
397
+ }
398
+
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
405
+ }
406
+ }
407
+ `,
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 }
421
+ })
422
+ unwrap(response, 'deleteFigureRecipe')
423
+ }
@@ -242,11 +242,10 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
242
242
  border-radius: 8px;
243
243
  font-size: 0.75rem;
244
244
  }
245
+ /* The side pane is narrow (200 px, 160 under 860): the name above its field, not beside it. */
245
246
  div[recipe-form] label[field] {
246
247
  display: grid;
247
- grid-template-columns: 1fr 5.5em;
248
- align-items: center;
249
- gap: 6px;
248
+ gap: 2px;
250
249
  }
251
250
  div[recipe-form] input[type='number'],
252
251
  div[recipe-form] select {
@@ -270,6 +269,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
270
269
  gap: 6px;
271
270
  }
272
271
  div[recipe-actions] button {
272
+ white-space: nowrap;
273
273
  display: inline-flex;
274
274
  align-items: center;
275
275
  gap: 4px;
@@ -49,3 +49,21 @@ export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): strin
49
49
  if (param.kind === 'ratio' && typeof value === 'number') return String(Math.round(value * 1e6) / 1e4)
50
50
  return String(value)
51
51
  }
52
+
53
+ /** The domain on screen, from the address (`/domain/<subdomain>/…`); '' when the address names none. */
54
+ export function subdomainOf(pathname: string): string {
55
+ return /^\/domain\/([^/]+)/.exec(pathname)?.[1] ?? ''
56
+ }
57
+
58
+ /** Whose a recipe row is: this domain's own (it may change it) or a parent's (copied in to be changed). */
59
+ export function recipeIsOwn(row: { domain?: { subdomain?: string } }, subdomain: string): boolean {
60
+ return !!subdomain && row.domain?.subdomain === subdomain
61
+ }
62
+
63
+ /** What a parameter takes, in a few words: a range, a percentage range, or its options. */
64
+ export function paramRangeOf(param: V3RecipeParam): string {
65
+ if (param.kind === 'ratio') return `${recipeFieldOf(param, param.min ?? 0)} – ${recipeFieldOf(param, param.max ?? 1)} %`
66
+ if (param.kind === 'mm') return `${param.min ?? ''} – ${param.max ?? ''} mm`
67
+ if (param.kind === 'flag') return ''
68
+ return (param.options ?? []).join(' / ')
69
+ }
@@ -0,0 +1,338 @@
1
+ import '@material/web/icon/icon.js'
2
+ import '../modeller/figure-v3-preview.js'
3
+
4
+ import { css, html, nothing } from 'lit'
5
+ import { customElement, state } from 'lit/decorators.js'
6
+
7
+ import { i18next, localize } from '@operato/i18n'
8
+ import { PageView } from '@operato/shell'
9
+ import { OxPrompt } from '@operato/popup'
10
+ import { ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
11
+ import { applyV3Recipe, parseV3Recipe, v3RecipeTestRoom } from '@hatiolab/figure-model'
12
+ import type { V3Asset, V3Recipe } from '@hatiolab/figure-model'
13
+
14
+ import { deleteFigureRecipe, fetchFigureRecipeRows, saveFigureRecipe, setFigureRecipeState, type FigureRecipeRow } from '../graphql/index.js'
15
+ import { paramRangeOf, recipeFieldOf, recipeIsOwn, recipesOf, recipeWord, subdomainOf } from '../modeller/v3-recipes.js'
16
+
17
+ /**
18
+ * The recipes (ADR-0095): what each one puts in, shown put into a room at several sizes, and whose it is.
19
+ *
20
+ * A recipe of this domain's own can be released, taken back to draft, changed or deleted. A parent's -- the ones the
21
+ * system domain ships -- is copied in under its name to be changed, and the copy stands in for it. Figures a recipe was
22
+ * put into keep their parts whatever happens here.
23
+ */
24
+ @customElement('figure-recipe-page')
25
+ export class FigureRecipePage extends localize(i18next)(PageView) {
26
+ static styles = [
27
+ WorkSurfacePageStyles,
28
+ ScrollbarStyles,
29
+ css`
30
+ :host {
31
+ display: grid;
32
+ grid-template-columns: minmax(220px, 280px) 1fr;
33
+ background-color: var(--md-sys-color-surface);
34
+ color: var(--md-sys-color-on-surface);
35
+ font-size: 0.85rem;
36
+ }
37
+ @container (max-width: 640px) {
38
+ :host {
39
+ grid-template-columns: 1fr;
40
+ }
41
+ }
42
+ nav {
43
+ overflow: auto;
44
+ border-right: 1px solid var(--md-sys-color-outline-variant);
45
+ padding: 8px;
46
+ }
47
+ nav p[intro] {
48
+ margin: 4px 4px 10px;
49
+ color: var(--md-sys-color-on-surface-variant);
50
+ line-height: 1.5;
51
+ }
52
+ button[row] {
53
+ display: grid;
54
+ gap: 2px;
55
+ width: 100%;
56
+ padding: 8px 10px;
57
+ text-align: left;
58
+ font: inherit;
59
+ color: inherit;
60
+ background: none;
61
+ border: none;
62
+ border-radius: 8px;
63
+ cursor: pointer;
64
+ }
65
+ button[row][on] {
66
+ background: var(--md-sys-color-secondary-container);
67
+ color: var(--md-sys-color-on-secondary-container);
68
+ }
69
+ button[row] small {
70
+ display: flex;
71
+ gap: 6px;
72
+ color: var(--md-sys-color-on-surface-variant);
73
+ }
74
+ span[chip] {
75
+ padding: 0 6px;
76
+ border-radius: 999em;
77
+ border: 1px solid var(--md-sys-color-outline-variant);
78
+ font-size: 0.7rem;
79
+ }
80
+ span[chip][draft] {
81
+ color: var(--md-sys-color-tertiary);
82
+ border-color: var(--md-sys-color-tertiary);
83
+ }
84
+ main {
85
+ overflow: auto;
86
+ padding: 12px 16px;
87
+ display: grid;
88
+ gap: 12px;
89
+ align-content: start;
90
+ }
91
+ main h2 {
92
+ margin: 0;
93
+ font-size: var(--ui-t-title, 16px);
94
+ }
95
+ main p {
96
+ margin: 0;
97
+ line-height: 1.5;
98
+ }
99
+ p[muted] {
100
+ color: var(--md-sys-color-on-surface-variant);
101
+ }
102
+ table {
103
+ border-collapse: collapse;
104
+ width: 100%;
105
+ max-width: 720px;
106
+ }
107
+ th,
108
+ td {
109
+ text-align: left;
110
+ padding: 4px 8px;
111
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
112
+ }
113
+ figure-v3-preview {
114
+ height: 420px;
115
+ border: 1px solid var(--md-sys-color-outline-variant);
116
+ border-radius: 8px;
117
+ overflow: hidden;
118
+ }
119
+ div[actions] {
120
+ display: flex;
121
+ flex-wrap: wrap;
122
+ gap: 8px;
123
+ }
124
+ div[actions] button {
125
+ display: inline-flex;
126
+ align-items: center;
127
+ gap: 4px;
128
+ padding: 6px 12px;
129
+ font: inherit;
130
+ color: var(--md-sys-color-on-surface);
131
+ background: none;
132
+ border: 1px solid var(--md-sys-color-outline-variant);
133
+ border-radius: 999em;
134
+ cursor: pointer;
135
+ }
136
+ div[actions] button[primary] {
137
+ color: var(--md-sys-color-on-primary);
138
+ background: var(--md-sys-color-primary);
139
+ border-color: transparent;
140
+ }
141
+ div[actions] button[danger] {
142
+ color: var(--md-sys-color-error);
143
+ border-color: var(--md-sys-color-error);
144
+ }
145
+ p[role='alert'] {
146
+ padding: 8px;
147
+ white-space: pre-wrap;
148
+ color: var(--md-sys-color-on-error-container);
149
+ background: var(--md-sys-color-error-container);
150
+ border-radius: 8px;
151
+ }
152
+ textarea {
153
+ width: 100%;
154
+ min-height: 320px;
155
+ font: 0.78rem/1.4 monospace;
156
+ box-sizing: border-box;
157
+ }
158
+ `
159
+ ]
160
+
161
+ @state() private rows: FigureRecipeRow[] = []
162
+ @state() private chosen = ''
163
+ @state() private error = ''
164
+ @state() private editing = false
165
+ @state() private text = ''
166
+
167
+ get context() {
168
+ return { title: i18next.t('figure.title.recipes') }
169
+ }
170
+
171
+ async pageUpdated(_changes: Record<string, unknown>, _lifecycle: unknown, before: { active?: boolean }) {
172
+ if (this.active && !before?.active) await this.read()
173
+ }
174
+
175
+ private async read() {
176
+ try {
177
+ this.rows = await fetchFigureRecipeRows()
178
+ if (!this.rows.some(r => r.name === this.chosen)) this.chosen = this.rows[0]?.name ?? ''
179
+ this.error = ''
180
+ } catch (error) {
181
+ this.error = error instanceof Error ? error.message : String(error)
182
+ }
183
+ }
184
+
185
+ private get subdomain() {
186
+ return subdomainOf(location.pathname)
187
+ }
188
+
189
+ render() {
190
+ const language = i18next.language || 'en'
191
+ const recipes = recipesOf(this.rows)
192
+ const row = this.rows.find(r => r.name === this.chosen)
193
+ const recipe = recipes.find(r => r.name === this.chosen)
194
+ return html`
195
+ <nav>
196
+ <p intro>${i18next.t('figure.text.recipes-intro')}</p>
197
+ ${this.rows.map(one => {
198
+ const r = recipes.find(x => x.name === one.name)
199
+ return html`<button row ?on=${one.name === this.chosen} @click=${() => this.choose(one.name)}>
200
+ <span>${r ? recipeWord(r.title, language) : one.name}</span>
201
+ <small>
202
+ <span>${one.name}</span>
203
+ ${one.state === 'draft' ? html`<span chip draft>${i18next.t('figure.label.recipe-draft')}</span>` : nothing}
204
+ ${recipeIsOwn(one, this.subdomain) ? nothing : html`<span chip>${i18next.t('figure.label.recipe-inherited')}</span>`}
205
+ </small>
206
+ </button>`
207
+ })}
208
+ </nav>
209
+ <main>
210
+ ${this.error ? html`<p role="alert">${this.error}</p>` : nothing}
211
+ ${row && recipe ? this.renderRecipe(row, recipe, language) : html`<p muted>${i18next.t('figure.text.recipes-empty')}</p>`}
212
+ </main>
213
+ `
214
+ }
215
+
216
+ private renderRecipe(row: FigureRecipeRow, recipe: V3Recipe, language: string) {
217
+ const own = recipeIsOwn(row, this.subdomain)
218
+ return html`
219
+ <h2>${recipeWord(recipe.title, language)}</h2>
220
+ <p>${recipeWord(recipe.description, language)}</p>
221
+ <p muted>
222
+ ${i18next.t(recipe.target === 'part' ? 'figure.text.recipe-target-part' : 'figure.text.recipe-target-figure')} ·
223
+ ${i18next.t(own ? 'figure.text.recipe-own' : 'figure.text.recipe-inherited', { domain: row.domain?.subdomain ?? '' })}
224
+ </p>
225
+ <table>
226
+ <thead>
227
+ <tr>
228
+ <th>${i18next.t('figure.label.recipe-param')}</th>
229
+ <th>${i18next.t('figure.label.recipe-default')}</th>
230
+ <th>${i18next.t('figure.label.recipe-range')}</th>
231
+ </tr>
232
+ </thead>
233
+ <tbody>
234
+ ${recipe.params.map(
235
+ p => html`<tr>
236
+ <td>${recipeWord(p.label, language) || p.name}</td>
237
+ <td>${p.kind === 'flag' ? i18next.t(p.default ? 'figure.label.yes' : 'figure.label.no') : recipeFieldOf(p, p.default)}${p.kind === 'ratio' ? ' %' : ''}</td>
238
+ <td>${paramRangeOf(p)}</td>
239
+ </tr>`
240
+ )}
241
+ </tbody>
242
+ </table>
243
+ ${this.renderPreview(recipe)}
244
+ <div actions>
245
+ ${own
246
+ ? html`${row.state === 'draft'
247
+ ? html`<button primary @click=${() => this.setState(row.name, 'released')}>${i18next.t('figure.button.recipe-release')}</button>`
248
+ : html`<button @click=${() => this.setState(row.name, 'draft')}>${i18next.t('figure.button.recipe-to-draft')}</button>`}
249
+ <button danger @click=${() => this.deleteRecipe(row.name)}><md-icon>delete</md-icon>${i18next.t('figure.button.recipe-delete')}</button>`
250
+ : html`<button primary @click=${() => this.copyIn(row)}>${i18next.t('figure.button.recipe-copy-in')}</button>`}
251
+ <button @click=${() => this.edit(row)}><md-icon>data_object</md-icon>${i18next.t('figure.button.recipe-edit-source')}</button>
252
+ </div>
253
+ ${this.editing
254
+ ? html`<p muted>${i18next.t('figure.text.recipe-edit-source')}</p>
255
+ <textarea .value=${this.text} @input=${(e: Event) => (this.text = (e.target as HTMLTextAreaElement).value)}></textarea>
256
+ <div actions>
257
+ <button primary @click=${() => this.saveText(own ? row.state : 'draft')}>${i18next.t('figure.button.save')}</button>
258
+ <button @click=${() => (this.editing = false)}>${i18next.t('figure.button.close')}</button>
259
+ </div>`
260
+ : nothing}
261
+ `
262
+ }
263
+
264
+ /** The recipe put into the room it is checked in, with its defaults: what it makes, at several sizes. */
265
+ private renderPreview(recipe: V3Recipe) {
266
+ let asset: V3Asset
267
+ try {
268
+ const room = v3RecipeTestRoom()
269
+ asset = applyV3Recipe(room.asset, parseV3Recipe(recipe), recipe.target === 'part' ? { target: room.target } : {}).asset
270
+ } catch (error) {
271
+ return html`<p role="alert">${error instanceof Error ? error.message : String(error)}</p>`
272
+ }
273
+ return html`<p muted>${i18next.t('figure.text.recipe-preview')}</p>
274
+ <figure-v3-preview .asset=${asset} .context=${`recipe:${recipe.name}`}></figure-v3-preview>`
275
+ }
276
+
277
+ private choose(name: string) {
278
+ this.chosen = name
279
+ this.editing = false
280
+ this.error = ''
281
+ }
282
+
283
+ private edit(row: FigureRecipeRow) {
284
+ this.editing = !this.editing
285
+ try {
286
+ this.text = JSON.stringify(JSON.parse(row.source), null, 2)
287
+ } catch {
288
+ this.text = row.source
289
+ }
290
+ }
291
+
292
+ private async run(action: () => Promise<unknown>) {
293
+ try {
294
+ await action()
295
+ this.error = ''
296
+ await this.read()
297
+ } catch (error) {
298
+ this.error = error instanceof Error ? error.message : String(error)
299
+ }
300
+ }
301
+
302
+ private setState(name: string, state: 'draft' | 'released') {
303
+ return this.run(() => setFigureRecipeState(name, state))
304
+ }
305
+
306
+ /* The app's own prompt, as every operato screen asks -- not the browser's confirm box (user, 2026-09-24). */
307
+ private async deleteRecipe(name: string) {
308
+ const sure = await OxPrompt.open({
309
+ type: 'warning',
310
+ title: i18next.t('figure.button.recipe-delete'),
311
+ text: i18next.t('figure.text.recipe-delete-confirm', { name }),
312
+ confirmButton: { text: i18next.t('figure.button.recipe-delete') },
313
+ cancelButton: { text: i18next.t('figure.button.close') }
314
+ })
315
+ if (sure) await this.run(() => deleteFigureRecipe(name))
316
+ }
317
+
318
+ /** A parent's recipe copied into this domain as a draft, to be changed here. */
319
+ private copyIn(row: FigureRecipeRow) {
320
+ return this.run(() => saveFigureRecipe(row.source, 'draft'))
321
+ }
322
+
323
+ /** The source as typed: checked by the server in the test room; a new name is a new recipe. */
324
+ private async saveText(state: 'draft' | 'released') {
325
+ let name = ''
326
+ try {
327
+ name = JSON.parse(this.text).name ?? ''
328
+ } catch (error) {
329
+ this.error = error instanceof Error ? error.message : String(error)
330
+ return
331
+ }
332
+ await this.run(async () => {
333
+ await saveFigureRecipe(this.text, state)
334
+ this.chosen = name
335
+ this.editing = false
336
+ })
337
+ }
338
+ }
package/client/route.ts CHANGED
@@ -4,6 +4,10 @@ export default function route(page: string) {
4
4
  import('./pages/figure-list-page')
5
5
  return page
6
6
 
7
+ case 'figure-recipes':
8
+ import('./pages/figure-recipe-page')
9
+ return page
10
+
7
11
  /*
8
12
  One modeller since ADR-0087 step 5. `figure-v3-modeller` is kept as an alias for links made before the switch.
9
13
  */
@@ -70,3 +70,19 @@ export declare function fetchFigureRecipes(): Promise<Array<{
70
70
  name: string;
71
71
  source: string;
72
72
  }>>;
73
+ /** A recipe row as the management screen reads it: whose it is and whether it is released. */
74
+ export interface FigureRecipeRow {
75
+ name: string;
76
+ source: string;
77
+ state: 'draft' | 'released';
78
+ updatedAt?: string;
79
+ domain?: {
80
+ id: string;
81
+ subdomain?: string;
82
+ };
83
+ }
84
+ /** Every recipe this domain sees, drafts too (ADR-0095). */
85
+ export declare function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]>;
86
+ export declare function saveFigureRecipe(source: string, state: 'draft' | 'released'): Promise<FigureRecipeRow>;
87
+ export declare function setFigureRecipeState(name: string, state: 'draft' | 'released'): Promise<void>;
88
+ export declare function deleteFigureRecipe(name: string): Promise<void>;
@@ -298,4 +298,64 @@ export async function fetchFigureRecipes() {
298
298
  });
299
299
  return unwrap(response, 'figureRecipes') ?? [];
300
300
  }
301
+ /** Every recipe this domain sees, drafts too (ADR-0095). */
302
+ export async function fetchFigureRecipeRows() {
303
+ const response = await client.query({
304
+ query: gql `
305
+ query {
306
+ figureRecipes(all: true) {
307
+ name
308
+ source
309
+ state
310
+ updatedAt
311
+ domain {
312
+ id
313
+ subdomain
314
+ }
315
+ }
316
+ }
317
+ `,
318
+ fetchPolicy: 'network-only'
319
+ });
320
+ return unwrap(response, 'figureRecipes') ?? [];
321
+ }
322
+ export async function saveFigureRecipe(source, state) {
323
+ const response = await client.mutate({
324
+ mutation: gql `
325
+ mutation ($source: String!, $state: String) {
326
+ saveFigureRecipe(source: $source, state: $state) {
327
+ name
328
+ source
329
+ state
330
+ }
331
+ }
332
+ `,
333
+ variables: { source, state }
334
+ });
335
+ return unwrap(response, 'saveFigureRecipe');
336
+ }
337
+ export async function setFigureRecipeState(name, state) {
338
+ const response = await client.mutate({
339
+ mutation: gql `
340
+ mutation ($name: String!, $state: String!) {
341
+ setFigureRecipeState(name: $name, state: $state) {
342
+ name
343
+ }
344
+ }
345
+ `,
346
+ variables: { name, state }
347
+ });
348
+ unwrap(response, 'setFigureRecipeState');
349
+ }
350
+ export async function deleteFigureRecipe(name) {
351
+ const response = await client.mutate({
352
+ mutation: gql `
353
+ mutation ($name: String!) {
354
+ deleteFigureRecipe(name: $name)
355
+ }
356
+ `,
357
+ variables: { name }
358
+ });
359
+ unwrap(response, 'deleteFigureRecipe');
360
+ }
301
361
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
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 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/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\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/** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */\nexport async function fetchFigureRecipes(): Promise<Array<{ name: string; source: string }>> {\n const response = await client.query({\n query: gql`\n query {\n figureRecipes {\n name\n source\n }\n }\n `,\n fetchPolicy: 'network-only'\n })\n return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []\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 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/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\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/** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */\nexport async function fetchFigureRecipes(): Promise<Array<{ name: string; source: string }>> {\n const response = await client.query({\n query: gql`\n query {\n figureRecipes {\n name\n source\n }\n }\n `,\n fetchPolicy: 'network-only'\n })\n return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []\n}\n\n/** A recipe row as the management screen reads it: whose it is and whether it is released. */\nexport interface FigureRecipeRow {\n name: string\n source: string\n state: 'draft' | 'released'\n updatedAt?: string\n domain?: { id: string; subdomain?: string }\n}\n\n/** Every recipe this domain sees, drafts too (ADR-0095). */\nexport async function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]> {\n const response = await client.query({\n query: gql`\n query {\n figureRecipes(all: true) {\n name\n source\n state\n updatedAt\n domain {\n id\n subdomain\n }\n }\n }\n `,\n fetchPolicy: 'network-only'\n })\n return unwrap<FigureRecipeRow[] | null>(response, 'figureRecipes') ?? []\n}\n\nexport async function saveFigureRecipe(source: string, state: 'draft' | 'released'): Promise<FigureRecipeRow> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($source: String!, $state: String) {\n saveFigureRecipe(source: $source, state: $state) {\n name\n source\n state\n }\n }\n `,\n variables: { source, state }\n })\n return unwrap<FigureRecipeRow>(response, 'saveFigureRecipe')\n}\n\nexport async function setFigureRecipeState(name: string, state: 'draft' | 'released'): Promise<void> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($name: String!, $state: String!) {\n setFigureRecipeState(name: $name, state: $state) {\n name\n }\n }\n `,\n variables: { name, state }\n })\n unwrap(response, 'setFigureRecipeState')\n}\n\nexport async function deleteFigureRecipe(name: string): Promise<void> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($name: String!) {\n deleteFigureRecipe(name: $name)\n }\n `,\n variables: { name }\n })\n unwrap(response, 'deleteFigureRecipe')\n}\n"]}
@@ -224,11 +224,10 @@ let FigureV3Parts = class FigureV3Parts extends localize(i18next)(LitElement) {
224
224
  border-radius: 8px;
225
225
  font-size: 0.75rem;
226
226
  }
227
+ /* The side pane is narrow (200 px, 160 under 860): the name above its field, not beside it. */
227
228
  div[recipe-form] label[field] {
228
229
  display: grid;
229
- grid-template-columns: 1fr 5.5em;
230
- align-items: center;
231
- gap: 6px;
230
+ gap: 2px;
232
231
  }
233
232
  div[recipe-form] input[type='number'],
234
233
  div[recipe-form] select {
@@ -252,6 +251,7 @@ let FigureV3Parts = class FigureV3Parts extends localize(i18next)(LitElement) {
252
251
  gap: 6px;
253
252
  }
254
253
  div[recipe-actions] button {
254
+ white-space: nowrap;
255
255
  display: inline-flex;
256
256
  align-items: center;
257
257
  gap: 4px;