@things-factory/figure-ui 10.1.3

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 (112) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/graphql/index.ts +209 -0
  3. package/client/index.ts +2 -0
  4. package/client/modeller/figure-ask.ts +247 -0
  5. package/client/modeller/figure-canvas.ts +482 -0
  6. package/client/modeller/figure-inspector.ts +1054 -0
  7. package/client/modeller/figure-parts.ts +479 -0
  8. package/client/modeller/figure-preview.ts +381 -0
  9. package/client/modeller/figure-report.ts +895 -0
  10. package/client/modeller/figure-settings.ts +285 -0
  11. package/client/modeller/figure-side.ts +162 -0
  12. package/client/modeller/figure-source.test.ts +336 -0
  13. package/client/modeller/figure-source.ts +338 -0
  14. package/client/modeller/figure-view.ts +79 -0
  15. package/client/modeller/maturity.ts +288 -0
  16. package/client/modeller/part-edits.test.ts +252 -0
  17. package/client/modeller/part-edits.ts +246 -0
  18. package/client/modeller/proposal.ts +142 -0
  19. package/client/modeller/scene-budget.ts +102 -0
  20. package/client/pages/figure-list-page.ts +274 -0
  21. package/client/pages/figure-modeller-page.ts +1067 -0
  22. package/client/route.ts +11 -0
  23. package/client/themes/brand.css +51 -0
  24. package/client/themes/dark.css +64 -0
  25. package/client/themes/light.css +95 -0
  26. package/client/tsconfig.json +19 -0
  27. package/client/types.ts +96 -0
  28. package/client/viewparts/figure-card.ts +226 -0
  29. package/dist-client/bootstrap.d.ts +14 -0
  30. package/dist-client/bootstrap.js +17 -0
  31. package/dist-client/bootstrap.js.map +1 -0
  32. package/dist-client/graphql/index.d.ts +41 -0
  33. package/dist-client/graphql/index.js +173 -0
  34. package/dist-client/graphql/index.js.map +1 -0
  35. package/dist-client/index.d.ts +2 -0
  36. package/dist-client/index.js +3 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/modeller/figure-ask.d.ts +36 -0
  39. package/dist-client/modeller/figure-ask.js +256 -0
  40. package/dist-client/modeller/figure-ask.js.map +1 -0
  41. package/dist-client/modeller/figure-canvas.d.ts +128 -0
  42. package/dist-client/modeller/figure-canvas.js +431 -0
  43. package/dist-client/modeller/figure-canvas.js.map +1 -0
  44. package/dist-client/modeller/figure-inspector.d.ts +149 -0
  45. package/dist-client/modeller/figure-inspector.js +991 -0
  46. package/dist-client/modeller/figure-inspector.js.map +1 -0
  47. package/dist-client/modeller/figure-parts.d.ts +64 -0
  48. package/dist-client/modeller/figure-parts.js +478 -0
  49. package/dist-client/modeller/figure-parts.js.map +1 -0
  50. package/dist-client/modeller/figure-preview.d.ts +62 -0
  51. package/dist-client/modeller/figure-preview.js +360 -0
  52. package/dist-client/modeller/figure-preview.js.map +1 -0
  53. package/dist-client/modeller/figure-report.d.ts +134 -0
  54. package/dist-client/modeller/figure-report.js +881 -0
  55. package/dist-client/modeller/figure-report.js.map +1 -0
  56. package/dist-client/modeller/figure-settings.d.ts +39 -0
  57. package/dist-client/modeller/figure-settings.js +277 -0
  58. package/dist-client/modeller/figure-settings.js.map +1 -0
  59. package/dist-client/modeller/figure-side.d.ts +41 -0
  60. package/dist-client/modeller/figure-side.js +175 -0
  61. package/dist-client/modeller/figure-side.js.map +1 -0
  62. package/dist-client/modeller/figure-source.d.ts +196 -0
  63. package/dist-client/modeller/figure-source.js +153 -0
  64. package/dist-client/modeller/figure-source.js.map +1 -0
  65. package/dist-client/modeller/figure-view.d.ts +57 -0
  66. package/dist-client/modeller/figure-view.js +36 -0
  67. package/dist-client/modeller/figure-view.js.map +1 -0
  68. package/dist-client/modeller/maturity.d.ts +55 -0
  69. package/dist-client/modeller/maturity.js +212 -0
  70. package/dist-client/modeller/maturity.js.map +1 -0
  71. package/dist-client/modeller/part-edits.d.ts +64 -0
  72. package/dist-client/modeller/part-edits.js +218 -0
  73. package/dist-client/modeller/part-edits.js.map +1 -0
  74. package/dist-client/modeller/proposal.d.ts +45 -0
  75. package/dist-client/modeller/proposal.js +96 -0
  76. package/dist-client/modeller/proposal.js.map +1 -0
  77. package/dist-client/modeller/scene-budget.d.ts +78 -0
  78. package/dist-client/modeller/scene-budget.js +80 -0
  79. package/dist-client/modeller/scene-budget.js.map +1 -0
  80. package/dist-client/pages/figure-list-page.d.ts +40 -0
  81. package/dist-client/pages/figure-list-page.js +280 -0
  82. package/dist-client/pages/figure-list-page.js.map +1 -0
  83. package/dist-client/pages/figure-modeller-page.d.ts +219 -0
  84. package/dist-client/pages/figure-modeller-page.js +1045 -0
  85. package/dist-client/pages/figure-modeller-page.js.map +1 -0
  86. package/dist-client/route.d.ts +1 -0
  87. package/dist-client/route.js +11 -0
  88. package/dist-client/route.js.map +1 -0
  89. package/dist-client/themes/brand.css +51 -0
  90. package/dist-client/themes/dark.css +64 -0
  91. package/dist-client/themes/light.css +95 -0
  92. package/dist-client/types.d.ts +93 -0
  93. package/dist-client/types.js +2 -0
  94. package/dist-client/types.js.map +1 -0
  95. package/dist-client/viewparts/figure-card.d.ts +24 -0
  96. package/dist-client/viewparts/figure-card.js +221 -0
  97. package/dist-client/viewparts/figure-card.js.map +1 -0
  98. package/dist-server/index.d.ts +7 -0
  99. package/dist-server/index.js +3 -0
  100. package/dist-server/index.js.map +1 -0
  101. package/package.json +41 -0
  102. package/server/index.ts +7 -0
  103. package/server/tsconfig.json +18 -0
  104. package/test/brand-palette.test.ts +188 -0
  105. package/test/heading-scale-guard.test.ts +144 -0
  106. package/test/resolve-ts-siblings.mjs +67 -0
  107. package/things-factory.config.js +11 -0
  108. package/translations/en.json +247 -0
  109. package/translations/ja.json +247 -0
  110. package/translations/ko.json +247 -0
  111. package/translations/ms.json +247 -0
  112. package/translations/zh.json +247 -0
