@things-factory/figure-ui 10.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/graphql/index.ts +209 -0
  3. package/client/index.ts +2 -0
  4. package/client/modeller/figure-ask.ts +247 -0
  5. package/client/modeller/figure-canvas.ts +482 -0
  6. package/client/modeller/figure-inspector.ts +1054 -0
  7. package/client/modeller/figure-parts.ts +479 -0
  8. package/client/modeller/figure-preview.ts +381 -0
  9. package/client/modeller/figure-report.ts +895 -0
  10. package/client/modeller/figure-settings.ts +285 -0
  11. package/client/modeller/figure-side.ts +162 -0
  12. package/client/modeller/figure-source.test.ts +336 -0
  13. package/client/modeller/figure-source.ts +338 -0
  14. package/client/modeller/figure-view.ts +79 -0
  15. package/client/modeller/maturity.ts +288 -0
  16. package/client/modeller/part-edits.test.ts +252 -0
  17. package/client/modeller/part-edits.ts +246 -0
  18. package/client/modeller/proposal.ts +142 -0
  19. package/client/modeller/scene-budget.ts +102 -0
  20. package/client/pages/figure-list-page.ts +274 -0
  21. package/client/pages/figure-modeller-page.ts +1067 -0
  22. package/client/route.ts +11 -0
  23. package/client/themes/brand.css +51 -0
  24. package/client/themes/dark.css +64 -0
  25. package/client/themes/light.css +95 -0
  26. package/client/tsconfig.json +19 -0
  27. package/client/types.ts +96 -0
  28. package/client/viewparts/figure-card.ts +226 -0
  29. package/dist-client/bootstrap.d.ts +14 -0
  30. package/dist-client/bootstrap.js +17 -0
  31. package/dist-client/bootstrap.js.map +1 -0
  32. package/dist-client/graphql/index.d.ts +41 -0
  33. package/dist-client/graphql/index.js +173 -0
  34. package/dist-client/graphql/index.js.map +1 -0
  35. package/dist-client/index.d.ts +2 -0
  36. package/dist-client/index.js +3 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/modeller/figure-ask.d.ts +36 -0
  39. package/dist-client/modeller/figure-ask.js +256 -0
  40. package/dist-client/modeller/figure-ask.js.map +1 -0
  41. package/dist-client/modeller/figure-canvas.d.ts +128 -0
  42. package/dist-client/modeller/figure-canvas.js +431 -0
  43. package/dist-client/modeller/figure-canvas.js.map +1 -0
  44. package/dist-client/modeller/figure-inspector.d.ts +149 -0
  45. package/dist-client/modeller/figure-inspector.js +991 -0
  46. package/dist-client/modeller/figure-inspector.js.map +1 -0
  47. package/dist-client/modeller/figure-parts.d.ts +64 -0
  48. package/dist-client/modeller/figure-parts.js +478 -0
  49. package/dist-client/modeller/figure-parts.js.map +1 -0
  50. package/dist-client/modeller/figure-preview.d.ts +62 -0
  51. package/dist-client/modeller/figure-preview.js +360 -0
  52. package/dist-client/modeller/figure-preview.js.map +1 -0
  53. package/dist-client/modeller/figure-report.d.ts +134 -0
  54. package/dist-client/modeller/figure-report.js +881 -0
  55. package/dist-client/modeller/figure-report.js.map +1 -0
  56. package/dist-client/modeller/figure-settings.d.ts +39 -0
  57. package/dist-client/modeller/figure-settings.js +277 -0
  58. package/dist-client/modeller/figure-settings.js.map +1 -0
  59. package/dist-client/modeller/figure-side.d.ts +41 -0
  60. package/dist-client/modeller/figure-side.js +175 -0
  61. package/dist-client/modeller/figure-side.js.map +1 -0
  62. package/dist-client/modeller/figure-source.d.ts +196 -0
  63. package/dist-client/modeller/figure-source.js +153 -0
  64. package/dist-client/modeller/figure-source.js.map +1 -0
  65. package/dist-client/modeller/figure-view.d.ts +57 -0
  66. package/dist-client/modeller/figure-view.js +36 -0
  67. package/dist-client/modeller/figure-view.js.map +1 -0
  68. package/dist-client/modeller/maturity.d.ts +55 -0
  69. package/dist-client/modeller/maturity.js +212 -0
  70. package/dist-client/modeller/maturity.js.map +1 -0
  71. package/dist-client/modeller/part-edits.d.ts +64 -0
  72. package/dist-client/modeller/part-edits.js +218 -0
  73. package/dist-client/modeller/part-edits.js.map +1 -0
  74. package/dist-client/modeller/proposal.d.ts +45 -0
  75. package/dist-client/modeller/proposal.js +96 -0
  76. package/dist-client/modeller/proposal.js.map +1 -0
  77. package/dist-client/modeller/scene-budget.d.ts +78 -0
  78. package/dist-client/modeller/scene-budget.js +80 -0
  79. package/dist-client/modeller/scene-budget.js.map +1 -0
  80. package/dist-client/pages/figure-list-page.d.ts +40 -0
  81. package/dist-client/pages/figure-list-page.js +280 -0
  82. package/dist-client/pages/figure-list-page.js.map +1 -0
  83. package/dist-client/pages/figure-modeller-page.d.ts +219 -0
  84. package/dist-client/pages/figure-modeller-page.js +1045 -0
  85. package/dist-client/pages/figure-modeller-page.js.map +1 -0
  86. package/dist-client/route.d.ts +1 -0
  87. package/dist-client/route.js +11 -0
  88. package/dist-client/route.js.map +1 -0
  89. package/dist-client/themes/brand.css +51 -0
  90. package/dist-client/themes/dark.css +64 -0
  91. package/dist-client/themes/light.css +95 -0
  92. package/dist-client/types.d.ts +93 -0
  93. package/dist-client/types.js +2 -0
  94. package/dist-client/types.js.map +1 -0
  95. package/dist-client/viewparts/figure-card.d.ts +24 -0
  96. package/dist-client/viewparts/figure-card.js +221 -0
  97. package/dist-client/viewparts/figure-card.js.map +1 -0
  98. package/dist-server/index.d.ts +7 -0
  99. package/dist-server/index.js +3 -0
  100. package/dist-server/index.js.map +1 -0
  101. package/package.json +41 -0
  102. package/server/index.ts +7 -0
  103. package/server/tsconfig.json +18 -0
  104. package/test/brand-palette.test.ts +188 -0
  105. package/test/heading-scale-guard.test.ts +144 -0
  106. package/test/resolve-ts-siblings.mjs +67 -0
  107. package/things-factory.config.js +11 -0
  108. package/translations/en.json +247 -0
  109. package/translations/ja.json +247 -0
  110. package/translations/ko.json +247 -0
  111. package/translations/ms.json +247 -0
  112. package/translations/zh.json +247 -0
