@things-factory/figure-ui 10.1.7 → 10.1.9

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 (39) hide show
  1. package/client/graphql/index.ts +24 -0
  2. package/client/modeller/figure-animations.ts +61 -4
  3. package/client/modeller/figure-ask.ts +6 -1
  4. package/client/modeller/figure-history-panel.ts +52 -1
  5. package/client/modeller/figure-inspector.ts +477 -124
  6. package/client/modeller/proposal-session.ts +92 -0
  7. package/client/pages/figure-modeller-page.ts +179 -53
  8. package/client/types.ts +33 -0
  9. package/dist-client/graphql/index.d.ts +3 -1
  10. package/dist-client/graphql/index.js +21 -0
  11. package/dist-client/graphql/index.js.map +1 -1
  12. package/dist-client/modeller/figure-animations.d.ts +4 -1
  13. package/dist-client/modeller/figure-animations.js +57 -4
  14. package/dist-client/modeller/figure-animations.js.map +1 -1
  15. package/dist-client/modeller/figure-ask.d.ts +3 -0
  16. package/dist-client/modeller/figure-ask.js +9 -0
  17. package/dist-client/modeller/figure-ask.js.map +1 -1
  18. package/dist-client/modeller/figure-history-panel.d.ts +7 -0
  19. package/dist-client/modeller/figure-history-panel.js +50 -0
  20. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  21. package/dist-client/modeller/figure-inspector.d.ts +3 -0
  22. package/dist-client/modeller/figure-inspector.js +464 -111
  23. package/dist-client/modeller/figure-inspector.js.map +1 -1
  24. package/dist-client/modeller/proposal-session.d.ts +23 -0
  25. package/dist-client/modeller/proposal-session.js +65 -0
  26. package/dist-client/modeller/proposal-session.js.map +1 -0
  27. package/dist-client/pages/figure-modeller-page.d.ts +43 -9
  28. package/dist-client/pages/figure-modeller-page.js +173 -50
  29. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  30. package/dist-client/tsconfig.tsbuildinfo +1 -1
  31. package/dist-client/types.d.ts +38 -0
  32. package/dist-client/types.js.map +1 -1
  33. package/package.json +4 -4
  34. package/test/ai-proposal-contract.test.ts +169 -0
  35. package/translations/en.json +32 -0
  36. package/translations/ja.json +32 -0
  37. package/translations/ko.json +42 -0
  38. package/translations/ms.json +32 -0
  39. package/translations/zh.json +32 -0
@@ -72,6 +72,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
72
72
  display: flex;
73
73
  flex-direction: column;
74
74
  min-height: 0;
75
+ --figure-control-height: 30px;
75
76
  overflow-y: auto;
76
77
  background-color: var(--md-sys-color-surface-container-lowest);
77
78
  color: var(--md-sys-color-on-surface);