@@ -0,0 +1,16 @@
1
+ /**
2
+ * 클라이언트 부트스트랩.
3
+ *
4
+ * 지금은 등록할 것이 없다. 그래도 이 파일을 두는 이유는 `things-factory.config.js` 가
5
+ * `bootstrap` 을 가리키기 때문이고, 데이터 그리드용 편집기·렌더러나 속성 편집기를
6
+ * 붙이게 되면 여기가 그 자리다(`board-ui/client/bootstrap.ts` 가 그 예다).
7
+ *
8
+ * ## 메뉴는 여기서 걸지 않는다
9
+ *
10
+ * things-factory 의 메뉴는 **`menu-base` 의 DB 레코드**다. 코드가 메뉴를 더하는 길은
11
+ * 없다 — 화면은 `things-factory.config.js` 의 `routes` 로 등록되고, 그 화면을 메뉴에
12
+ * 올리는 것은 운영자가 메뉴를 만들어 `figure-list` 를 가리키게 하는 일이다.
13
+ */
14
+ export default function bootstrap() {
15
+ // 등록할 것이 생기면 여기에 둔다.
16
+ }
@@ -0,0 +1,209 @@
1
+ import gql from 'graphql-tag'
2
+ import { client } from '@operato/graphql'
3
+
4
+ import type { Figure, FigureFinding, FigureListResult, FigurePatch, FigureProposal, NewFigure } from '../types.js'
5
+
6
+ /**
7
+ * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**
8
+ *
9
+ * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가
10
+ * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는
11
+ * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI
12
+ * 요청에서 그렇게 났다.
13
+ *
14
+ * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.
15
+ * 여기서 막는 것은 **`data` 자체가 없는 것**이다.
16
+ */
17
+ function unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {
18
+ const said = response.errors?.[0]?.message
19
+ if (said) throw new Error(said)
20
+
21
+ if (!response.data) {
22
+ throw new Error(`서버가 ${field} 를 돌려주지 않았습니다.`)
23
+ }
24
+
25
+ return response.data[field] as T
26
+ }
27
+
28
+
29
+ /**
30
+ * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.
31
+ *
32
+ * `score`·`triangles` 를 함께 받는 이유는 카드에 그것을 보여 주기 때문이다. 저작자가
33
+ * 목록에서 「이 Figure 가 무거운가」를 바로 알아야 한다.
34
+ */
35
+ const FIGURE_LIST_FIELDS = `
36
+ id
37
+ type
38
+ name
39
+ description
40
+ category
41
+ state
42
+ score
43
+ triangles
44
+ groups
45
+ thumbnail
46
+ thumbnailUpdatedAt
47
+ updatedAt
48
+ updater { id name }
49
+ `
50
+
51
+ export async function fetchFigureList(params: {
52
+ page?: number
53
+ limit?: number
54
+ search?: string
55
+ category?: string
56
+ state?: string
57
+ }): Promise<FigureListResult> {
58
+ const filters: { name: string; operator: string; value: unknown }[] = []
59
+ if (params.category) filters.push({ name: 'category', operator: 'eq', value: params.category })
60
+ if (params.state) filters.push({ name: 'state', operator: 'eq', value: params.state })
61
+
62
+ const response = await client.query({
63
+ query: gql`
64
+ query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
65
+ figures(filters: $filters, pagination: $pagination, sortings: $sortings) {
66
+ items { ${FIGURE_LIST_FIELDS} }
67
+ total
68
+ }
69
+ }
70
+ `,
71
+ variables: {
72
+ filters,
73
+ pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },
74
+ sortings: [{ name: 'updatedAt', desc: true }]
75
+ },
76
+ fetchPolicy: 'network-only'
77
+ })
78
+
79
+ return unwrap<FigureListResult>(response, 'figures')
80
+ }
81
+
82
+ /** 저작면에 필요한 전부 — `source` 를 포함한다. */
83
+ export async function fetchFigure(id: string): Promise<Figure> {
84
+ const response = await client.query({
85
+ query: gql`
86
+ query ($id: String!) {
87
+ figure(id: $id) {
88
+ ${FIGURE_LIST_FIELDS}
89
+ source
90
+ properties
91
+ version
92
+ }
93
+ }
94
+ `,
95
+ variables: { id },
96
+ fetchPolicy: 'network-only'
97
+ })
98
+
99
+ return unwrap<Figure>(response, 'figure')
100
+ }
101
+
102
+ /**
103
+ * 이미 쓰이고 있는 타입 이름.
104
+ *
105
+ * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러
106
+ * 거절당하고 나서 아는 것은 늦다.
107
+ */
108
+ export async function fetchFigureTypeNames(): Promise<string[]> {
109
+ const response = await client.query({
110
+ query: gql`
111
+ query {
112
+ figureTypeNames
113
+ }
114
+ `,
115
+ fetchPolicy: 'network-only'
116
+ })
117
+
118
+ return unwrap<string[] | null>(response, 'figureTypeNames') ?? []
119
+ }
120
+
121
+ export interface SaveResult {
122
+ figure: Figure
123
+ /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */
124
+ violations: FigureFinding[]
125
+ }
126
+
127
+ export async function createFigure(figure: NewFigure): Promise<SaveResult> {
128
+ const response = await client.mutate({
129
+ mutation: gql`
130
+ mutation ($figure: NewFigure!) {
131
+ createFigure(figure: $figure) {
132
+ figure { ${FIGURE_LIST_FIELDS} source }
133
+ violations { code message at }
134
+ }
135
+ }
136
+ `,
137
+ variables: { figure }
138
+ })
139
+
140
+ return unwrap<SaveResult>(response, 'createFigure')
141
+ }
142
+
143
+ export async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {
144
+ const response = await client.mutate({
145
+ mutation: gql`
146
+ mutation ($id: String!, $patch: FigurePatch!) {
147
+ updateFigure(id: $id, patch: $patch) {
148
+ figure { ${FIGURE_LIST_FIELDS} source version }
149
+ violations { code message at }
150
+ }
151
+ }
152
+ `,
153
+ variables: { id, patch }
154
+ })
155
+
156
+ return unwrap<SaveResult>(response, 'updateFigure')
157
+ }
158
+
159
+ export async function deleteFigure(id: string): Promise<boolean> {
160
+ const response = await client.mutate({
161
+ mutation: gql`
162
+ mutation ($id: String!) {
163
+ deleteFigure(id: $id)
164
+ }
165
+ `,
166
+ variables: { id }
167
+ })
168
+
169
+ return unwrap<boolean>(response, 'deleteFigure')
170
+ }
171
+
172
+ /**
173
+ * 저작 보조에게 후보를 시킨다.
174
+ *
175
+ * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고
176
+ * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을
177
+ * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.
178
+ *
179
+ * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.
180
+ */
181
+ export async function proposeFigure(request: {
182
+ prompt: string
183
+ base?: string
184
+ type?: string
185
+ palette: string[]
186
+ image?: File
187
+ }): Promise<FigureProposal> {
188
+ const response = await client.mutate({
189
+ mutation: gql`
190
+ mutation ProposeFigure($request: ProposeRequest!) {
191
+ proposeFigure(request: $request) {
192
+ source
193
+ score
194
+ grade
195
+ triangles
196
+ groups
197
+ attempts
198
+ violations {
199
+ code
200
+ message
201
+ }
202
+ }
203
+ }
204
+ `,
205
+ variables: { request }
206
+ })
207
+
208
+ return unwrap<FigureProposal>(response, 'proposeFigure')
209
+ }
@@ -0,0 +1,2 @@
1
+ export * from './types.js'
2
+ export * from './graphql/index.js'
@@ -0,0 +1,247 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import type { FigureSource } from '@hatiolab/figure-model'
8
+ import { activePalette } from '@hatiolab/things-scene'
9
+
10
+ import { proposeFigure } from '../graphql/index.js'
11
+ import type { FigureProposal } from '../types.js'
12
+
13
+ /**
14
+ * 말로 시키는 줄.
15
+ *
16
+ * ## 무엇을 시키는지가 화면에 보여야 한다
17
+ *
18
+ * 정본이 있으면 **고치는 것**이고, 없으면 **만드는 것**이다. 같은 칸으로 두 가지를
19
+ * 하면 저작자가 「이게 지금 것을 덮어쓰나」를 매번 걱정한다. 그래서 무엇을 할지
20
+ * 칸 옆에 적어 둔다.
21
+ *
22
+ * ## 후보를 여기서 받지 않는다
23
+ *
24
+ * 이 컴포넌트는 시키고 결과를 **알릴 뿐**이다. 받을지 버릴지는 저작면이 정한다 —
25
+ * 정본은 한 벌이고 그 한 벌을 가진 자리가 정해야 한다.
26
+ *
27
+ * ## 팔레트를 함께 보낸다
28
+ *
29
+ * 서버는 팔레트를 모른다. 그리는 쪽이 어떤 토큰을 풀 수 있는지 아는 유일한 자리다.
30
+ */
31
+ @customElement('figure-ask')
32
+ export class FigureAsk extends localize(i18next)(LitElement) {
33
+ static styles = css`
34
+ :host {
35
+ display: block;
36
+ background-color: var(--md-sys-color-surface-container-lowest);
37
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
38
+ font: var(--label-font, inherit);
39
+ /*
40
+ 이 줄의 글자 크기를 여기서 정한다.
41
+
42
+ 안 적어 두면 브라우저 기본 크기(16px)를 물려받는다. 아래 패널들은 전부 12px
43
+ 언저리라 위 두 줄만 커 보이고, 단추가 화면 전체와 따로 논다.
44
+
45
+ form 요소(input · button)는 글꼴을 물려받지 않으므로 각자 적어야 한다.
46
+ */
47
+ font-size: 0.76rem;
48
+ }
49
+
50
+ form {
51
+ display: flex;
52
+ align-items: center;
53
+ gap: var(--spacing-medium, 8px);
54
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
55
+ }
56
+
57
+ md-icon[lead] {
58
+ --md-icon-size: 18px;
59
+ flex: none;
60
+ color: var(--md-sys-color-primary);
61
+ }
62
+
63
+ input[ask] {
64
+ flex: 1;
65
+ min-width: 0;
66
+ padding: 6px 10px;
67
+ font: var(--input-field-font, inherit);
68
+ font-size: 0.78rem;
69
+ color: var(--md-sys-color-on-surface);
70
+ background-color: var(--md-sys-color-surface-container-lowest);
71
+ border: 1px solid var(--md-sys-color-outline-variant);
72
+ border-radius: 6px;
73
+ }
74
+ input[ask]:focus {
75
+ outline: 2px solid var(--md-sys-color-primary);
76
+ outline-offset: -1px;
77
+ }
78
+ input[ask][disabled] {
79
+ opacity: 0.5;
80
+ }
81
+
82
+ /* 무엇을 시키는 것인지 — 만드나 고치나 */
83
+ span[mode] {
84
+ flex: none;
85
+ font-size: 0.72rem;
86
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
87
+ }
88
+
89
+ /* 그림 붙이기. 붙인 것이 있으면 이름을 보여 준다 */
90
+ label[picture] {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 4px;
94
+ flex: none;
95
+ padding: 5px 9px;
96
+ font-size: 0.74rem;
97
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
98
+ border: 1px solid var(--md-sys-color-outline-variant);
99
+ border-radius: 6px;
100
+ cursor: pointer;
101
+ }
102
+ label[picture]:hover {
103
+ background-color: var(--md-sys-color-surface-container);
104
+ }
105
+ label[picture][has] {
106
+ color: var(--md-sys-color-on-primary-container);
107
+ background-color: var(--md-sys-color-primary-container);
108
+ border-color: transparent;
109
+ }
110
+ label[picture] md-icon {
111
+ --md-icon-size: 16px;
112
+ }
113
+ label[picture] input {
114
+ display: none;
115
+ }
116
+ span[filename] {
117
+ max-width: 8em;
118
+ overflow: hidden;
119
+ text-overflow: ellipsis;
120
+ white-space: nowrap;
121
+ }
122
+
123
+ /*
124
+ 채운 파랑 단추를 저장과 나눠 갖지 않는다.
125
+
126
+ 머리줄의 저장이 이 화면의 주 동작이다. 바로 아래 줄에 같은 파랑 단추가 하나 더
127
+ 있으면 어느 쪽이 주인지 눈이 못 고른다. 이쪽은 톤 단추로 한 단계 내린다.
128
+ */
129
+ button[go] {
130
+ flex: none;
131
+ padding: 6px 14px;
132
+ font: var(--label-font, inherit);
133
+ font-size: 0.76rem;
134
+ font-weight: 600;
135
+ color: var(--md-sys-color-on-primary-container);
136
+ background-color: var(--md-sys-color-primary-container);
137
+ border: none;
138
+ border-radius: 6px;
139
+ cursor: pointer;
140
+ }
141
+ button[go]:hover:not([disabled]) {
142
+ background-color: color-mix(in srgb, var(--md-sys-color-primary) 24%, var(--md-sys-color-primary-container));
143
+ }
144
+ button[go][disabled] {
145
+ opacity: 0.4;
146
+ cursor: default;
147
+ }
148
+
149
+ /* 못 만들었을 때. 사유를 삼키지 않는다 — 말을 고쳐 다시 시킬 단서다 */
150
+ div[failed] {
151
+ display: flex;
152
+ gap: 6px;
153
+ padding: 0 var(--spacing-large, 12px) var(--spacing-medium, 8px) var(--spacing-large, 12px);
154
+ font-size: 0.74rem;
155
+ line-height: 1.5;
156
+ color: var(--md-sys-color-error);
157
+ }
158
+ div[failed] md-icon {
159
+ --md-icon-size: 16px;
160
+ flex: none;
161
+ }
162
+ `
163
+
164
+ /** 지금 정본. 있으면 고치는 것이고 없으면 만드는 것이다. */
165
+ @property({ type: Object }) source?: FigureSource
166
+ /** 새로 만들 때 쓸 타입 이름. */
167
+ @property({ type: String }) type = ''
168
+
169
+ @state() private asking = false
170
+ @state() private failure = ''
171
+ @state() private picture?: File
172
+
173
+ render() {
174
+ const revising = !!this.source
175
+
176
+ return html`
177
+ <form @submit=${(e: Event) => this.submit(e)}>
178
+ <md-icon lead>auto_awesome</md-icon>
179
+ <span mode>${i18next.t(revising ? 'text.ask-to-revise' : 'text.ask-to-create')}</span>
180
+ <input
181
+ ask
182
+ ?disabled=${this.asking}
183
+ placeholder=${i18next.t(revising ? 'text.ask-revise-example' : 'text.ask-create-example')}
184
+ />
185
+ <label picture ?has=${!!this.picture} title=${i18next.t('text.picture-is-for-proportions')}>
186
+ <md-icon>image</md-icon>
187
+ ${this.picture
188
+ ? html`<span filename>${this.picture.name}</span>`
189
+ : i18next.t('button.attach-a-picture')}
190
+ <input type="file" accept="image/*" @change=${(e: Event) => this.pick(e)} />
191
+ </label>
192
+ <button go ?disabled=${this.asking}>
193
+ ${this.asking ? i18next.t('text.asking') : i18next.t('button.ask')}
194
+ </button>
195
+ </form>
196
+
197
+ ${this.failure
198
+ ? html`
199
+ <div failed>
200
+ <md-icon>error</md-icon>
201
+ <span>${this.failure}</span>
202
+ </div>
203
+ `
204
+ : nothing}
205
+ `
206
+ }
207
+
208
+ private pick(e: Event) {
209
+ const input = e.target as HTMLInputElement
210
+ this.picture = input.files?.[0]
211
+ }
212
+
213
+ private async submit(e: Event) {
214
+ e.preventDefault()
215
+
216
+ const input = this.renderRoot.querySelector('input[ask]') as HTMLInputElement
217
+ const prompt = input?.value?.trim()
218
+ if (!prompt || this.asking) {
219
+ return
220
+ }
221
+
222
+ this.asking = true
223
+ this.failure = ''
224
+
225
+ try {
226
+ const proposal = await proposeFigure({
227
+ prompt,
228
+ base: this.source ? JSON.stringify(this.source) : undefined,
229
+ type: this.source ? undefined : this.type,
230
+ // 그리는 쪽이 풀 수 있는 토큰만 보낸다. 서버는 이 목록 밖의 색을 거절한다.
231
+ palette: Object.keys(activePalette() as Record<string, string>),
232
+ image: this.picture
233
+ })
234
+
235
+ this.dispatchEvent(
236
+ new CustomEvent('proposed', { detail: { proposal }, bubbles: true, composed: true })
237
+ )
238
+ input.value = ''
239
+ this.picture = undefined
240
+ } catch (err) {
241
+ // 사유를 그대로 보여 준다. 「실패했습니다」로 뭉개면 말을 어떻게 고칠지 모른다.
242
+ this.failure = (err as Error).message
243
+ } finally {
244
+ this.asking = false
245
+ }
246
+ }
247
+ }