@things-factory/figure-ui 10.1.7 → 10.1.8

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
@@ -69,6 +69,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
69
69
  display: flex;
70
70
  flex-direction: column;
71
71
  min-height: 0;
72
+ --figure-control-height: 30px;
72
73
  overflow-y: auto;
73
74
  background-color: var(--md-sys-color-surface-container-lowest);
74
75
  color: var(--md-sys-color-on-surface);
@@ -93,9 +94,39 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
93
94
  칸 높이는 건드리지 않는다(작게 만드는 것이 아니라 **빈 곳을 줄이는 것**이다).
94
95
  */
95
96
  section {
96
- padding: 6px 10px;
97
+ padding: 8px 10px;
97
98
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
98
99
  }
100
+ /* 입력 행은 촘촘하게, 서로 다른 편집 의도 사이에는 호흡을 둔다. */
101
+ section + section {
102
+ margin-top: 6px;
103
+ }
104
+ section[collapsible] {
105
+ padding: 0;
106
+ }
107
+ details[group] > summary {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ min-height: 34px;
112
+ padding: 0 10px;
113
+ cursor: pointer;
114
+ list-style: none;
115
+ }
116
+ details[group] > summary::-webkit-details-marker {
117
+ display: none;
118
+ }
119
+ details[group] > summary h3 {
120
+ margin: 0;
121
+ }
122
+ details[group] > summary md-icon {
123
+ --md-icon-size: 16px;
124
+ color: var(--md-sys-color-on-surface-variant);
125
+ }
126
+ details[group] > div[group-content] {
127
+ padding: 4px 10px 8px;
128
+ border-top: 1px solid var(--md-sys-color-outline-variant);
129
+ }
99
130
  h3 {
100
131
  display: flex;
101
132
  align-items: center;
@@ -108,6 +139,42 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
108
139
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
109
140
  }
110
141
 
