@things-factory/figure-ui 10.1.151 → 10.1.153

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 (86) hide show
  1. package/client/modeller/ai-proposal.ts +14 -13
  2. package/client/modeller/command-preview-controller.ts +210 -0
  3. package/client/modeller/editor-styles.ts +307 -0
  4. package/client/modeller/editor.ts +65 -634
  5. package/client/modeller/fasten-geometry.ts +32 -0
  6. package/client/modeller/figure-first-save.ts +9 -1
  7. package/client/modeller/figure-links.ts +32 -14
  8. package/client/modeller/mesh-import.ts +0 -10
  9. package/client/modeller/modeller-keys.ts +60 -0
  10. package/client/modeller/pane-fold.ts +61 -0
  11. package/client/modeller/panel-memory.ts +53 -0
  12. package/client/modeller/refusal-teller.ts +93 -0
  13. package/client/modeller/refusals.ts +32 -2
  14. package/dist-client/modeller/ai-proposal.d.ts +6 -5
  15. package/dist-client/modeller/ai-proposal.js +9 -9
  16. package/dist-client/modeller/ai-proposal.js.map +1 -1
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/command-preview-controller.d.ts +75 -0
  19. package/dist-client/modeller/command-preview-controller.js +178 -0
  20. package/dist-client/modeller/command-preview-controller.js.map +1 -0
  21. package/dist-client/modeller/editor-styles.d.ts +2 -0
  22. package/dist-client/modeller/editor-styles.js +307 -0
  23. package/dist-client/modeller/editor-styles.js.map +1 -0
  24. package/dist-client/modeller/editor.d.ts +5 -56
  25. package/dist-client/modeller/editor.js +76 -656
  26. package/dist-client/modeller/editor.js.map +1 -1
  27. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  28. package/dist-client/modeller/fasten-geometry.js +29 -0
  29. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  30. package/dist-client/modeller/figure-first-save.d.ts +1 -0
  31. package/dist-client/modeller/figure-first-save.js +8 -1
  32. package/dist-client/modeller/figure-first-save.js.map +1 -1
  33. package/dist-client/modeller/figure-links.d.ts +9 -2
  34. package/dist-client/modeller/figure-links.js +35 -14
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/mesh-import.d.ts +0 -2
  37. package/dist-client/modeller/mesh-import.js +0 -11
  38. package/dist-client/modeller/mesh-import.js.map +1 -1
  39. package/dist-client/modeller/modeller-keys.d.ts +22 -0
  40. package/dist-client/modeller/modeller-keys.js +43 -0
  41. package/dist-client/modeller/modeller-keys.js.map +1 -0
  42. package/dist-client/modeller/pane-fold.d.ts +15 -0
  43. package/dist-client/modeller/pane-fold.js +60 -0
  44. package/dist-client/modeller/pane-fold.js.map +1 -0
  45. package/dist-client/modeller/panel-memory.d.ts +15 -0
  46. package/dist-client/modeller/panel-memory.js +54 -0
  47. package/dist-client/modeller/panel-memory.js.map +1 -0
  48. package/dist-client/modeller/product-session.d.ts +4 -28
  49. package/dist-client/modeller/refusal-teller.d.ts +36 -0
  50. package/dist-client/modeller/refusal-teller.js +81 -0
  51. package/dist-client/modeller/refusal-teller.js.map +1 -0
  52. package/dist-client/modeller/refusals.d.ts +12 -1
  53. package/dist-client/modeller/refusals.js +26 -1
  54. package/dist-client/modeller/refusals.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +5 -5
  57. package/test/browser/build.mjs +7 -2
  58. package/test/browser/editing.check.mjs +14 -0
  59. package/test/browser/fix-alignment.check.mjs +112 -0
  60. package/test/browser/links.check.mjs +69 -13
  61. package/test/browser/mesh-select.check.mjs +91 -0
  62. package/test/browser/modeller-page.js +36 -0
  63. package/test/browser/modeller-revision.check.mjs +18 -6
  64. package/test/browser/modeller.check.mjs +25 -0
  65. package/test/browser/part-ids.check.mjs +24 -6
  66. package/test/browser/parts-page.js +9 -1
  67. package/test/browser/parts.check.mjs +61 -2
  68. package/test/browser/places-mesh.check.mjs +25 -9
  69. package/test/browser/product-page.check.mjs +42 -2
  70. package/test/browser/row-preview.check.mjs +81 -0
  71. package/test/browser/screen.check.mjs +35 -1
  72. package/test/clip-ai-proposal.test.ts +4 -1
  73. package/test/clip-consent.test.ts +9 -2
  74. package/test/fields.test.ts +5 -9
  75. package/test/fix-alignment.test.ts +32 -0
  76. package/test/layout.test.ts +3 -1
  77. package/test/mesh-import.test.ts +1 -8
  78. package/test/parts-wiring.test.ts +1 -5
  79. package/test/refusals.test.ts +46 -10
  80. package/test/theme.test.ts +5 -15
  81. package/translations/en.json +8 -0
  82. package/translations/es.json +8 -0
  83. package/translations/ja.json +8 -0
  84. package/translations/ko.json +8 -0
  85. package/translations/ms.json +8 -0
  86. package/translations/zh.json +8 -0
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { FigureProposalStep } from '@hatiolab/figure-model'
9
9
  import type { FigureProductSession } from './product-session.js'
