@things-factory/figure-ui 10.1.38 → 10.1.40

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 (50) hide show
  1. package/client/graphql/index.ts +53 -0
  2. package/client/modeller/figure-history-panel.ts +11 -85
  3. package/client/modeller/figure-v3-properties.ts +254 -0
  4. package/client/modeller/figure-v3-verdict.ts +247 -0
  5. package/client/modeller/gate-findings.ts +105 -0
  6. package/client/modeller/v3-canvas.ts +27 -3
  7. package/client/modeller/v3-editor.ts +39 -11
  8. package/client/modeller/v3-figure-facts.ts +53 -0
  9. package/client/modeller/v3-occupancy.ts +6 -2
  10. package/client/pages/figure-v3-modeller-page.ts +250 -17
  11. package/dist-client/graphql/index.d.ts +8 -0
  12. package/dist-client/graphql/index.js +51 -0
  13. package/dist-client/graphql/index.js.map +1 -1
  14. package/dist-client/modeller/figure-history-panel.d.ts +6 -0
  15. package/dist-client/modeller/figure-history-panel.js +9 -85
  16. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  17. package/dist-client/modeller/figure-v3-properties.d.ts +46 -0
  18. package/dist-client/modeller/figure-v3-properties.js +259 -0
  19. package/dist-client/modeller/figure-v3-properties.js.map +1 -0
  20. package/dist-client/modeller/figure-v3-verdict.d.ts +50 -0
  21. package/dist-client/modeller/figure-v3-verdict.js +268 -0
  22. package/dist-client/modeller/figure-v3-verdict.js.map +1 -0
  23. package/dist-client/modeller/gate-findings.d.ts +12 -0
  24. package/dist-client/modeller/gate-findings.js +102 -0
  25. package/dist-client/modeller/gate-findings.js.map +1 -0
  26. package/dist-client/modeller/v3-canvas.d.ts +6 -0
  27. package/dist-client/modeller/v3-canvas.js +27 -3
  28. package/dist-client/modeller/v3-canvas.js.map +1 -1
  29. package/dist-client/modeller/v3-editor.js +39 -11
  30. package/dist-client/modeller/v3-editor.js.map +1 -1
  31. package/dist-client/modeller/v3-figure-facts.d.ts +26 -0
  32. package/dist-client/modeller/v3-figure-facts.js +34 -0
  33. package/dist-client/modeller/v3-figure-facts.js.map +1 -0
  34. package/dist-client/modeller/v3-occupancy.d.ts +5 -1
  35. package/dist-client/modeller/v3-occupancy.js +4 -2
  36. package/dist-client/modeller/v3-occupancy.js.map +1 -1
  37. package/dist-client/pages/figure-v3-modeller-page.d.ts +40 -0
  38. package/dist-client/pages/figure-v3-modeller-page.js +264 -18
  39. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  40. package/dist-client/route.d.ts +1 -1
  41. package/dist-client/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +4 -4
  43. package/test/v3-properties.test.ts +93 -0
  44. package/test/v3-theme.test.ts +67 -0
  45. package/test/v3-verdict.test.ts +82 -0
  46. package/translations/en.json +12 -0
  47. package/translations/ja.json +12 -0
  48. package/translations/ko.json +12 -0
  49. package/translations/ms.json +12 -0
  50. package/translations/zh.json +12 -0
