@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.
- package/client/modeller/ai-proposal.ts +14 -13
- package/client/modeller/command-preview-controller.ts +210 -0
- package/client/modeller/editor-styles.ts +307 -0
- package/client/modeller/editor.ts +65 -634
- package/client/modeller/fasten-geometry.ts +32 -0
- package/client/modeller/figure-first-save.ts +9 -1
- package/client/modeller/figure-links.ts +32 -14
- package/client/modeller/mesh-import.ts +0 -10
- package/client/modeller/modeller-keys.ts +60 -0
- package/client/modeller/pane-fold.ts +61 -0
- package/client/modeller/panel-memory.ts +53 -0
- package/client/modeller/refusal-teller.ts +93 -0
- package/client/modeller/refusals.ts +32 -2
- package/dist-client/modeller/ai-proposal.d.ts +6 -5
- package/dist-client/modeller/ai-proposal.js +9 -9
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/command-preview-controller.d.ts +75 -0
- package/dist-client/modeller/command-preview-controller.js +178 -0
- package/dist-client/modeller/command-preview-controller.js.map +1 -0
- package/dist-client/modeller/editor-styles.d.ts +2 -0
- package/dist-client/modeller/editor-styles.js +307 -0
- package/dist-client/modeller/editor-styles.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +5 -56
- package/dist-client/modeller/editor.js +76 -656
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +13 -0
- package/dist-client/modeller/fasten-geometry.js +29 -0
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +1 -0
- package/dist-client/modeller/figure-first-save.js +8 -1
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +9 -2
- package/dist-client/modeller/figure-links.js +35 -14
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/mesh-import.d.ts +0 -2
- package/dist-client/modeller/mesh-import.js +0 -11
- package/dist-client/modeller/mesh-import.js.map +1 -1
- package/dist-client/modeller/modeller-keys.d.ts +22 -0
- package/dist-client/modeller/modeller-keys.js +43 -0
- package/dist-client/modeller/modeller-keys.js.map +1 -0
- package/dist-client/modeller/pane-fold.d.ts +15 -0
- package/dist-client/modeller/pane-fold.js +60 -0
- package/dist-client/modeller/pane-fold.js.map +1 -0
- package/dist-client/modeller/panel-memory.d.ts +15 -0
- package/dist-client/modeller/panel-memory.js +54 -0
- package/dist-client/modeller/panel-memory.js.map +1 -0
- package/dist-client/modeller/product-session.d.ts +4 -28
- package/dist-client/modeller/refusal-teller.d.ts +36 -0
- package/dist-client/modeller/refusal-teller.js +81 -0
- package/dist-client/modeller/refusal-teller.js.map +1 -0
- package/dist-client/modeller/refusals.d.ts +12 -1
- package/dist-client/modeller/refusals.js +26 -1
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/build.mjs +7 -2
- package/test/browser/editing.check.mjs +14 -0
- package/test/browser/fix-alignment.check.mjs +112 -0
- package/test/browser/links.check.mjs +69 -13
- package/test/browser/mesh-select.check.mjs +91 -0
- package/test/browser/modeller-page.js +36 -0
- package/test/browser/modeller-revision.check.mjs +18 -6
- package/test/browser/modeller.check.mjs +25 -0
- package/test/browser/part-ids.check.mjs +24 -6
- package/test/browser/parts-page.js +9 -1
- package/test/browser/parts.check.mjs +61 -2
- package/test/browser/places-mesh.check.mjs +25 -9
- package/test/browser/product-page.check.mjs +42 -2
- package/test/browser/row-preview.check.mjs +81 -0
- package/test/browser/screen.check.mjs +35 -1
- package/test/clip-ai-proposal.test.ts +4 -1
- package/test/clip-consent.test.ts +9 -2
- package/test/fields.test.ts +5 -9
- package/test/fix-alignment.test.ts +32 -0
- package/test/layout.test.ts +3 -1
- package/test/mesh-import.test.ts +1 -8
- package/test/parts-wiring.test.ts +1 -5
- package/test/refusals.test.ts +46 -10
- package/test/theme.test.ts +5 -15
- package/translations/en.json +8 -0
- package/translations/es.json +8 -0
- package/translations/ja.json +8 -0
- package/translations/ko.json +8 -0
- package/translations/ms.json +8 -0
- 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
|
-
/**
|
|
23
|
-
|
|
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(
|
|
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
|
-
:
|
|
77
|
-
? say('figure.text.proposal-would-drop-clips', { clips: (
|
|
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
|
|
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
|
|
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
|
|
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
|
+
`
|