@@ -96,9 +97,39 @@ export class FigureInspector extends localize(i18next)(LitElement) {
96
97
  칸 높이는 건드리지 않는다(작게 만드는 것이 아니라 **빈 곳을 줄이는 것**이다).
97
98
  */
98
99
  section {
99
- padding: 6px 10px;
100
+ padding: 8px 10px;
100
101
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
101
102
  }
103
+ /* 입력 행은 촘촘하게, 서로 다른 편집 의도 사이에는 호흡을 둔다. */
104
+ section + section {
105
+ margin-top: 6px;
106
+ }
107
+ section[collapsible] {
108
+ padding: 0;
109
+ }
110
+ details[group] > summary {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: space-between;
114
+ min-height: 34px;
115
+ padding: 0 10px;
116
+ cursor: pointer;
117
+ list-style: none;
118
+ }
119
+ details[group] > summary::-webkit-details-marker {
120
+ display: none;
121
+ }
122
+ details[group] > summary h3 {
123
+ margin: 0;
124
+ }
125
+ details[group] > summary md-icon {
126
+ --md-icon-size: 16px;
127
+ color: var(--md-sys-color-on-surface-variant);
128
+ }
129
+ details[group] > div[group-content] {
130
+ padding: 4px 10px 8px;
131
+ border-top: 1px solid var(--md-sys-color-outline-variant);
132
+ }
102
133
  h3 {
103
134
  display: flex;
104
135
  align-items: center;
@@ -111,6 +142,42 @@ export class FigureInspector extends localize(i18next)(LitElement) {
111
142
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
112
143
  }
113
144
 
145
+ /* 자산 메타데이터는 부품을 고친다는 현재 문맥에서는 요약으로만 둔다. */
146
+ details[asset] {
147
+ margin: -1px 0;
148
+ }
149
+ details[asset] summary {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 6px;
153
+ min-height: 26px;
154
+ cursor: pointer;
155
+ list-style: none;
156
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
157
+ }
158
+ details[asset] summary::-webkit-details-marker {
159
+ display: none;
160
+ }
161
+ details[asset] summary::before {
162
+ content: '›';
163
+ font-size: 1.1rem;
164
+ line-height: 1;
165
+ transition: transform 120ms ease;
166
+ }
167
+ details[asset][open] summary::before {
168
+ transform: rotate(90deg);
169
+ }
170
+ details[asset] summary b {
171
+ font-size: var(--ui-t-micro, 11px);
172
+ letter-spacing: 0.04em;
173
+ }
174
+ details[asset] summary small {
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ font-size: 0.7rem;
179
+ }
180
+
114
181
  label {
115
182
  display: flex;
116
183
  align-items: center;
@@ -156,6 +223,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
156
223
  input,
157
224
  select {
158
225
  width: 100%;
226
+ min-height: var(--figure-control-height);
159
227
  box-sizing: border-box;
160
228
  padding: 4px 7px;
161
229
  font: var(--input-field-font, inherit);
@@ -180,6 +248,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
180
248
  */
181
249
  div[cell] {
182
250
  display: flex;
251
+ box-sizing: border-box;
252
+ height: var(--figure-control-height);
253
+ min-height: var(--figure-control-height);
183
254
  align-items: center;
184
255
  gap: 2px;
185
256
  padding-left: 6px;
@@ -200,6 +271,8 @@ export class FigureInspector extends localize(i18next)(LitElement) {
200
271
  }
201
272
  div[cell] input {
202
273
  min-width: 0;
274
+ min-height: 0;
275
+ height: calc(var(--figure-control-height) - 2px);
203
276
  padding: 4px 6px 4px 2px;
204
277
  font-family: var(--mono-font, monospace);
205
278
  text-align: right;
@@ -219,11 +292,163 @@ export class FigureInspector extends localize(i18next)(LitElement) {
219
292
  }
220
293
  div[field] + div[field],
221
294
  div[triple] + div[field],
222
- div[field] + div[triple] {
295
+ div[field] + div[triple],
296
+ div[inline-field] + div[inline-field],
297
+ div[inline-field] + div[field],
298
+ div[field] + div[inline-field],
299
+ div[vector-row] + div[vector-row],
300
+ div[vector-row] + div[field],
301
+ div[field] + div[vector-row] {
223
302
  /* 칸 사이 7 → 5. 칸 자체는 그대로 두고 사이만 좁힌다. */
224
303
  margin-top: 5px;
225
304
  }
226
305
 
306
+ /* 짧은 라벨과 단일 값은 같은 줄에서 읽는다. 복잡한 선택기에는 쓰지 않는다. */
307
+ div[inline-field] {
308
+ display: grid;
309
+ /* 같은 편집 밀도 안에서는 입력 시작선을 맞춘다. 라벨 길이가 칸 폭을 흔들면 안 된다. */
310
+ grid-template-columns: 64px minmax(0, 1fr);
311
+ align-items: center;
312
+ gap: 7px;
313
+ }
314
+ div[inline-field] > label {
315
+ margin: 0;
316
+ white-space: nowrap;
317
+ }
318
+ /* 한 줄 입력의 설명은 입력칸을 밀어내지 않고 바로 아래 전폭에서 읽는다. */
319
+ div[inline-field] > p[hint] {
320
+ grid-column: 1 / -1;
321
+ margin: -2px 0 1px;
322
+ }
323
+ div[inline-field] > code[fixed] {
324
+ min-width: 0;
325
+ overflow: hidden;
326
+ text-overflow: ellipsis;
327
+ white-space: nowrap;
328
+ }
329
+
330
+ /* X·Y·Z는 함께 비교하는 값이므로, 묶음 제목만 왼쪽에 두고 입력은 한 행에 둔다. */
331
+ div[vector-row] {
332
+ display: grid;
333
+ grid-template-columns: 64px minmax(0, 1fr);
334
+ align-items: center;
335
+ gap: 7px;
336
+ }
337
+ div[vector-row] > label {
338
+ margin: 0;
339
+ white-space: nowrap;
340
+ }
341
+ div[vector-row] > p[hint] {
342
+ grid-column: 1 / -1;
343
+ margin: -2px 0 1px;
344
+ }
345
+ div[sizing-choices] {
346
+ display: grid;
347
+ grid-template-columns: 1fr 1fr;
348
+ gap: 5px;
349
+ }
350
+ button[sizing-choice] {
351
+ display: flex;
352
+ flex-direction: column;
353
+ align-items: flex-start;
354
+ gap: 2px;
355
+ min-height: 52px;
356
+ padding: 7px 8px;
357
+ color: var(--md-sys-color-on-surface);
358
+ background: var(--md-sys-color-surface-container-lowest);
359
+ border: 1px solid var(--md-sys-color-outline-variant);
360
+ border-radius: 7px;
361
+ text-align: left;
362
+ cursor: pointer;
363
+ }
364
+ button[sizing-choice][on] {
365
+ color: var(--md-sys-color-on-primary-container);
366
+ background: var(--md-sys-color-primary-container);
367
+ border-color: transparent;
368
+ }
369
+ button[sizing-choice] b {
370
+ font-size: 0.74rem;
371
+ }
372
+ button[sizing-choice] small {
373
+ font-size: 0.64rem;
374
+ line-height: 1.35;
375
+ opacity: 0.8;
376
+ }
377
+ div[anchor-rows] {
378
+ display: grid;
379
+ gap: 6px;
380
+ }
381
+ div[anchor-row] {
382
+ display: grid;
383
+ grid-template-columns: 46px minmax(0, 1fr);
384
+ align-items: start;
385
+ gap: 8px;
386
+ padding: 6px 7px;
387
+ background: var(--md-sys-color-surface-container);
388
+ border-radius: 7px;
389
+ }
390
+ div[anchor-row] label {
391
+ margin: 0;
392
+ font-size: 0.7rem;
393
+ font-weight: 700;
394
+ }
395
+ div[anchor-options] {
396
+ display: grid;
397
+ grid-template-columns: repeat(3, minmax(0, 1fr));
398
+ gap: 4px;
399
+ }
400
+ button[anchor-choice] {
401
+ min-width: 0;
402
+ height: var(--figure-control-height);
403
+ box-sizing: border-box;
404
+ padding: 4px 3px;
405
+ overflow: hidden;
406
+ color: var(--md-sys-color-on-surface);
407
+ font: var(--label-font, inherit);
408
+ font-size: 0.63rem;
409
+ line-height: 1.2;
410
+ text-overflow: ellipsis;
411
+ white-space: nowrap;
412
+ background: var(--md-sys-color-surface-container-lowest);
413
+ border: 1px solid var(--md-sys-color-outline-variant);
414
+ border-radius: 5px;
415
+ cursor: pointer;
416
+ }
417
+ button[anchor-choice][on] {
418
+ color: var(--md-sys-color-on-primary-container);
419
+ background: var(--md-sys-color-primary-container);
420
+ border-color: transparent;
421
+ }
422
+ details[anchor-advanced] {
423
+ grid-column: 2;
424
+ margin-top: 4px;
425
+ }
426
+ details[anchor-advanced] summary {
427
+ cursor: pointer;
428
+ font-size: 0.62rem;
429
+ color: var(--md-sys-color-on-surface-variant);
430
+ }
431
+ details[anchor-advanced] div[anchor-options] {
432
+ margin-top: 4px;
433
+ }
434
+ div[palette-meaning] {
435
+ display: grid;
436
+ gap: 2px;
437
+ margin-top: 7px;
438
+ padding: 6px 8px;
439
+ color: var(--md-sys-color-on-surface);
440
+ background: var(--md-sys-color-surface-container);
441
+ border-radius: 7px;
442
+ }
443
+ div[palette-meaning] b {
444
+ font-size: 0.72rem;
445
+ }
446
+ div[palette-meaning] small {
447
+ color: var(--md-sys-color-on-surface-variant);
448
+ font-size: 0.64rem;
449
+ line-height: 1.35;
450
+ }
451
+
227
452
  /*
228
453
  정밀 제작 도구.
229
454
 
@@ -232,13 +457,17 @@ export class FigureInspector extends localize(i18next)(LitElement) {
232
457
  늘 조금씩 어긋나고, 기준 치수를 바꾸면 전부 다시 해야 한다.
233
458
  */
234
459
  div[tools] {
235
- display: flex;
236
- flex-wrap: wrap;
237
- gap: 3px;
238
- margin-top: 6px;
460
+ display: grid;
461
+ grid-template-columns: repeat(6, minmax(0, 1fr));
462
+ gap: 4px;
239
463
  }
240
464
  div[tools] button {
241
- padding: 3px 8px;
465
+ display: grid;
466
+ place-items: center;
467
+ min-width: 0;
468
+ height: var(--figure-control-height);
469
+ box-sizing: border-box;
470
+ padding: 0;
242
471
  font: var(--label-font, inherit);
243
472
  font-size: 0.7rem;
244
473
  color: var(--md-sys-color-on-surface);
@@ -247,6 +476,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
247
476
  border-radius: 6px;
248
477
  cursor: pointer;
249
478
  }
479
+ div[tools] button md-icon {
480
+ font-size: 17px;
481
+ }
250
482
  div[tools] button[disabled] {
251
483
  opacity: 0.35;
252
484
  cursor: default;
@@ -256,11 +488,6 @@ export class FigureInspector extends localize(i18next)(LitElement) {
256
488
  background-color: var(--md-sys-color-primary-container);
257
489
  border-color: transparent;
258
490
  }
259
- div[tools] span[sep] {
260
- width: 1px;
261
- margin: 2px 3px;
262
- background-color: var(--md-sys-color-outline-variant);
263
- }
264
491
 
265
492
  /* 지금 어느 축이 늘어나나 — 크기를 고치면 바뀔 수 있어서 값으로 적는다 */
266
493
  p[axis-now] {
@@ -319,7 +546,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
319
546
  */
320
547
  div[segments] {
321
548
  display: grid;
322
- grid-template-columns: repeat(3, 1fr);
549
+ grid-template-columns: repeat(4, 1fr);
323
550
  gap: 4px;
324
551
  }
325
552
  /* 원 유지 · 끄기 — 고르는 값이라 분할 수와 같은 모양으로 둔다 */
@@ -377,8 +604,8 @@ export class FigureInspector extends localize(i18next)(LitElement) {
377
604
  button[seg] svg {
378
605
  display: block;
379
606
  /* 작으면 12 와 16 이 둘 다 동그라미로 보인다 — 각이 읽힐 만큼은 키운다 */
380
- width: 40px;
381
- height: 40px;
607
+ width: 32px;
608
+ height: 32px;
382
609
  }
383
610
  button[seg] svg polygon {
384
611
  fill: var(--md-sys-color-surface-container-highest);
@@ -401,7 +628,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
401
628
  button[seg][on] small {
402
629
  color: inherit;
403
630
  }
404
- /* 프리셋 밖의 값도 있다. 다만 권하는 쪽을 크게 둔다 */
631
+ /* 성능·실루엣 검증을 마친 프리셋만 고른다. */
405
632
  details[free] {
406
633
  margin-top: 6px;
407
634
  }
@@ -494,6 +721,26 @@ export class FigureInspector extends localize(i18next)(LitElement) {
494
721
  label[toggle] input {
495
722
  width: auto;
496
723
  }
724
+ div[role-choices] {
725
+ display: grid;
726
+ grid-template-columns: repeat(3, minmax(0, 1fr));
727
+ gap: 4px;
728
+ }
729
+ div[role-choices] label[toggle] {
730
+ justify-content: center;
731
+ min-height: 30px;
732
+ margin: 0;
733
+ padding: 2px 4px;
734
+ border: 1px solid var(--md-sys-color-outline-variant);
735
+ border-radius: 6px;
736
+ font-size: 0.68rem;
737
+ text-align: center;
738
+ }
739
+ div[role-choices] label[toggle]:has(input:checked) {
740
+ color: var(--md-sys-color-on-primary-container);
741
+ background: var(--md-sys-color-primary-container);
742
+ border-color: transparent;
743
+ }
497
744
 
498
745
  p[quiet] {
499
746
  margin: 0;
@@ -552,7 +799,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
552
799
  const part = this.chosen
553
800
  if (!part || !this.board) {
554
801
  return html`
555
- ${this.renderFigure()}
802
+ ${this.renderFigure(false)}
556
803
  <section>
557
804
  <h3>${i18next.t('figure.label.properties')}</h3>
558
805
  <p quiet>${i18next.t('figure.text.select-a-part-to-edit')}</p>
@@ -571,7 +818,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
571
818
  묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
572
819
  */
573
820
  return html`
574
- ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
821
+ ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
575
822
  ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
576
823
  ${this.renderBehaviour(shown)}
577
824
  `
@@ -597,18 +844,20 @@ export class FigureInspector extends localize(i18next)(LitElement) {
597
844
  <section>
598
845
  <h3>${i18next.t('figure.label.role-in-capability')}${this.help('figure.text.anchors-are-not-drawn')}</h3>
599
846
 
600
- ${(PART_ROLES as readonly PartRole[]).map(
601
- one => html`
602
- <label toggle>
603
- <input
604
- type="checkbox"
605
- .checked=${live(roles.includes(one))}
606
- @change=${(e: Event) => this.toggleRole(one, (e.target as HTMLInputElement).checked)}
607
- />
608
- ${i18next.t('figure.label.role-' + one)}
609
- </label>
610
- `
611
- )}
847
+ <div role-choices>
848
+ ${(PART_ROLES as readonly PartRole[]).map(
849
+ one => html`
850
+ <label toggle>
851
+ <input
852
+ type="checkbox"
853
+ .checked=${live(roles.includes(one))}
854
+ @change=${(e: Event) => this.toggleRole(one, (e.target as HTMLInputElement).checked)}
855
+ />
856
+ ${i18next.t('figure.label.role-' + one)}
857
+ </label>
858
+ `
859
+ )}
860
+ </div>
612
861
  ${roles.length === 0 ? html`<p quiet>${i18next.t('figure.text.role-none-means-drawn')}</p>` : nothing}
613
862
 
614
863
  ${roles.includes('slot')
@@ -695,11 +944,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
695
944
  *
696
945
  * 저장되는 식별자다. 배치된 보드가 이 이름으로 타입을 찾으므로 만든 뒤에는 못 바꾼다.
697
946
  */
698
- private renderFigure() {
699
- return html`
700
- <section>
701
- <h3>${i18next.t('figure.label.figure')}</h3>
702
- <div field>
947
+ private renderFigure(compact = false) {
948
+ const fields = html`
949
+ <div inline-field>
703
950
  <label>${i18next.t('figure.label.name')}</label>
704
951
  <input
705
952
  .value=${this.figureName}
@@ -714,34 +961,57 @@ export class FigureInspector extends localize(i18next)(LitElement) {
714
961
  )}
715
962
  />
716
963
  </div>
717
- <div field>
718
- <label>${i18next.t('figure.label.figure-type')}${this.help('figure.text.type-name-cannot-change')}</label>
964
+ <div inline-field>
965
+ <label>${i18next.t('figure.label.figure-type')}${this.helpButton('figure.text.type-name-cannot-change')}</label>
719
966
  <code fixed title=${i18next.t('figure.text.type-name-cannot-change')}>
720
967
  <md-icon>lock</md-icon>${this.board?.figureType ?? ''}
721
968
  </code>
969
+ ${this.helpHint('figure.text.type-name-cannot-change')}
722
970
  </div>
723
971
  <!--
724
972
  왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
725
973
  알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
726
974
  -->
727
975
 
728
- <div field>
729
- <label>${i18next.t('figure.label.category')}${this.help('figure.text.category-explained')}</label>
976
+ <div inline-field>
977
+ <label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
730
978
  <input
731
979
  .value=${this.figureCategory ?? ''}
732
980
  placeholder=${i18next.t('figure.text.category-placeholder')}
733
981
  @change=${(e: Event) => this.tellCatalog({ category: (e.target as HTMLInputElement).value.trim() || undefined })}
734
982
  />
983
+ ${this.helpHint('figure.text.category-explained')}
735
984
  </div>
736
985
 
737
- <div field>
738
- <label>${i18next.t('figure.label.tags')}${this.help('figure.text.tags-explained')}</label>
986
+ <div inline-field>
987
+ <label>${i18next.t('figure.label.tags')}${this.helpButton('figure.text.tags-explained')}</label>
739
988
  <input
740
989
  .value=${(this.figureTags ?? []).join(', ')}
741
990
  placeholder=${i18next.t('figure.text.tags-placeholder')}
742
991
  @change=${(e: Event) => this.tellTags((e.target as HTMLInputElement).value)}
743
992
  />
993
+ ${this.helpHint('figure.text.tags-explained')}
744
994
  </div>
995
+ `
996
+
997
+ if (compact) {
998
+ return html`
999
+ <section>
1000
+ <details asset>
1001
+ <summary>
1002
+ <b>${i18next.t('figure.label.figure')}</b>
1003
+ <small>${this.figureName || this.board?.figureType || ''}</small>
1004
+ </summary>
1005
+ ${fields}
1006
+ </details>
1007
+ </section>
1008
+ `
1009
+ }
1010
+
1011
+ return html`
1012
+ <section>
1013
+ <h3>${i18next.t('figure.label.figure')}</h3>
1014
+ ${fields}
745
1015
  </section>
746
1016
  `
747
1017
  }
@@ -775,14 +1045,15 @@ export class FigureInspector extends localize(i18next)(LitElement) {
775
1045
  return html`
776
1046
  <section>
777
1047
  <h3>${i18next.t('figure.label.part')}</h3>
778
- <div field>
779
- <label>${i18next.t('figure.label.name')}${this.help('figure.text.renaming-a-part-breaks-bindings')}</label>
1048
+ <div inline-field>
1049
+ <label>${i18next.t('figure.label.name')}${this.helpButton('figure.text.renaming-a-part-breaks-bindings')}</label>
780
1050
  <input
781
1051
  .value=${part.name}
782
1052
  @change=${(e: Event) => this.put({ name: (e.target as HTMLInputElement).value })}
783
1053
  />
1054
+ ${this.helpHint('figure.text.renaming-a-part-breaks-bindings')}
784
1055
  </div>
785
- <div field>
1056
+ <div inline-field>
786
1057
  <label>${i18next.t('figure.label.shape')}</label>
787
1058
  <select @change=${(e: Event) => this.changePrimitive((e.target as HTMLSelectElement).value as PrimitiveKind)}>
788
1059
  ${PRIMITIVE_KINDS.map(
@@ -806,16 +1077,18 @@ export class FigureInspector extends localize(i18next)(LitElement) {
806
1077
  <section>
807
1078
  <h3>${i18next.t('figure.label.placement')}</h3>
808
1079
 
809
- <label>${i18next.t('figure.label.part-position')}</label>
810
- <div triple>
811
- ${(['x', 'y', 'z'] as const).map(axis =>
812
- this.number(axis.toUpperCase(), t.position[axis], value =>
813
- this.putTransform({ position: { ...t.position, [axis]: value } })
814
- )
815
- )}
1080
+ <div vector-row>
1081
+ <label>${i18next.t('figure.label.part-position')}</label>
1082
+ <div triple>
1083
+ ${(['x', 'y', 'z'] as const).map(axis =>
1084
+ this.number(axis.toUpperCase(), t.position[axis], value =>
1085
+ this.putTransform({ position: { ...t.position, [axis]: value } })
1086
+ )
1087
+ )}
1088
+ </div>
816
1089
  </div>
817
1090
 
818
- <div field>
1091
+ <div vector-row>
819
1092
  <label>${i18next.t('figure.label.part-size')}</label>
820
1093
  <div triple>
821
1094
  ${(['x', 'y', 'z'] as const).map(axis =>
@@ -829,48 +1102,64 @@ export class FigureInspector extends localize(i18next)(LitElement) {
829
1102
  </div>
830
1103
  </div>
831
1104
 
832
- <div field>
833
- <label>${i18next.t('figure.label.tools')}</label>
1105
+ <div vector-row>
1106
+ <label>${i18next.t('figure.label.rotation-degrees')}</label>
1107
+ <div triple>
1108
+ ${(['x', 'y', 'z'] as const).map(axis =>
1109
+ this.number(axis.toUpperCase(), rotation[axis] ?? 0, value =>
1110
+ this.putTransform({ rotation: { ...rotation, [axis]: value } })
1111
+ )
1112
+ )}
1113
+ </div>
1114
+ </div>
1115
+
1116
+ <div vector-row>
1117
+ <label>${i18next.t('figure.label.tools')}${this.helpButton('figure.text.tools-explained')}</label>
834
1118
  <div tools>
835
- <button title=${i18next.t('figure.text.center-on-x')} @click=${() => this.ask('center', 'x')}>
836
- ${i18next.t('figure.button.center-x')}
1119
+ <button
1120
+ aria-label=${i18next.t('figure.button.center-x')}
1121
+ title=${i18next.t('figure.text.center-on-x')}
1122
+ @click=${() => this.ask('center', 'x')}
1123
+ >
1124
+ <md-icon>horizontal_distribute</md-icon>
837
1125
  </button>
838
- <button title=${i18next.t('figure.text.center-on-z')} @click=${() => this.ask('center', 'z')}>
839
- ${i18next.t('figure.button.center-z')}
1126
+ <button
1127
+ aria-label=${i18next.t('figure.button.center-z')}
1128
+ title=${i18next.t('figure.text.center-on-z')}
1129
+ @click=${() => this.ask('center', 'z')}
1130
+ >
1131
+ <md-icon>vertical_align_center</md-icon>
840
1132
  </button>
841
- <button title=${i18next.t('figure.text.put-on-floor')} @click=${() => this.ask('floor', 'y')}>
842
- ${i18next.t('figure.button.to-floor')}
1133
+ <button
1134
+ aria-label=${i18next.t('figure.button.to-floor')}
1135
+ title=${i18next.t('figure.text.put-on-floor')}
1136
+ @click=${() => this.ask('floor', 'y')}
1137
+ >
1138
+ <md-icon>vertical_align_bottom</md-icon>
843
1139
  </button>
844
- <span sep></span>
845
1140
  ${(['x', 'z'] as const).map(axis => {
846
1141
  // 이미 가운데면 대칭이 제자리다 — 겹친 부품이 조용히 생기지 않게 막는다
847
1142
  const able = this.board ? canMirror(this.board, this.parts, this.selected, axis) : false
848
1143
  return html`
849
1144
  <button
850
1145
  ?disabled=${!able}
1146
+ aria-label=${i18next.t('figure.button.mirror-' + axis)}
851
1147
  title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
852
1148
  @click=${() => this.ask('mirror', axis)}
853
1149
  >
854
- ${i18next.t('figure.button.mirror-' + axis)}
1150
+ <md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
855
1151
  </button>
856
1152
  `
857
1153
  })}
858
- <span sep></span>
859
- <button title=${i18next.t('figure.text.snap-explained')} @click=${() => this.ask('snap', 'x')}>
860
- ${i18next.t('figure.button.snap')}
1154
+ <button
1155
+ aria-label=${i18next.t('figure.button.snap')}
1156
+ title=${i18next.t('figure.text.snap-explained')}
1157
+ @click=${() => this.ask('snap', 'x')}
1158
+ >
1159
+ <md-icon>grid_on</md-icon>
861
1160
  </button>
862
1161
  </div>
863
- </div>
864
-
865
- <div field>
866
- <label>${i18next.t('figure.label.rotation-degrees')}</label>
867
- <div triple>
868
- ${(['x', 'y', 'z'] as const).map(axis =>
869
- this.number(axis.toUpperCase(), rotation[axis] ?? 0, value =>
870
- this.putTransform({ rotation: { ...rotation, [axis]: value } })
871
- )
872
- )}
873
- </div>
1162
+ ${this.helpHint('figure.text.tools-explained')}
874
1163
  </div>
875
1164
  </section>
876
1165
  `
@@ -907,11 +1196,11 @@ export class FigureInspector extends localize(i18next)(LitElement) {
907
1196
  this.put({ shape: { ...(part.shape ?? {}), ...over } })
908
1197
 
909
1198
  return html`
910
- <div field>
1199
+ <div inline-field>
911
1200
  <label>${i18next.t('figure.label.corner-round')}</label>
912
1201
  ${this.number('', part.shape?.round ?? 0, value => reshape({ round: value }))}
913
1202
  </div>
914
- <div field>
1203
+ <div inline-field>
915
1204
  <label>${i18next.t('figure.label.hollow-wall')}</label>
916
1205
  ${this.number('', wall, value =>
917
1206
  reshape({ hollow: value > 0 ? { ...(hollow ?? {}), wall: value } : undefined })
@@ -919,7 +1208,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
919
1208
  </div>
920
1209
  ${wall > 0
921
1210
  ? html`
922
- <div field>
1211
+ <div inline-field>
923
1212
  <label>${i18next.t('figure.label.hollow-floor')}</label>
924
1213
  ${this.number('', hollow?.floor ?? wall, value => reshape({ hollow: { ...hollow, wall, floor: value } }))}
925
1214
  </div>
@@ -964,12 +1253,14 @@ export class FigureInspector extends localize(i18next)(LitElement) {
964
1253
 
965
1254
  private renderSegments(part: FigurePart) {
966
1255
  const current = part.segments ?? SEGMENT_PRESETS[1]
1256
+ const common = SEGMENT_PRESETS.slice(0, 4)
1257
+ const advanced = SEGMENT_PRESETS.slice(4)
967
1258
 
968
1259
  return html`
969
1260
  <div field>
970
1261
  <label>${i18next.t('figure.label.segments')}</label>
971
1262
  <div segments>
972
- ${SEGMENT_PRESETS.map(preset => {
1263
+ ${common.map(preset => {
973
1264
  const triangles = trianglesOf({ ...part, segments: preset })
974
1265
  return html`
975
1266
  <button seg ?on=${current === preset} @click=${() => this.put({ segments: preset })}>
@@ -981,9 +1272,13 @@ export class FigureInspector extends localize(i18next)(LitElement) {
981
1272
  })}
982
1273
  </div>
983
1274
 
984
- <details free>
985
- <summary>${i18next.t('figure.text.other-segment-count')}</summary>
986
- ${this.number('', current, value => this.put({ segments: value }), 3)}
1275
+ <details free ?open=${advanced.includes(current)}>
1276
+ <summary>${i18next.t('figure.text.more-segment-options')}</summary>
1277
+ <div presets>
1278
+ ${advanced.map(preset => html`
1279
+ <button ?on=${current === preset} @click=${() => this.put({ segments: preset })}>${preset}</button>
1280
+ `)}
1281
+ </div>
987
1282
  </details>
988
1283
  </div>
989
1284
  `
@@ -1011,17 +1306,38 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1011
1306
  }
1012
1307
 
1013
1308
  /** 견본 한 줄. 무리마다 부른다. */
1309
+ private palettePurpose(token?: string) {
1310
+ const purposes: Record<string, string> = {
1311
+ 'palette.primary': 'primary',
1312
+ 'palette.accent': 'accent',
1313
+ 'palette.neutral': 'neutral',
1314
+ 'palette.dark': 'dark',
1315
+ 'palette.light': 'light',
1316
+ 'palette.surface': 'surface',
1317
+ 'palette.status-danger': 'danger',
1318
+ 'palette.status-warning': 'warning',
1319
+ 'palette.status-info': 'info',
1320
+ 'palette.status-success': 'success'
1321
+ }
1322
+
1323
+ return i18next.t(`figure.label.palette-purpose-${purposes[token ?? ''] ?? 'registered'}`)
1324
+ }
1325
+
1014
1326
  private swatches(entries: [string, string][], chosen?: string) {
1015
1327
  return entries.map(
1016
- ([token, color]) => html`
1328
+ ([token, color]) => {
1329
+ const purpose = this.palettePurpose(token)
1330
+ return html`
1017
1331
  <button
1018
1332
  swatch
1019
1333
  ?on=${token === chosen}
1020
- title=${token}
1334
+ aria-label=${purpose}
1335
+ title=${purpose}
1021
1336
  style="--swatch:${color}"
1022
1337
  @click=${() => this.putMaterial({ token })}
1023
1338
  ></button>
1024
1339
  `
1340
+ }
1025
1341
  )
1026
1342
  }
1027
1343
 
@@ -1048,7 +1364,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1048
1364
  <h3>${i18next.t('figure.label.part-material')}</h3>
1049
1365
 
1050
1366
  <div field>
1051
- <label>${i18next.t('figure.label.palette-token')}</label>
1367
+ <label>${i18next.t('figure.label.palette-role')}</label>
1052
1368
  <div swatches>
1053
1369
  ${this.swatches(painted, material.token)}
1054
1370
  </div>
@@ -1060,7 +1376,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1060
1376
  </div>
1061
1377
  `
1062
1378
  : ''}
1063
- <span token>${material.token ?? i18next.t('figure.label.no-color-chosen')}</span>
1379
+ <div palette-meaning>
1380
+ <b>${material.token ? this.palettePurpose(material.token) : i18next.t('figure.label.no-color-chosen')}</b>
1381
+ <small>${i18next.t('figure.text.palette-purpose-explained')}</small>
1382
+ </div>
1064
1383
  </div>
1065
1384
 
1066
1385
  <div field>
@@ -1185,21 +1504,25 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1185
1504
  return html`
1186
1505
  <section>
1187
1506
  <h3>${i18next.t('figure.label.sizing-rule')}${this.help('figure.text.sizing-' + sizing + '-explained')}</h3>
1188
- <select @change=${(e: Event) => this.put({ sizing: (e.target as HTMLSelectElement).value as never })}>
1507
+ <div sizing-choices>
1189
1508
  ${SIZING_RULES.map(
1190
1509
  rule => html`
1191
- <option value=${rule} ?selected=${rule === sizing}>${i18next.t('figure.label.sizing-' + rule)}</option>
1510
+ <button sizing-choice ?on=${rule === sizing} @click=${() => this.put({ sizing: rule })}>
1511
+ <b>${i18next.t('figure.label.sizing-' + rule)}</b>
1512
+ <small>${i18next.t('figure.text.sizing-' + rule + '-explained')}</small>
1513
+ </button>
1192
1514
  `
1193
1515
  )}
1194
- </select>
1195
- ${sizing === 'scale' ? nothing : this.help('figure.text.sizing-costs-one-draw-call')}
1196
- ${sizing === 'repeat' ? this.help('figure.text.repeat-edits-the-unit') : nothing}
1516
+ </div>
1517
+ ${sizing === 'scale'
1518
+ ? nothing
1519
+ : html`<p cost>${i18next.t('figure.text.sizing-costs-one-draw-call')}</p>`}
1197
1520
 
1198
1521
  ${this.renderAnchors(part)}
1199
1522
 
1200
1523
  ${sizing === 'repeat'
1201
1524
  ? html`
1202
- <div field>
1525
+ <div inline-field>
1203
1526
  <label>${i18next.t('figure.label.repeat-axis')}</label>
1204
1527
  <select
1205
1528
  @change=${(e: Event) =>
@@ -1210,9 +1533,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1210
1533
  )}
1211
1534
  </select>
1212
1535
  </div>
1213
- <div field>
1214
- <label>${i18next.t('figure.label.repeat-pitch')}${this.help('figure.text.repeat-pitch-explained')}</label>
1536
+ <div inline-field>
1537
+ <label>${i18next.t('figure.label.repeat-pitch')}${this.helpButton('figure.text.repeat-pitch-explained')}</label>
1215
1538
  ${this.number('', repeat.pitch, value => this.put({ repeat: { ...repeat, pitch: value } }), 1)}
1539
+ ${this.helpHint('figure.text.repeat-pitch-explained')}
1216
1540
  </div>
1217
1541
  ${this.renderRepeatCount(part)}
1218
1542
  `
@@ -1302,6 +1626,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1302
1626
  * 계속 고치는 사람이 매번 다시 펴야 하면 접은 것이 되레 방해가 된다.
1303
1627
  */
1304
1628
  private help(key: string) {
1629
+ return html`${this.helpButton(key)}${this.helpHint(key)}`
1630
+ }
1631
+
1632
+ private helpButton(key: string) {
1305
1633
  const shown = this.opened.has(key)
1306
1634
 
1307
1635
  return html`
@@ -1316,20 +1644,38 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1316
1644
  >
1317
1645
  <md-icon>${shown ? 'help' : 'help_outline'}</md-icon>
1318
1646
  </button>
1319
- ${shown ? html`<p hint>${i18next.t(key)}</p>` : nothing}
1320
1647
  `
1321
1648
  }
1322
1649
 
1650
+ private helpHint(key: string) {
1651
+ return this.opened.has(key) ? html`<p hint>${i18next.t(key)}</p>` : nothing
1652
+ }
1653
+
1323
1654
  private renderAnchors(part: FigurePart) {
1324
1655
  const base = this.board
1325
1656
  ? { x: this.board.width, y: this.board.height, z: this.board.depth }
1326
1657
  : { x: 0, y: 0, z: 0 }
1658
+ const nameOf = (rule: string, axis: Axis) =>
1659
+ `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
1660
+ const setAnchor = (axis: Axis, rule?: AnchorRule) => {
1661
+ const anchor = { ...(part.anchor ?? {}) }
1662
+ /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1663
+ if (rule) anchor[axis] = rule
1664
+ else delete anchor[axis]
1665
+ this.put({ anchor: Object.keys(anchor).length ? anchor : undefined })
1666
+ }
1667
+ const shortNameOf = (rule: AnchorRule | 'auto', axis: Axis) => {
1668
+ if (rule === 'auto') return i18next.t('figure.label.anchor-choice-auto')
1669
+ if (rule === 'min' || rule === 'max') return i18next.t(`figure.label.anchor-choice-${rule}-${axis}`)
1670
+ return i18next.t(`figure.label.anchor-choice-${rule}`)
1671
+ }
1327
1672
 
1328
1673
  return html`
1329
1674
  <div field>
1330
- <!-- anchor.x」로 적힌 사유를 화면에서 찾을 있게 이름에 그 낱말을 남긴다 -->
1675
+ <!-- 저장 키(anchor)는 남기되, 사용자는 축마다 어느 면을 기준으로 삼는지 읽는다. -->
1331
1676
  <label>${i18next.t('figure.label.anchor')} (anchor)${this.help('figure.text.anchor-explained')}</label>
1332
- ${(['x', 'y', 'z'] as const).map(axis => {
1677
+ <div anchor-rows>
1678
+ ${(['x', 'y', 'z'] as const).map(axis => {
1333
1679
  const said = part.anchor?.[axis]
1334
1680
  const auto = anchorOfPart(part, base, axis)
1335
1681
 
@@ -1348,35 +1694,42 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1348
1694
  `max` 는 정본에 저장되는 이름이고 서버 오류·형식 문서·게이트 사유가 다 그 이름을 쓴다.
1349
1695
  숨기면 안 되는 종류다. 동작이 먼저고 이름이 괄호로 따라온다.
1350
1696
  */
1351
- const nameOf = (rule: string) =>
1352
- `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
1353
-
1354
1697
  return html`
1355
- <div cell>
1356
- <span axis>${axis}</span>
1357
- <select
1358
- @change=${(e: Event) => {
1359
- const value = (e.target as HTMLSelectElement).value
1360
- const anchor = { ...(part.anchor ?? {}) }
1361
- /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1362
- if (value) {
1363
- anchor[axis] = value as AnchorRule
1364
- } else {
1365
- delete anchor[axis]
1366
- }
1367
- this.put({ anchor: Object.keys(anchor).length ? anchor : undefined })
1368
- }}
1369
- >
1370
- <option value="" ?selected=${!said}>
1371
- ${i18next.t('figure.label.anchor-auto', { rule: nameOf(auto) })}
1372
- </option>
1373
- ${ANCHOR_RULES.map(
1374
- rule => html`<option value=${rule} ?selected=${rule === said}>${nameOf(rule)}</option>`
1375
- )}
1376
- </select>
1698
+ <div anchor-row>
1699
+ <label>${axis.toUpperCase()}축</label>
1700
+ <div anchor-options>
1701
+ ${(['auto', 'scale', 'min', 'center', 'max', 'span'] as const).map(rule => {
1702
+ const selected = rule === 'auto' ? !said : rule === said
1703
+ const fullName = rule === 'auto' ? i18next.t('figure.label.anchor-auto', { rule: nameOf(auto, axis) }) : nameOf(rule, axis)
1704
+ return html`
1705
+ <button
1706
+ anchor-choice
1707
+ ?on=${selected}
1708
+ aria-pressed=${selected ? 'true' : 'false'}
1709
+ title=${fullName}
1710
+ @click=${() => setAnchor(axis, rule === 'auto' ? undefined : rule)}
1711
+ >${shortNameOf(rule, axis)}</button>
1712
+ `
1713
+ })}
1714
+ </div>
1715
+ <details anchor-advanced>
1716
+ <summary>${i18next.t('figure.label.anchor-advanced')}</summary>
1717
+ <div anchor-options>
1718
+ ${ANCHOR_RULES.filter(rule => rule.startsWith('aspect-')).map(rule => html`
1719
+ <button
1720
+ anchor-choice
1721
+ ?on=${rule === said}
1722
+ aria-pressed=${rule === said ? 'true' : 'false'}
1723
+ title=${nameOf(rule, axis)}
1724
+ @click=${() => setAnchor(axis, rule)}
1725
+ >${i18next.t(`figure.label.anchor-choice-${rule}`)}</button>
1726
+ `)}
1727
+ </div>
1728
+ </details>
1377
1729
  </div>
1378
1730
  `
1379
- })}
1731
+ })}
1732
+ </div>
1380
1733
  </div>
1381
1734
  `
1382
1735
  }