@@ -0,0 +1,247 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import { parseV3Asset, v3CostOf } from '@hatiolab/figure-model'
8
+
9
+ import { inspectFigureDraft } from '../graphql/index.js'
10
+ import type { FigureInspection } from '../types.js'
11
+ import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
12
+ import { capacityFrom, MEASURED } from './scene-budget.js'
13
+ import type { FigureCapacity } from './scene-budget.js'
14
+
15
+ /** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
16
+ export const VERDICT_SETTLE_MS = 400
17
+
18
+ /**
19
+ * 판정 — the V3 modeller's verdict while editing (parity row ①).
20
+ *
21
+ * The V2 modeller shows, as the author edits, how many of the figure fit on one screen and what would stop it
22
+ * being published. The V3 page showed neither: the author learned what blocked a release only after saving, in the
23
+ * release panel. This tab answers both from the source being edited.
24
+ *
25
+ * The count is computed here, from `v3CostOf` and the same `capacityFrom` the catalogue cards use, so the tab and
26
+ * the card cannot disagree. The findings are not: they come from the server's release door (`inspectFigureDraft`),
27
+ * the same door and the same sentences as the release panel. A second judgement on the screen would say something
28
+ * different the first time either changed.
29
+ *
30
+ * No grade. V2's grade measures V2 reuse; nothing measures it for V3, so the tab shows no letter rather than one
31
+ * nothing computed.
32
+ */
33
+ @customElement('figure-v3-verdict')
34
+ export class FigureV3Verdict extends localize(i18next)(LitElement) {
35
+ static styles = [
36
+ gateFindingStyles,
37
+ css`
38
+ :host {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 14px;
42
+ padding: 12px;
43
+ overflow: auto;
44
+ font-size: 0.76rem;
45
+ color: var(--md-sys-color-on-surface);
46
+ }
47
+ section > h3 {
48
+ margin: 0 0 6px 0;
49
+ font-size: var(--ui-t-micro, 11px);
50
+ font-weight: 700;
51
+ letter-spacing: 0.04em;
52
+ text-transform: uppercase;
53
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
54
+ }
55
+ p {
56
+ margin: 4px 0 0;
57
+ }
58
+ p[lead],
59
+ p[basis],
60
+ p[quiet] {
61
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
62
+ }
63
+ div[count] {
64
+ display: flex;
65
+ align-items: baseline;
66
+ gap: 6px;
67
+ }
68
+ span[capacity] {
69
+ font-size: 2.1rem;
70
+ font-weight: 800;
71
+ line-height: 1;
72
+ letter-spacing: -0.01em;
73
+ color: var(--md-sys-color-primary);
74
+ }
75
+ span[unit] {
76
+ font-size: 0.9rem;
77
+ font-weight: 600;
78
+ color: var(--md-sys-color-primary);
79
+ }
80
+ p[clear] {
81
+ color: var(--md-sys-color-primary);
82
+ font-weight: 600;
83
+ }
84
+ p[failed] {
85
+ color: var(--md-sys-color-error);
86
+ }
87
+ button[fit] {
88
+ align-self: flex-start;
89
+ margin-top: 4px;
90
+ padding: 4px 10px;
91
+ font: inherit;
92
+ color: var(--md-sys-color-on-secondary-container);
93
+ background: var(--md-sys-color-secondary-container);
94
+ border: none;
95
+ border-radius: 999em;
96
+ cursor: pointer;
97
+ }
98
+ `
99
+ ]
100
+
101
+ /** The source being edited, serialised. Every change of it is a new question. */
102
+ @property({ type: String }) source?: string
103
+ /** The saved row, if any — the server reads its type and last release from it. */
104
+ @property({ type: String }) figureId?: string
105
+ /** Changing the type changes the verdict (a temporary type is refused), so it is a reason to ask again. */
106
+ @property({ type: String }) figureType?: string
107
+
108
+ @state() private capacity?: FigureCapacity
109
+ /** Bodies in the asset, drawn or hidden. With none there is nothing to fit the space to, so the button is not offered. */
110
+ @state() private bodies = 0
111
+ @state() private inspection?: FigureInspection
112
+ @state() private failure = ''
113
+ @state() private asking = false
114
+
115
+ private timer?: ReturnType<typeof setTimeout>
116
+ /** Answers can come back out of order; only the newest question's answer is shown. */
117
+ private asked = 0
118
+
119
+ disconnectedCallback() {
120
+ clearTimeout(this.timer)
121
+ super.disconnectedCallback()
122
+ }
123
+
124
+ updated(changes: Map<string, unknown>) {
125
+ if (changes.has('source')) this.measure()
126
+ if (changes.has('source') || changes.has('figureId') || changes.has('figureType')) this.schedule()
127
+ }
128
+
129
+ private measure() {
130
+ let capacity: FigureCapacity | undefined
131
+ let bodies = 0
132
+ try {
133
+ const cost = this.source ? v3CostOf(parseV3Asset(this.source)) : undefined
134
+ capacity = cost ? capacityFrom(cost.groups, cost.triangles) : undefined
135
+ bodies = cost ? cost.groups + cost.hidden : 0
136
+ } catch {
137
+ /* A source the format refuses has no count; the server's finding below says why. */
138
+ capacity = undefined
139
+ }
140
+ this.capacity = capacity
141
+ this.bodies = bodies
142
+ this.dispatchEvent(new CustomEvent('capacity-changed', { detail: { instances: capacity?.instances } }))
143
+ }
144
+
145
+ private schedule() {
146
+ clearTimeout(this.timer)
147
+ if (!this.source) {
148
+ this.inspection = undefined
149
+ return
150
+ }
151
+ this.timer = setTimeout(() => this.ask(), VERDICT_SETTLE_MS)
152
+ }
153
+
154
+ private async ask() {
155
+ const question = ++this.asked
156
+ const source = this.source!
157
+ this.asking = true
158
+ try {
159
+ const inspection = await inspectFigureDraft(source, this.figureId)
160
+ if (question !== this.asked) return
161
+ this.inspection = inspection
162
+ this.failure = ''
163
+ } catch (error) {
164
+ if (question !== this.asked) return
165
+ /* The server's reason as it gave it — the screen does not make one up. The last answer is dropped with it. */
166
+ this.inspection = undefined
167
+ this.failure = error instanceof Error ? error.message : String(error)
168
+ } finally {
169
+ if (question === this.asked) this.asking = false
170
+ }
171
+ }
172
+
173
+ render() {
174
+ return html`${this.renderCount()} ${this.renderGate()}`
175
+ }
176
+
177
+ private renderCount() {
178
+ const capacity = this.capacity
179
+ return html`
180
+ <section>
181
+ <h3>${i18next.t('figure.label.maturity')}</h3>
182
+ <p lead>${i18next.t('figure.text.how-many-fit')}</p>
183
+ ${capacity
184
+ ? html`
185
+ <div count>
186
+ <span capacity>${capacity.instances.toLocaleString()}</span>
187
+ <span unit>${i18next.t('figure.text.instances-unit')}</span>
188
+ </div>
189
+ <p basis>
190
+ ${i18next.t('figure.text.bound-by-' + capacity.bound, {
191
+ per: capacity.per.toLocaleString(),
192
+ budget: capacity.budget.toLocaleString()
193
+ })}
194
+ <em>
195
+ ${capacity.bound === 'groups'
196
+ ? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
197
+ : i18next.t('figure.text.budget-not-measured-yet')}
198
+ </em>
199
+ </p>
200
+ `
201
+ : html`<p quiet>${i18next.t('figure.text.maturity-blocked-no-parts')}</p>`}
202
+ </section>
203
+ `
204
+ }
205
+
206
+ private renderGate() {
207
+ const findings = this.inspection?.findings ?? []
208
+ const blocking = findings.filter(finding => finding.blocking)
209
+ const advisory = findings.filter(finding => !finding.blocking)
210
+ return html`
211
+ <section>
212
+ <h3>${i18next.t('figure.label.release-verdict')}</h3>
213
+ ${this.failure
214
+ ? html`<p failed role="alert">${i18next.t('figure.text.verdict-not-received', { reason: this.failure })}</p>`
215
+ : !this.inspection
216
+ ? html`<p quiet>${i18next.t(this.asking ? 'figure.text.verdict-asking' : 'figure.text.verdict-waiting')}</p>`
217
+ : blocking.length
218
+ ? html`<p>${i18next.t('figure.text.cannot-release-now')}</p>`
219
+ : html`<p clear>${i18next.t('figure.text.nothing-blocks-release')}</p>`}
220
+ ${blocking.length
221
+ ? html`<ul gate>
222
+ <li head>${i18next.t('figure.text.must-fix-to-release')}</li>
223
+ ${blocking.map(finding => renderGateFinding(finding, true, this.actionsFor(finding.code)))}
224
+ </ul>`
225
+ : nothing}
226
+ ${advisory.length
227
+ ? html`<ul gate>
228
+ <li head quiet>${i18next.t('figure.text.does-not-block')}</li>
229
+ ${advisory.map(finding => renderGateFinding(finding, false))}
230
+ </ul>`
231
+ : nothing}
232
+ </section>
233
+ `
234
+ }
235
+
236
+ /** The same way out the release panel offers for the same two findings — declare the space from the parts. */
237
+ private actionsFor(code: string) {
238
+ if (code !== 'part-outside-occupancy' && code !== 'occupancy-undeclared') return nothing
239
+ /* A new figure with no parts is refused as undeclared too, but fitting needs parts to measure (seen 2026-09-24). */
240
+ if (!this.bodies) return nothing
241
+ return html`<button fit @click=${this.fit}>${i18next.t('figure.button.fit-base-to-parts')}</button>`
242
+ }
243
+
244
+ private fit = () => {
245
+ this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
246
+ }
247
+ }
@@ -0,0 +1,105 @@
1
+ import { css, html, nothing, type TemplateResult } from 'lit'
2
+ import { i18next } from '@operato/i18n'
3
+
4
+ import type { FigureGateFinding } from '../types.js'
5
+
6
+ /**
7
+ * One release-gate finding as a line on screen — the measured fact, where, why it matters, and what to change.
8
+ *
9
+ * Two places draw these: the release panel (`figure-history-panel`) and the V3 modeller's verdict while editing
10
+ * (`figure-v3-verdict`). They show the same findings from the same server door, so they draw them one way; a copy
11
+ * in each would drift the first time one of them is touched. The caller passes its own action buttons, because
12
+ * what can be done about a finding depends on the screen it is shown on.
13
+ */
14
+ export function renderGateFinding(finding: FigureGateFinding, blocking: boolean, actions: unknown = nothing): TemplateResult {
15
+ return html`
16
+ <li finding ?blocking=${blocking}>
17
+ <span msg>${finding.message}</span>
18
+ ${finding.at
19
+ ? html`
20
+ <details affected>
21
+ <summary>${i18next.t('figure.text.show-affected-parts')}</summary>
22
+ <code where>${finding.at}</code>
23
+ </details>
24
+ `
25
+ : nothing}
26
+ ${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
27
+ ${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
28
+ ${actions}
29
+ </li>
30
+ `
31
+ }
32
+
33
+ export const gateFindingStyles = css`
34
+ ul[gate] {
35
+ margin: var(--spacing-small, 6px) 0 0;
36
+ padding: 0;
37
+ list-style: none;
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 3px;
41
+ }
42
+
43
+ li[head] {
44
+ font: var(--label-font, inherit);
45
+ font-size: 0.72rem;
46
+ color: var(--md-sys-color-error);
47
+ }
48
+
49
+ li[head][quiet] {
50
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
51
+ }
52
+
53
+ li[finding] {
54
+ display: flex;
55
+ flex-direction: column;
56
+ align-items: flex-start;
57
+ gap: 3px;
58
+ padding: 3px 6px;
59
+ font-size: 0.72rem;
60
+ line-height: 1.5;
61
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
62
+ background-color: var(--md-sys-color-surface-container);
63
+ border-radius: 4px;
64
+ }
65
+
66
+ li[finding][blocking] {
67
+ color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
68
+ background-color: var(--md-sys-color-error-container);
69
+ }
70
+
71
+ li[finding] code[where] {
72
+ font-family: var(--mono-font, monospace);
73
+ font-size: 0.68rem;
74
+ opacity: 0.85;
75
+ }
76
+
77
+ li[finding] details[affected] {
78
+ width: 100%;
79
+ color: inherit;
80
+ }
81
+
82
+ li[finding] details[affected] summary {
83
+ cursor: pointer;
84
+ font-weight: 600;
85
+ }
86
+
87
+ /* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
88
+ li[finding] span[why-line] {
89
+ opacity: 0.8;
90
+ }
91
+
92
+ /* 무엇을 바꾸면 되나 — 이 줄이 실제로 사람이 할 일이다. 연장 표를 달아 눈에 걸리게 한다 */
93
+ li[finding] span[how-line] {
94
+ display: flex;
95
+ align-items: flex-start;
96
+ gap: 4px;
97
+ padding-top: 2px;
98
+ font-weight: 600;
99
+ }
100
+ li[finding] span[how-line] md-icon {
101
+ --md-icon-size: 13px;
102
+ flex: none;
103
+ margin-top: 1px;
104
+ }
105
+ `
@@ -13,7 +13,12 @@ export class FigureV3Canvas extends LitElement {
13
13
  min-width: 0;
14
14
  min-height: 0;
15
15
  position: relative;
16
- background: #eef2f4;
16
+ /*
17
+ The working board is one step above the surface in both themes (UX, 2026-09-24): surface-container is 238
18
+ in light and 31 in dark. The 3D background is read from this same value (paintBackground), so the
19
+ first paint and the scene agree and there is no flash between them.
20
+ */
21
+ background: var(--md-sys-color-surface-container);
17
22
  }