10
+ import { clipLossOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'
10
11
 
11
12
  /** What the card carries, as `proposeFigureCandidate` returned it. */
12
13
  export interface FigureProposalCard {
@@ -19,8 +20,11 @@ export interface FigureProposalCard {
19
20
  revision?: number
20
21
  }
21
22
 
22
- /** The `figure-apply` event's detail. The page that takes it sets `applied`, and `error` when refused. */
23
- export interface FigureApplyDetail {
23
+ /**
24
+ * The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in
25
+ * figure-model's shape (`code`, `details`, the `clips` it would stop).
26
+ */
27
+ export interface FigureApplyDetail extends FigureRefusalRecord {
24
28
  key?: string
25
29
  steps: FigureProposalStep[]
26
30
  create: boolean
@@ -28,9 +32,6 @@ export interface FigureApplyDetail {
28
32
  revision?: number
29
33
  applied?: boolean
30
34
  error?: string
31
- code?: string
32
- /** The clips a refused proposal would have stopped (CLIPS_DROPPED), by name. */
33
- clips?: string[]
34
35
  }
35
36
 
36
37
  export type Say = (key: string, params?: Record<string, string>) => string
@@ -63,7 +64,9 @@ export function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; typ
63
64
  /** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */
64
65
  const STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])
65
66
 
66
- function refused(code: string | undefined, error: string | undefined, say: Say, clips?: string[]): FigureProposalOutcome {
67
+ function refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): FigureProposalOutcome {
68
+ const { code, error } = refusal
69
+ const clips = clipLossOf(refusal)
67
70
  /*
68
71
  A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not
69
72
  asked about -- the person edits by hand if they mean it (designer 2026-09-27).
@@ -73,8 +76,8 @@ function refused(code: string | undefined, error: string | undefined, say: Say,
73
76
  ? say('figure.text.proposal-stale')
74
77
  : code === 'NEW_FIGURE_UNSAVED'
75
78
  ? (error ?? '')
76
- : code === 'CLIPS_DROPPED'
77
- ? say('figure.text.proposal-would-drop-clips', { clips: (clips ?? []).join(', ') })
79
+ : clips
80
+ ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
78
81
  : say('figure.text.proposal-refused', { reason: error ?? '' })
79
82
  return { verdict: 'refused', errorCode: code ?? 'refused', note }
80
83
  }
@@ -110,10 +113,8 @@ export function takeFigureOffer(
110
113
  return true
111
114
  } catch (error) {
112
115
  detail.applied = false
113
- detail.code = (error as { code?: string })?.code
116
+ Object.assign(detail, refusalRecordOf(error))
114
117
  detail.error = error instanceof Error ? error.message : String(error)
115
- const clips = (error as { clips?: Array<{ clip: string }> })?.clips
116
- if (clips) detail.clips = clips.map(c => c.clip)
117
118
  return false
118
119
  }
119
120
  }
@@ -130,7 +131,7 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
130
131
 
131
132
  const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })
132
133
  if (detail.applied) return accepted()
133
- if (detail.applied === false) return refused(detail.code, detail.error, say, detail.clips)
134
+ if (detail.applied === false) return refused(detail, say)
134
135
 
135
136
  /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */
136
137
  if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }
@@ -139,7 +140,7 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
139
140
  if (opened.applied) return accepted()
140
141
  return opened.code === 'not-opened'
141
142
  ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }
142
- : refused(opened.code, opened.error, say)
143
+ : refused(opened, say)
143
144
  }
