@things-factory/figure-ui 10.1.224 → 10.1.225

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 (41) hide show
  1. package/client/modeller/editor-styles.ts +3 -2
  2. package/client/modeller/editor.ts +3 -1
  3. package/client/modeller/figure-capability-panel.ts +6 -0
  4. package/client/modeller/figure-clips.ts +1 -1
  5. package/client/modeller/figure-data-panel.ts +17 -14
  6. package/client/modeller/figure-preview.ts +46 -6
  7. package/client/modeller/product-session.ts +3 -2
  8. package/client/pages/figure-modeller-page.ts +2 -2
  9. package/dist-client/modeller/editor-styles.js +3 -2
  10. package/dist-client/modeller/editor-styles.js.map +1 -1
  11. package/dist-client/modeller/editor.js +5 -3
  12. package/dist-client/modeller/editor.js.map +1 -1
  13. package/dist-client/modeller/figure-capability-panel.js +6 -0
  14. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  15. package/dist-client/modeller/figure-clips.js +1 -1
  16. package/dist-client/modeller/figure-clips.js.map +1 -1
  17. package/dist-client/modeller/figure-data-panel.js +18 -14
  18. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  19. package/dist-client/modeller/figure-preview.js +46 -6
  20. package/dist-client/modeller/figure-preview.js.map +1 -1
  21. package/dist-client/modeller/product-session.d.ts +4 -2
  22. package/dist-client/modeller/product-session.js +3 -2
  23. package/dist-client/modeller/product-session.js.map +1 -1
  24. package/dist-client/pages/figure-modeller-page.js +2 -2
  25. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +3 -3
  28. package/test/ai-proposal.test.ts +2 -2
  29. package/test/browser/product-page.check.mjs +25 -0
  30. package/test/browser/side-panel-sweep.check.mjs +182 -0
  31. package/test/figure-assistant-surface.test.ts +1 -1
  32. package/test/first-save.test.ts +2 -2
  33. package/test/product-session.test.ts +5 -5
  34. package/test/proposal-record.test.ts +3 -3
  35. package/test/revert-keeping.test.ts +1 -1
  36. package/translations/en.json +1 -0
  37. package/translations/es.json +1 -0
  38. package/translations/ja.json +1 -0
  39. package/translations/ko.json +1 -0
  40. package/translations/ms.json +1 -0
  41. package/translations/zh.json +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.224",
3
+ "version": "10.1.225",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "npm run build:server && npm run build:client",
20
20
  "test": "FIGURE_FREEZE_COMPILED=1 node --import ./test/resolve-ts-siblings.mjs --test test/*.test.ts",
21
- "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs test/browser/props.check.mjs test/browser/retire.check.mjs test/browser/namespace.check.mjs test/browser/public.check.mjs test/browser/category.check.mjs test/browser/property-section.check.mjs test/browser/list.check.mjs test/browser/item.check.mjs",
21
+ "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs test/browser/props.check.mjs test/browser/retire.check.mjs test/browser/namespace.check.mjs test/browser/public.check.mjs test/browser/category.check.mjs test/browser/property-section.check.mjs test/browser/list.check.mjs test/browser/item.check.mjs test/browser/side-panel-sweep.check.mjs",
22
22
  "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
23
23
  "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
24
24
  "build:server": "npx tsc --p ./server/tsconfig.json",
@@ -48,5 +48,5 @@
48
48
  "@things-factory/setting-base": "^10.1.223",
49
49
  "three": "^0.185.1"
50
50
  },
51
- "gitHead": "7edcdca6fc9862973e99048812e22f2aa9b2f9c9"
51
+ "gitHead": "9929ca76f614ff84bea1f61dabe0e1b7f00cd18a"
52
52
  }
@@ -157,7 +157,7 @@ test('the same new figure\'s card in another tab before the first save makes a s
157
157
  })
158
158
  const made = [{ kind: 'add-part', part: { id: 'lid', kind: 'box', dimensions: { width: 100, height: 10, depth: 100 } } }] as any[]