142
+ /* 자산 메타데이터는 부품을 고친다는 현재 문맥에서는 요약으로만 둔다. */
143
+ details[asset] {
144
+ margin: -1px 0;
145
+ }
146
+ details[asset] summary {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 6px;
150
+ min-height: 26px;
151
+ cursor: pointer;
152
+ list-style: none;
153
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
154
+ }
155
+ details[asset] summary::-webkit-details-marker {
156
+ display: none;
157
+ }
158
+ details[asset] summary::before {
159
+ content: '›';
160
+ font-size: 1.1rem;
161
+ line-height: 1;
162
+ transition: transform 120ms ease;
163
+ }
164
+ details[asset][open] summary::before {
165
+ transform: rotate(90deg);
166
+ }
167
+ details[asset] summary b {
168
+ font-size: var(--ui-t-micro, 11px);
169
+ letter-spacing: 0.04em;
170
+ }
171
+ details[asset] summary small {
172
+ overflow: hidden;
173
+ text-overflow: ellipsis;
174
+ white-space: nowrap;
175
+ font-size: 0.7rem;
176
+ }
177
+
111
178
  label {
112
179
  display: flex;
113
180
  align-items: center;
@@ -153,6 +220,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
153
220
  input,
154
221
  select {
155
222
  width: 100%;
223
+ min-height: var(--figure-control-height);
156
224
  box-sizing: border-box;
157
225
  padding: 4px 7px;
158
226
  font: var(--input-field-font, inherit);
@@ -177,6 +245,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
177
245
  */
178
246
  div[cell] {
179
247
  display: flex;
248
+ box-sizing: border-box;
249
+ height: var(--figure-control-height);
250
+ min-height: var(--figure-control-height);
180
251
  align-items: center;
181
252
  gap: 2px;
182
253
  padding-left: 6px;
@@ -197,6 +268,8 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
197
268
  }
198
269
  div[cell] input {
199
270
  min-width: 0;
271
+ min-height: 0;
272
+ height: calc(var(--figure-control-height) - 2px);
200
273
  padding: 4px 6px 4px 2px;
201
274
  font-family: var(--mono-font, monospace);
202
275
  text-align: right;
@@ -216,11 +289,163 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
216
289
  }
217
290
  div[field] + div[field],
218
291
  div[triple] + div[field],
219
- div[field] + div[triple] {
292
+ div[field] + div[triple],
293
+ div[inline-field] + div[inline-field],
294
+ div[inline-field] + div[field],
295
+ div[field] + div[inline-field],
296
+ div[vector-row] + div[vector-row],
297
+ div[vector-row] + div[field],
298
+ div[field] + div[vector-row] {
220
299
  /* 칸 사이 7 → 5. 칸 자체는 그대로 두고 사이만 좁힌다. */
221
300
  margin-top: 5px;
222
301
  }
223
302
 
303
+ /* 짧은 라벨과 단일 값은 같은 줄에서 읽는다. 복잡한 선택기에는 쓰지 않는다. */
304
+ div[inline-field] {
305
+ display: grid;
306
+ /* 같은 편집 밀도 안에서는 입력 시작선을 맞춘다. 라벨 길이가 칸 폭을 흔들면 안 된다. */
307
+ grid-template-columns: 64px minmax(0, 1fr);
308
+ align-items: center;
309
+ gap: 7px;
310
+ }
311
+ div[inline-field] > label {
312
+ margin: 0;
313
+ white-space: nowrap;
314
+ }
315
+ /* 한 줄 입력의 설명은 입력칸을 밀어내지 않고 바로 아래 전폭에서 읽는다. */
316
+ div[inline-field] > p[hint] {
317
+ grid-column: 1 / -1;
318
+ margin: -2px 0 1px;
319
+ }
320
+ div[inline-field] > code[fixed] {
321
+ min-width: 0;
322
+ overflow: hidden;
323
+ text-overflow: ellipsis;
324
+ white-space: nowrap;
325
+ }
326
+
327
+ /* X·Y·Z는 함께 비교하는 값이므로, 묶음 제목만 왼쪽에 두고 입력은 한 행에 둔다. */
328
+ div[vector-row] {
329
+ display: grid;
330
+ grid-template-columns: 64px minmax(0, 1fr);
331
+ align-items: center;
332
+ gap: 7px;
333
+ }
334
+ div[vector-row] > label {
335
+ margin: 0;
336
+ white-space: nowrap;
337
+ }
338
+ div[vector-row] > p[hint] {
339
+ grid-column: 1 / -1;
340
+ margin: -2px 0 1px;
341
+ }
342
+ div[sizing-choices] {
343
+ display: grid;
344
+ grid-template-columns: 1fr 1fr;
345
+ gap: 5px;
346
+ }
347
+ button[sizing-choice] {
348
+ display: flex;
349
+ flex-direction: column;
350
+ align-items: flex-start;
351
+ gap: 2px;
352
+ min-height: 52px;
353
+ padding: 7px 8px;
354
+ color: var(--md-sys-color-on-surface);
355
+ background: var(--md-sys-color-surface-container-lowest);
356
+ border: 1px solid var(--md-sys-color-outline-variant);
357
+ border-radius: 7px;
358
+ text-align: left;
359
+ cursor: pointer;
360
+ }
361
+ button[sizing-choice][on] {
362
+ color: var(--md-sys-color-on-primary-container);
363
+ background: var(--md-sys-color-primary-container);
364
+ border-color: transparent;
365
+ }
366
+ button[sizing-choice] b {
367
+ font-size: 0.74rem;
368
+ }
369
+ button[sizing-choice] small {
370
+ font-size: 0.64rem;
371
+ line-height: 1.35;
372
+ opacity: 0.8;
373
+ }
374
+ div[anchor-rows] {
375
+ display: grid;
376
+ gap: 6px;
377
+ }
378
+ div[anchor-row] {
379
+ display: grid;
380
+ grid-template-columns: 46px minmax(0, 1fr);
381
+ align-items: start;
382
+ gap: 8px;
383
+ padding: 6px 7px;
384
+ background: var(--md-sys-color-surface-container);
385
+ border-radius: 7px;
386
+ }
387
+ div[anchor-row] label {
388
+ margin: 0;
389
+ font-size: 0.7rem;
390
+ font-weight: 700;
391
+ }
392
+ div[anchor-options] {
393
+ display: grid;
394
+ grid-template-columns: repeat(3, minmax(0, 1fr));
395
+ gap: 4px;
396
+ }
397
+ button[anchor-choice] {
398
+ min-width: 0;
399
+ height: var(--figure-control-height);
400
+ box-sizing: border-box;
401
+ padding: 4px 3px;
402
+ overflow: hidden;
403
+ color: var(--md-sys-color-on-surface);
404
+ font: var(--label-font, inherit);
405
+ font-size: 0.63rem;
406
+ line-height: 1.2;
407
+ text-overflow: ellipsis;
408
+ white-space: nowrap;
409
+ background: var(--md-sys-color-surface-container-lowest);
410
+ border: 1px solid var(--md-sys-color-outline-variant);
411
+ border-radius: 5px;
412
+ cursor: pointer;
413
+ }
414
+ button[anchor-choice][on] {
415
+ color: var(--md-sys-color-on-primary-container);
416
+ background: var(--md-sys-color-primary-container);
417
+ border-color: transparent;
418
+ }
419
+ details[anchor-advanced] {
420
+ grid-column: 2;
421
+ margin-top: 4px;
422
+ }
423
+ details[anchor-advanced] summary {
424
+ cursor: pointer;
425
+ font-size: 0.62rem;
426
+ color: var(--md-sys-color-on-surface-variant);
427
+ }
428
+ details[anchor-advanced] div[anchor-options] {
429
+ margin-top: 4px;
430
+ }
431
+ div[palette-meaning] {
432
+ display: grid;
433
+ gap: 2px;
434
+ margin-top: 7px;
435
+ padding: 6px 8px;
436
+ color: var(--md-sys-color-on-surface);
437
+ background: var(--md-sys-color-surface-container);
438
+ border-radius: 7px;
439
+ }
440
+ div[palette-meaning] b {
441
+ font-size: 0.72rem;
442
+ }
443
+ div[palette-meaning] small {
444
+ color: var(--md-sys-color-on-surface-variant);
445
+ font-size: 0.64rem;
446
+ line-height: 1.35;
447
+ }
448
+
224
449
  /*
225
450
  정밀 제작 도구.
226
451
 
@@ -229,13 +454,17 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
229
454
  늘 조금씩 어긋나고, 기준 치수를 바꾸면 전부 다시 해야 한다.
230
455
  */
231
456
  div[tools] {
232
- display: flex;
233
- flex-wrap: wrap;
234
- gap: 3px;
235
- margin-top: 6px;
457
+ display: grid;
458
+ grid-template-columns: repeat(6, minmax(0, 1fr));
459
+ gap: 4px;
236
460
  }
237
461
  div[tools] button {
238
- padding: 3px 8px;
462
+ display: grid;
463
+ place-items: center;
464
+ min-width: 0;
465
+ height: var(--figure-control-height);
466
+ box-sizing: border-box;
467
+ padding: 0;
239
468
  font: var(--label-font, inherit);
240
469
  font-size: 0.7rem;
241
470
  color: var(--md-sys-color-on-surface);
@@ -244,6 +473,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
244
473
  border-radius: 6px;
245
474
  cursor: pointer;
246
475
  }
476
+ div[tools] button md-icon {
477
+ font-size: 17px;
478
+ }
247
479
  div[tools] button[disabled] {
248
480
  opacity: 0.35;
249
481
  cursor: default;
@@ -253,11 +485,6 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
253
485
  background-color: var(--md-sys-color-primary-container);
254
486
  border-color: transparent;
255
487
  }
256
- div[tools] span[sep] {
257
- width: 1px;
258
- margin: 2px 3px;
259
- background-color: var(--md-sys-color-outline-variant);
260
- }
261
488
 
262
489
  /* 지금 어느 축이 늘어나나 — 크기를 고치면 바뀔 수 있어서 값으로 적는다 */
263
490
  p[axis-now] {
@@ -316,7 +543,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
316
543
  */
317
544
  div[segments] {
318
545
  display: grid;
319
- grid-template-columns: repeat(3, 1fr);
546
+ grid-template-columns: repeat(4, 1fr);
320
547
  gap: 4px;
321
548
  }
322
549
  /* 원 유지 · 끄기 — 고르는 값이라 분할 수와 같은 모양으로 둔다 */
@@ -374,8 +601,8 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
374
601
  button[seg] svg {
375
602
  display: block;
376
603
  /* 작으면 12 와 16 이 둘 다 동그라미로 보인다 — 각이 읽힐 만큼은 키운다 */
377
- width: 40px;
378
- height: 40px;
604
+ width: 32px;
605
+ height: 32px;
379
606
  }
380
607
  button[seg] svg polygon {
381
608
  fill: var(--md-sys-color-surface-container-highest);
@@ -398,7 +625,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
398
625
  button[seg][on] small {
399
626
  color: inherit;
400
627
  }
401
- /* 프리셋 밖의 값도 있다. 다만 권하는 쪽을 크게 둔다 */
628
+ /* 성능·실루엣 검증을 마친 프리셋만 고른다. */
402
629
  details[free] {
403
630
  margin-top: 6px;
404
631
  }
@@ -491,6 +718,26 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
491
718
  label[toggle] input {
492
719
  width: auto;
493
720
  }
721
+ div[role-choices] {
722
+ display: grid;
723
+ grid-template-columns: repeat(3, minmax(0, 1fr));
724
+ gap: 4px;
725
+ }
726
+ div[role-choices] label[toggle] {
727
+ justify-content: center;
728
+ min-height: 30px;
729
+ margin: 0;
730
+ padding: 2px 4px;
731
+ border: 1px solid var(--md-sys-color-outline-variant);
732
+ border-radius: 6px;
733
+ font-size: 0.68rem;
734
+ text-align: center;
735
+ }
736
+ div[role-choices] label[toggle]:has(input:checked) {
737
+ color: var(--md-sys-color-on-primary-container);
738
+ background: var(--md-sys-color-primary-container);
739
+ border-color: transparent;
740
+ }
494
741
 
495
742
  p[quiet] {
496
743
  margin: 0;
@@ -531,7 +778,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
531
778
  const part = this.chosen;
532
779
  if (!part || !this.board) {
533
780
  return html `
534
- ${this.renderFigure()}
781
+ ${this.renderFigure(false)}
535
782
  <section>
536
783
  <h3>${i18next.t('figure.label.properties')}</h3>
537
784
  <p quiet>${i18next.t('figure.text.select-a-part-to-edit')}</p>
@@ -548,7 +795,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
548
795
  묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
549
796
  */
550
797
  return html `
551
- ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
798
+ ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
552
799
  ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
553
800
  ${this.renderBehaviour(shown)}
554
801
  `;
@@ -572,16 +819,18 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
572
819
  <section>
573
820
  <h3>${i18next.t('figure.label.role-in-capability')}${this.help('figure.text.anchors-are-not-drawn')}</h3>
574
821
 
575
- ${PART_ROLES.map(one => html `
576
- <label toggle>
577
- <input
578
- type="checkbox"
579
- .checked=${live(roles.includes(one))}
580
- @change=${(e) => this.toggleRole(one, e.target.checked)}
581
- />
582
- ${i18next.t('figure.label.role-' + one)}
583
- </label>
584
- `)}
822
+ <div role-choices>
823
+ ${PART_ROLES.map(one => html `
824
+ <label toggle>
825
+ <input
826
+ type="checkbox"
827
+ .checked=${live(roles.includes(one))}
828
+ @change=${(e) => this.toggleRole(one, e.target.checked)}
829
+ />
830
+ ${i18next.t('figure.label.role-' + one)}
831
+ </label>
832
+ `)}
833
+ </div>
585
834
  ${roles.length === 0 ? html `<p quiet>${i18next.t('figure.text.role-none-means-drawn')}</p>` : nothing}
586
835
 
587
836
  ${roles.includes('slot')
@@ -666,11 +915,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
666
915
  *
667
916
  * 저장되는 식별자다. 배치된 보드가 이 이름으로 타입을 찾으므로 만든 뒤에는 못 바꾼다.
668
917
  */
669
- renderFigure() {
670
- return html `
671
- <section>
672
- <h3>${i18next.t('figure.label.figure')}</h3>
673
- <div field>
918
+ renderFigure(compact = false) {
919
+ const fields = html `
920
+ <div inline-field>
674
921
  <label>${i18next.t('figure.label.name')}</label>
675
922
  <input
676
923
  .value=${this.figureName}
@@ -682,34 +929,55 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
682
929
  }))}
683
930
  />
684
931
  </div>
685
- <div field>
686
- <label>${i18next.t('figure.label.figure-type')}${this.help('figure.text.type-name-cannot-change')}</label>
932
+ <div inline-field>
933
+ <label>${i18next.t('figure.label.figure-type')}${this.helpButton('figure.text.type-name-cannot-change')}</label>
687
934
  <code fixed title=${i18next.t('figure.text.type-name-cannot-change')}>
688
935
  <md-icon>lock</md-icon>${this.board?.figureType ?? ''}
689
936
  </code>
937
+ ${this.helpHint('figure.text.type-name-cannot-change')}
690
938
  </div>
691
939
  <!--
692
940
  왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
693
941
  알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
694
942
  -->
695
943
 
696
- <div field>
697
- <label>${i18next.t('figure.label.category')}${this.help('figure.text.category-explained')}</label>
944
+ <div inline-field>
945
+ <label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
698
946
  <input
699
947
  .value=${this.figureCategory ?? ''}
700
948
  placeholder=${i18next.t('figure.text.category-placeholder')}
701
949
  @change=${(e) => this.tellCatalog({ category: e.target.value.trim() || undefined })}
702
950
  />
951
+ ${this.helpHint('figure.text.category-explained')}
703
952
  </div>
704
953
 
705
- <div field>
706
- <label>${i18next.t('figure.label.tags')}${this.help('figure.text.tags-explained')}</label>
954
+ <div inline-field>
955
+ <label>${i18next.t('figure.label.tags')}${this.helpButton('figure.text.tags-explained')}</label>
707
956
  <input
708
957
  .value=${(this.figureTags ?? []).join(', ')}
709
958
  placeholder=${i18next.t('figure.text.tags-placeholder')}
710
959
  @change=${(e) => this.tellTags(e.target.value)}
711
960
  />
961
+ ${this.helpHint('figure.text.tags-explained')}
712
962
  </div>
963
+ `;
964
+ if (compact) {
965
+ return html `
966
+ <section>
967
+ <details asset>
968
+ <summary>
969
+ <b>${i18next.t('figure.label.figure')}</b>
970
+ <small>${this.figureName || this.board?.figureType || ''}</small>
971
+ </summary>
972
+ ${fields}
973
+ </details>
974
+ </section>
975
+ `;
976
+ }
977
+ return html `
978
+ <section>
979
+ <h3>${i18next.t('figure.label.figure')}</h3>
980
+ ${fields}
713
981
  </section>
714
982
  `;
715
983
  }
@@ -737,14 +1005,15 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
737
1005
  return html `
738
1006
  <section>
739
1007
  <h3>${i18next.t('figure.label.part')}</h3>
740
- <div field>
741
- <label>${i18next.t('figure.label.name')}${this.help('figure.text.renaming-a-part-breaks-bindings')}</label>
1008
+ <div inline-field>
1009
+ <label>${i18next.t('figure.label.name')}${this.helpButton('figure.text.renaming-a-part-breaks-bindings')}</label>
742
1010
  <input
743
1011
  .value=${part.name}
744
1012
  @change=${(e) => this.put({ name: e.target.value })}
745
1013
  />
1014
+ ${this.helpHint('figure.text.renaming-a-part-breaks-bindings')}
746
1015
  </div>
747
- <div field>
1016
+ <div inline-field>
748
1017
  <label>${i18next.t('figure.label.shape')}</label>
749
1018
  <select @change=${(e) => this.changePrimitive(e.target.value)}>
750
1019
  ${PRIMITIVE_KINDS.map(kind => html `
@@ -764,56 +1033,74 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
764
1033
  <section>
765
1034
  <h3>${i18next.t('figure.label.placement')}</h3>
766
1035
 
767
- <label>${i18next.t('figure.label.part-position')}</label>
768
- <div triple>
769
- ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.position[axis], value => this.putTransform({ position: { ...t.position, [axis]: value } })))}
1036
+ <div vector-row>
1037
+ <label>${i18next.t('figure.label.part-position')}</label>
1038
+ <div triple>
1039
+ ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.position[axis], value => this.putTransform({ position: { ...t.position, [axis]: value } })))}
1040
+ </div>
770
1041
  </div>
771
1042
 
772
- <div field>
1043
+ <div vector-row>
773
1044
  <label>${i18next.t('figure.label.part-size')}</label>
774
1045
  <div triple>
775
1046
  ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.size[axis], value => this.putTransform({ size: { ...t.size, [axis]: value } }), 1))}