144
145
 
145
146
  /*
@@ -0,0 +1,210 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit'
2
+ import { i18next } from '@operato/i18n'
3
+ import { figurePartFaceOf, figureRowOf, type FigureAuthoringAction, type FigureFaceArea } from '@hatiolab/figure-model'
4
+ import { faceHighlightOf, ghostPartsOf, previewWordsOf, type HeldPreview } from './command-preview.js'
5
+ import { partLabelOf } from './fields.js'
6
+ import type { FigureProductSession } from './product-session.js'
7
+ import type { FigureRenderPart } from './renderer.js'
8
+ import { derivedHeld, derivedOf, type FigureRevision } from './revision.js'
9
+
10
+ /** The words a panel is given when there are none: one array, so it does not read as a change. */
11
+ export const NO_WORDS: string[] = []
12
+
13
+ /** How long a command's candidate rests before its preview is worked out. */
14
+ const PREVIEW_SETTLE_MS = 150
15
+
16
+ /** A command a form is trying, with the face it names when it names one. */
17
+ export type PreviewCandidate = { action: FigureAuthoringAction; face?: { part: string; face: string } }
18
+
19
+ /** What the preview reads of the editor. */
20
+ export interface CommandPreviewHost {
21
+ session(): FigureProductSession | undefined
22
+ selected(): string
23
+ /** The figure on screen at its version (`revision.ts`). */
24
+ shown(): FigureRevision | undefined
25
+ /** Whether the properties panel, where the forms are, is in sight: the edit middle, the properties tab. */
26
+ formsInSight(): boolean
27
+ /** A refusal in the person's words. */
28
+ said(error: unknown): string
29
+ }
30
+
31
+ /**
32
+ * A command seen before it is done (ADR-0097 §5.5): worked out on a copy for the figure, version, part and command it
33
+ * names, and shown only while all four are still the ones on screen. Nothing of it is saved or undone. And what taking
34
+ * the selected part off would do, worked out the same way. Both wait for the figure to rest; the editor taken down
35
+ * stops both.
36
+ */
37
+ export class CommandPreview implements ReactiveController {
38
+ private preview?: HeldPreview & { part: string }
39
+ private drawn?: { of: HeldPreview; ghost: FigureRenderPart[]; highlight: FigureRenderPart | null }
40
+ /*
41
+ What a panel is handed is the same object while it says the same thing: a new array each render reads as a change to
42
+ the panel, which draws itself again for nothing (sixteen times for one edit).
43
+ */
44
+ private wordsSaid?: { preview: unknown; part: string; language: string; words: string[] }
45
+ private previewTimer?: ReturnType<typeof setTimeout>
46
+ /*
47
+ What taking the selected part off would do, worked out on a copy before the button is pressed (ADR-0097 §5.5): whether
48
+ it stays where it stands now, what stays fastened to it, which clips would go. "It stays where it is" is said only
49
+ when the copy shows it does.
50
+ */
51
+ private detachTimer?: ReturnType<typeof setTimeout>
52
+ private detachAsked?: { shown: FigureRevision; part: string }
53
+
54
+ constructor(
55
+ private readonly host: ReactiveControllerHost,
56
+ private readonly of: CommandPreviewHost
57
+ ) {
58
+ host.addController(this)
59
+ }
60
+
61
+ hostConnected() {}
62
+
63
+ hostDisconnected() {
64
+ clearTimeout(this.previewTimer)
65
+ clearTimeout(this.detachTimer)
66
+ }
67
+
68
+ /** Drawn again only when it is another preview, as a state of the editor would be. */
69
+ private hold(preview: (HeldPreview & { part: string }) | undefined) {
70
+ if (preview === this.preview) return
71
+ this.preview = preview
72
+ this.host.requestUpdate()
73
+ }
74
+
75
+ /**
76
+ * The fastening form's command, seen before it is done; null clears what was shown. What a command would do is worked
77
+ * out on a copy of the figure -- several compiles of it -- so it waits for the field to rest: a number typed digit by
78
+ * digit asks once, for the number it ends on. Clearing is at once, and the same question on the same version is not
79
+ * asked again.
80
+ */
81
+ ask(candidate: PreviewCandidate | null) {
82
+ clearTimeout(this.previewTimer)
83
+ const session = this.of.session()
84
+ const selected = this.of.selected()
85
+ if (!candidate || !session || !selected) {
86
+ this.hold(undefined)
87
+ return
88
+ }
89
+ const view = session.read()
90
+ const key = JSON.stringify(candidate)
91
+ const same =
92
+ this.preview &&
93
+ this.preview.key === key &&
94
+ this.preview.context === view.context &&
95
+ this.preview.part === selected &&
96
+ 'revision' in this.preview &&
97
+ this.preview.revision === view.revision
98
+ if (same) return
99
+ /*
100
+ What another command would do is not said of this one while this one is worked out: a row's pitch refused stayed
101
+ on screen for the count chosen after it (:3300, 2026-10-11).
102
+ */
103
+ if (this.preview && this.preview.key !== key) this.hold(undefined)
104
+ this.previewTimer = setTimeout(() => this.compute(candidate), PREVIEW_SETTLE_MS)
105
+ }
106
+
107
+ private compute(candidate: PreviewCandidate) {
108
+ const session = this.of.session()
109
+ const selected = this.of.selected()
110
+ if (!session || !selected) return
111
+ const view = session.read()
112
+ const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: selected }
113
+ try {
114
+ const seen = session.previewCommand(candidate.action)
115
+ let face: FigureFaceArea | undefined
116
+ if (candidate.face) {
117
+ try {
118
+ const shown = this.of.shown()!
119
+ face = figurePartFaceOf(shown.asset, candidate.face.part, candidate.face.face, shown.inputs)
120
+ } catch {
121
+ face = undefined
122
+ }
123
+ }
124
+ /* A row is read on the figure the command would make, by figure-model, as drawn. */
125
+ const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, selected, this.of.shown()!.inputs) : undefined
126
+ this.hold({
127
+ ...held,
128
+ context: seen.context,
129
+ revision: seen.revision,
130
+ geometry: seen.geometry,
131
+ moves: seen.moves,
132
+ added: seen.added,
133
+ ...(row ? { row } : {}),
134
+ clipsWouldGo: seen.clipsWouldGo,
135
+ face,
136
+ faceUnknown: !!candidate.face && !face
137
+ })
138
+ } catch (error) {
139
+ /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
140
+ this.hold({ ...held, refused: this.of.said(error) })
141
+ }
142
+ }
143
+
144
+ /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
145
+ current(): (HeldPreview & { part: string }) | undefined {
146
+ const p = this.preview
147
+ const view = this.of.session()?.read()
148
+ /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
149
+ if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.of.selected() || !this.of.formsInSight()) return undefined
150
+ return p
151
+ }
152
+
153
+ /** What the preview says, the same array while it says the same thing. */
154
+ words(): string[] {
155
+ const preview = this.current()
156
+ const selected = this.of.selected()
157
+ const language = i18next.language
158
+ if (this.wordsSaid && this.wordsSaid.preview === preview && this.wordsSaid.part === selected && this.wordsSaid.language === language) return this.wordsSaid.words
159
+ const words = previewWordsOf(preview, selected, (key, params) => i18next.t(key, params))
160
+ this.wordsSaid = { preview, part: selected, language, words }
161
+ return words
162
+ }
163
+
164
+ /** What the canvas draws of a preview, made once per preview: a new list every render would stand the board up each time. */
165
+ drawnOf(p: (HeldPreview & { part: string }) | undefined): { ghost: FigureRenderPart[]; highlight: FigureRenderPart | null } {
166
+ if (!p || p.refused) return { ghost: [], highlight: null }
167
+ if (this.drawn?.of !== p) this.drawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) }
168
+ return this.drawn
169
+ }
170
+
171
+ /**
172
+ * Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
173
+ * edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
174
+ */
175
+ detachWords(shown: FigureRevision): string[] {
176
+ const part = this.of.selected()
177
+ if (!this.of.session() || !shown.asset.joints?.some(j => j.body1.part === part)) return NO_WORDS
178
+ const key = `detach|${part}`
179
+ const said = derivedHeld<string[]>(shown, key)
180
+ if (said) return said
181
+ if (this.detachAsked?.shown !== shown || this.detachAsked.part !== part) {
182
+ this.detachAsked = { shown, part }
183
+ clearTimeout(this.detachTimer)
184
+ this.detachTimer = setTimeout(() => {
185
+ if (this.of.shown() !== shown || this.of.selected() !== part) return
186
+ derivedOf(shown, key, () => this.computeDetachWords(shown, part))
187
+ this.host.requestUpdate()
188
+ }, PREVIEW_SETTLE_MS)
189
+ }
190
+ return NO_WORDS
191
+ }
192
+
193
+ private computeDetachWords(shown: FigureRevision, part: string): string[] {
194
+ const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
195
+ let words: string[]
196
+ try {
197
+ const seen = this.of.session()!.previewCommand({ kind: 'detach', part })
198
+ const own = seen.moves.some(m => m.body === part)
199
+ words = own
200
+ ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key: `detach|${part}`, moves: seen.moves }, part, say)]
201
+ : /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ []
202
+ const children = (shown.asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => partLabelOf(j.body1.part))
203
+ if (children.length) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
204
+ if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
205
+ } catch (error) {
206
+ words = [say('figure.text.detach-refused', { reason: this.of.said(error) })]
207
+ }
208
+ return words
209
+ }
210
+ }
@@ -0,0 +1,307 @@
1
+ import { css } from 'lit'
2
+
3
+ /** The modeller editor's own styles: the bar's tools, the panes, the start card, the side's tabs and panels. */
4
+ export const EditorStyles = css`
5
+ :host {
6
+ display: flex;
7
+ flex-direction: column;
8
+ min-height: 0;
9
+ height: 100%;
10
+ /* The editor folds by the width it is given, not the window's (UX ruling, 2026-09-24). */
11
+ container-type: inline-size;
12
+ font: 13px sans-serif;
13
+ color: var(--md-sys-color-on-surface);
14
+ }
15
+ /* The bar over the work is ox-canvas-bar (ADR-0060 decision 8): the folds at its edges, undo, redo and
16
+ fit in the suite's places. What the editor puts in it - the mode switch, the status, save - is styled here. */
17
+ ox-canvas-bar button[mode] {
18
+ height: 28px;
19
+ padding: 0 10px;
20
+ border-radius: 999em;
21
+ border-color: transparent;
22
+ background: none;
23
+ }
24
+ ox-canvas-bar button[mode][aria-pressed='true'] {
25
+ border-color: var(--md-sys-color-primary);
26
+ }
27
+ /* The gizmo's tools, drawn as the bar's own icon buttons (undo, redo, fit). */
28
+ ox-canvas-bar .gizmo-tools {
29
+ display: inline-flex;
30
+ gap: 2px;
31
+ padding-left: 4px;
32
+ border-left: 1px solid var(--md-sys-color-outline-variant);
33
+ }
34
+ ox-canvas-bar button[tool] {
35
+ display: inline-grid;
36
+ place-items: center;
37
+ width: 32px;
38
+ height: 32px;
39
+ padding: 0;
40
+ border: none;
41
+ border-radius: var(--md-sys-shape-corner-small, 8px);
42
+ background: transparent;
43
+ color: var(--md-sys-color-on-surface-variant);
44
+ cursor: pointer;
45
+ }
46
+ ox-canvas-bar button[tool]:hover:not(:disabled) {
47
+ background: var(--md-sys-color-surface-container-high);
48
+ color: var(--md-sys-color-on-surface);
49
+ }
50
+ ox-canvas-bar button[tool][aria-pressed='true'] {
51
+ background: var(--md-sys-color-secondary-container);
52
+ color: var(--md-sys-color-on-secondary-container);
53
+ }
54
+ ox-canvas-bar button[tool]:disabled {
55
+ opacity: 0.38;
56
+ cursor: default;
57
+ }
58
+ /*
59
+ Slotted into the bar from here, the tools do not get the bar's own icon size (20px, set inside its shadow): they
60
+ showed at md-icon's 24px, heavier than undo, redo and fit beside them. The same size, and the bar's focus ring.
61
+ */
62
+ ox-canvas-bar button[tool] md-icon {
63
+ --md-icon-size: 20px;
64
+ }
65
+ ox-canvas-bar button[tool]:focus-visible {
66
+ outline: 2px solid var(--md-sys-color-primary);
67
+ outline-offset: 1px;
68
+ }
69
+ figure-preview {
70
+ min-width: 0;
71
+ min-height: 0;
72
+ }
73
+ /* The edit canvas stays standing while the preview is in front (a 3D scene costs a third of a second to make), out of sight. */
74
+ div[middle][hidden] {
75
+ display: none;
76
+ }
77
+ /* The preview, once made, stays standing out of sight too: its 3D scene is not made again each time it is asked for. */
78
+ figure-preview[hidden] {
79
+ display: none;
80
+ }
81
+ ox-canvas-bar .status {
82
+ padding: 0 8px;
83
+ color: var(--md-sys-color-on-surface-variant);
84
+ }
85
+ ox-canvas-bar .status[dirty] {
86
+ color: var(--md-sys-color-primary);
87
+ font-weight: 600;
88
+ }
89
+ ox-canvas-bar button[save] {
90
+ height: 32px;
91
+ padding: 0 14px;
92
+ border: none;
93
+ border-radius: 999em;
94
+ color: var(--md-sys-color-on-primary);
95
+ background: var(--md-sys-color-primary);
96
+ }
97
+ /* The three panes are ox-three-pane's (ADR-0060 decision 8): widths 200 | 1fr | 340, 160 | 300 under
98
+ 860px and one column with the middle first under 560px are the frame's, as are the dividers. */
99
+ ox-three-pane {
100
+ flex: 1;
101
+ min-height: 0;
102
+ }
103
+ figure-parts {
104
+ min-height: 0;
105
+ }
106
+ figure-canvas {
107
+ flex: 1;
108
+ min-width: 0;
109
+ min-height: 0;
110
+ }
111
+ div[middle] {
112
+ position: relative;
113
+ display: flex;
114
+ min-width: 0;
115
+ min-height: 0;
116
+ }
117
+ /* A new figure's first question, over the empty canvas. */
118
+ div[start] {
119
+ position: absolute;
120
+ inset: 50% auto auto 50%;
121
+ z-index: 10;
122
+ color: var(--md-sys-color-on-surface);
123
+ transform: translate(-50%, -50%);
124
+ width: min(440px, calc(100% - 32px));
125
+ box-sizing: border-box;
126
+ padding: 20px;
127
+ border-radius: 12px;
128
+ background: var(--md-sys-color-surface, #fff);
129
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
130
+ }
131
+ div[start] button[start-button] {
132
+ width: auto;
133
+ margin: 0;
134
+ font-size: var(--ui-t-body);
135
+ height: 36px;
136
+ padding: 0 16px;
137
+ border: 1px solid var(--md-sys-color-outline);
138
+ border-radius: 999em;
139
+ color: var(--md-sys-color-primary);
140
+ background: var(--md-sys-color-surface, #fff);
141
+ cursor: pointer;
142
+ }
143
+ div[start] button[start-button]:hover:not(:disabled) {
144
+ background: var(--md-sys-color-primary-container);
145
+ }
146
+ div[start] button[start-button][pressed] {
147
+ color: var(--md-sys-color-on-primary);
148
+ background: var(--md-sys-color-primary);
149
+ border-color: var(--md-sys-color-primary);
150
+ }
151
+ div[start] button[start-button]:disabled {
152
+ opacity: 0.5;
153
+ cursor: default;
154
+ }
155
+ div[start] h2 {
156
+ margin: 0 0 12px;
157
+ font-size: var(--ui-t-title);
158
+ }
159
+ div[start-paths] {
160
+ display: grid;
161
+ gap: 14px;
162
+ }
163
+ div[start-paths] h3 {
164
+ margin: 0 0 6px;
165
+ font-size: var(--ui-t-body);
166
+ }
167
+ div[start-shapes] {
168
+ display: flex;
169
+ gap: 8px;
170
+ flex-wrap: wrap;
171
+ }
172
+ div[start] small {
173
+ display: block;
174
+ margin-top: 4px;
175
+ color: var(--md-sys-color-on-surface-variant);
176
+ }
177
+ /* The side: tabs over one panel at a time, each using the whole height. */
178
+ aside {
179
+ display: flex;
180
+ flex-direction: column;
181
+ min-height: 0;
182
+ background: var(--md-sys-color-surface-container-lowest);
183
+ }
184
+ div[tabs] {
185
+ display: flex;
186
+ flex: none;
187
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
188
+ }
189
+ button[tab] {
190
+ flex: 1;
191
+ position: relative;
192
+ display: flex;
193
+ flex-direction: column;
194
+ align-items: center;
195
+ justify-content: center;
196
+ gap: 1px;
197
+ min-width: 0;
198
+ height: 48px;
199
+ padding: 0;
200
+ border: none;
201
+ border-radius: 0;
202
+ border-bottom: 2px solid transparent;
203
+ background: none;
204
+ color: var(--md-sys-color-on-surface-variant);
205
+ }
206
+ button[tab]:hover:not([on]) {
207
+ background: var(--md-sys-color-surface-container);
208
+ }
209
+ button[tab][on] {
210
+ color: var(--md-sys-color-primary);
211
+ border-bottom-color: var(--md-sys-color-primary);
212
+ }
213
+ button[tab] md-icon {
214
+ font-size: 20px;
215
+ width: 20px;
216
+ height: 20px;
217
+ }
218
+ button[tab] span[tab-label] {
219
+ max-width: 100%;
220
+ overflow: hidden;
221
+ font-size: 0.66rem;
222
+ line-height: 1.2;
223
+ white-space: nowrap;
224
+ text-overflow: ellipsis;
225
+ }
226
+ span[badge] {
227
+ position: absolute;
228
+ top: 3px;
229
+ right: 2px;
230
+ padding: 0 5px;
231
+ font-size: 0.62rem;
232
+ font-weight: 700;
233
+ line-height: 1.5;
234
+ color: var(--md-sys-color-on-primary-container);
235
+ background: var(--md-sys-color-primary-container);
236
+ border-radius: 999em;
237
+ pointer-events: none;
238
+ }
239
+ /* The sections inside carry the margins (side-panel-styles); the panel only scrolls. */
240
+ div[panel] {
241
+ flex: 1;
242
+ min-height: 0;
243
+ overflow: auto;
244
+ padding: 0;
245
+ }
246
+ div[panel][flush] {
247
+ padding: 0;
248
+ }
249
+ /* Hidden, not removed: a panel keeps what it holds (the verdict its last answer) while another tab is open. */
250
+ [off] {
251
+ display: none !important;
252
+ }
253
+ /* Which part the panel speaks of. */
254
+ div[subject] {
255
+ position: sticky;
256
+ top: 0;
257
+ z-index: 1;
258
+ display: grid;
259
+ gap: 4px;
260
+ padding: 10px 12px;
261
+ background: var(--md-sys-color-surface-container-lowest);
262
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
263
+ }
264
+ /* The panel's three sizes (side-panel-styles): the part's kind at the title's, its two lines at the hint's. */
265
+ div[subject-summary] {
266
+ display: grid;
267
+ gap: 2px;
268
+ }
269
+ div[subject-summary] b {
270
+ font-size: var(--ui-t-body, 13px);
271
+ font-weight: 600;
272
+ }
273
+ div[subject-summary] b span {
274
+ font-weight: 400;
275
+ color: var(--md-sys-color-on-surface-variant);
276
+ }
277
+ div[subject-summary] small {
278
+ color: var(--md-sys-color-on-surface-variant);
279
+ }
280
+ button {
281
+ font: inherit;
282
+ box-sizing: border-box;
283
+ height: 32px;
284
+ border: 1px solid var(--md-sys-color-outline-variant);
285
+ border-radius: 6px;
286
+ background: var(--md-sys-color-surface);
287
+ color: inherit;
288
+ padding: 0 8px;
289
+ min-width: 0;
290
+ }
291
+ button {
292
+ cursor: pointer;
293
+ }
294
+ button:disabled {
295
+ opacity: 0.45;
296
+ cursor: default;
297
+ }
298
+ button[aria-pressed='true'] {
299
+ background: var(--md-sys-color-secondary-container);
300
+ color: var(--md-sys-color-on-secondary-container);
301
+ border-color: var(--md-sys-color-primary);
302
+ }
303
+ .hint {
304
+ color: var(--md-sys-color-on-surface-variant);
305
+ line-height: 1.5;
306
+ }
307
+ `