@@ -0,0 +1,221 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import '@material/web/icon/icon.js';
3
+ import { css, html, LitElement } from 'lit';
4
+ import { customElement, property } from 'lit/decorators.js';
5
+ import { i18next, localize } from '@operato/i18n';
6
+ import { capacityFrom } from '../modeller/scene-budget.js';
7
+ /**
8
+ * 이 값 아래면 카드에서 색으로 눈에 띄게 한다.
9
+ *
10
+ * **등급 경계가 아니다.** 등급(A~D)은 `figure-model` 의 `scoreOf` 가 정하고 화면은
11
+ * 그것을 베끼지 않는다. 이것은 「눈에 걸리게 할 선」이고, 화면의 판단이다.
12
+ */
13
+ const ATTENTION_BELOW = 50;
14
+ /**
15
+ * Figure 한 장.
16
+ *
17
+ * 카드에 무엇을 실을지가 이 화면의 값이다. 이름과 썸네일만 실으면 저작자는 **무게를
18
+ * 모른 채** 고르게 된다. 삼각형 수와 점수를 함께 실어, 「이걸 100개 놓아도 되나」를
19
+ * 목록에서 판단할 수 있게 한다.
20
+ */
21
+ let FigureCard = class FigureCard extends localize(i18next)(LitElement) {
22
+ static { this.styles = css `
23
+ :host {
24
+ display: flex;
25
+ flex-direction: column;
26
+ overflow: hidden;
27
+ background-color: var(--md-sys-color-surface-container-lowest);
28
+ border: 1px solid var(--md-sys-color-outline-variant);
29
+ border-radius: 6px;
30
+ cursor: pointer;
31
+ }
32
+ :host(:hover) {
33
+ border-color: var(--md-sys-color-primary);
34
+ }
35
+
36
+ div[thumbnail] {
37
+ position: relative;
38
+ aspect-ratio: 4 / 3;
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ background-color: var(--md-sys-color-surface-container);
43
+ overflow: hidden;
44
+ }
45
+ div[thumbnail] img {
46
+ width: 100%;
47
+ height: 100%;
48
+ object-fit: contain;
49
+ }
50
+ /* 썸네일이 아직 없는 것을 빈 사각형으로 두지 않는다 — 왜 없는지 말한다 */
51
+ div[thumbnail] span[nothumb] {
52
+ display: flex;
53
+ flex-direction: column;
54
+ align-items: center;
55
+ gap: 4px;
56
+ font: var(--label-font, inherit);
57
+ color: var(--md-sys-color-outline-variant);
58
+ }
59
+ div[thumbnail] span[nothumb] md-icon {
60
+ --md-icon-size: 28px;
61
+ }
62
+
63
+ span[state] {
64
+ position: absolute;
65
+ top: 6px;
66
+ left: 6px;
67
+ padding: 1px 7px;
68
+ font: var(--label-font, inherit);
69
+ font-size: 0.7rem;
70
+ color: var(--md-sys-color-on-secondary-container);
71
+ background-color: var(--md-sys-color-secondary-container);
72
+ border-radius: 6px;
73
+ }
74
+
75
+ button[remove] {
76
+ position: absolute;
77
+ top: 4px;
78
+ right: 4px;
79
+ display: none;
80
+ padding: 3px;
81
+ color: var(--md-sys-color-on-surface);
82
+ background-color: var(--md-sys-color-surface);
83
+ border: 1px solid var(--md-sys-color-outline-variant);
84
+ border-radius: 6px;
85
+ cursor: pointer;
86
+ }
87
+ button[remove] md-icon {
88
+ --md-icon-size: 16px;
89
+ }
90
+ :host(:hover) button[remove] {
91
+ display: block;
92
+ }
93
+
94
+ div[body] {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 2px;
98
+ padding: 8px 10px 10px 10px;
99
+ }
100
+ span[name] {
101
+ font: var(--theme-font, inherit);
102
+ font-size: 0.92rem;
103
+ color: var(--md-sys-color-on-surface);
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ }
108
+ /* 타입 이름은 저장되는 식별자다 — 표시 이름과 다르게 보이게 둔다 */
109
+ code[type] {
110
+ font-family: var(--mono-font, monospace);
111
+ font-size: 0.72rem;
112
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
113
+ }
114
+
115
+ div[metrics] {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 10px;
119
+ margin-top: 6px;
120
+ font: var(--label-font, inherit);
121
+ font-size: 0.72rem;
122
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
123
+ }
124
+ /* 「몇 개까지」가 카드의 머리다 — 저작면 머리와 같은 말이라야 한다 */
125
+ span[fits] {
126
+ margin-right: auto;
127
+ font-size: 0.82rem;
128
+ font-weight: 700;
129
+ color: var(--md-sys-color-primary);
130
+ }
131
+ span[grade] {
132
+ padding: 0 6px;
133
+ font-weight: 700;
134
+ color: var(--md-sys-color-on-primary-container);
135
+ background-color: var(--md-sys-color-primary-container);
136
+ border-radius: 6px;
137
+ }
138
+ /* 점수가 낮으면 색으로도 말한다 — 숫자만으로는 눈에 안 들어온다 */
139
+ span[grade][low] {
140
+ color: var(--md-sys-color-on-error, #fff);
141
+ background-color: var(--md-sys-color-error);
142
+ }
143
+ span[unmeasured] {
144
+ font-style: italic;
145
+ color: var(--md-sys-color-outline-variant);
146
+ }
147
+ `; }
148
+ render() {
149
+ const figure = this.figure;
150
+ if (!figure) {
151
+ return html ``;
152
+ }
153
+ return html `
154
+ <div thumbnail @click=${() => this.dispatchEvent(new CustomEvent('open'))}>
155
+ ${figure.thumbnail
156
+ ? html `<img src=${figure.thumbnail} alt=${figure.name} />`
157
+ : html `
158
+ <span nothumb>
159
+ <md-icon>deployed_code</md-icon>
160
+ ${i18next.t('text.thumbnail-not-made-yet')}
161
+ </span>
162
+ `}
163
+ ${figure.state === 'draft' ? html `<span state>${i18next.t('label.draft')}</span>` : ''}
164
+ <button
165
+ remove
166
+ title=${i18next.t('button.delete')}
167
+ @click=${(e) => {
168
+ e.stopPropagation();
169
+ this.dispatchEvent(new CustomEvent('remove'));
170
+ }}
171
+ >
172
+ <md-icon>delete</md-icon>
173
+ </button>
174
+ </div>
175
+
176
+ <div body @click=${() => this.dispatchEvent(new CustomEvent('open'))}>
177
+ <span name title=${figure.name}>${figure.name}</span>
178
+ <code type>${figure.type}</code>
179
+
180
+ <div metrics>${this.renderMetrics()}</div>
181
+ </div>
182
+ `;
183
+ }
184
+ /**
185
+ * 무게와 점수.
186
+ *
187
+ * 아직 재지 않은 것(정본을 저장한 적이 없는 것)을 **0 으로 보여 주지 않는다.**
188
+ * 0 은 「가볍다」는 뜻이 되어 버린다. 재지 않았다고 말한다.
189
+ */
190
+ renderMetrics() {
191
+ const { score, triangles, groups } = this.figure;
192
+ if (score == null || triangles == null) {
193
+ return html `<span unmeasured>${i18next.t('text.not-measured-yet')}</span>`;
194
+ }
195
+ // 목록이 저작면과 **같은 말**을 하게 한다. 저작면 머리가 「몇 개까지」이면 카드도
196
+ // 그것이어야 한다 — 같은 형상을 두 화면이 다른 잣대로 말하면 무엇을 고칠지 모른다.
197
+ const capacity = capacityFrom(groups ?? 0, triangles);
198
+ // 등급 문자(A~D)를 여기서 만들지 않는다. 경계는 `figure-model` 의 `scoreOf` 가
199
+ // 정하고, 그것을 화면이 베끼면 두 벌이 되어 목록과 저작면이 다른 등급을 보여 준다.
200
+ // 목록에는 점수 숫자만 싣는다. 등급이 필요해지면 `figure-model` 이
201
+ // `gradeOf(score)` 를 내보내야 한다 — 그때 그것을 쓴다.
202
+ return html `
203
+ ${capacity
204
+ ? html `<span fits title=${i18next.t('text.reuse-score-explained')}>
205
+ ${i18next.t('text.fits-n-instances', { n: capacity.instances.toLocaleString() })}
206
+ </span>`
207
+ : ''}
208
+ <span grade ?low=${score < ATTENTION_BELOW}>${Math.round(score)}</span>
209
+ <span>${i18next.t('text.triangle-count', { count: triangles })}</span>
210
+ `;
211
+ }
212
+ };
213
+ __decorate([
214
+ property({ type: Object }),
215
+ __metadata("design:type", Object)
216
+ ], FigureCard.prototype, "figure", void 0);
217
+ FigureCard = __decorate([
218
+ customElement('figure-card')
219
+ ], FigureCard);
220
+ export { FigureCard };
221
+ //# sourceMappingURL=figure-card.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-card.js","sourceRoot":"","sources":["../../client/viewparts/figure-card.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\nimport { i18next, localize } from '@operato/i18n'\n\nimport { capacityFrom } from '../modeller/scene-budget.js'\nimport type { Figure } from '../types.js'\n\n/**\n * 이 값 아래면 카드에서 색으로 눈에 띄게 한다.\n *\n * **등급 경계가 아니다.** 등급(A~D)은 `figure-model` 의 `scoreOf` 가 정하고 화면은\n * 그것을 베끼지 않는다. 이것은 「눈에 걸리게 할 선」이고, 화면의 판단이다.\n */\nconst ATTENTION_BELOW = 50\n\n/**\n * Figure 한 장.\n *\n * 카드에 무엇을 실을지가 이 화면의 값이다. 이름과 썸네일만 실으면 저작자는 **무게를\n * 모른 채** 고르게 된다. 삼각형 수와 점수를 함께 실어, 「이걸 100개 놓아도 되나」를\n * 목록에서 판단할 수 있게 한다.\n */\n@customElement('figure-card')\nexport class FigureCard extends localize(i18next)(LitElement) {\n static styles = css`\n :host {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n background-color: var(--md-sys-color-surface-container-lowest);\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 6px;\n cursor: pointer;\n }\n :host(:hover) {\n border-color: var(--md-sys-color-primary);\n }\n\n div[thumbnail] {\n position: relative;\n aspect-ratio: 4 / 3;\n display: flex;\n align-items: center;\n justify-content: center;\n background-color: var(--md-sys-color-surface-container);\n overflow: hidden;\n }\n div[thumbnail] img {\n width: 100%;\n height: 100%;\n object-fit: contain;\n }\n /* 썸네일이 아직 없는 것을 빈 사각형으로 두지 않는다 — 왜 없는지 말한다 */\n div[thumbnail] span[nothumb] {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n font: var(--label-font, inherit);\n color: var(--md-sys-color-outline-variant);\n }\n div[thumbnail] span[nothumb] md-icon {\n --md-icon-size: 28px;\n }\n\n span[state] {\n position: absolute;\n top: 6px;\n left: 6px;\n padding: 1px 7px;\n font: var(--label-font, inherit);\n font-size: 0.7rem;\n color: var(--md-sys-color-on-secondary-container);\n background-color: var(--md-sys-color-secondary-container);\n border-radius: 6px;\n }\n\n button[remove] {\n position: absolute;\n top: 4px;\n right: 4px;\n display: none;\n padding: 3px;\n color: var(--md-sys-color-on-surface);\n background-color: var(--md-sys-color-surface);\n border: 1px solid var(--md-sys-color-outline-variant);\n border-radius: 6px;\n cursor: pointer;\n }\n button[remove] md-icon {\n --md-icon-size: 16px;\n }\n :host(:hover) button[remove] {\n display: block;\n }\n\n div[body] {\n display: flex;\n flex-direction: column;\n gap: 2px;\n padding: 8px 10px 10px 10px;\n }\n span[name] {\n font: var(--theme-font, inherit);\n font-size: 0.92rem;\n color: var(--md-sys-color-on-surface);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n /* 타입 이름은 저장되는 식별자다 — 표시 이름과 다르게 보이게 둔다 */\n code[type] {\n font-family: var(--mono-font, monospace);\n font-size: 0.72rem;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n }\n\n div[metrics] {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-top: 6px;\n font: var(--label-font, inherit);\n font-size: 0.72rem;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n }\n /* 「몇 개까지」가 카드의 머리다 — 저작면 머리와 같은 말이라야 한다 */\n span[fits] {\n margin-right: auto;\n font-size: 0.82rem;\n font-weight: 700;\n color: var(--md-sys-color-primary);\n }\n span[grade] {\n padding: 0 6px;\n font-weight: 700;\n color: var(--md-sys-color-on-primary-container);\n background-color: var(--md-sys-color-primary-container);\n border-radius: 6px;\n }\n /* 점수가 낮으면 색으로도 말한다 — 숫자만으로는 눈에 안 들어온다 */\n span[grade][low] {\n color: var(--md-sys-color-on-error, #fff);\n background-color: var(--md-sys-color-error);\n }\n span[unmeasured] {\n font-style: italic;\n color: var(--md-sys-color-outline-variant);\n }\n `\n\n @property({ type: Object }) figure!: Figure\n\n render() {\n const figure = this.figure\n if (!figure) {\n return html``\n }\n\n return html`\n <div thumbnail @click=${() => this.dispatchEvent(new CustomEvent('open'))}>\n ${figure.thumbnail\n ? html`<img src=${figure.thumbnail} alt=${figure.name} />`\n : html`\n <span nothumb>\n <md-icon>deployed_code</md-icon>\n ${i18next.t('text.thumbnail-not-made-yet')}\n </span>\n `}\n ${figure.state === 'draft' ? html`<span state>${i18next.t('label.draft')}</span>` : ''}\n <button\n remove\n title=${i18next.t('button.delete')}\n @click=${(e: Event) => {\n e.stopPropagation()\n this.dispatchEvent(new CustomEvent('remove'))\n }}\n >\n <md-icon>delete</md-icon>\n </button>\n </div>\n\n <div body @click=${() => this.dispatchEvent(new CustomEvent('open'))}>\n <span name title=${figure.name}>${figure.name}</span>\n <code type>${figure.type}</code>\n\n <div metrics>${this.renderMetrics()}</div>\n </div>\n `\n }\n\n /**\n * 무게와 점수.\n *\n * 아직 재지 않은 것(정본을 저장한 적이 없는 것)을 **0 으로 보여 주지 않는다.**\n * 0 은 「가볍다」는 뜻이 되어 버린다. 재지 않았다고 말한다.\n */\n private renderMetrics() {\n const { score, triangles, groups } = this.figure\n\n if (score == null || triangles == null) {\n return html`<span unmeasured>${i18next.t('text.not-measured-yet')}</span>`\n }\n\n // 목록이 저작면과 **같은 말**을 하게 한다. 저작면 머리가 「몇 개까지」이면 카드도\n // 그것이어야 한다 — 같은 형상을 두 화면이 다른 잣대로 말하면 무엇을 고칠지 모른다.\n const capacity = capacityFrom(groups ?? 0, triangles)\n\n // 등급 문자(A~D)를 여기서 만들지 않는다. 경계는 `figure-model` 의 `scoreOf` 가\n // 정하고, 그것을 화면이 베끼면 두 벌이 되어 목록과 저작면이 다른 등급을 보여 준다.\n // 목록에는 점수 숫자만 싣는다. 등급이 필요해지면 `figure-model` 이\n // `gradeOf(score)` 를 내보내야 한다 — 그때 그것을 쓴다.\n return html`\n ${capacity\n ? html`<span fits title=${i18next.t('text.reuse-score-explained')}>\n ${i18next.t('text.fits-n-instances', { n: capacity.instances.toLocaleString() })}\n </span>`\n : ''}\n <span grade ?low=${score < ATTENTION_BELOW}>${Math.round(score)}</span>\n <span>${i18next.t('text.triangle-count', { count: triangles })}</span>\n `\n }\n}\n"]}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 서버 몫이 없다 — 저장·검증은 `@things-factory/figure-service` 가 한다.
3
+ *
4
+ * 이 파일이 있는 이유는 `main` 이 가리키는 자리가 필요하기 때문이다. `board-ui` 도
5
+ * 같은 모양이다.
6
+ */
7
+ export {};
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * 서버 몫이 없다 — 저장·검증은 `@things-factory/figure-service` 가 한다.\n *\n * 이 파일이 있는 이유는 `main` 이 가리키는 자리가 필요하기 때문이다. `board-ui` 도\n * 같은 모양이다.\n */\nexport {}\n"]}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@things-factory/figure-ui",
3
+ "version": "10.1.3",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "@things-factory:registry": "https://registry.npmjs.org"
7
+ },
8
+ "description": "Figure 저작 화면 — 목록과 3D 저작면. 컴포넌트 저작도구의 클라이언트 몫.",
9
+ "main": "dist-server/index.js",
10
+ "things-factory": true,
11
+ "author": "heartyoh <heartyoh@hatiolab.com>",
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hatiolab/things-factory.git",
16
+ "directory": "packages/board-ui"
17
+ },
18
+ "scripts": {
19
+ "build": "npm run build:server && npm run build:client",
20
+ "test": "node --import ./test/resolve-ts-siblings.mjs --test --test-force-exit test/*.test.ts",
21
+ "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
22
+ "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
23
+ "build:server": "npx tsc --p ./server/tsconfig.json",
24
+ "clean:client": "npx rimraf dist-client",
25
+ "clean:server": "npx rimraf dist-server",
26
+ "clean": "npm run clean:server && npm run clean:client"
27
+ },
28
+ "dependencies": {
29
+ "@hatiolab/figure-model": "^0.1.12",
30
+ "@hatiolab/things-scene": "^10.1.6",
31
+ "@material/web": "^2.0.0",
32
+ "@operato/graphql": "^10.0.0",
33
+ "@operato/i18n": "^10.0.0",
34
+ "@operato/layout": "^10.0.0",
35
+ "@operato/shell": "^10.0.0",
36
+ "@operato/styles": "^10.0.0",
37
+ "@things-factory/figure-service": "^10.1.3",
38
+ "three": "^0.185.1"
39
+ },
40
+ "gitHead": "8407b1e9d1f47f6676af144d4a33c8ac01cb49c4"
41
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 서버 몫이 없다 — 저장·검증은 `@things-factory/figure-service` 가 한다.
3
+ *
4
+ * 이 파일이 있는 이유는 `main` 이 가리키는 자리가 필요하기 때문이다. `board-ui` 도
5
+ * 같은 모양이다.
6
+ */
7
+ export {}
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "experimentalDecorators": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "declaration": true,
8
+ "baseUrl": "./",
9
+ "outDir": "../dist-server"
10
+ },
11
+ "include": [
12
+ "./**/*"
13
+ ],
14
+ "exclude": [
15
+ "**/*.test.ts",
16
+ "**/*.spec.ts"
17
+ ]
18
+ }
@@ -0,0 +1,188 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import test from 'node:test'
5
+
6
+ /**
7
+ * 색이 **한 곳에만 살고, 글자가 읽힌다.**
8
+ *
9
+ * ── 무엇을 막나 ────────────────────────────────────────────────────────────
10
+ * 두 가지가 조용히 무너진다.
11
+ *
12
+ * **하나. 같은 색이 두 곳에 산다.** figure-ui 에는 팔레트가 없었고 하네스가 자기 `:root` 에
13
+ * 값을 직접 적고 있었다. 한쪽만 고치면 아무 오류 없이 두 얼굴이 된다 — 하네스에서 고른
14
+ * 색과 앱에서 보이는 색이 다른데 어디서도 말해 주지 않는다. plant 가 같은 결함을 겪었다.
15
+ *
16
+ * **둘. 색을 예쁘게 고치다가 글자가 안 읽히게 된다.** plant 가 실제로 그랬다. `#0e8a56` 에
17
+ * 「버튼 · 링크에 쓸 수 있다」고 적어 두었는데 흰 바탕 대비가 4.39 라 **큰 글자 기준만**
18
+ * 넘었다. 링크 글자와 작은 배지 두 자리가 조용히 미달이었다.
19
+ *
20
+ * 그래서 여기서 대비를 직접 잰다. 색을 무엇으로 정할지는 사람이 정한다 — 이 테스트가 보는
21
+ * 것은 **한 곳에 있는가**와 **읽히는가** 둘뿐이다.
22
+ */
23
+
24
+ const HERE = import.meta.dirname
25
+ const BRAND = join(HERE, '..', 'client', 'themes', 'brand.css')
26
+ const THEMES = ['light.css', 'dark.css'].map(f => join(HERE, '..', 'client', 'themes', f))
27
+ /** 저작 하네스. 이 저장소 안의 두 번째 사본 자리다. */
28
+ const HARNESS = join(HERE, '..', '..', '..', 'test', 'browser', 'modeller-page.mjs')
29
+
30
+ const read = (path: string) => readFileSync(path, 'utf8')
31
+
32
+ /** `--이름: 값;` 을 모은다. 한 줄에 여러 개 붙어 있어도 잡는다. */
33
+ function definitions(css: string): Map<string, string[]> {
34
+ const found = new Map<string, string[]>()
35
+
36
+ for (const match of css.matchAll(/--([a-z0-9-]+)\s*:\s*([^;}]+)/g)) {
37
+ const name = `--${match[1]}`
38
+ found.set(name, [...(found.get(name) ?? []), match[2]!.trim()])
39
+ }
40
+
41
+ return found
42
+ }
43
+
44
+ /** WCAG 상대 휘도. */
45
+ function luminance(hex: string): number {
46
+ const parts = [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16) / 255)
47
+ const linear = parts.map(v => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
48
+
49
+ return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!
50
+ }
51
+
52
+ /** 두 색의 대비. 작은 글자 기준은 4.5, 큰 글자와 굵은 선은 3.0 이다. */
53
+ function contrast(a: string, b: string): number {
54
+ const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x)
55
+
56
+ return (high! + 0.05) / (low! + 0.05)
57
+ }
58
+
59
+ /** 그 파일이 정한 색값. `var(...)` 로 넘긴 것은 따라가지 않는다. */
60
+ function colorOf(css: string, name: string): string {
61
+ const value = definitions(css).get(name)?.[0]
62
+
63
+ assert.ok(value, `${name} 이 없다`)
64
+ assert.match(value, /^#[0-9a-f]{6}$/i, `${name} 이 6자리 hex 가 아니다: ${value}`)
65
+
66
+ return value
67
+ }
68
+
69
+ test('브랜드 색의 정본이 있다 — 없으면 아래 테스트가 다 통과해 버린다', () => {
70
+ const brand = definitions(read(BRAND))
71
+
72
+ for (const name of ['--brand-figure', '--brand-figure-2', '--brand-figure-wash']) {
73
+ assert.ok(brand.has(name), `${name} 이 brand.css 에 없다`)
74
+ }
75
+ })
76
+
77
+ test('앱 팔레트가 브랜드 색을 스스로 정하지 않는다 — 정본을 읽는다', () => {
78
+ for (const path of THEMES) {
79
+ const css = read(path)
80
+
81
+ assert.match(css, /\.\/brand\.css/, `${path} 가 brand.css 를 안 문다`)
82
+
83
+ const own = [...definitions(css).keys()].filter(name => name.startsWith('--brand-'))
84
+
85
+ assert.deepEqual(own, [], `${path} 가 브랜드 색을 스스로 정한다: ${own.join(' · ')}`)
86
+ }
87
+ })
88
+
89
+ test('하네스가 색을 스스로 정하지 않는다 — 이것이 원래 결함이었다', () => {
90
+ /*
91
+ * 하네스가 `--md-sys-color-primary: #4a5bbd` 처럼 열여섯 줄을 직접 들고 있었다. 그래서
92
+ * 하네스에서 보이는 화면과 앱에서 보일 화면이 다른 색이었고, 어느 쪽이 맞는지 알 방법이
93
+ * 없었다.
94
+ */
95
+ const css = read(HARNESS)
96
+ const own = [...definitions(css).keys()].filter(
97
+ name => name.startsWith('--md-sys-color-') || name.startsWith('--brand-')
98
+ )
99
+
100
+ assert.deepEqual(own, [], `하네스가 색을 스스로 정한다: ${own.join(' · ')}`)
101
+ })
102
+
103
+ test('밝은 면과 어두운 면을 함께 든다 — 한쪽만 있으면 어두운 화면이 안 바뀐다', () => {
104
+ const brand = read(BRAND)
105
+
106
+ assert.ok(definitions(brand).has('--brand-figure'), '밝은 면용이 없다')
107
+ assert.ok(definitions(brand).has('--brand-figure-2'), '어두운 면용이 없다')
108
+ })
109
+
110
+ test('브랜드 색 위의 흰 글자가 읽힌다 — 작은 글자 기준 4.5', () => {
111
+ const figure = colorOf(read(BRAND), '--brand-figure')
112
+ const measured = contrast(figure, '#ffffff')
113
+
114
+ assert.ok(measured >= 4.5, `${figure} 위 흰 글자 대비가 ${measured.toFixed(2)} 다`)
115
+ })
116
+
117
+ test('어두운 면 위의 브랜드 색이 읽힌다 — 그쪽은 -2 가 맡는다', () => {
118
+ const bright = colorOf(read(BRAND), '--brand-figure-2')
119
+ const surface = colorOf(read(THEMES[1]!), '--md-sys-color-surface')
120
+ const measured = contrast(bright, surface)
121
+
122
+ assert.ok(measured >= 4.5, `${bright} 를 ${surface} 위에 쓴 대비가 ${measured.toFixed(2)} 다`)
123
+ })
124
+
125
+ test('밝은 쪽 값을 어두운 면에 쓰지 않는다 — 이름이 뜻이고 값은 자리에 따라 다르다', () => {
126
+ /*
127
+ * `--brand-figure` 를 어두운 면에 그대로 쓰면 대비 1.6 이라 사라진다. 그 실수를 잡는다.
128
+ */
129
+ const figure = colorOf(read(BRAND), '--brand-figure')
130
+ const darkSurface = colorOf(read(THEMES[1]!), '--md-sys-color-surface')
131
+
132
+ assert.ok(
133
+ contrast(figure, darkSurface) < 4.5,
134
+ '밝은 면용 값이 어두운 면에서도 읽힌다 — 그렇다면 -2 를 둘 이유가 없다. 값을 다시 보라'
135
+ )
136
+
137
+ const dark = read(THEMES[1]!)
138
+ assert.match(dark, /--md-sys-color-primary:\s*var\(--brand-figure-2\)/, '어두운 테마가 -2 를 안 쓴다')
139
+ })
140
+
141
+ test('글자 셋이 다 읽힌다 — 흐린 것까지', () => {
142
+ const light = read(THEMES[0]!)
143
+ const surface = colorOf(light, '--md-sys-color-surface')
144
+
145
+ for (const name of [
146
+ '--md-sys-color-on-surface',
147
+ '--md-sys-color-on-surface-variant',
148
+ '--md-sys-color-on-surface-dim'
149
+ ]) {
150
+ const measured = contrast(colorOf(light, name), surface)
151
+
152
+ assert.ok(measured >= 4.5, `${name} 대비가 ${measured.toFixed(2)} 다`)
153
+ }
154
+ })
155
+
156
+ test('뜻이 있는 색이 배지 바탕 위에서 읽힌다 — 주의와 오류', () => {
157
+ const light = read(THEMES[0]!)
158
+
159
+ for (const [ink, ground] of [
160
+ ['--md-sys-color-on-tertiary-container', '--md-sys-color-tertiary-container'],
161
+ ['--md-sys-color-on-error-container', '--md-sys-color-error-container'],
162
+ ['--md-sys-color-on-secondary-container', '--md-sys-color-secondary-container']
163
+ ]) {
164
+ const measured = contrast(colorOf(light, ink!), colorOf(light, ground!))
165
+
166
+ assert.ok(measured >= 4.5, `${ink} / ${ground} 대비가 ${measured.toFixed(2)} 다`)
167
+ }
168
+ })
169
+
170
+ test('배지 바탕에 파스텔을 깔지 않는다 — 뜻은 글자색이 든다', () => {
171
+ /*
172
+ * 작은 면에 옅은 색을 깔면 화면이 싸구려로 보이고, 캔버스의 부품 색과도 섞인다. 배지 바탕은
173
+ * 무채색이어야 한다 — 세 채널이 서로 가까우면 무채색이다.
174
+ */
175
+ const light = read(THEMES[0]!)
176
+
177
+ for (const name of [
178
+ '--md-sys-color-tertiary-container',
179
+ '--md-sys-color-error-container',
180
+ '--md-sys-color-secondary-container'
181
+ ]) {
182
+ const hex = colorOf(light, name)
183
+ const [r, g, b] = [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
184
+ const spread = Math.max(r!, g!, b!) - Math.min(r!, g!, b!)
185
+
186
+ assert.ok(spread <= 12, `${name} 이 ${hex} 로 색을 띤다 (채널 차 ${spread})`)
187
+ }
188
+ })
@@ -0,0 +1,144 @@
1
+ /*
2
+ * **제목 크기는 눈금에서 고른다** — 화면마다 새 값을 만들지 않게 (2026-09-06).
3
+ *
4
+ * ── 무엇이 있었나 ───────────────────────────────────────────────────────────
5
+ * 사용자가 operato-plant 의 한 화면을 보고 「이렇게까지 엉망일 수가 있나」라고 했다. 그 화면
6
+ * 하나의 문제가 아니었다 — 두 저장소의 화면 110개를 훑어 보니 제목 크기 선언이 **23가지**였다.
7
+ *
8
+ * 18px 10회 · 15px 6회 · 17px 4회 · 12px 4회 · 13px 4회 · 14px 4회 · 19px 3회 · 13.5px 3회 ·
9
+ * 0.72rem 3회 · clamp() 둘 · 1.06em · 1.2rem · 1rem · 0.95rem · 0.8rem · 22px · 11px …
10
+ *
11
+ * 눈금은 이미 있다(`--ui-t-hero · title · body · small · micro`). 그런데 그 토큰을 쓴 자리는
12
+ * 넷뿐이었다. **어휘가 없어서가 아니라 아무도 쓰지 않아서** 화면마다 값이 생겼다.
13
+ *
14
+ * ── 이 가드가 막는 것 ───────────────────────────────────────────────────────
15
+ * 지금 있는 것을 한꺼번에 바꾸는 것은 화면 수십 개를 눈으로 확인하지 않고 움직이는 일이라 하지
16
+ * 않았다. 대신 **더 늘지 않게** 막는다. 새 화면이 22px 을 적으면 여기서 걸린다.
17
+ *
18
+ * ── 이 꾸러미의 화면 크기는 아직 대부분 직접 적혀 있다 ──────────────────────
19
+ * font-size 선언 72개가 전부 토큰이 아니다. 그 전부를 오늘 옮기는 것은 화면을 눈으로 확인하지
20
+ * 않고 움직이는 일이라 하지 않았다 — 제목만 정리하고, **더 늘지 않게** 여기서 막는다.
21
+ *
22
+ * ── 예외를 두되, 이유를 적게 한다 ───────────────────────────────────────────
23
+ * 눈금이 안 맞는 자리가 있다 — 읽는 문서(긴 글은 다른 눈금이고 반응형이 필요하다) · 서서 보는
24
+ * 현장 화면(팔을 뻗은 거리에서 장갑 낀 손으로 쓴다).
25
+ *
26
+ * 그래서 값을 막지 않고 **말을 요구한다.** 제목 블록 앞에 「조판 예외:」로 시작하는 주석이 있으면
27
+ * 통과한다. 목록을 이 파일에 모아 두는 방법도 있었는데, 그러면 이유가 코드에서 멀어지고 목록만
28
+ * 남는다 — 다음 사람이 그 줄을 보고 왜 예외인지 알 수 없다.
29
+ */
30
+ import { test } from 'node:test'
31
+ import assert from 'node:assert/strict'
32
+ import { readFileSync } from 'node:fs'
33
+ import { fileURLToPath } from 'node:url'
34
+ import { globSync } from 'node:fs'
35
+
36
+ const CLIENT = fileURLToPath(new URL('../client/', import.meta.url))
37
+
38
+ /**
39
+ * 제목 선택자의 스타일 블록.
40
+ *
41
+ * **제목으로 끝나는 선택자만** 본다 — `h2 {` · `header h2 {` · `.body h1 {`. 제목 **안의** 조각은
42
+ * 제목이 아니다(`.axis > h2 > .rate` 는 그 제목 옆에 붙는 수이고, `.body h1 + p` 는 문단이다).
43
+ * 그것까지 잡으면 가드가 눈금과 상관없는 자리에서 울린다.
44
+ */
45
+ const HEADING_BLOCK = /(^|\n)([^\n{}]{0,80}\bh[1-6]\b(?:::?[a-z-]+(?:\([^)]*\))?)?\s*)\{([^}]{0,400}?)\}/gs
46
+
47
+ /** 그 블록이 정한 글자 크기. */
48
+ const FONT_SIZE = /font-size:\s*([^;]+);/
49
+
50
+ /** 이 자리는 눈금 밖이라고 **적어 둔** 것. 이유가 함께 있어야 한다. */
51
+ const EXCUSED = /조판 예외:/
52
+
53
+ type Finding = { file: string; selector: string; value: string }
54
+
55
+ function scan(): { findings: Finding[]; scanned: number; files: number } {
56
+ const findings: Finding[] = []
57
+ let scanned = 0
58
+ let files = 0
59
+
60
+ for (const path of globSync('**/*.ts', { cwd: CLIENT })) {
61
+ const source = readFileSync(CLIENT + path, 'utf-8')
62
+ if (!source.includes('css`')) continue
63
+
64
+ files++
65
+
66
+ for (const match of source.matchAll(HEADING_BLOCK)) {
67
+ const size = FONT_SIZE.exec(match[3])
68
+ if (!size) continue
69
+
70
+ scanned++
71
+
72
+ const value = size[1].trim()
73
+ if (value.startsWith('var(--')) continue
74
+
75
+ /*
76
+ * 블록 앞에 이유가 적혀 있나. 창을 넉넉히 잡는다 — 문서 조판처럼 제목 규칙 서넛이 붙어 있는
77
+ * 자리에서는 한 번 적은 이유가 그 무리 전체를 덮는 것이 자연스럽다.
78
+ */
79
+ const before = source.slice(Math.max(0, (match.index ?? 0) - 1200), match.index ?? 0)
80
+ if (EXCUSED.test(before)) continue
81
+
82
+ findings.push({ file: path, selector: match[2].trim(), value })
83
+ }
84
+ }
85
+
86
+ return { findings, scanned, files }
87
+ }
88
+
89
+ test('제목 크기는 눈금 토큰에서 고른다 — 화면마다 새 값을 만들지 않게', () => {
90
+ const { findings } = scan()
91
+
92
+ assert.deepEqual(
93
+ findings.map(one => `${one.file} ${one.selector} → ${one.value}`),
94
+ [],
95
+ '제목 크기를 직접 적은 자리다. --ui-t-hero · title · body · small · micro 중에서 고르거나,\n' +
96
+ '눈금이 안 맞는 자리면 그 블록 앞에 「조판 예외: 왜 다른 눈금이어야 하는가」를 적는다.'
97
+ )
98
+ })
99
+
100
+ /**
101
+ * **가드가 빈 검사가 되지 않게.**
102
+ *
103
+ * 경로가 어긋나면 읽는 파일이 0이 되고, 0이면 언제나 통과한다 — 검사가 죽었는데 초록으로 보인다.
104
+ *
105
+ * 세는 것은 **제목 수가 아니라 읽은 화면 파일 수**다. 제목 요소를 안 쓰는 꾸러미가 있어서
106
+ * (ops-master 는 절 제목을 클래스로 그린다) 제목 수로 재면 그 꾸러미에서는 이 가드를 놓을 수가
107
+ * 없다. 규칙이 도는지는 아래 「무엇을 잡고 무엇을 통과시키나」가 따로 증명한다.
108
+ */
109
+ test('★ 이 가드가 실제로 화면을 읽고 있다 — 0이면 검사가 죽은 것이다', () => {
110
+ const { files, scanned } = scan()
111
+
112
+ assert.ok(files >= 5, `css 를 쓰는 화면 파일을 ${files}개 읽었다. 경로가 어긋났을 수 있다`)
113
+ assert.ok(scanned >= 0, `제목 블록 ${scanned}개`)
114
+ })
115
+
116
+ /** 잡아야 하는 모양과 통과해야 하는 모양을 검사 자신이 준다. */
117
+ test('★ 무엇을 잡고 무엇을 통과시키는지 — 규칙을 검사가 보인다', () => {
118
+ const catches = `
119
+ h2 { font-size: 19px; }
120
+ `
121
+ const passesToken = `
122
+ h2 { font-size: var(--ui-t-title, 15px); }
123
+ `
124
+ const passesExcused = `
125
+ /* 조판 예외: 읽는 문서의 눈금이다 */
126
+ .body h1 { font-size: clamp(28px, 4.6vw, 40px); }
127
+ `
128
+
129
+ const judge = (source: string) => {
130
+ for (const match of source.matchAll(HEADING_BLOCK)) {
131
+ const size = FONT_SIZE.exec(match[3])
132
+ if (!size) continue
133
+ if (size[1].trim().startsWith('var(--')) continue
134
+ const before = source.slice(Math.max(0, (match.index ?? 0) - 1200), match.index ?? 0)
135
+ if (EXCUSED.test(before)) continue
136
+ return 'caught'
137
+ }
138
+ return 'passed'
139
+ }
140
+
141
+ assert.equal(judge(catches), 'caught', '직접 적은 크기를 못 잡는다')
142
+ assert.equal(judge(passesToken), 'passed', '토큰을 잡으면 안 된다')
143
+ assert.equal(judge(passesExcused), 'passed', '이유를 적은 예외를 잡으면 안 된다')
144
+ })