776
1047
  </div>
777
1048
  </div>
778
1049
 
779
- <div field>
780
- <label>${i18next.t('figure.label.tools')}</label>
1050
+ <div vector-row>
1051
+ <label>${i18next.t('figure.label.rotation-degrees')}</label>
1052
+ <div triple>
1053
+ ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), rotation[axis] ?? 0, value => this.putTransform({ rotation: { ...rotation, [axis]: value } })))}
1054
+ </div>
1055
+ </div>
1056
+
1057
+ <div vector-row>
1058
+ <label>${i18next.t('figure.label.tools')}${this.helpButton('figure.text.tools-explained')}</label>
781
1059
  <div tools>
782
- <button title=${i18next.t('figure.text.center-on-x')} @click=${() => this.ask('center', 'x')}>
783
- ${i18next.t('figure.button.center-x')}
1060
+ <button
1061
+ aria-label=${i18next.t('figure.button.center-x')}
1062
+ title=${i18next.t('figure.text.center-on-x')}
1063
+ @click=${() => this.ask('center', 'x')}
1064
+ >
1065
+ <md-icon>horizontal_distribute</md-icon>
784
1066
  </button>
785
- <button title=${i18next.t('figure.text.center-on-z')} @click=${() => this.ask('center', 'z')}>
786
- ${i18next.t('figure.button.center-z')}
1067
+ <button
1068
+ aria-label=${i18next.t('figure.button.center-z')}
1069
+ title=${i18next.t('figure.text.center-on-z')}
1070
+ @click=${() => this.ask('center', 'z')}
1071
+ >
1072
+ <md-icon>vertical_align_center</md-icon>
787
1073
  </button>