159
159
  const first = tab()
160
- first.startNew()
160
+ first.startNew({ name: 'New' })
161
161
  const detail: FigureApplyDetail = { key: K, steps: made, create: true }
162
162
  const here = { ...taker({}), offer: (d: FigureApplyDetail) => (takeFigureOffer(first, d, { onSavedFigure: false, say }), d) }
163
163
  assert.equal((await runFigureProposal(detail, here, say)).verdict, 'accepted')
@@ -166,7 +166,7 @@ test('the same new figure\'s card in another tab before the first save makes a s
166
166
  const there = {
167
167
  ...taker({}),
168
168
  async openNew(s: FigureProposalStep[], key?: string) {
169
- second.startNew()
169
+ second.startNew({ name: 'New' })
170
170
  second.createUnder(key!)
171
171
  second.applyProposal(s, second.read().revision!, second.read().context!, key)
172
172
  return { applied: true }
@@ -173,6 +173,11 @@ describe('the first save of a new figure', () => {
173
173
  return { first: popup.shadowRoot.querySelector('input')?.id, type: popup.shadowRoot.querySelector('#type').value, focused: active?.id }
174
174
  })
175
175
 
176
+ test('a new figure is named in the screen\'s words, the tab too (sweep 2026-10-27 U8)', async () => {
177
+ assert.equal(await read(() => product.page().session.read().name), 'figure.text.new-figure-name')
178
+ assert.equal(await read(() => product.page().context.title), 'figure.text.new-figure-name')
179
+ })
180
+
176
181
  test('before it is saved: the type can be written, held by the page, and the release waits', async () => {
177
182
  const field = await read(() => { const input = product.page().shadowRoot.querySelector('label[type-field] input'); return input && { disabled: input.disabled } })
178
183
  assert.deepEqual(field, { disabled: false })
@@ -242,6 +247,22 @@ describe('opening another figure', () => {
242
247
  })
243
248
  })
244
249
 
250
+ test('nothing open on the page: its properties say nothing of an empty figure (sweep 2026-10-27 U3)', async () => {
251
+ await open('a')
252
+ const empty = await read(async () => {
253
+ const page = product.page()
254
+ /* The state a page is in before it is told which figure, and after it is left: the session holds none. */
255
+ page.session.close()
256
+ const editor = product.deep('figure-editor')[0]
257
+ editor.requestUpdate()
258
+ await editor.updateComplete
259
+ return editor.shadowRoot.querySelectorAll('section[empty-figure]').length
260
+ })
261
+ /* Told another figure, the page opens it again: the checks after this one start from an open page. */
262
+ await open('b')
263
+ assert.equal(empty, 0)
264
+ })
265
+
245
266
  test('the row is emptied first: the type of the one before does not stand while the next one loads', async () => {
246
267
  const typeField = () => read(() => product.page().shadowRoot.querySelector('label[type-field] input')?.value)
247
268
  await open('a')
@@ -258,6 +279,10 @@ describe('opening another figure', () => {
258
279
  await until(() => !!window.letB, 'b asked for')
259
280
  await read(() => product.page().updateComplete)
260
281
  assert.equal(await typeField(), '')
282
+ /* Still loading, the figure is not an empty one: no 「add a part」 (sweep 2026-10-27 U3). */
283
+ await read(() => product.deep('figure-editor')[0].updateComplete)
284
+ assert.equal(await read(() => product.deep('figure-editor >>> section[empty-figure]').length), 0)
285
+ assert.equal(await read(() => product.page().context.title), 'figure.label.figure')
261
286
  await read(() => window.letB())
262
287
  await until(() => product.page().shadowRoot.querySelector('label[type-field] input')?.value === 'type-b', 'b\'s type')
263
288
  })
@@ -0,0 +1,182 @@
1
+ /*
2
+ * What the modeller sweep of 2026-10-27 found on the side panel and the preview, in a real browser
3
+ * (operato-figure docs/audits/modeller-ux-sweep-2026-10-27.md, U1 U2 U4 U5 U6 U7): each as a person would meet it -- a
4
+ * box that reads as a box, a heading nothing is drawn over, a delete that looks like one, the empty animation said in
5
+ * its own words, a field added by Enter, the preview's values starting in one column and its help in the bar's look.
6
+ * The page loads no translations, so the words read are i18n keys.
7
+ *
8
+ * Run: node --test test/browser/side-panel-sweep.check.mjs
9
+ */
10
+ import { test, describe, before, after } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
14
+
15
+ import { buildPage } from './build.mjs'
16
+ import { openPage } from './serve.mjs'
17
+
18
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
19
+
20
+ let opened
21
+ before(async () => {
22
+ opened = await openPage(await buildPage('links-page.js'))
23
+ })
24
+ after(async () => {
25
+ await opened?.close()
26
+ })
27
+
28
+ const page = () => opened.page
29
+ const read = (fn, ...args) => page().evaluate(fn, ...args)
30
+ const source = () => read(() => probe.source())
31
+ const settled = () => read(() => document.querySelector('figure-editor').updateComplete)
32
+
33
+ async function until(fn, args, what, ms = 8000) {
34
+ const deadline = Date.now() + ms
35
+ for (;;) {
36
+ const got = await read(fn, ...args)
37
+ if (got) return got
38
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
39
+ await sleep(50)
40
+ }
41
+ }
42
+ async function mount(kind, text = null) {
43
+ await read((kind, text) => harness.mount(kind, text), kind, text)
44
+ await until(
45
+ () => {
46
+ try {
47
+ return probe.ids().some(id => !!probe.partOnPage(id))
48
+ } catch {
49
+ return false
50
+ }
51
+ },
52
+ [],
53
+ 'the figure drawn in 3D',
54
+ 15000
55
+ )
56
+ }
57
+ async function click(point, what) {
58
+ assert.ok(point, `${what} is on the page to click`)
59
+ await page().mouse.click(point.x, point.y)
60
+ await settled()
61
+ }
62
+ const tab = name => read(l => probe.tab(l), `figure.label.tab-short-${name}`)
63
+
64
+ /** Every element matching the selector through every shadow root, shown on the page. */
65
+ const DEEP = `(selector => {
66
+ const deep = (root = document) => [...root.querySelectorAll(selector)].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
67
+ return deep().filter(e => e.getBoundingClientRect().height > 0)
68
+ })`
69
+
70
+ describe('the side panel', () => {
71
+ before(() => mount('box'))
72
+
73
+ test('U1 the capability tab: the item box keeps its size beside its long note', async () => {
74
+ await click(await tab('capability'), 'the capability tab')
75
+ /* The note as Korean reads it: words that wrap (the key alone is one word that never does). */
76
+ const width = await read(DEEP => {
77
+ eval(DEEP)('label[item] em')[0].textContent = '홀더가 자리에 앉혀 나르는 물건의 모양입니다. 릴 · 트레이 · 매거진. 아이템 타입 표에서 이 피규어를 고를 수 있습니다.'
78
+ return eval(DEEP)('label[item] > input')[0]?.getBoundingClientRect().width
79
+ }, DEEP)
80
+ assert.ok(width >= 12, `the item box is ${width}px wide`)
81
+ })
82
+
83
+ test('U2 scrolled under the heading, the places chips are drawn below it, not over it', async () => {
84
+ /* A short window, so the panel scrolls. */
85
+ await page().setViewport({ width: 1400, height: 520 })
86
+ await click(await read(id => probe.row(id), 'box'), 'the box in the parts list')
87
+ await click(await tab('capability'), 'the capability tab')
88
+ const hit = await read(DEEP => {
89
+ const chips = eval(DEEP)('ox-input-select-buttons[places-by]')[0]
90
+ const subject = eval(DEEP)('div[subject]')[0]
91
+ if (!chips || !subject) return 'no chips or no heading'
92
+ let scroller = chips.parentNode
93
+ for (;;) {
94
+ if (!scroller) return 'nothing scrolls'
95
+ if (scroller instanceof ShadowRoot) scroller = scroller.host
96
+ else if (scroller instanceof Element && /auto|scroll/.test(getComputedStyle(scroller).overflowY) && scroller.scrollHeight > scroller.clientHeight) break
97
+ else scroller = scroller.parentNode
98
+ }
99
+ const head = subject.getBoundingClientRect()
100
+ const before = chips.getBoundingClientRect()
101
+ scroller.scrollTop += before.top - (head.top + head.height / 2) + 4
102
+ const at = chips.getBoundingClientRect()
103
+ const x = at.left + Math.min(40, at.width / 2),
104
+ y = at.top + 6
105
+ if (y > head.bottom || y < head.top) return `the chips stand at ${Math.round(y)}, the heading ${Math.round(head.top)}..${Math.round(head.bottom)}`
106
+ let el = document.elementFromPoint(x, y)
107
+ while (el?.shadowRoot) {
108
+ const inner = el.shadowRoot.elementFromPoint(x, y)
109
+ if (!inner || inner === el) break
110
+ el = inner
111
+ }
112
+ for (let n = el; n; n = n.parentNode instanceof ShadowRoot ? n.parentNode.host : n.parentNode) if (n === subject) return 'heading'
113
+ return el?.tagName.toLowerCase()
114
+ }, DEEP)
115
+ await page().setViewport({ width: 1400, height: 900 })
116
+ assert.equal(hit, 'heading')
117
+ })
118
+
119
+ test('U5 the motion tab: no animation yet is said in its own words, the controls\' words said once', async () => {
120
+ await click(await tab('motion'), 'the motion tab')
121
+ const hints = await read(DEEP => eval(DEEP)('p[hint]').map(p => p.textContent.trim()), DEEP)
122
+ assert.ok(hints.includes('figure.text.no-clips'), hints.join(' | '))
123
+ assert.equal(hints.filter(h => h === 'figure.text.no-controls').length, 1)
124
+ })
125
+
126
+ test('U6 the data tab: a field name and Enter adds the field', async () => {
127
+ await click(await tab('data'), 'the data tab')
128
+ await click(await read(() => look.at('input[aria-label="figure.label.data-name"]')), 'the field name')
129
+ await page().keyboard.type('speed')
130
+ await page().keyboard.press('Enter')
131
+ await settled()
132
+ await until(() => probe.source().dataInterface?.schema?.some(f => f.name === 'speed'), [], 'the field added')
133
+ assert.equal((await read(DEEP => eval(DEEP)('input[aria-label="figure.label.data-name"]')[0].value, DEEP)), '')
134
+ })
135
+
136
+ test('U4 a field opened: its delete is the bin, not a cross that reads as close', async () => {
137
+ await click(await read(() => look.at('button', 'speed')), 'the speed field')
138
+ const icon = await until(DEEP => eval(DEEP)('button[title="figure.button.remove-data-field"] md-icon')[0]?.textContent.trim(), [DEEP], 'the field opened')
139
+ assert.equal(icon, 'delete')
140
+ assert.ok((await source()).dataInterface.schema.some(f => f.name === 'speed'))
141
+ })
142
+ })
143
+
144
+ describe('the preview', () => {
145
+ /* A number and a text, of names far apart in length, each read by a table: the preview gives each a field. */
146
+ const asset = addFigureBox(createFigureAsset('Probe'), { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } })
147
+ asset.dataInterface = {
148
+ version: 'figure-data-interface-2',
149
+ schema: [
150
+ { name: 'A', kind: 'number' },
151
+ { name: 'CARRIERSTATUS', kind: 'text' }
152
+ ],
153
+ bindings: [
154
+ { id: 'a', accessor: 'A', rule: 'table', lookup: 'ranges', targets: { parts: ['box'] }, table: { '~10': { color: '#ff0000' }, default: { color: '#0000ff' } } },
155
+ { id: 's', accessor: 'CARRIERSTATUS', rule: 'table', lookup: 'code', targets: { parts: ['box'] }, table: { X: { color: '#00ff00' } } }
156
+ ]
157
+ }
158
+ before(async () => {
159
+ await mount('box', serializeFigureAsset(asset))
160
+ await click(await read(() => probe.modeButton('preview')), 'the preview')
161
+ await until(DEEP => eval(DEEP)('figure-preview')[0]?.shadowRoot?.querySelectorAll('div[clips] input').length === 2, [DEEP], 'the two values in the preview')
162
+ })
163
+
164
+ test('U7 a number\'s field and a text\'s start in one column, whatever the names\' lengths', async () => {
165
+ const lefts = await read(DEEP => [...eval(DEEP)('figure-preview')[0].shadowRoot.querySelectorAll('div[clips] input')].map(i => Math.round(i.getBoundingClientRect().left)), DEEP)
166
+ assert.equal(lefts[0], lefts[1], `the fields start at ${lefts.join(' and ')}`)
167
+ })
168
+
169
+ test('U7 the size help\'s choice and button are in the bar\'s look, not the browser\'s grey', async () => {
170
+ const looks = await read(DEEP => {
171
+ const root = eval(DEEP)('figure-preview')[0].shadowRoot
172
+ return ['div[setup] select', 'div[setup] button', 'div[clips] input'].map(s => {
173
+ const c = getComputedStyle(root.querySelector(s))
174
+ return { radius: c.borderTopLeftRadius, background: c.backgroundColor }
175
+ })
176
+ }, DEEP)
177
+ assert.deepEqual(looks[0], looks[1])
178
+ assert.deepEqual(looks[0], looks[2])
179
+ assert.equal(looks[0].radius, '5px')
180
+ assert.notEqual(looks[0].background, 'rgb(239, 239, 239)')
181
+ })
182
+ })
@@ -95,7 +95,7 @@ test('a new figure keeps its creation id through a new figure\'s proposal and th
95
95
  return { id: q.id ?? q.creationId!, source: q.source }
96
96
  }
97
97
  })
98
- session.startNew()
98
+ session.startNew({ name: 'New' })
99
99
  const creationId = session.read().creationId
100
100
  assert.match(creationId ?? '', /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
101
101
  const create: FigureApplyDetail = { key: K1, steps: lid, create: true }
@@ -47,7 +47,7 @@ function recording() {
47
47
  test('타입은 새 피규어를 만드는 저장에만 실린다 — 이미 있는 피규어의 저장은 타입을 보내지 않는다', async () => {
48
48
  const { store, requests } = recording()
49
49
  const session = createFigureProductSession(store)
50
- session.startNew()
50
+ session.startNew({ name: 'New' })
51
51
  await session.save({ type: 'agv-lifter' })
52
52
  assert.equal(requests[0]!.type, 'agv-lifter')
53
53
  assert.equal(requests[0]!.id, undefined, '만드는 저장')
@@ -61,7 +61,7 @@ test('타입은 새 피규어를 만드는 저장에만 실린다 — 이미 있
61
61
  test('타입을 주지 않으면 싣지 않는다 — 서버가 임시 이름을 붙인다', async () => {
62
62
  const { store, requests } = recording()
63
63
  const session = createFigureProductSession(store)
64
- session.startNew()
64
+ session.startNew({ name: 'New' })
65
65
  await session.save()
66
66
  assert.equal('type' in requests[0]!, false)
67
67
  })
@@ -28,7 +28,7 @@ test('existing model -> new clears geometry, ID, history and derived state', asy
28
28
  const s = createFigureProductSession(store)
29
29
  await s.open('A')
30
30
  assert.equal(s.read().geometry.length, 1)
31
- const v = s.startNew()
31
+ const v = s.startNew({ name: 'New' })
32
32
  assert.equal(v.id, undefined)
33
33
  assert.equal(v.geometry.length, 0)
34
34
  assert.equal(v.canUndo, false)
@@ -46,7 +46,7 @@ test('late load cannot overwrite another model or a new figure', async () => {
46
46
  const p = deferred<FigureProductRecord>(),
47
47
  t = createFigureProductSession({ ...store, load: () => p.promise })
48
48
  const loading = t.open('A')
49
- t.startNew()
49
+ t.startNew({ name: 'New' })
50
50
  p.resolve({ id: 'A', source: source() })
51
51
  await loading
52
52
  assert.equal(t.read().id, undefined)
@@ -95,10 +95,10 @@ test('save carries exact baseline and acknowledges only matching response', asyn
95
95
  test('pending save locks edits/navigation and duplicate create', async () => {
96
96
  const p = deferred<FigureProductRecord>(),
97
97
  s = createFigureProductSession({ ...store, save: () => p.promise })
98
- s.startNew()
98
+ s.startNew({ name: 'New' })
99
99
  const text = s.source(),
100
100
  saving = s.save()
101
- assert.throws(() => s.startNew())
101
+ assert.throws(() => s.startNew({ name: 'New' }))
102
102
  assert.throws(() => s.undo())
103
103
  await assert.rejects(s.save())
104
104
  p.resolve({ id: 'new', source: text })
@@ -124,7 +124,7 @@ test('retry after a lost create response reuses the same creation identity', asy
124
124
  return { id: q.creationId!, source: q.source }
125
125
  }
126
126
  })
127
- s.startNew()
127
+ s.startNew({ name: 'New' })
128
128
  await assert.rejects(s.save())
129
129
  await s.save()
130
130
  assert.equal(keys.length, 2)
@@ -67,14 +67,14 @@ test('a save names the proposals its text holds: undone, not; redone, again; und
67
67
  test('a new figure opened for a proposal is created under its key; one taken on the open new figure keeps its id', async () => {
68
68
  const { store, saves } = server()
69
69
  const s = createFigureProductSession(store)
70
- s.startNew()
70
+ s.startNew({ name: 'New' })
71
71
  s.createUnder(K1)
72
72
  s.applyProposal(lid, s.read().revision!, s.read().context!, K1)
73
73
  await s.save({ type: 'LIDDED' })
74
74
  assert.equal(saves.at(-1)!.creationId, K1, 'created under the proposal key: taken twice, it meets the first figure')
75
75
  assert.deepEqual(saves.at(-1)!.proposalKeys, [K1])
76
76
 
77
- s.startNew({ discard: true })
77
+ s.startNew({ discard: true, name: 'New' })
78
78
  const own = s.read().creationId
79
79
  const detail: FigureApplyDetail = { key: K2, steps: lid, create: true }
80
80
  assert.equal(takeFigureOffer(s, detail, { onSavedFigure: false, say }), true)
@@ -82,7 +82,7 @@ test('a new figure opened for a proposal is created under its key; one taken on
82
82
  assert.deepEqual({ creationId: saves.at(-1)!.creationId, proposalKeys: saves.at(-1)!.proposalKeys }, { creationId: own, proposalKeys: [K2] })
83
83
 
84
84
  /* A new figure the person has touched is theirs: the proposal is not put on it (NEW_FIGURE_UNSAVED), its id stays. */
85
- s.startNew({ discard: true })
85
+ s.startNew({ discard: true, name: 'New' })
86
86
  s.command({ kind: 'rename', name: 'Mine' } as never)
87
87
  assert.throws(() => s.createUnder(K2))
88
88
  /* Nor on a saved one. */
@@ -50,7 +50,7 @@ test('the source a revert expects is what the server is confirmed to hold -- not
50
50
  return { id: q.id ?? 'a', source: row }
51
51
  }
52
52
  })
53
- session.startNew()
53
+ session.startNew({ name: 'New' })
54
54
  assert.equal(session.confirmedSource(), undefined, 'nothing saved: nothing to revert over')
55
55
  await session.open('a')
56
56
  const loaded = row
@@ -640,6 +640,7 @@
640
640
  "figure.label.controls": "Controls",
641
641
  "figure.text.controls-explained": "A board or a connection sets these values to pose the figure. Move a slider to try one.",
642
642
  "figure.text.no-controls": "No controls yet. Adding a motion to a part makes one.",
643
+ "figure.text.no-clips": "No motion to play yet.",
643
644
  "figure.button.keep-pose": "Keep this pose as the default",
644
645
  "figure.label.control-name": "Name",
645
646
  "figure.text.control-starts-at": "Starts at {value} {unit} when placed on a board.",
@@ -640,6 +640,7 @@
640
640
  "figure.label.controls": "Controles",
641
641
  "figure.text.controls-explained": "Un tablero o una conexión define estos valores para dar postura a la figura. Mueva un control deslizante para probar uno.",
642
642
  "figure.text.no-controls": "Aún no hay controles. Al añadir un movimiento a una pieza se crea uno.",
643
+ "figure.text.no-clips": "Todavía no hay movimientos para reproducir.",
643
644
  "figure.button.keep-pose": "Mantener esta postura como predeterminada",
644
645
  "figure.label.control-name": "Nombre",
645
646
  "figure.text.control-starts-at": "Empieza en {value} {unit} al colocarla en un tablero.",
@@ -640,6 +640,7 @@
640
640
  "figure.label.controls": "操作値",
641
641
  "figure.text.controls-explained": "ボードや連携がこの値を変えて図形の姿勢を動かします。スライダーで試せます。",
642
642
  "figure.text.no-controls": "操作値はまだありません。部品の「動き」で動きを追加すると作られます。",
643
+ "figure.text.no-clips": "再生する動きがありません。",
643
644
  "figure.button.keep-pose": "今の姿勢を既定にする",
644
645
  "figure.label.control-name": "名前",
645
646
  "figure.text.control-starts-at": "ボードに置くと {value} {unit} から始まります。",
@@ -640,6 +640,7 @@
640
640
  "figure.label.controls": "조작값",
641
641
  "figure.text.controls-explained": "보드나 연결이 이 값을 바꿔 피규어의 자세를 움직입니다. 슬라이더로 움직여 볼 수 있습니다.",
642
642
  "figure.text.no-controls": "조작값이 없습니다. 부품의 「움직임」에서 움직임을 추가하면 생깁니다.",
643
+ "figure.text.no-clips": "재생할 움직임이 없습니다.",
643
644
  "figure.button.keep-pose": "지금 자세를 기본으로",
644
645
  "figure.label.control-name": "이름",
645
646
  "figure.text.control-starts-at": "보드에 놓이면 {value} {unit} 에서 시작합니다.",
@@ -640,6 +640,7 @@
640
640
  "figure.label.controls": "Kawalan",
641
641
  "figure.text.controls-explained": "Papan atau sambungan menetapkan nilai ini untuk menggerakkan rajah. Gerakkan peluncur untuk mencuba.",
642
642
  "figure.text.no-controls": "Belum ada kawalan. Menambah gerakan pada bahagian akan menciptanya.",
643
+ "figure.text.no-clips": "Belum ada gerakan untuk dimainkan.",
643
644
  "figure.button.keep-pose": "Jadikan pose ini lalai",
644
645
  "figure.label.control-name": "Nama",
645
646
  "figure.text.control-starts-at": "Bermula pada {value} {unit} apabila diletakkan pada papan.",
@@ -640,6 +640,7 @@
640
640
  "figure.label.controls": "控制值",
641
641
  "figure.text.controls-explained": "看板或连接通过设置这些值来改变图形的姿态。拖动滑块即可试用。",
642
642
  "figure.text.no-controls": "还没有控制值。在部件的“运动”中添加运动即可生成。",
643
+ "figure.text.no-clips": "还没有可播放的动作。",
643
644
  "figure.button.keep-pose": "将当前姿态设为默认",
644
645
  "figure.label.control-name": "名称",
645
646
  "figure.text.control-starts-at": "放到看板上时从 {value} {unit} 开始。",