@things-factory/figure-ui 10.1.157 → 10.1.159

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.157",
3
+ "version": "10.1.159",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -41,10 +41,10 @@
41
41
  "@operato/slot-layout-editor": "^10.29.25",
42
42
  "@operato/styles": "^10.0.0",
43
43
  "@operato/utils": "^10.0.0",
44
- "@things-factory/ai-assistant": "^10.1.157",
45
- "@things-factory/figure-service": "^10.1.157",
44
+ "@things-factory/ai-assistant": "^10.1.159",
45
+ "@things-factory/figure-service": "^10.1.159",
46
46
  "@things-factory/figure-viewer": "^10.1.155",
47
47
  "three": "^0.185.1"
48
48
  },
49
- "gitHead": "bde810f804966549a7041457ad7ede40fcb78488"
49
+ "gitHead": "fc15cdcb18e562ca1e723a1e1bf042f9582cbbfb"
50
50
  }
@@ -25,13 +25,17 @@ const HERE = import.meta.dirname
25
25
  const FOLDER = 'harness-theme'
26
26
  const BRAND = join(HERE, '..', 'client', FOLDER, 'brand.css')
27
27
  const THEMES = ['light.css', 'dark.css'].map(f => join(HERE, '..', 'client', FOLDER, f))
28
- /** 저작 하네스 둘. 이 저장소 안의 두 번째 사본 자리다. */
29
- const HARNESSES = ['modeller-page.mjs', 'real-modeller.mjs'].map(f =>
28
+ /** 저작 하네스 — 앱의 모델러 페이지를 그대로 세우는 것 하나다(옛 V2 픽스처 페이지는 2026-10-13 에 지웠다). */
29
+ const HARNESSES = ['real-modeller.mjs'].map(f =>
30
30
  join(HERE, '..', '..', '..', 'test', 'browser', f)
31
31
  )
32
32
 
33
33
  const read = (path: string) => readFileSync(path, 'utf8')
34
34
 
35
+ /** brand.css 가 들여오는 teal — operato-suite 의 틀. 하네스의 `/pkg/` 처럼 저장소 안 경로로 읽는다. */
36
+ const TEAL_IMPORT = /@import url\('(\.\.\/\.\.\/\.\.\/operato-suite\/client\/frames\/teal\.css)'\);/
37
+ const TEAL = join(HERE, '..', 'client', FOLDER, '../../../operato-suite/client/frames/teal.css')
38
+
35
39
  /** `--이름: 값;` 을 모은다. 한 줄에 여러 개 붙어 있어도 잡는다. */
36
40
  function definitions(css: string): Map<string, string[]> {
37
41
  const found = new Map<string, string[]>()
@@ -69,6 +73,37 @@ function colorOf(css: string, name: string): string {
69
73
  return value
70
74
  }
71
75
 
76
+ /**
77
+ * 브랜드 이름의 실제 색. brand.css 는 `var(--teal-on-*)` 로 suite 를 가리키므로 그 값을 따라가
78
+ * 6자리 hex 로 돌려준다.
79
+ */
80
+ function brandColor(name: string): string {
81
+ const value = definitions(read(BRAND)).get(name)?.[0]
82
+ const teal = /^var\((--teal-on-(?:light|dark))\)$/.exec(value ?? '')
83
+
84
+ assert.ok(teal, `${name} 이 suite 의 teal 이름을 읽지 않는다: ${value}`)
85
+
86
+ const literal = definitions(read(TEAL)).get(teal[1]!)?.[0] ?? ''
87
+ const rgb = /^rgb\((\d+) (\d+) (\d+)\)$/.exec(literal)
88
+
89
+ assert.ok(rgb, `suite 의 ${teal[1]} 를 읽을 수 없다: ${literal}`)
90
+ return '#' + [rgb[1], rgb[2], rgb[3]].map(v => Number(v).toString(16).padStart(2, '0')).join('')
91
+ }
92
+
93
+ test('teal 값은 operato-suite 에만 있다 — brand.css 는 들여와 이름만 잇는다', () => {
94
+ /*
95
+ * operato-figure · operato-twin 의 앱 화면이 읽는 teal 과 하네스의 teal 이 사본으로 따로 살았다
96
+ * (2026-10-13 까지). 이제 brand.css 는 suite 의 `frames/teal.css` 를 들여오고 값을 적지 않는다.
97
+ */
98
+ const brand = read(BRAND)
99
+
100
+ assert.match(brand, TEAL_IMPORT, 'brand.css 가 suite 의 teal 틀을 들여오지 않는다')
101
+ assert.ok(existsSync(TEAL), `들여오는 자리에 teal 틀이 없다: ${TEAL}`)
102
+
103
+ const literals = [...brand.replace(/\/\*[\s\S]*?\*\//g, ' ').matchAll(/#[0-9a-f]{3,8}\b|rgba?\(/gi)].map(m => m[0])
104
+ assert.deepEqual(literals, [], `brand.css 가 색을 직접 적는다: ${literals.join(' · ')}`)
105
+ })
106
+
72
107
  test('브랜드 색의 정본이 있다 — 없으면 아래 테스트가 다 통과해 버린다', () => {
73
108
  const brand = definitions(read(BRAND))
74
109
 
@@ -168,7 +203,7 @@ test('산출물에 소스에 없는 css 가 없다 — copyfiles 는 지운 것
168
203
  옮긴 자리를 하네스가 가리키지 않으면 하네스는 스타일시트 없이 뜬다 — 오류 없이 무채색 화면이
169
204
  되고, 아래 대비 시험들은 **어느 화면에도 없는 값**을 재게 된다.
170
205
  */
171
- test('하네스 둘이 옮긴 팔레트를 가리킨다 — 밝은 것과 어두운 것 모두', () => {
206
+ test('하네스가 옮긴 팔레트를 가리킨다 — 밝은 것과 어두운 것 모두', () => {
172
207
  for (const path of HARNESSES) {
173
208
  const html = read(path)
174
209
 
@@ -189,14 +224,14 @@ test('밝은 면과 어두운 면을 함께 든다 — 한쪽만 있으면 어
189
224
  })
190
225
 
191
226
  test('브랜드 색 위의 흰 글자가 읽힌다 — 작은 글자 기준 4.5', () => {
192
- const figure = colorOf(read(BRAND), '--figure-brand')
227
+ const figure = brandColor('--figure-brand')
193
228
  const measured = contrast(figure, '#ffffff')
194
229
 
195
230
  assert.ok(measured >= 4.5, `${figure} 위 흰 글자 대비가 ${measured.toFixed(2)} 다`)
196
231
  })
197
232
 
198
233
  test('어두운 면 위의 브랜드 색이 읽힌다 — 그쪽은 -2 가 맡는다', () => {
199
- const bright = colorOf(read(BRAND), '--figure-brand-2')
234
+ const bright = brandColor('--figure-brand-2')
200
235
  const surface = colorOf(read(THEMES[1]!), '--md-sys-color-surface')
201
236
  const measured = contrast(bright, surface)
202
237
 
@@ -207,7 +242,7 @@ test('밝은 쪽 값을 어두운 면에 쓰지 않는다 — 이름이 뜻이
207
242
  /*
208
243
  * `--figure-brand` 를 어두운 면에 그대로 쓰면 대비 1.6 이라 사라진다. 그 실수를 잡는다.
209
244
  */
210
- const figure = colorOf(read(BRAND), '--figure-brand')
245
+ const figure = brandColor('--figure-brand')
211
246
  const darkSurface = colorOf(read(THEMES[1]!), '--md-sys-color-surface')
212
247
 
213
248
  assert.ok(
@@ -116,9 +116,14 @@ test('데이터 탭 · 클립 패널 · 미리보기의 배선과 문구', () =>
116
116
  assert.match(preview, /tellInstances\('motion'/)
117
117
  const clips = read('../client/modeller/figure-clips.ts')
118
118
  assert.match(clips, /kind: 'remove-driver', state: track\.state, clip: clip\.name/)
119
+ /* A key's value is shown to three decimals: a slider's 14.399999999999999 is not what a person typed. */
120
+ assert.match(clips, /value: current === undefined \? '' : shown\(current\)/)
121
+ assert.match(clips, /\$\{key\.seconds\}s → \$\{shown\(key\.value\)\}/)
119
122
  const keys = [read('../client/modeller/figure-data-panel.ts'), clips, read('../client/modeller/refusals.ts')].flatMap(src => [...src.matchAll(/'(figure\.[a-z.-]*[a-z])'/g)].map(m => m[1]!))
120
123
  /* Keys made from a value: each value the kernel can give has its words. */
121
124
  const made = [
125
+ /* The tab's short name, the one it shows under its icon (renderTab reads figure.label.tab-short-<tab>). */
126
+ 'figure.label.tab-short-data',
122
127
  ...['number', 'boolean', 'enum'].map(k => `figure.label.data-kind-${k}`),
123
128
  ...['state', 'palette', 'motion-clip', 'motion-speed'].flatMap(k => [`figure.label.data-target-${k}`, `figure.text.data-target-absent-${k}`]),
124
129
  ...['observed', 'commanded'].map(k => `figure.label.data-${k}`),
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import test from 'node:test'
5
+
6
+ /**
7
+ * 저작 하네스(`test/browser/serve-modeller.mjs`)가 서고, 표본이 모델러에서 열린다.
8
+ *
9
+ * import map 이 꾸러미의 `exports` 하위 경로를 따른다.
10
+ *
11
+ * 하네스(`test/browser/serve.mjs`)는 `name/` 접두 하나만 두고 있었다. 그래서
12
+ * `@operato/popup/ox-popup-actions.js` 처럼 들이는 이름과 파일 자리(`dist/src/...`)가 다른 모듈이
13
+ * 패키지 맨 위에서 찾아져 404 가 되었고, 그것을 무는 모델러가 하네스에서 통째로 안 떴다(2026-10-13).
14
+ */
15
+
16
+ const SERVE = join(import.meta.dirname, '..', '..', '..', 'test', 'browser', 'serve.mjs')
17
+ const REAL = join(import.meta.dirname, '..', '..', '..', 'test', 'browser', 'real-modeller.mjs')
18
+
19
+ test('exports 의 하위 경로가 그 파일 자리로 간다', async () => {
20
+ const { buildImportMap } = await import(pathToFileURL(SERVE).href)
21
+ const { imports } = buildImportMap()
22
+
23
+ assert.equal(imports['@operato/popup/ox-popup-actions.js'], '/nm/@operato/popup/dist/src/ox-popup-actions.js')
24
+ assert.equal(imports['@operato/styles/action-heights.js'], '/nm/@operato/styles/dist/src/action-heights.js')
25
+ assert.equal(imports['@operato/popup/'], '/nm/@operato/popup/', 'the prefix stays for paths exports does not name')
26
+ })
27
+
28
+ test('표본은 모두 지금의 자산 형식이라 모델러가 연다', async () => {
29
+ /*
30
+ * 손으로 쓴 옛 부품 형식 표본은 figure-model 이 `ASSET_SCHEMA: asset: missing version` 으로 거절해
31
+ * 하나도 열리지 않았다(2026-10-13). 이제 저장소의 V3 자산(시험 고정물 · 레시피 기본 모델)을 그대로 읽는다.
32
+ */
33
+ const { currentSamples } = await import(pathToFileURL(REAL).href)
34
+ const { parseFigureAsset } = await import('@hatiolab/figure-model')
35
+ const samples = currentSamples()
36
+ const refused: string[] = []
37
+
38
+ for (const sample of samples) {
39
+ try {
40
+ parseFigureAsset(JSON.stringify(sample.source))
41
+ } catch (error) {
42
+ refused.push(`${sample.key}: ${(error as Error).message}`)
43
+ }
44
+ }
45
+
46
+ assert.ok(samples.length > 10, `표본이 ${samples.length} 개뿐이다`)
47
+ assert.equal(new Set(samples.map(s => s.key)).size, samples.length, '표본 키가 겹친다')
48
+ assert.deepEqual(refused, [])
49
+ })
@@ -1107,5 +1107,6 @@
1107
1107
  "figure.text.motion-setting-clip-not-in-group": "{clip} is not a clip of {group}.",
1108
1108
  "figure.text.motion-setting-speed-out-of-range": "The speed of {group} is 0 to 3.",
1109
1109
  "figure.text.motion-setting-group-selection-required": "Choose which clip of {group} plays.",
1110
- "figure.text.motion-key-outside-range": "The key {value} of {input} is outside {min} to {max}; a grouped clip keys inside the range."
1110
+ "figure.text.motion-key-outside-range": "The key {value} of {input} is outside {min} to {max}; a grouped clip keys inside the range.",
1111
+ "figure.label.tab-short-data": "Data"
1111
1112
  }
@@ -504,7 +504,7 @@
504
504
  "figure.text.view-settings-are-not-saved": "esta configuración no se guarda.",
505
505
  "figure.text.what-a-figure-is": "una Figure es un componente 3D que usted crea. compóngala a partir de formas sencillas y se convierte en un componente que puede colocar en un plano, compartiendo forma y material entre todas las instancias.",
506
506
  "figure.text.what-changed-in-this-release": "qué cambió en esta publicación",
507
- "figure.title.figures": "figuras",
507
+ "figure.title.figures": "catálogo de figuras",
508
508
  "figure.title.new-figure": "nueva figura",
509
509
  "figure.title.save-new-figure": "guardar borrador de figura",
510
510
  "figure.text.no-parts-yet": "Aún no hay piezas. Elija una forma arriba para colocar una.",
@@ -1107,5 +1107,6 @@
1107
1107
  "figure.text.motion-setting-clip-not-in-group": "{clip} no es un clip de {group}.",
1108
1108
  "figure.text.motion-setting-speed-out-of-range": "La velocidad de {group} es de 0 a 3.",
1109
1109
  "figure.text.motion-setting-group-selection-required": "Elija qué clip de {group} se reproduce.",
1110
- "figure.text.motion-key-outside-range": "La clave {value} de {input} está fuera de {min} a {max}; un clip de grupo usa claves dentro del rango."
1110
+ "figure.text.motion-key-outside-range": "La clave {value} de {input} está fuera de {min} a {max}; un clip de grupo usa claves dentro del rango.",
1111
+ "figure.label.tab-short-data": "Datos"
1111
1112
  }
@@ -1107,5 +1107,6 @@
1107
1107
  "figure.text.motion-setting-clip-not-in-group": "{clip} は {group} のクリップではありません。",
1108
1108
  "figure.text.motion-setting-speed-out-of-range": "{group} の速度は 0 ~ 3 です。",
1109
1109
  "figure.text.motion-setting-group-selection-required": "{group} で再生するクリップを選びます。",
1110
- "figure.text.motion-key-outside-range": "{input} のキー {value} が範囲 {min} ~ {max} の外です。グループのクリップは範囲内のキーだけを使います。"
1110
+ "figure.text.motion-key-outside-range": "{input} のキー {value} が範囲 {min} ~ {max} の外です。グループのクリップは範囲内のキーだけを使います。",
1111
+ "figure.label.tab-short-data": "データ"
1111
1112
  }
@@ -1107,5 +1107,6 @@
1107
1107
  "figure.text.motion-setting-clip-not-in-group": "{clip} 클립은 {group} 그룹의 클립이 아닙니다.",
1108
1108
  "figure.text.motion-setting-speed-out-of-range": "{group} 그룹의 배속은 0 ~ 3입니다.",
1109
1109
  "figure.text.motion-setting-group-selection-required": "{group} 그룹에서 재생할 클립을 고릅니다.",
1110
- "figure.text.motion-key-outside-range": "{input} 조작값의 키 {value} 값이 범위 {min} ~ {max} 밖입니다. 그룹의 클립은 범위 안의 키만 씁니다."
1110
+ "figure.text.motion-key-outside-range": "{input} 조작값의 키 {value} 값이 범위 {min} ~ {max} 밖입니다. 그룹의 클립은 범위 안의 키만 씁니다.",
1111
+ "figure.label.tab-short-data": "데이터"
1111
1112
  }
@@ -1107,5 +1107,6 @@
1107
1107
  "figure.text.motion-setting-clip-not-in-group": "{clip} bukan klip {group}.",
1108
1108
  "figure.text.motion-setting-speed-out-of-range": "Kelajuan {group} ialah 0 hingga 3.",
1109
1109
  "figure.text.motion-setting-group-selection-required": "Pilih klip {group} yang dimainkan.",
1110
- "figure.text.motion-key-outside-range": "Kunci {value} bagi {input} di luar {min} hingga {max}; klip berkumpulan mesti dalam julat."
1110
+ "figure.text.motion-key-outside-range": "Kunci {value} bagi {input} di luar {min} hingga {max}; klip berkumpulan mesti dalam julat.",
1111
+ "figure.label.tab-short-data": "Data"
1111
1112
  }
@@ -1107,5 +1107,6 @@
1107
1107
  "figure.text.motion-setting-clip-not-in-group": "{clip} 不是 {group} 的片段。",
1108
1108
  "figure.text.motion-setting-speed-out-of-range": "{group} 的倍速为 0 ~ 3。",
1109
1109
  "figure.text.motion-setting-group-selection-required": "请选择 {group} 播放的片段。",
1110
- "figure.text.motion-key-outside-range": "{input} 的关键帧 {value} 超出 {min} ~ {max};组内片段只能在范围内设关键帧。"
1110
+ "figure.text.motion-key-outside-range": "{input} 的关键帧 {value} 超出 {min} ~ {max};组内片段只能在范围内设关键帧。",
1111
+ "figure.label.tab-short-data": "数据"
1111
1112
  }