788
- <button title=${i18next.t('figure.text.put-on-floor')} @click=${() => this.ask('floor', 'y')}>
789
- ${i18next.t('figure.button.to-floor')}
1074
+ <button
1075
+ aria-label=${i18next.t('figure.button.to-floor')}
1076
+ title=${i18next.t('figure.text.put-on-floor')}
1077
+ @click=${() => this.ask('floor', 'y')}
1078
+ >
1079
+ <md-icon>vertical_align_bottom</md-icon>
790
1080
  </button>
791
- <span sep></span>
792
1081
  ${['x', 'z'].map(axis => {
793
1082
  // 이미 가운데면 대칭이 제자리다 — 겹친 부품이 조용히 생기지 않게 막는다
794
1083
  const able = this.board ? canMirror(this.board, this.parts, this.selected, axis) : false;
795
1084
  return html `
796
1085
  <button
797
1086
  ?disabled=${!able}
1087
+ aria-label=${i18next.t('figure.button.mirror-' + axis)}
798
1088
  title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
799
1089
  @click=${() => this.ask('mirror', axis)}
800
1090
  >
801
- ${i18next.t('figure.button.mirror-' + axis)}
1091
+ <md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
802
1092
  </button>
803
1093
  `;
804
1094
  })}
805
- <span sep></span>
806
- <button title=${i18next.t('figure.text.snap-explained')} @click=${() => this.ask('snap', 'x')}>
807
- ${i18next.t('figure.button.snap')}
1095
+ <button
1096
+ aria-label=${i18next.t('figure.button.snap')}
1097
+ title=${i18next.t('figure.text.snap-explained')}
1098
+ @click=${() => this.ask('snap', 'x')}
1099
+ >
1100
+ <md-icon>grid_on</md-icon>
808
1101
  </button>
