@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
@@ -0,0 +1,92 @@
1
+ import type { FigureSource } from '@hatiolab/figure-model'
2
+
3
+ import type { FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'
4
+ import { diffProposal } from './proposal.js'
5
+
6
+ /**
7
+ * 한 번의 AI 후보 검토에만 존재하는 상태다.
8
+ *
9
+ * 후보는 정본이 아니다. 후보 원본·선택·메모·측정값을 한 값으로 묶어 두면 화면이
10
+ * 수락/거절 중 일부만 이전 후보에서 남기는 일이 없다.
11
+ */
12
+ export interface ProposalSession {
13
+ source: FigureSource
14
+ title: string
15
+ picked: Set<string>
16
+ note: string
17
+ metrics?: Pick<FigureProposal, 'grade' | 'triangles' | 'groups' | 'attempts' | 'quality'>
18
+ }
19
+
20
+ function selectedKeys(current: FigureSource | undefined, source: FigureSource): Set<string> {
21
+ return new Set(diffProposal(current, source).map(change => change.key))
22
+ }
23
+
24
+ /** 서버 후보를 검토 세션으로 연다. 저장 형식이 아니면 화면에 후보를 열지 않는다. */
25
+ export function openAiProposal(current: FigureSource | undefined, proposal: FigureProposal): ProposalSession {
26
+ const source = JSON.parse(proposal.source) as FigureSource
27
+ if (!source || !Array.isArray(source.parts)) {
28
+ throw new Error('AI 후보의 Figure 형식이 올바르지 않습니다.')
29
+ }
30
+
31
+ return {
32
+ source,
33
+ title: '',
34
+ picked: selectedKeys(current, source),
35
+ note: '',
36
+ metrics: {
37
+ grade: proposal.grade,
38
+ triangles: proposal.triangles,
39
+ groups: proposal.groups,
40
+ attempts: proposal.attempts,
41
+ quality: proposal.quality
42
+ }
43
+ }
44
+ }
45
+
46
+ /** 발행 게이트가 측정한 한-앵커 수정도 같은 후보 세션으로 연다. */
47
+ export function openSizingFix(current: FigureSource, fix: FigureGateFix): ProposalSession | undefined {
48
+ if (!current.parts.some(part => part.name === fix.part)) return undefined
49
+
50
+ const source: FigureSource = {
51
+ ...current,
52
+ parts: current.parts.map(part =>
53
+ part.name === fix.part ? { ...part, anchor: { ...(part.anchor ?? {}), [fix.axis]: fix.to } } : part
54
+ )
55
+ }
56
+
57
+ return {
58
+ source,
59
+ title: `발행 검사 수정안 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} → ${fix.to}`,
60
+ picked: selectedKeys(current, source),
61
+ note: ''
62
+ }
63
+ }
64
+
65
+ /** 후보 검토 중 사람의 선택을 뒤집는다. 기존 Set을 바꾸지 않아 Lit 상태 경계도 보존한다. */
66
+ export function toggleProposalChange(session: ProposalSession, key: string): ProposalSession {
67
+ const picked = new Set(session.picked)
68
+ if (picked.has(key)) picked.delete(key)
69
+ else picked.add(key)
70
+ return { ...session, picked }
71
+ }
72
+
73
+ /** 사람의 결정을, 다음 AI 요청에만 쓰는 제한된 피드백으로 만든다. */
74
+ export function feedbackFromSession(
75
+ current: FigureSource | undefined,
76
+ session: ProposalSession,
77
+ outcome: ProposalFeedback['outcome']
78
+ ): ProposalFeedback | undefined {
79
+ if (!session.metrics) return undefined
80
+
81
+ return {
82
+ outcome,
83
+ selectedChanges: outcome === 'accepted' ? session.picked.size : 0,
84
+ totalChanges: diffProposal(current, session.source).length,
85
+ note: session.note.trim().slice(0, 500) || undefined,
86
+ grade: session.metrics.grade,
87
+ quality: {
88
+ status: session.metrics.quality.status,
89
+ findingCodes: session.metrics.quality.findings.map(finding => finding.code)
90
+ }
91
+ }
92
+ }
@@ -17,12 +17,14 @@ import type { DetailLevel, FigureSource, PrimitiveKind } from '@hatiolab/figure-
17
17
 
18
18
  import * as edits from '../modeller/part-edits.js'
19
19
  import * as proposals from '../modeller/proposal.js'
20
+ import * as proposalSessions from '../modeller/proposal-session.js'
21
+ import type { ProposalSession } from '../modeller/proposal-session.js'
20
22
  import { toFigureSource, fromFigureSource } from '../modeller/figure-source.js'
21
23
  import type { BoardModel, PartModel, PlaneAxis } from '../modeller/figure-source.js'
22
24
  import { DEFAULT_VIEW } from '../modeller/figure-view.js'
23
25
  import type { ViewSettings } from '../modeller/figure-view.js'
24
26
  import { createFigure, fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
25
- import type { Figure, FigureFinding } from '../types.js'
27
+ import type { Figure, FigureFinding, FigureGateFix, FigureProposal, ProposalFeedback } from '../types.js'
26
28
 
27
29
  const FigureModellerPageBase = localize(i18next)(PageView) as typeof PageView
28
30
 
@@ -436,6 +438,39 @@ export class FigureModellerPage extends FigureModellerPageBase {
436
438
  cursor: default;
437
439
  }
438
440
 
441
+ /* AI의 평가는 판정문이 아니라 수락 전에 읽을 근거다. 숨기지 않고 변경 칩 위에 둔다. */
442
+ div[quality-review] {
443
+ padding: 7px var(--spacing-large, 12px);
444
+ background-color: var(--md-sys-color-primary-container);
445
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
446
+ color: var(--md-sys-color-on-primary-container);
447
+ font-size: 0.72rem;
448
+ line-height: 1.45;
449
+ }
450
+ div[quality-review][review] {
451
+ background-color: var(--md-sys-color-tertiary-container);
452
+ color: var(--md-sys-color-on-tertiary-container);
453
+ }
454
+ div[quality-review] strong { display: block; font-size: 0.74rem; }
455
+ div[quality-review] ul { margin: 4px 0 0; padding-left: 18px; }
456
+ div[proposal-note] {
457
+ padding: 6px var(--spacing-large, 12px) 8px;
458
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
459
+ background-color: var(--md-sys-color-surface-container-low);
460
+ }
461
+ div[proposal-note] label { display: block; font-size: 0.71rem; color: var(--md-sys-color-on-surface-variant); }
462
+ div[proposal-note] input {
463
+ box-sizing: border-box;
464
+ width: 100%;
465
+ margin-top: 4px;
466
+ padding: 5px 7px;
467
+ border: 1px solid var(--md-sys-color-outline-variant);
468
+ border-radius: 5px;
469
+ color: var(--md-sys-color-on-surface);
470
+ background: var(--md-sys-color-surface);
471
+ font: inherit;
472
+ }
473
+
439
474
  /*
440
475
  무엇을 바꾸자는 것인가 — 칩 하나가 변경 하나다.
441
476
 
@@ -531,12 +566,10 @@ export class FigureModellerPage extends FigureModellerPageBase {
531
566
  * 미저장 표시(`dirty`)는 그대로 남으므로 데이터를 잃지는 않았다. 잃은 것은 **사유**다.
532
567
  */
533
568
  @state() private saveFailure = ''
534
- /** 저작 보조가 후보. 받기 전에는 원본 데이터가 아니다. */
535
- @state() private proposal?: FigureSource
536
- /** 후보의 셈. 무엇을 받는 것인지 숫자로도 보여 준다. */
537
- @state() private proposalOf?: { grade: string; triangles: number; groups: number; attempts: number }
538
- /** 받기로 고른 변경들. 처음에는 전부 골라 둔다. */
539
- @state() private picked = new Set<string>()
569
+ /** 저작 보조 또는 발행 게이트가 연 후보 검토 세션. 받기 전에는 정본이 아니다. */
570
+ @state() private proposalSession?: ProposalSession
571
+ /** 인라인 요청창과 AI 도크가 함께 쓰는 현재 Figure의 제한된 세션 피드백. */
572
+ @state() private proposalFeedback: ProposalFeedback[] = []
540
573
 
541
574
  /**
542
575
  * 저장 형식으로 변환한 것. 씬 모델이 바뀔 때만 다시 만든다.
@@ -552,6 +585,35 @@ export class FigureModellerPage extends FigureModellerPageBase {
552
585
  }
553
586
  }
554
587
 
588
+ /**
589
+ * 페이지 밖 AI 도크에도 **지금 손에 든 정본**을 알려 준다.
590
+ *
591
+ * 모델러 안의 `figure-ask` 는 이미 `folded`를 직접 받는다. 반면 asidebar 도크는 주소의 id만
592
+ * 알았으므로 DB에 저장된 옛 판을 읽었다. 저장 전 변경을 두고 "왜 발행이 안 되나" 또는
593
+ * "이 부품을 줄여 달라"고 물으면 대화가 엉뚱한 판을 보게 되는 결함이었다.
594
+ *
595
+ * 이 이벤트는 저장하지 않는다. 도크가 대화 인자로만 잠시 들고 있고, 서버 도구도 도메인 소속
596
+ * Figure를 먼저 확인한 뒤에만 이 source를 사용한다. 페이지를 떠날 때는 아래에서 지워 오래된
597
+ * 초안이 다음 Figure에 섞이지 않게 한다.
598
+ */
599
+ updated(changed: Map<string, unknown>) {
600
+ if (changed.has('figure') && (changed.get('figure') as Figure | undefined)?.id !== this.figure?.id) {
601
+ this.proposalFeedback = []
602
+ }
603
+ if (changed.has('board') || changed.has('parts') || changed.has('figure')) {
604
+ window.dispatchEvent(
605
+ new CustomEvent('figure-ai-context', {
606
+ detail: { figureId: this.figure?.id, source: this.folded }
607
+ })
608
+ )
609
+ }
610
+ }
611
+
612
+ disconnectedCallback() {
613
+ window.dispatchEvent(new CustomEvent('figure-ai-context', { detail: undefined }))
614
+ super.disconnectedCallback()
615
+ }
616
+
555
617
  get context() {
556
618
  return {
557
619
  title: this.figure?.name ?? i18next.t('figure.title.new-figure'),
@@ -587,6 +649,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
587
649
  <figure-ask
588
650
  .source=${source}
589
651
  .type=${source?.type ?? ''}
652
+ .feedback=${this.proposalFeedback}
590
653
  @proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
591
654
  ></figure-ask>
592
655
  ${this.saveFailure ? html`<span save-failed title=${this.saveFailure}>${this.saveFailure}</span>` : ''}
@@ -603,6 +666,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
603
666
  @figure-catalog=${(e: CustomEvent) => this.catalog(e.detail)}
604
667
  @figure-released=${(e: CustomEvent) => this.released(e.detail.figure)}
605
668
  @figure-reverted=${() => this.figure?.id && this.load(this.figure.id)}
669
+ @preview-sizing-fix=${(e: CustomEvent) => this.previewSizingFix(e.detail)}
670
+ @apply-sizing-fix=${(e: CustomEvent) => this.applySizingFix(e.detail)}
606
671
  @add-part=${(e: CustomEvent) => this.addPart(e.detail.primitive)}
607
672
  @select-part=${(e: CustomEvent) => (this.selected = e.detail.index)}
608
673
  @remove-part=${(e: CustomEvent) => this.removePart(e.detail.index)}
@@ -626,8 +691,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
626
691
 
627
692
  <div middle>
628
693
  ${this.renderBar()}
629
- ${this.proposal ? this.renderDecision() : ''}
630
- ${this.proposal
694
+ ${this.proposalSession ? this.renderDecision() : ''}
695
+ ${this.proposalSession
631
696
  ? html`
632
697
  <div lanes>
633
698
  <div lane>
@@ -642,10 +707,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
642
707
  <div lane candidate>
643
708
  <h4>
644
709
  ${i18next.t('figure.label.proposed')}
645
- ${this.proposalOf
710
+ ${this.proposalSession.metrics
646
711
  ? html`<small>
647
- ${this.proposalOf.grade} ·
648
- ${i18next.t('figure.text.n-triangles', { count: this.proposalOf.triangles })}
712
+ ${this.proposalSession.metrics.grade} ·
713
+ ${i18next.t('figure.text.n-triangles', { count: this.proposalSession.metrics.triangles })}
714
+ ${this.proposalSession.metrics.quality ? html` · ${this.proposalSession.metrics.quality.summary}` : ''}
649
715
  </small>`
650
716
  : ''}
651
717
  </h4>
@@ -711,7 +777,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
711
777
  `
712
778
 
713
779
  return html`<div modes>
714
- ${this.renderCollapse('left')}${this.proposal ? '' : html`${one('edit')}${one('preview')}`}
780
+ ${this.renderCollapse('left')}${this.proposalSession ? '' : html`${one('edit')}${one('preview')}`}
715
781
  <div spacer></div>
716
782
  ${this.renderCollapse('right')}
717
783
  </div>`
@@ -746,16 +812,17 @@ export class FigureModellerPage extends FigureModellerPageBase {
746
812
  }
747
813
 
748
814
  /**
749
- * AI 후보를 나란히 세운다.
815
+ * AI 후보를 실제 배치 크기에서도 본다.
750
816
  *
751
- * 후보는 저장 형식으로 온다. 미리보기는 모델을 받으므로 여기서 번 변환한다 —
752
- * 후보를 받아들이기 전까지는 편집 데이터가 아니므로 상태로 두지 않는다.
817
+ * 편집 캔버스 장으로는 `repeat`·`fixed`·`anchor`가 맞는지 없다. 후보는 아직
818
+ * 정본이 아니므로 저장하지 않고, `figure-preview`가 같은 FigureSource를 기준 크기와
819
+ * 일곱 가지 확장 축에서 실제 FigureInstance로 세운다. 따라서 저작자는 "좋아 보이는
820
+ * 한 장"이 아니라 배치될 때도 붙어 있는지를 보고 수락한다.
753
821
  */
754
822
  private renderProposed() {
755
- if (!this.proposal) return ''
823
+ if (!this.proposalSession) return ''
756
824
 
757
- const { board, parts } = fromFigureSource(this.proposal)
758
- return html`<figure-canvas .board=${board} .parts=${parts} .viewOnly=${true}></figure-canvas>`
825
+ return html`<figure-preview .source=${this.proposalSession.source}></figure-preview>`
759
826
  }
760
827
 
761
828
  /**
@@ -773,22 +840,35 @@ export class FigureModellerPage extends FigureModellerPageBase {
773
840
  * 안 들고, 우리가 생각 못 한 조합을 막아 주기 때문이다.
774
841
  */
775
842
  private renderDecision() {
776
- if (!this.proposal) return ''
843
+ if (!this.proposalSession) return ''
777
844
 
778
- const changes = proposals.diffProposal(this.folded, this.proposal)
779
- const picked = proposals.applyProposal(this.folded, this.proposal, this.picked)
845
+ const changes = proposals.diffProposal(this.folded, this.proposalSession.source)
846
+ const picked = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
780
847
  const { errors, violations } = validate(picked)
781
848
 
782
849
  return html`
783
850
  <div decide>
784
- <span>${i18next.t('figure.text.assistant-proposed-a-figure')}</span>
851
+ <span>${this.proposalSession.title || i18next.t('figure.text.assistant-proposed-a-figure')}</span>
785
852
  <div spacer></div>
786
853
  <button drop @click=${() => this.discard()}>${i18next.t('figure.button.discard')}</button>
787
- <button take ?disabled=${errors.length > 0 || this.picked.size === 0} @click=${() => this.take()}>
788
- ${i18next.t('figure.button.take-n-changes', { n: this.picked.size })}
854
+ <button take ?disabled=${errors.length > 0 || this.proposalSession.picked.size === 0} @click=${() => this.take()}>
855
+ ${i18next.t('figure.button.take-n-changes', { n: this.proposalSession.picked.size })}
789
856
  </button>
790
857
  </div>
791
858
 
859
+ ${this.renderQualityReview()}
860
+ <div proposal-note>
861
+ <label>
862
+ ${i18next.t('figure.text.proposal-feedback-note')}
863
+ <input
864
+ maxlength="500"
865
+ .value=${this.proposalSession.note}
866
+ placeholder=${i18next.t('figure.text.proposal-feedback-placeholder')}
867
+ @input=${(event: Event) => (this.proposalSession = { ...this.proposalSession!, note: (event.target as HTMLInputElement).value })}
868
+ />
869
+ </label>
870
+ </div>
871
+
792
872
  ${changes.length > 0
793
873
  ? html`
794
874
  <div changes>
@@ -804,6 +884,25 @@ export class FigureModellerPage extends FigureModellerPageBase {
804
884
  `
805
885
  }
806
886
 
887
+ /** 후보 품질 판단의 근거를 수락 전에 전부 보여 준다. */
888
+ private renderQualityReview() {
889
+ const quality = this.proposalSession?.metrics?.quality
890
+ if (!quality) return ''
891
+
892
+ return html`
893
+ <div quality-review ?review=${quality.status === 'review'}>
894
+ <strong>${i18next.t('figure.label.candidate-review')}</strong>
895
+ <span>${quality.summary}</span>
896
+ <span> · top-view ${Math.round(quality.visualReadability * 100)}% · ${quality.visualRegions} regions</span>
897
+ ${quality.findings.length > 0
898
+ ? html`<ul>
899
+ ${quality.findings.map(finding => html`<li><code>${finding.dimension}</code> — ${finding.message}</li>`)}
900
+ </ul>`
901
+ : ''}
902
+ </div>
903
+ `
904
+ }
905
+
807
906
  /** 변경 하나. 무엇을 하겠다는 것인지 부호로 먼저 말한다. */
808
907
  private renderChange(change: proposals.ProposalChange) {
809
908
  const icon = {
@@ -816,7 +915,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
816
915
  styleKit: 'palette'
817
916
  }[change.kind]
818
917
 
819
- const on = this.picked.has(change.key)
918
+ const on = this.proposalSession?.picked.has(change.key)
820
919
 
821
920
  return html`
822
921
  <button
@@ -832,10 +931,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
832
931
  }
833
932
 
834
933
  private toggle(key: string) {
835
- const next = new Set(this.picked)
836
- if (next.has(key)) next.delete(key)
837
- else next.add(key)
838
- this.picked = next
934
+ if (this.proposalSession) this.proposalSession = proposalSessions.toggleProposalChange(this.proposalSession, key)
839
935
  }
840
936
 
841
937
  /**
@@ -844,22 +940,29 @@ export class FigureModellerPage extends FigureModellerPageBase {
844
940
  * 원본 데이터에 넣지 않는다. 나란히 두고 저작자가 고른다. AI 가 원본 데이터에 직접 쓰면
845
941
  * 무엇이 자기 것이고 무엇이 기계 것인지 모르게 된다.
846
942
  */
847
- private receive(proposal: {
848
- source: string
849
- grade: string
850
- triangles: number
851
- groups: number
852
- attempts: number
853
- }) {
854
- this.proposal = JSON.parse(proposal.source) as FigureSource
855
- // 처음에는 전부 골라 둔다 — 통째로 받는 것이 보통의 길이다.
856
- this.picked = new Set(proposals.diffProposal(this.folded, this.proposal).map(change => change.key))
857
- this.proposalOf = {
858
- grade: proposal.grade,
859
- triangles: proposal.triangles,
860
- groups: proposal.groups,
861
- attempts: proposal.attempts
862
- }
943
+ private receive(proposal: FigureProposal) {
944
+ this.proposalSession = proposalSessions.openAiProposal(this.folded, proposal)
945
+ }
946
+
947
+ /**
948
+ * 발행 게이트의 구조화된 수정안 하나를 현재 작업 사본에만 겹쳐 본다. 서버가 준 값은
949
+ * `part.anchor.axis = to`라는 데이터이지 사람이 읽을 문장을 다시 해석한 결과가 아니다.
950
+ * 원본 `board`/`parts`는 여기서 전혀 바꾸지 않고, 아래의 `take()`만 바꾼 사본을 적용한다.
951
+ */
952
+ private previewSizingFix(fix: FigureGateFix) {
953
+ if (!this.folded) return
954
+ this.proposalSession = proposalSessions.openSizingFix(this.folded, fix)
955
+ }
956
+
957
+ /**
958
+ * 원클릭 적용은 서버가 모든 크기 변경 case를 재검사해 안전하다고 표시한 한-앵커 후보에만 연다.
959
+ * 그래도 저장은 저작자의 별도 행위다. 잘못 눌러도 DB 정본이나 발행판은 바뀌지 않는다.
960
+ */
961
+ private applySizingFix(fix: FigureGateFix) {
962
+ if (!fix.safe || !this.folded) return
963
+ this.previewSizingFix(fix)
964
+ if (!this.proposalSession) return
965
+ this.take()
863
966
  }
864
967
 
865
968
  /**
@@ -869,22 +972,45 @@ export class FigureModellerPage extends FigureModellerPageBase {
869
972
  * 하므로 그쪽이 자연스럽다. 받아들인 결과는 다시 씬 모델로 되돌려 편집을 이어간다.
870
973
  */
871
974
  private take() {
872
- if (!this.proposal) return
975
+ if (!this.proposalSession) return
873
976
 
874
- const taken = proposals.applyProposal(this.folded, this.proposal, this.picked)
977
+ const taken = proposals.applyProposal(this.folded, this.proposalSession.source, this.proposalSession.picked)
875
978
  const { board, parts } = fromFigureSource(taken)
876
979
 
877
980
  this.board = board
878
981
  this.parts = parts
879
982
  this.selected = parts.length ? 0 : -1
880
983
  this.dirty = true
881
- this.discard()
984
+ this.reportProposalFeedback('accepted')
985
+ this.discard(false)
986
+ }
987
+
988
+ /**
989
+ * 후보에 대한 사람의 결정을 도크에만 알린다.
990
+ *
991
+ * 이 기록은 분석 이벤트나 Figure 속성이 아니다. 같은 모델러 세션에서 다음 요청을 더 잘
992
+ * 이해시키는 짧은 신호일 뿐이다. 원본 source나 사람의 작업 내용을 다시 보내지 않고, 사람이
993
+ * 후보 변경 중 몇 개를 받아들였는지와 이미 화면에 보였던 품질 판정만 보낸다.
994
+ */
995
+ private reportProposalFeedback(outcome: 'accepted' | 'discarded') {
996
+ if (!this.proposalSession) return
997
+
998
+ const feedback = proposalSessions.feedbackFromSession(this.folded, this.proposalSession, outcome)
999
+ if (!feedback) return
1000
+ this.proposalFeedback = [...this.proposalFeedback, feedback].slice(-5)
1001
+ window.dispatchEvent(
1002
+ new CustomEvent('figure-ai-feedback', {
1003
+ detail: {
1004
+ figureId: this.figure?.id,
1005
+ ...feedback
1006
+ }
1007
+ })
1008
+ )
882
1009
  }
883
1010
 
884
- private discard() {
885
- this.proposal = undefined
886
- this.proposalOf = undefined
887
- this.picked = new Set()
1011
+ private discard(reportFeedback = true) {
1012
+ if (reportFeedback) this.reportProposalFeedback('discarded')
1013
+ this.proposalSession = undefined
888
1014
  }
889
1015
 
890
1016
  /**
package/client/types.ts CHANGED
@@ -112,12 +112,31 @@ export interface FigureProposal {
112
112
  triangles: number
113
113
  /** 재질 묶음 = 인스턴스 하나가 무는 draw call. */
114
114
  groups: number
115
+ /** Rule-based review of measurable design concerns; it is not an aesthetic verdict. */
116
+ quality: {
117
+ status: 'ready' | 'review'
118
+ summary: string
119
+ findings: Array<{ dimension: string; code: string; message: string }>
120
+ visualRegions: number
121
+ visualCoverage: number
122
+ visualReadability: number
123
+ }
115
124
  /** 형식은 맞으나 정책을 넘은 것. 막지 않는다 — 저작자가 정한다. */
116
125
  violations: FigureFinding[]
117
126
  /** 몇 번 만에 됐나. 진단용이다. */
118
127
  attempts: number
119
128
  }
120
129
 
130
+ /** 사람이 후보를 수락하거나 버릴 때 직접 남긴 현재 저작 세션의 참고 신호. */
131
+ export interface ProposalFeedback {
132
+ outcome: 'accepted' | 'discarded'
133
+ selectedChanges: number
134
+ totalChanges: number
135
+ note?: string
136
+ grade?: string
137
+ quality?: { status?: 'ready' | 'review'; findingCodes?: string[] }
138
+ }
139
+
121
140
  /**
122
141
  * 발행 판정 한 줄.
123
142
  *
@@ -125,6 +144,18 @@ export interface FigureProposal {
125
144
  * 발행 판정은 막는 것과 알리는 것을 함께 내므로 어느 쪽인지를 값이 스스로 말한다 — 코드를 보고
126
145
  * 화면이 다시 분류하면 규칙이 두 벌이 된다.
127
146
  */
147
+ /** One reversible anchor edit the sizing gate has measured against this exact source. */
148
+ export interface FigureGateFix {
149
+ findingWay: string
150
+ part: string
151
+ axis: 'x' | 'y' | 'z'
152
+ from?: string
153
+ to: 'min' | 'center' | 'max' | 'span'
154
+ resolved: number
155
+ /** All blocking sizing cases clear after this exact edit was remeasured server-side. */
156
+ safe: boolean
157
+ }
158
+
128
159
  export interface FigureGateFinding {
129
160
  code: string
130
161
  message: string
@@ -134,6 +165,8 @@ export interface FigureGateFinding {
134
165
  how?: string
135
166
  /** 갈라진 부품 이름들, 또는 정본 안의 경로. */
136
167
  at?: string
168
+ /** Candidate edits; the UI must preview and explicitly apply these, never auto-save them. */
169
+ fixes?: FigureGateFix[]
137
170
  blocking: boolean
138
171
  }
139
172
 
@@ -1,4 +1,4 @@
1
- import type { Figure, FigureFinding, FigureInspection, FigureListResult, FigurePatch, FigureProposal, FigureVersion, NewFigure } from '../types.js';
1
+ import type { Figure, FigureFinding, FigureInspection, FigureListResult, FigurePatch, FigureProposal, ProposalFeedback, FigureVersion, NewFigure } from '../types.js';
2
2
  /**
3
3
  * 목록을 가져온다.
4
4
  *
@@ -46,6 +46,8 @@ export declare function proposeFigure(request: {
46
46
  base?: string;
47
47
  type?: string;
48
48
  palette: string[];
49
+ refine?: boolean;
50
+ feedback?: ProposalFeedback[];
49
51
  image?: File;
50
52
  }): Promise<FigureProposal>;
51
53
  /**
@@ -162,6 +162,18 @@ export async function proposeFigure(request) {
162
162
  grade
163
163
  triangles
164
164
  groups
165
+ quality {
166
+ status
167
+ summary
168
+ findings {
169
+ dimension
170
+ code
171
+ message
172
+ }
173
+ visualRegions
174
+ visualCoverage
175
+ visualReadability
176
+ }
165
177
  attempts
166
178
  violations {
167
179
  code
@@ -229,6 +241,15 @@ export async function inspectFigure(id) {
229
241
  why
230
242
  how
231
243
  at
244
+ fixes {
245
+ findingWay
246
+ part
247
+ axis
248
+ from
249
+ to
250
+ resolved
251
+ safe
252
+ }
232
253
  blocking
233
254
  }
234
255
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n throw new Error(`서버가 ${field} 를 돌려주지 않았습니다.`)\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../client/graphql/index.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\nimport { client } from '@operato/graphql'\n\nimport type {\n Figure,\n FigureFinding,\n FigureInspection,\n FigureListResult,\n FigurePatch,\n FigureProposal,\n ProposalFeedback,\n FigureVersion,\n NewFigure\n} from '../types.js'\n\n/**\n * 응답에서 값을 꺼낸다. **서버가 말한 사유를 그대로 올린다.**\n *\n * 전에는 `response.data.figures` 처럼 바로 꺼냈다. 서버가 오류를 돌려주면 `data` 가\n * 없으므로 `Cannot read properties of undefined (reading 'figures')` 가 났고, 화면에는\n * 그 문장이 그대로 떴다. 사용자는 자기가 무엇을 잘못했는지 알 수 없다 — 실제로 AI\n * 요청에서 그렇게 났다.\n *\n * 값이 `null` 인 것은 오류가 아니다. 지운 것을 물었을 때처럼 없는 것이 답인 경우가 있다.\n * 여기서 막는 것은 **`data` 자체가 없는 것**이다.\n */\nfunction unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly { message: string }[] }, field: string): T {\n const said = response.errors?.[0]?.message\n if (said) throw new Error(said)\n\n if (!response.data) {\n throw new Error(`서버가 ${field} 를 돌려주지 않았습니다.`)\n }\n\n return response.data[field] as T\n}\n\n\n/**\n * 목록에 필요한 필드만 가져온다 — `source` 는 무겁고 목록에서 쓰지 않는다.\n *\n * **`thumbnail` 도 여기 없다.** base64 문자열이라 목록 응답에 실으면 카탈로그를 열 때마다 그림\n * 전부가 다시 오고(표본 14개 227KB), data URL 은 브라우저가 캐시하지 못한다. 그림은 주소로\n * 부른다(`figure-thumbnail` 라우트). 대신 `thumbnailUpdatedAt` 을 받는다 — 그림이 있나 없나를\n * 그것으로 알고, 판을 가리는 값으로도 쓴다.\n */\nconst FIGURE_LIST_FIELDS = `\n id\n type\n name\n description\n category\n tags\n state\n version\n score\n triangles\n groups\n thumbnailUpdatedAt\n updatedAt\n updater { id name }\n`\n\n/**\n * 목록을 가져온다.\n *\n * 걸러 보기·정렬·쪽 나누기를 **그대로 넘긴다.** 화면이 `search`·`state` 같은 이름을 따로 만들어\n * filter 로 옮기던 것을 그만두었다 — 그 이름들은 격자(ox-grist)가 이미 컬럼 설정에서 만들어 주고,\n * 중간에 한 벌 더 두면 격자가 아는 조건과 서버에 가는 조건이 어긋난다.\n */\nexport async function fetchFigureList(params: {\n page?: number\n limit?: number\n filters?: unknown[]\n sortings?: { name: string; desc?: boolean }[]\n}): Promise<FigureListResult> {\n const response = await client.query({\n query: gql`\n query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {\n figures(filters: $filters, pagination: $pagination, sortings: $sortings) {\n items { ${FIGURE_LIST_FIELDS} }\n total\n }\n }\n `,\n variables: {\n filters: params.filters ?? [],\n pagination: { page: params.page ?? 1, limit: params.limit ?? 30 },\n sortings: params.sortings?.length ? params.sortings : [{ name: 'updatedAt', desc: true }]\n },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureListResult>(response, 'figures')\n}\n\n/** 저작면에 필요한 전부 — `source` 를 포함한다. */\nexport async function fetchFigure(id: string): Promise<Figure> {\n const response = await client.query({\n query: gql`\n query ($id: String!) {\n figure(id: $id) {\n ${FIGURE_LIST_FIELDS}\n source\n properties\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<Figure>(response, 'figure')\n}\n\n/**\n * 이미 쓰이고 있는 타입 이름.\n *\n * `type` 은 만든 뒤에 고칠 수 없으므로 **만들기 전에** 알려 줘야 한다. 저장을 눌러\n * 거절당하고 나서 아는 것은 늦다.\n */\nexport async function fetchFigureTypeNames(): Promise<string[]> {\n const response = await client.query({\n query: gql`\n query {\n figureTypeNames\n }\n `,\n fetchPolicy: 'network-only'\n })\n\n return unwrap<string[] | null>(response, 'figureTypeNames') ?? []\n}\n\nexport interface SaveResult {\n figure: Figure\n /** 막지 않는 발견. 저장은 됐지만 화면이 보여 줘야 한다. */\n violations: FigureFinding[]\n}\n\nexport async function createFigure(figure: NewFigure): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($figure: NewFigure!) {\n createFigure(figure: $figure) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { figure }\n })\n\n return unwrap<SaveResult>(response, 'createFigure')\n}\n\nexport async function updateFigure(id: string, patch: FigurePatch): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!, $patch: FigurePatch!) {\n updateFigure(id: $id, patch: $patch) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, patch }\n })\n\n return unwrap<SaveResult>(response, 'updateFigure')\n}\n\nexport async function deleteFigure(id: string): Promise<boolean> {\n const response = await client.mutate({\n mutation: gql`\n mutation ($id: String!) {\n deleteFigure(id: $id)\n }\n `,\n variables: { id }\n })\n\n return unwrap<boolean>(response, 'deleteFigure')\n}\n\n/**\n * 저작 보조에게 후보를 시킨다.\n *\n * 팔레트를 **클라이언트가 보낸다.** 서버는 팔레트를 모른다 — things-scene 이 갖고\n * 있는데 그 패키지의 node 조건은 팔레트가 없는 번들로 간다. 그리는 쪽이 어떤 토큰을\n * 풀 수 있는지 아는 유일한 자리이므로 그쪽이 보낸다.\n *\n * 돌아오는 것은 **후보**다. 저장되지 않았다 — 저작자가 받아야 정본이 된다.\n */\nexport async function proposeFigure(request: {\n prompt: string\n base?: string\n type?: string\n palette: string[]\n refine?: boolean\n feedback?: ProposalFeedback[]\n image?: File\n}): Promise<FigureProposal> {\n const response = await client.mutate({\n mutation: gql`\n mutation ProposeFigure($request: ProposeRequest!) {\n proposeFigure(request: $request) {\n source\n score\n grade\n triangles\n groups\n quality {\n status\n summary\n findings {\n dimension\n code\n message\n }\n visualRegions\n visualCoverage\n visualReadability\n }\n attempts\n violations {\n code\n message\n }\n }\n }\n `,\n variables: { request }\n })\n\n return unwrap<FigureProposal>(response, 'proposeFigure')\n}\n\n/**\n * 발행한다 — 판 번호가 오르고, 그 순간이 판본으로 남는다.\n *\n * 이미 발행된 것을 다시 발행하면 서버가 거절한다. 고쳐서 다시 내려면 저장이 먼저이고, 저장은\n * 발행을 푼다(초안으로 돌아온다).\n */\nexport async function releaseFigure(id: string, comment?: string): Promise<Figure> {\n const response = await client.mutate({\n mutation: gql`\n mutation ReleaseFigure($id: String!, $comment: String) {\n releaseFigure(id: $id, comment: $comment) {\n id\n version\n state\n updatedAt\n }\n }\n `,\n variables: { id, comment }\n })\n\n return unwrap<Figure>(response, 'releaseFigure')\n}\n\n/** 옛 판을 초안으로 되살린다. 정본·속성·그림이 돌아오고 이름·설명은 그대로다. */\nexport async function revertFigureVersion(id: string, version: number): Promise<SaveResult> {\n const response = await client.mutate({\n mutation: gql`\n mutation RevertFigureVersion($id: String!, $version: Float!) {\n revertFigureVersion(id: $id, version: $version) {\n figure { ${FIGURE_LIST_FIELDS} source }\n violations { code message at }\n }\n }\n `,\n variables: { id, version }\n })\n\n return unwrap<SaveResult>(response, 'revertFigureVersion')\n}\n\n/**\n * 발행해도 되나 — **누르기 전에** 묻는다.\n *\n * 막는 것은 서버의 `releaseFigure` 이고 이것은 이유를 미리 보여 주기 위한 것이다. 허가증이\n * 아니므로 이 답이 통과라 해도 발행은 다시 판정을 받는다.\n */\nexport async function inspectFigure(id: string): Promise<FigureInspection> {\n const response = await client.query({\n query: gql`\n query InspectFigure($id: String!) {\n inspectFigure(id: $id) {\n blocked\n findings {\n code\n message\n why\n how\n at\n fixes {\n findingWay\n part\n axis\n from\n to\n resolved\n safe\n }\n blocking\n }\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureInspection>(response, 'inspectFigure')\n}\n\n/** 발행된 판들 — 최근 것부터 열 개. */\nexport async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {\n const response = await client.query({\n query: gql`\n query FigureVersions($id: String!) {\n figureVersions(id: $id) {\n version\n comment\n state\n updatedAt\n updater { id name }\n score\n triangles\n groups\n }\n }\n `,\n variables: { id },\n fetchPolicy: 'network-only'\n })\n\n return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []\n}\n"]}
@@ -7,6 +7,7 @@ export declare class FigureAnimations extends FigureAnimations_base {
7
7
  parts: PartModel[];
8
8
  /** 펼쳐 둔 clip. 이름이 아니라 자리로 잡는다 — 이름은 편집 중에 바뀐다. */
9
9
  private open;
10
+ private recipeTarget;
10
11
  private get clips();
11
12
  /** 부품 이름 — 채널이 이것을 가리킨다. */
12
13
  private get partNames();
@@ -24,7 +25,7 @@ export declare class FigureAnimations extends FigureAnimations_base {
24
25
  * 「clip 을 추가하세요」라고만 쓰면 처음 여는 사람은 clip 이 무엇인지 모른 채 단추를 누른다.
25
26
  * 그래서 무엇을 만들 수 있는지 예를 들고, 그것이 어떻게 움직이는지(사실이 들어온다)를 적는다.
26
27
  */
27
- private renderEmpty;
28
+ private renderRecipes;
28
29
  private renderClips;
29
30
  private renderClip;
30
31
  private renderChannels;
@@ -70,6 +71,8 @@ export declare class FigureAnimations extends FigureAnimations_base {
70
71
  private patchClip;
71
72
  private patchChannel;
72
73
  private addClip;
74
+ /** Recipe creates a valid, editable two-pose clip; it never saves or publishes the Figure. */
75
+ private addRecipe;
73
76
  private renameClip;
74
77
  private setDrive;
75
78
  private removeClip;