18
23
  canvas {
19
24
  display: block;
@@ -25,7 +30,7 @@ export class FigureV3Canvas extends LitElement {
25
30
  inset: 45% 1rem auto;
26
31
  text-align: center;
27
32
  pointer-events: none;
28
- color: #526773;
33
+ color: var(--md-sys-color-on-surface-variant);
29
34
  }
30
35
  `
31
36
  @property({ attribute: false }) parts: V3RenderPart[] = []
@@ -37,6 +42,7 @@ export class FigureV3Canvas extends LitElement {
37
42
  private readonly camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000000)
38
43
  private controls?: OrbitControls
39
44
  private observer?: ResizeObserver
45
+ private themeObserver?: MutationObserver
40
46
  private initialized = false
41
47
  private contextAtRender = ''
42
48
  render() {
@@ -55,7 +61,16 @@ export class FigureV3Canvas extends LitElement {
55
61
  const canvas = this.renderRoot.querySelector('canvas')!
56
62
  this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true })
57
63
  this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
58
- this.scene.background = new THREE.Color('#eef2f4')
64
+ this.paintBackground()
65
+ /*
66
+ The app switches theme by the body's class (light · dark). Reading the colour once at start left a light
67
+ board in the middle of a dark screen after every switch (seen 2026-09-24), so it is read again then.
68
+ */
69
+ this.themeObserver = new MutationObserver(() => {
70
+ this.paintBackground()
71
+ this.draw()
72
+ })
73
+ this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] })
59
74
  this.scene.add(this.group, new THREE.HemisphereLight(0xffffff, 0x62717d, 2.5))
60
75
  const light = new THREE.DirectionalLight(0xffffff, 3)
61
76
  light.position.set(1, 2, 3)
@@ -113,6 +128,14 @@ export class FigureV3Canvas extends LitElement {
113
128
  this.controls?.update()
114
129
  this.draw()
115
130
  }
131
+ /**
132
+ * The scene's background, taken from the host's computed background — the token resolved and normalised to
133
+ * `rgb(r, g, b)`, which THREE.Color reads. The token's own text may use the space-separated form it does not.
134
+ */
135
+ private paintBackground() {
136
+ const colour = getComputedStyle(this).backgroundColor
137
+ if (colour && colour !== 'rgba(0, 0, 0, 0)' && colour !== 'transparent') this.scene.background = new THREE.Color(colour)
138
+ }
116
139
  private draw = () => {
117
140
  if (this.renderer) this.renderer.render(this.scene, this.camera)
118
141
  }
@@ -134,6 +157,7 @@ export class FigureV3Canvas extends LitElement {
134
157
  }
135
158
  disconnectedCallback() {
136
159
  this.observer?.disconnect()
160
+ this.themeObserver?.disconnect()
137
161
  this.controls?.dispose()
138
162
  disposeV3Meshes(this.group)
139
163
  this.renderer?.dispose()
@@ -42,6 +42,12 @@ export class FigureV3Editor extends LitElement {
42
42
  flex-direction: column;
43
43
  min-height: 0;
44
44
  height: 100%;
45
+ /*
46
+ The editor folds by the width it is given, not the window's. The rail (208 open, 56 closed) and the page's
47
+ 320 side take their share first, so a window rule was wrong whenever either was there: at 945 with the rail
48
+ open the editor had 417 and still drew its 660 of columns, over the side (UX ruling, 2026-09-24).
49
+ */
50
+ container-type: inline-size;
45
51
  font: 13px sans-serif;
46
52
  color: var(--md-sys-color-on-surface, #22343f);
47
53
  }
@@ -50,7 +56,7 @@ export class FigureV3Editor extends LitElement {
50
56
  align-items: center;
51
57
  gap: 8px;
52
58
  padding: 10px;
53
- border-bottom: 1px solid #d4dde2;
59
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
54
60
  flex-wrap: wrap;
55
61
  }
56
62
  main {
@@ -65,10 +71,10 @@ export class FigureV3Editor extends LitElement {
65
71
  background: var(--md-sys-color-surface, #fff);
66
72
  }
67
73
  aside:first-child {
68
- border-right: 1px solid #d4dde2;
74
+ border-right: 1px solid var(--md-sys-color-outline-variant);
69
75
  }
70
76
  aside:last-child {
71
- border-left: 1px solid #d4dde2;
77
+ border-left: 1px solid var(--md-sys-color-outline-variant);
72
78
  }
73
79
  h3 {
74
80
  font-size: var(--ui-t-body, 13px);
@@ -83,7 +89,7 @@ export class FigureV3Editor extends LitElement {
83
89
  font: inherit;
84
90
  box-sizing: border-box;
85
91
  height: 34px;
86
- border: 1px solid #cbd5dc;
92
+ border: 1px solid var(--md-sys-color-outline-variant);
87
93
  border-radius: 6px;
88
94
  background: var(--md-sys-color-surface, #fff);
89
95
  color: inherit;
@@ -98,8 +104,9 @@ export class FigureV3Editor extends LitElement {
98
104
  cursor: default;
99
105
  }
100
106
  button[aria-pressed='true'] {
101
- background: #d6edf0;
102
- border-color: #337783;
107
+ background: var(--md-sys-color-secondary-container);
108
+ color: var(--md-sys-color-on-secondary-container);
109
+ border-color: var(--md-sys-color-primary);
103
110
  }
104
111
  .parts {
105
112
  display: grid;
@@ -122,25 +129,46 @@ export class FigureV3Editor extends LitElement {
122
129
  }
123
130
  .error {
124
131
  padding: 10px;
125
- background: #fff0eb;
126
- color: #922716;
132
+ background: var(--md-sys-color-error-container);
133
+ color: var(--md-sys-color-on-error-container);
127
134
  white-space: pre-wrap;
128
135
  }
129
136
  .status {
130
137
  margin-left: auto;
131
138
  }
132
139
  .hint {
133
- color: #637783;
140
+ color: var(--md-sys-color-on-surface-variant);
134
141
  line-height: 1.5;
135
142
  }
136
- @media (max-width: 800px) {
143
+ /* 660 and over: parts | canvas | inputs. Below that the sum of the columns' least widths no longer fits. */
144
+ @container (max-width: 659px) {
137
145
  main {
138
146
  grid-template-columns: 140px minmax(160px, 1fr);
139
147
  }
140
148
  aside:last-child {
141
149
  grid-column: 1/-1;
142
150
  max-height: 260px;
143
- border-top: 1px solid #d4dde2;
151
+ border-left: none;
152
+ border-top: 1px solid var(--md-sys-color-outline-variant);
153
+ }
154
+ }
155
+ /* Under 420: one column. The canvas first — seeing what is being made comes before the lists — and main scrolls. */
156
+ @container (max-width: 419px) {
157
+ main {
158
+ grid-template-columns: minmax(0, 1fr);
159
+ grid-template-rows: minmax(240px, 45%);
160
+ grid-auto-rows: auto;
161
+ overflow-y: auto;
162
+ }
163
+ figure-v3-canvas {
164
+ order: -1;
165
+ }
166
+ aside:first-child {
167
+ border-right: none;
168
+ border-top: 1px solid var(--md-sys-color-outline-variant);
169
+ }
170
+ aside:last-child {
171
+ max-height: none;
144
172
  }
145
173
  }
146
174
  `
@@ -0,0 +1,53 @@
1
+ import { parseV3Asset } from '@hatiolab/figure-model'
2
+ import type { V3Asset, V3Face } from '@hatiolab/figure-model'
3
+
4
+ /*
5
+ * What the V3 properties tab reads, kept apart from the element so it can be tested without a browser.
6
+ */
7
+
8
+ export type Placement = 'floor' | 'ceiling' | 'center'
9
+
10
+ /** What the properties tab reads off the asset. Absent parts of the asset read as absent, never as a default. */
11
+ export interface V3FigureFacts {
12
+ name: string
13
+ parts: string[]
14
+ declared: boolean
15
+ placement?: Placement
16
+ mounting?: { part: string; face: V3Face }
17
+ }
18
+
19
+ /**
20
+ * The asset's own facts, as the properties tab shows them.
21
+ *
22
+ * The mounting face is read back from the feature `set-mounting-face` writes (`<part>.mount.<face>`): the asset
23
+ * records the face only as that node, so this is where the screen learns which one is chosen. Parts are the placed
24
+ * bodies (`place@1`), in the order the model lists them.
25
+ */
26
+ export function v3FigureFacts(source: string): V3FigureFacts {
27
+ const asset = parseV3Asset(source) as V3Asset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
28
+ const parts = asset.document.model.nodes.filter(node => node.op === 'place@1').map(node => node.id)
29
+ const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
30
+ const found = feature?.match(/^(.+)\.mount\.(left|right|bottom|top|back|front)\.value$/)
31
+ return {
32
+ name: asset.name,
33
+ parts,
34
+ declared: !!asset.occupancy,
35
+ placement: asset.occupancy?.placement as Placement | undefined,
36
+ mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A tag line as a list — the V2 inspector's rule: split on commas, drop a leading `#`, keep each once. An empty
42
+ * line is an empty list, so an author can clear every tag.
43
+ */
44
+ export function tagsOf(raw: string): string[] {
45
+ return [
46
+ ...new Set(
47
+ raw
48
+ .split(',')
49
+ .map(one => one.trim().replace(/^#+/, '').trim())
50
+ .filter(one => one.length > 0)
51
+ )
52
+ ]
53
+ }
@@ -12,11 +12,15 @@ import { parseV3Asset, proposeV3Occupancy, type V3AuthoringAction } from '@hatio
12
12
  * 막는다. 배치 기준은 이미 선언된 것을 지키고, 처음 선언하는 도형은 바닥에 선다고 둔다 — 새로 만든 도형의
13
13
  * 모델러 기본이 바닥이다.
14
14
  */
15
- export function occupancyFromParts(source: string): V3AuthoringAction {
15
+ export function occupancyFromParts(
16
+ source: string,
17
+ /** A different face to place it by — the properties tab's choice. Absent keeps the declared one. */
18
+ placement?: 'floor' | 'ceiling' | 'center'
19
+ ): Extract<V3AuthoringAction, { kind: 'declare-occupancy' }> {
16
20
  const asset = parseV3Asset(source)
17
21
  return {
18
22
  kind: 'declare-occupancy',
19
23
  proposal: proposeV3Occupancy(asset, { over: 'range' }),
20
- placement: asset.occupancy?.placement ?? 'floor'
24
+ placement: placement ?? asset.occupancy?.placement ?? 'floor'
21
25
  }
22
26
  }