809
1102
  </div>
810
- </div>
811
-
812
- <div field>
813
- <label>${i18next.t('figure.label.rotation-degrees')}</label>
814
- <div triple>
815
- ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), rotation[axis] ?? 0, value => this.putTransform({ rotation: { ...rotation, [axis]: value } })))}
816
- </div>
1103
+ ${this.helpHint('figure.text.tools-explained')}
817
1104
  </div>
818
1105
  </section>
819
1106
  `;
@@ -845,17 +1132,17 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
845
1132
  const wall = hollow?.wall ?? 0;
846
1133
  const reshape = (over) => this.put({ shape: { ...(part.shape ?? {}), ...over } });
847
1134
  return html `
848
- <div field>
1135
+ <div inline-field>
849
1136
  <label>${i18next.t('figure.label.corner-round')}</label>
850
1137
  ${this.number('', part.shape?.round ?? 0, value => reshape({ round: value }))}
851
1138
  </div>
852
- <div field>
1139
+ <div inline-field>
853
1140
  <label>${i18next.t('figure.label.hollow-wall')}</label>
854
1141
  ${this.number('', wall, value => reshape({ hollow: value > 0 ? { ...(hollow ?? {}), wall: value } : undefined }))}
855
1142
  </div>
856
1143
  ${wall > 0
857
1144
  ? html `
858
- <div field>
1145
+ <div inline-field>
859
1146
  <label>${i18next.t('figure.label.hollow-floor')}</label>
860
1147
  ${this.number('', hollow?.floor ?? wall, value => reshape({ hollow: { ...hollow, wall, floor: value } }))}
861
1148
  </div>
@@ -897,11 +1184,13 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
897
1184
  }
898
1185
  renderSegments(part) {
899
1186
  const current = part.segments ?? SEGMENT_PRESETS[1];
1187
+ const common = SEGMENT_PRESETS.slice(0, 4);
1188
+ const advanced = SEGMENT_PRESETS.slice(4);
900
1189
  return html `
901
1190
  <div field>
902
1191
  <label>${i18next.t('figure.label.segments')}</label>
903
1192
  <div segments>
904
- ${SEGMENT_PRESETS.map(preset => {
1193
+ ${common.map(preset => {
905
1194
  const triangles = trianglesOf({ ...part, segments: preset });
906
1195
  return html `
907
1196
  <button seg ?on=${current === preset} @click=${() => this.put({ segments: preset })}>
@@ -913,9 +1202,13 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
913
1202
  })}
914
1203
  </div>
915
1204
 
916
- <details free>
917
- <summary>${i18next.t('figure.text.other-segment-count')}</summary>
918
- ${this.number('', current, value => this.put({ segments: value }), 3)}
1205
+ <details free ?open=${advanced.includes(current)}>
1206
+ <summary>${i18next.t('figure.text.more-segment-options')}</summary>
1207
+ <div presets>
1208
+ ${advanced.map(preset => html `
1209
+ <button ?on=${current === preset} @click=${() => this.put({ segments: preset })}>${preset}</button>
1210
+ `)}
1211
+ </div>
919
1212
  </details>
920
1213
  </div>
921
1214
  `;
@@ -940,16 +1233,35 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
940
1233
  `;
941
1234
  }
942
1235
  /** 견본 한 줄. 무리마다 부른다. */
1236
+ palettePurpose(token) {
1237
+ const purposes = {
1238
+ 'palette.primary': 'primary',
1239
+ 'palette.accent': 'accent',
1240
+ 'palette.neutral': 'neutral',
1241
+ 'palette.dark': 'dark',
1242
+ 'palette.light': 'light',
1243
+ 'palette.surface': 'surface',
1244
+ 'palette.status-danger': 'danger',
1245
+ 'palette.status-warning': 'warning',
1246
+ 'palette.status-info': 'info',
1247
+ 'palette.status-success': 'success'
1248
+ };
1249
+ return i18next.t(`figure.label.palette-purpose-${purposes[token ?? ''] ?? 'registered'}`);
1250
+ }
943
1251
  swatches(entries, chosen) {
944
- return entries.map(([token, color]) => html `
1252
+ return entries.map(([token, color]) => {
1253
+ const purpose = this.palettePurpose(token);
1254
+ return html `
945
1255
  <button
946
1256
  swatch
947
1257
  ?on=${token === chosen}
948
- title=${token}
1258
+ aria-label=${purpose}
1259
+ title=${purpose}
949
1260
  style="--swatch:${color}"
950
1261
  @click=${() => this.putMaterial({ token })}
951
1262
  ></button>
952
- `);
1263
+ `;
1264
+ });
953
1265
  }
954
1266
  renderMaterial(part) {
955
1267
  const material = part.material ?? {};
@@ -971,7 +1283,7 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
971
1283
  <h3>${i18next.t('figure.label.part-material')}</h3>
972
1284
 
973
1285
  <div field>
974
- <label>${i18next.t('figure.label.palette-token')}</label>
1286
+ <label>${i18next.t('figure.label.palette-role')}</label>
975
1287
  <div swatches>
976
1288
  ${this.swatches(painted, material.token)}
977
1289
  </div>
@@ -983,7 +1295,10 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
983
1295
  </div>
984
1296
  `
985
1297
  : ''}
986
- <span token>${material.token ?? i18next.t('figure.label.no-color-chosen')}</span>
1298
+ <div palette-meaning>
1299
+ <b>${material.token ? this.palettePurpose(material.token) : i18next.t('figure.label.no-color-chosen')}</b>
1300
+ <small>${i18next.t('figure.text.palette-purpose-explained')}</small>
1301
+ </div>
987
1302
  </div>
988
1303
 
989
1304
  <div field>
@@ -1101,19 +1416,23 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1101
1416
  return html `
1102
1417
  <section>
1103
1418
  <h3>${i18next.t('figure.label.sizing-rule')}${this.help('figure.text.sizing-' + sizing + '-explained')}</h3>
1104
- <select @change=${(e) => this.put({ sizing: e.target.value })}>
1419
+ <div sizing-choices>
1105
1420
  ${SIZING_RULES.map(rule => html `
1106
- <option value=${rule} ?selected=${rule === sizing}>${i18next.t('figure.label.sizing-' + rule)}</option>
1421
+ <button sizing-choice ?on=${rule === sizing} @click=${() => this.put({ sizing: rule })}>
1422
+ <b>${i18next.t('figure.label.sizing-' + rule)}</b>
1423
+ <small>${i18next.t('figure.text.sizing-' + rule + '-explained')}</small>
1424
+ </button>
1107
1425
  `)}
1108
- </select>
1109
- ${sizing === 'scale' ? nothing : this.help('figure.text.sizing-costs-one-draw-call')}
1110
- ${sizing === 'repeat' ? this.help('figure.text.repeat-edits-the-unit') : nothing}
1426
+ </div>
1427
+ ${sizing === 'scale'
1428
+ ? nothing
1429
+ : html `<p cost>${i18next.t('figure.text.sizing-costs-one-draw-call')}</p>`}
1111
1430
 
1112
1431
  ${this.renderAnchors(part)}
1113
1432
 
1114
1433
  ${sizing === 'repeat'
1115
1434
  ? html `
1116
- <div field>
1435
+ <div inline-field>
1117
1436
  <label>${i18next.t('figure.label.repeat-axis')}</label>
1118
1437
  <select
1119
1438
  @change=${(e) => this.put({ repeat: { ...repeat, axis: e.target.value } })}
@@ -1121,9 +1440,10 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1121
1440
  ${['x', 'y', 'z'].map(axis => html `<option value=${axis} ?selected=${axis === repeat.axis}>${axis}</option>`)}
1122
1441
  </select>
1123
1442
  </div>
1124
- <div field>
1125
- <label>${i18next.t('figure.label.repeat-pitch')}${this.help('figure.text.repeat-pitch-explained')}</label>
1443
+ <div inline-field>
1444
+ <label>${i18next.t('figure.label.repeat-pitch')}${this.helpButton('figure.text.repeat-pitch-explained')}</label>
1126
1445
  ${this.number('', repeat.pitch, value => this.put({ repeat: { ...repeat, pitch: value } }), 1)}
1446
+ ${this.helpHint('figure.text.repeat-pitch-explained')}
1127
1447
  </div>
1128
1448
  ${this.renderRepeatCount(part)}
1129
1449
  `
@@ -1210,6 +1530,9 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1210
1530
  * 계속 고치는 사람이 매번 다시 펴야 하면 접은 것이 되레 방해가 된다.
1211
1531
  */
1212
1532
  help(key) {
1533
+ return html `${this.helpButton(key)}${this.helpHint(key)}`;
1534
+ }
1535
+ helpButton(key) {
1213
1536
  const shown = this.opened.has(key);
1214
1537
  return html `
1215
1538
  <button
@@ -1223,18 +1546,38 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1223
1546
  >
1224
1547
  <md-icon>${shown ? 'help' : 'help_outline'}</md-icon>
1225
1548
  </button>
1226
- ${shown ? html `<p hint>${i18next.t(key)}</p>` : nothing}
1227
1549
  `;
1228
1550
  }
1551
+ helpHint(key) {
1552
+ return this.opened.has(key) ? html `<p hint>${i18next.t(key)}</p>` : nothing;
1553
+ }
1229
1554
  renderAnchors(part) {
1230
1555
  const base = this.board
1231
1556
  ? { x: this.board.width, y: this.board.height, z: this.board.depth }
1232
1557
  : { x: 0, y: 0, z: 0 };
1558
+ const nameOf = (rule, axis) => `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`;
1559
+ const setAnchor = (axis, rule) => {
1560
+ const anchor = { ...(part.anchor ?? {}) };
1561
+ /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1562
+ if (rule)
1563
+ anchor[axis] = rule;
1564
+ else
1565
+ delete anchor[axis];
1566
+ this.put({ anchor: Object.keys(anchor).length ? anchor : undefined });
1567
+ };
1568
+ const shortNameOf = (rule, axis) => {
1569
+ if (rule === 'auto')
1570
+ return i18next.t('figure.label.anchor-choice-auto');
1571
+ if (rule === 'min' || rule === 'max')
1572
+ return i18next.t(`figure.label.anchor-choice-${rule}-${axis}`);
1573
+ return i18next.t(`figure.label.anchor-choice-${rule}`);
1574
+ };
1233
1575
  return html `
1234
1576
  <div field>
1235
- <!-- anchor.x」로 적힌 사유를 화면에서 찾을 있게 이름에 그 낱말을 남긴다 -->
1577
+ <!-- 저장 키(anchor)는 남기되, 사용자는 축마다 어느 면을 기준으로 삼는지 읽는다. -->
1236
1578
  <label>${i18next.t('figure.label.anchor')} (anchor)${this.help('figure.text.anchor-explained')}</label>
1237
- ${['x', 'y', 'z'].map(axis => {
1579
+ <div anchor-rows>
1580
+ ${['x', 'y', 'z'].map(axis => {
1238
1581
  const said = part.anchor?.[axis];
1239
1582
  const auto = anchorOfPart(part, base, axis);
1240
1583
  /*
@@ -1252,32 +1595,42 @@ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement
1252
1595
  `max` 는 정본에 저장되는 이름이고 서버 오류·형식 문서·게이트 사유가 다 그 이름을 쓴다.
1253
1596
  숨기면 안 되는 종류다. 동작이 먼저고 이름이 괄호로 따라온다.
1254
1597
  */
1255
- const nameOf = (rule) => `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`;
1256
1598
  return html `
1257
- <div cell>
1258
- <span axis>${axis}</span>
1259
- <select
1260
- @change=${(e) => {
1261
- const value = e.target.value;
1262
- const anchor = { ...(part.anchor ?? {}) };
1263
- /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1264
- if (value) {
1265
- anchor[axis] = value;
1266
- }
1267
- else {
1268
- delete anchor[axis];
1269
- }
1270
- this.put({ anchor: Object.keys(anchor).length ? anchor : undefined });
1271
- }}
1272
- >
1273
- <option value="" ?selected=${!said}>
1274
- ${i18next.t('figure.label.anchor-auto', { rule: nameOf(auto) })}
1275
- </option>
1276
- ${ANCHOR_RULES.map(rule => html `<option value=${rule} ?selected=${rule === said}>${nameOf(rule)}</option>`)}
1277
- </select>
1599
+ <div anchor-row>
1600
+ <label>${axis.toUpperCase()}축</label>
1601
+ <div anchor-options>
1602
+ ${['auto', 'scale', 'min', 'center', 'max', 'span'].map(rule => {
1603
+ const selected = rule === 'auto' ? !said : rule === said;
1604
+ const fullName = rule === 'auto' ? i18next.t('figure.label.anchor-auto', { rule: nameOf(auto, axis) }) : nameOf(rule, axis);
1605
+ return html `
1606
+ <button
1607
+ anchor-choice
1608
+ ?on=${selected}
1609
+ aria-pressed=${selected ? 'true' : 'false'}
1610
+ title=${fullName}
1611
+ @click=${() => setAnchor(axis, rule === 'auto' ? undefined : rule)}
1612
+ >${shortNameOf(rule, axis)}</button>
1613
+ `;
1614
+ })}
1615
+ </div>
1616
+ <details anchor-advanced>
1617
+ <summary>${i18next.t('figure.label.anchor-advanced')}</summary>
1618
+ <div anchor-options>
1619
+ ${ANCHOR_RULES.filter(rule => rule.startsWith('aspect-')).map(rule => html `
1620
+ <button
1621
+ anchor-choice
1622
+ ?on=${rule === said}
1623
+ aria-pressed=${rule === said ? 'true' : 'false'}
1624
+ title=${nameOf(rule, axis)}
1625
+ @click=${() => setAnchor(axis, rule)}
1626
+ >${i18next.t(`figure.label.anchor-choice-${rule}`)}</button>
1627
+ `)}
1628
+ </div>
1629
+ </details>
1278
1630
  </div>
1279
1631
  `;
1280
1632
  })}
1633
+ </div>
1281
1634
  </div>
1282
1635
  `;
1283
1636
  }