@selvajs/ui 6.3.1 → 6.4.0
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/dist/components/compute/ComputeApp.svelte +32 -2
- package/dist/components/compute/ComputeApp.svelte.d.ts +7 -1
- package/dist/components/compute/ComputeMessagesDialog.svelte +118 -95
- package/dist/components/compute/ComputeMessagesDialog.svelte.d.ts +4 -0
- package/dist/components/compute/SolveMessages.svelte +367 -0
- package/dist/components/compute/SolveMessages.svelte.d.ts +28 -0
- package/dist/components/layout/AppShell.svelte +5 -1
- package/dist/components/layout/AppShell.svelte.d.ts +3 -0
- package/dist/components/layout/PageFooter.svelte +34 -14
- package/dist/components/layout/PageFooter.svelte.d.ts +3 -0
- package/dist/components/preview/TabContent.svelte +18 -16
- package/dist/compute/useSolveSession.svelte.js +28 -4
- package/dist/i18n/messages.d.ts +9 -0
- package/dist/i18n/messages.js +18 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -1
- package/dist/public.d.ts +2 -2
- package/dist/schema/visibility-rules.d.ts +6 -0
- package/dist/schema/visibility-rules.js +9 -0
- package/package.json +18 -18
- package/src/lib/components/compute/ComputeApp.svelte +32 -2
- package/src/lib/components/compute/ComputeMessagesDialog.svelte +118 -95
- package/src/lib/components/compute/SolveMessages.svelte +367 -0
- package/src/lib/components/layout/AppShell.svelte +5 -1
- package/src/lib/components/layout/PageFooter.svelte +34 -14
- package/src/lib/components/preview/TabContent.svelte +18 -16
- package/src/lib/compute/useSolveSession.svelte.ts +28 -4
- package/src/lib/i18n/messages.ts +28 -0
- package/src/lib/index.ts +10 -2
- package/src/lib/public.ts +4 -2
- package/src/lib/schema/visibility-rules.ts +12 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The one place a solve talks to the user.
|
|
3
|
+
//
|
|
4
|
+
// Only a Selva Message component interrupts: the author wrote that text for whoever is using
|
|
5
|
+
// the app. Grasshopper's own warnings are about the definition, not the result, and go to the
|
|
6
|
+
// footer log instead — interrupting for them trains people to dismiss the one that mattered.
|
|
7
|
+
//
|
|
8
|
+
// While the solve runs the panel is fed by live events (messages and progress) and offers
|
|
9
|
+
// Abort. When it ends the reported result replaces them outright — it lists the same messages,
|
|
10
|
+
// remarks included — so nothing is ever shown twice.
|
|
11
|
+
//
|
|
12
|
+
// Of those, only warnings and errors get buttons. A remark states something without asking
|
|
13
|
+
// anything, so it appears on its own after the solve and fades.
|
|
14
|
+
|
|
15
|
+
import { untrack } from 'svelte';
|
|
16
|
+
import { CircleAlert, TriangleAlert, Info, LoaderCircle } from '@lucide/svelte';
|
|
17
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
18
|
+
import type { LiveSolveState, SolvePhase } from '@selvajs/solve/client';
|
|
19
|
+
import { Button } from '../primitives/button';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** What the running (or most recent) solve has said over the live channel. */
|
|
23
|
+
live: LiveSolveState;
|
|
24
|
+
/** The session's phase: `solving`, `review` (result held), `blocked` or `idle`. */
|
|
25
|
+
phase: SolvePhase;
|
|
26
|
+
/** The last result's messages, with level, source and `isGate`. */
|
|
27
|
+
diagnostics?: SolveDiagnostic[];
|
|
28
|
+
onabort: () => void;
|
|
29
|
+
onconfirm: () => void;
|
|
30
|
+
ondiscard: () => void;
|
|
31
|
+
labels?: Labels;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Labels {
|
|
35
|
+
solving: string;
|
|
36
|
+
blockedTitle: string;
|
|
37
|
+
messagesTitle: string;
|
|
38
|
+
blockedDescription: string;
|
|
39
|
+
messagesDescription: string;
|
|
40
|
+
confirm: string;
|
|
41
|
+
discard: string;
|
|
42
|
+
abort: string;
|
|
43
|
+
dismiss: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_LABELS: Labels = {
|
|
47
|
+
solving: 'Solving…',
|
|
48
|
+
blockedTitle: 'Solve stopped',
|
|
49
|
+
messagesTitle: 'Solve messages',
|
|
50
|
+
blockedDescription: 'The definition stopped this solve. No results were produced.',
|
|
51
|
+
messagesDescription: 'This solve reported the following. Continue to see the result.',
|
|
52
|
+
confirm: 'Continue',
|
|
53
|
+
discard: 'Discard',
|
|
54
|
+
abort: 'Abort',
|
|
55
|
+
dismiss: 'Dismiss'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let {
|
|
59
|
+
live,
|
|
60
|
+
phase: sessionPhase,
|
|
61
|
+
diagnostics = [],
|
|
62
|
+
onabort,
|
|
63
|
+
onconfirm,
|
|
64
|
+
ondiscard,
|
|
65
|
+
labels = DEFAULT_LABELS
|
|
66
|
+
}: Props = $props();
|
|
67
|
+
|
|
68
|
+
const REMARK_LINGER_MS = 6000;
|
|
69
|
+
|
|
70
|
+
const progress = $derived([...live.progress.values()]);
|
|
71
|
+
|
|
72
|
+
// A report only arrives when a step starts, so a bar that just shows `fraction` stands still
|
|
73
|
+
// through the step and then jumps. When the plugin knows how long the step took last time, the
|
|
74
|
+
// bar runs linearly to where the step will end, over that time. Steps are weighted by time, so
|
|
75
|
+
// linear gives one steady speed across the solve; any easing curve visibly speeds up and
|
|
76
|
+
// crawls within each step. A slower run waits at the step's end; a faster one snaps ahead.
|
|
77
|
+
let bars = $state.raw<Record<string, { width: number; ms: number }>>({});
|
|
78
|
+
// Any live event re-runs the effect below. Only a source with a new report may re-snap;
|
|
79
|
+
// the rest keep easing where they are.
|
|
80
|
+
let seen = new Map<string, unknown>();
|
|
81
|
+
let targets: Record<string, { width: number; ms: number }> = {};
|
|
82
|
+
$effect(() => {
|
|
83
|
+
const snapped: Record<string, { width: number; ms: number }> = {};
|
|
84
|
+
const eased: Record<string, { width: number; ms: number }> = {};
|
|
85
|
+
const current = untrack(() => bars);
|
|
86
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping for this effect; reactive reads here would re-trigger it
|
|
87
|
+
const nextSeen = new Map<string, unknown>();
|
|
88
|
+
for (const p of progress) {
|
|
89
|
+
if (p.fraction === undefined) continue;
|
|
90
|
+
const key = p.source ?? '';
|
|
91
|
+
nextSeen.set(key, p);
|
|
92
|
+
if (seen.get(key) === p && current[key]) {
|
|
93
|
+
snapped[key] = current[key];
|
|
94
|
+
// This run's cleanup cancelled the frame that would have started the ease.
|
|
95
|
+
if (targets[key] && current[key] !== targets[key]) eased[key] = targets[key];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
snapped[key] = { width: p.fraction, ms: 150 };
|
|
99
|
+
if (p.nextFraction !== undefined && p.stepMs !== undefined) {
|
|
100
|
+
eased[key] = { width: p.nextFraction, ms: p.stepMs };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
seen = nextSeen;
|
|
104
|
+
targets = eased;
|
|
105
|
+
bars = snapped;
|
|
106
|
+
if (Object.keys(eased).length === 0) return;
|
|
107
|
+
// Two frames: the snapped width must be painted before the eased one is set, or the
|
|
108
|
+
// browser folds both into one style change and there is nothing to transition from.
|
|
109
|
+
let frame = requestAnimationFrame(() => {
|
|
110
|
+
frame = requestAnimationFrame(() => (bars = { ...snapped, ...eased }));
|
|
111
|
+
});
|
|
112
|
+
return () => cancelAnimationFrame(frame);
|
|
113
|
+
});
|
|
114
|
+
// Grasshopper's own complaints stay in the footer log; this panel shows what the author wrote.
|
|
115
|
+
const authored = $derived(diagnostics.filter((d) => d.isGate));
|
|
116
|
+
|
|
117
|
+
// A remark states something without asking anything, so it never belongs next to
|
|
118
|
+
// Discard/Continue — the buttons would imply it is part of what the user is deciding. It gets
|
|
119
|
+
// the quiet treatment instead: shown alone after the solve, then faded.
|
|
120
|
+
const authoredGating = $derived(authored.filter((d) => d.level !== 'remark'));
|
|
121
|
+
const authoredRemarks = $derived(authored.filter((d) => d.level === 'remark'));
|
|
122
|
+
|
|
123
|
+
let dismissedFor = $state.raw<SolveDiagnostic[] | null>(null);
|
|
124
|
+
|
|
125
|
+
// The session's phase, except that a blocked solve the user dismissed reads as idle here.
|
|
126
|
+
type Phase = 'live' | 'review' | 'blocked' | 'idle';
|
|
127
|
+
const phase = $derived<Phase>(
|
|
128
|
+
sessionPhase === 'solving'
|
|
129
|
+
? 'live'
|
|
130
|
+
: sessionPhase === 'blocked' && dismissedFor === diagnostics
|
|
131
|
+
? 'idle'
|
|
132
|
+
: sessionPhase
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Remarks gate nothing, so after the solve they show briefly and fade.
|
|
136
|
+
//
|
|
137
|
+
// Keyed on the diagnostics array's identity, which every report renews, rather than on a
|
|
138
|
+
// solve id: only the live channel has one, and on the Compute path remarks arrive with the
|
|
139
|
+
// result. Once per array, so remarks that sat behind a review do not reappear after Continue.
|
|
140
|
+
let lingering = $state(false);
|
|
141
|
+
let lingeredFor: SolveDiagnostic[] | null = null;
|
|
142
|
+
$effect(() => {
|
|
143
|
+
if (phase === 'live') {
|
|
144
|
+
lingering = false;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (authoredRemarks.length === 0 || lingeredFor === diagnostics) return;
|
|
148
|
+
lingeredFor = diagnostics;
|
|
149
|
+
if (phase !== 'idle') return;
|
|
150
|
+
lingering = true;
|
|
151
|
+
const timer = setTimeout(() => (lingering = false), REMARK_LINGER_MS);
|
|
152
|
+
return () => clearTimeout(timer);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// A solve that finishes quickly never shows the live panel: flashing it for 50 ms reads as a
|
|
156
|
+
// glitch, not as progress. Once shown, it stays long enough to read and ends on a full bar
|
|
157
|
+
// ("finishing"), so a solve just past the reveal does not blink the panel away mid-bar. A
|
|
158
|
+
// result that needs an answer (review, blocked) replaces it at once instead.
|
|
159
|
+
const LIVE_REVEAL_MS = 300;
|
|
160
|
+
const LIVE_MIN_VISIBLE_MS = 400;
|
|
161
|
+
const FINISH_MS = 200;
|
|
162
|
+
let revealed = $state(false);
|
|
163
|
+
let finishing = $state(false);
|
|
164
|
+
let revealedAt = 0;
|
|
165
|
+
$effect(() => {
|
|
166
|
+
if (phase === 'live') {
|
|
167
|
+
finishing = false;
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
revealed = true;
|
|
170
|
+
revealedAt = performance.now();
|
|
171
|
+
}, LIVE_REVEAL_MS);
|
|
172
|
+
return () => clearTimeout(timer);
|
|
173
|
+
}
|
|
174
|
+
if (!untrack(() => revealed) || phase !== 'idle') {
|
|
175
|
+
revealed = false;
|
|
176
|
+
finishing = false;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
finishing = true;
|
|
180
|
+
const hold = Math.max(FINISH_MS, LIVE_MIN_VISIBLE_MS - (performance.now() - revealedAt));
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
revealed = false;
|
|
183
|
+
finishing = false;
|
|
184
|
+
}, hold);
|
|
185
|
+
return () => clearTimeout(timer);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// What the panel is showing, which lags the session while a finished solve's panel winds down.
|
|
189
|
+
const view = $derived<Phase>(finishing ? 'live' : phase);
|
|
190
|
+
|
|
191
|
+
// While solving, the live channel is all there is. Once the result lands it is authoritative
|
|
192
|
+
// — it carries remarks too — so the live list is dropped entirely and nothing is shown twice.
|
|
193
|
+
const rows = $derived<SolveDiagnostic[]>(
|
|
194
|
+
view === 'live' ? [...live.diagnostics] : view === 'idle' ? authoredRemarks : authoredGating
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Steps a few milliseconds long would swap the headline faster than it can be read. Each one
|
|
198
|
+
// stays up for at least this long; steps that end sooner are skipped in the label but still
|
|
199
|
+
// count in the bar. The label and its step count move together so they never disagree.
|
|
200
|
+
const HEADLINE_MIN_MS = 200;
|
|
201
|
+
let headline = $state.raw<(typeof progress)[number] | undefined>(undefined);
|
|
202
|
+
let headlineAt = 0;
|
|
203
|
+
$effect(() => {
|
|
204
|
+
const next = progress[0];
|
|
205
|
+
const shown = untrack(() => headline);
|
|
206
|
+
if (next?.label === shown?.label && next?.done === shown?.done) {
|
|
207
|
+
headline = next;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const show = () => {
|
|
211
|
+
headline = next;
|
|
212
|
+
headlineAt = performance.now();
|
|
213
|
+
};
|
|
214
|
+
const wait = HEADLINE_MIN_MS - (performance.now() - headlineAt);
|
|
215
|
+
if (!shown || wait <= 0) {
|
|
216
|
+
show();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const timer = setTimeout(show, wait);
|
|
220
|
+
return () => clearTimeout(timer);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const visible = $derived(
|
|
224
|
+
view === 'review' || view === 'blocked'
|
|
225
|
+
? true
|
|
226
|
+
: view === 'live'
|
|
227
|
+
? revealed && (rows.length > 0 || progress.length > 0)
|
|
228
|
+
: rows.length > 0 && lingering
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
function dismiss() {
|
|
232
|
+
dismissedFor = diagnostics;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Escape resolves to the safe side — discard, keeping what is on screen — never to silently
|
|
236
|
+
// applying a result the user has not accepted.
|
|
237
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
238
|
+
if (e.key !== 'Escape') return;
|
|
239
|
+
if (phase === 'review') ondiscard();
|
|
240
|
+
else if (phase === 'blocked') dismiss();
|
|
241
|
+
}
|
|
242
|
+
</script>
|
|
243
|
+
|
|
244
|
+
<svelte:window onkeydown={handleKeydown} />
|
|
245
|
+
|
|
246
|
+
{#if visible}
|
|
247
|
+
<div
|
|
248
|
+
class="bottom-4 p-3 shadow-lg fixed left-1/2 z-50 w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 rounded-lg border bg-background {view ===
|
|
249
|
+
'blocked'
|
|
250
|
+
? 'border-destructive/50'
|
|
251
|
+
: ''}"
|
|
252
|
+
role={view === 'live' || view === 'idle' ? 'status' : 'alert'}
|
|
253
|
+
data-testid="solve-messages"
|
|
254
|
+
data-view={view}
|
|
255
|
+
>
|
|
256
|
+
{#if view === 'live'}
|
|
257
|
+
{@const head = headline ?? progress[0]}
|
|
258
|
+
<div>
|
|
259
|
+
<div>
|
|
260
|
+
<div class="gap-2 flex items-center">
|
|
261
|
+
<LoaderCircle class="h-4 w-4 animate-spin shrink-0 text-muted-foreground" />
|
|
262
|
+
<span class="min-w-0 text-sm flex-1 truncate">{head?.label ?? labels.solving}</span>
|
|
263
|
+
{#if head?.done !== undefined && head?.total !== undefined}
|
|
264
|
+
<span class="text-xs shrink-0 text-muted-foreground tabular-nums">
|
|
265
|
+
{head.done} / {head.total}
|
|
266
|
+
</span>
|
|
267
|
+
{/if}
|
|
268
|
+
<Button
|
|
269
|
+
variant="secondary"
|
|
270
|
+
size="sm"
|
|
271
|
+
class="-my-1 h-7 px-3 text-xs shrink-0"
|
|
272
|
+
disabled={finishing}
|
|
273
|
+
onclick={onabort}
|
|
274
|
+
>
|
|
275
|
+
{labels.abort}
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
{#each progress as p, i (p.source ?? '')}
|
|
279
|
+
<div class="mt-2" data-testid="solve-progress">
|
|
280
|
+
<!-- The first bar's label is the headline above; further bars label themselves. -->
|
|
281
|
+
{#if i > 0}
|
|
282
|
+
<div class="mb-1 gap-2 text-xs flex justify-between text-muted-foreground">
|
|
283
|
+
<span class="truncate">{p.label ?? p.source ?? ''}</span>
|
|
284
|
+
{#if p.done !== undefined && p.total !== undefined}
|
|
285
|
+
<span class="shrink-0 tabular-nums">{p.done} / {p.total}</span>
|
|
286
|
+
{/if}
|
|
287
|
+
</div>
|
|
288
|
+
{/if}
|
|
289
|
+
<div
|
|
290
|
+
class="h-1 overflow-hidden rounded-full bg-muted"
|
|
291
|
+
role="progressbar"
|
|
292
|
+
aria-label={p.label ?? p.source ?? labels.solving}
|
|
293
|
+
aria-valuemin={0}
|
|
294
|
+
aria-valuemax={100}
|
|
295
|
+
aria-valuenow={p.fraction !== undefined ? Math.round(p.fraction * 100) : undefined}
|
|
296
|
+
>
|
|
297
|
+
{#if p.fraction !== undefined}
|
|
298
|
+
{@const bar = finishing
|
|
299
|
+
? { width: 1, ms: FINISH_MS }
|
|
300
|
+
: (bars[p.source ?? ''] ?? { width: p.fraction, ms: 150 })}
|
|
301
|
+
<div
|
|
302
|
+
class="h-full rounded-full bg-primary transition-[width] ease-linear"
|
|
303
|
+
style="width: {bar.width * 100}%; transition-duration: {bar.ms}ms"
|
|
304
|
+
></div>
|
|
305
|
+
{:else}
|
|
306
|
+
<div class="animate-pulse h-full w-1/3 rounded-full bg-primary/60"></div>
|
|
307
|
+
{/if}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
{/each}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
{:else if view === 'review' || view === 'blocked'}
|
|
314
|
+
<div class="mb-2">
|
|
315
|
+
<div class="gap-2 text-sm font-medium flex items-center">
|
|
316
|
+
{#if view === 'blocked'}
|
|
317
|
+
<CircleAlert class="h-4 w-4 shrink-0 text-destructive" />
|
|
318
|
+
{labels.blockedTitle}
|
|
319
|
+
{:else}
|
|
320
|
+
<TriangleAlert class="h-4 w-4 text-amber-500 shrink-0" />
|
|
321
|
+
{labels.messagesTitle}
|
|
322
|
+
{/if}
|
|
323
|
+
</div>
|
|
324
|
+
<p class="text-xs text-muted-foreground">
|
|
325
|
+
{view === 'blocked' ? labels.blockedDescription : labels.messagesDescription}
|
|
326
|
+
</p>
|
|
327
|
+
</div>
|
|
328
|
+
{/if}
|
|
329
|
+
|
|
330
|
+
{#if rows.length > 0}
|
|
331
|
+
<ul
|
|
332
|
+
class="max-h-40 space-y-2 text-sm overflow-y-auto {view === 'live'
|
|
333
|
+
? 'mt-3 pt-3 border-t'
|
|
334
|
+
: ''}"
|
|
335
|
+
>
|
|
336
|
+
{#each rows as row, i (i)}
|
|
337
|
+
<li class="gap-2 flex">
|
|
338
|
+
{#if row.level === 'error'}
|
|
339
|
+
<CircleAlert class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
340
|
+
{:else if row.level === 'warning'}
|
|
341
|
+
<TriangleAlert class="mt-0.5 h-4 w-4 text-amber-500 shrink-0" />
|
|
342
|
+
{:else}
|
|
343
|
+
<Info class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
344
|
+
{/if}
|
|
345
|
+
<div class="min-w-0 flex-1">
|
|
346
|
+
<p class="wrap-anywhere">{row.message}</p>
|
|
347
|
+
{#if row.source}
|
|
348
|
+
<p class="text-xs text-muted-foreground">{row.source}</p>
|
|
349
|
+
{/if}
|
|
350
|
+
</div>
|
|
351
|
+
</li>
|
|
352
|
+
{/each}
|
|
353
|
+
</ul>
|
|
354
|
+
{/if}
|
|
355
|
+
|
|
356
|
+
{#if view === 'review' || view === 'blocked'}
|
|
357
|
+
<div class="mt-3 gap-2 flex justify-end">
|
|
358
|
+
{#if view === 'blocked'}
|
|
359
|
+
<Button variant="outline" size="sm" onclick={dismiss}>{labels.dismiss}</Button>
|
|
360
|
+
{:else}
|
|
361
|
+
<Button variant="outline" size="sm" onclick={ondiscard}>{labels.discard}</Button>
|
|
362
|
+
<Button size="sm" onclick={onconfirm}>{labels.confirm}</Button>
|
|
363
|
+
{/if}
|
|
364
|
+
</div>
|
|
365
|
+
{/if}
|
|
366
|
+
</div>
|
|
367
|
+
{/if}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
2
|
+
import type { LiveSolveState, SolvePhase } from '@selvajs/solve/client';
|
|
3
|
+
interface Labels {
|
|
4
|
+
solving: string;
|
|
5
|
+
blockedTitle: string;
|
|
6
|
+
messagesTitle: string;
|
|
7
|
+
blockedDescription: string;
|
|
8
|
+
messagesDescription: string;
|
|
9
|
+
confirm: string;
|
|
10
|
+
discard: string;
|
|
11
|
+
abort: string;
|
|
12
|
+
dismiss: string;
|
|
13
|
+
}
|
|
14
|
+
interface Props {
|
|
15
|
+
/** What the running (or most recent) solve has said over the live channel. */
|
|
16
|
+
live: LiveSolveState;
|
|
17
|
+
/** The session's phase: `solving`, `review` (result held), `blocked` or `idle`. */
|
|
18
|
+
phase: SolvePhase;
|
|
19
|
+
/** The last result's messages, with level, source and `isGate`. */
|
|
20
|
+
diagnostics?: SolveDiagnostic[];
|
|
21
|
+
onabort: () => void;
|
|
22
|
+
onconfirm: () => void;
|
|
23
|
+
ondiscard: () => void;
|
|
24
|
+
labels?: Labels;
|
|
25
|
+
}
|
|
26
|
+
declare const SolveMessages: import("svelte").Component<Props, {}, "">;
|
|
27
|
+
type SolveMessages = ReturnType<typeof SolveMessages>;
|
|
28
|
+
export default SolveMessages;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
3
4
|
import PageHeader from './PageHeader.svelte';
|
|
4
5
|
import PageFooter from './PageFooter.svelte';
|
|
5
6
|
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
showFooter?: boolean;
|
|
33
34
|
errors?: string[];
|
|
34
35
|
warnings?: string[];
|
|
36
|
+
/** Structured messages for the footer badge and message centre. */
|
|
37
|
+
diagnostics?: SolveDiagnostic[];
|
|
35
38
|
/** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
|
|
36
39
|
footerText?: string;
|
|
37
40
|
footerChildren?: Snippet;
|
|
@@ -59,6 +62,7 @@
|
|
|
59
62
|
showFooter = false,
|
|
60
63
|
errors = [],
|
|
61
64
|
warnings = [],
|
|
65
|
+
diagnostics,
|
|
62
66
|
footerText,
|
|
63
67
|
footerChildren,
|
|
64
68
|
class: className = '',
|
|
@@ -138,7 +142,7 @@
|
|
|
138
142
|
|
|
139
143
|
{#if showFooter}
|
|
140
144
|
<div class="bottom-0 sticky z-10 shrink-0">
|
|
141
|
-
<PageFooter {errors} {warnings} copyrightName={_copyright} {footerText}>
|
|
145
|
+
<PageFooter {errors} {warnings} {diagnostics} copyrightName={_copyright} {footerText}>
|
|
142
146
|
{#if footerChildren}
|
|
143
147
|
{@render footerChildren()}
|
|
144
148
|
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
2
3
|
interface AppShellProps {
|
|
3
4
|
showHeader?: boolean;
|
|
4
5
|
homeUrl?: string;
|
|
@@ -21,6 +22,8 @@ interface AppShellProps {
|
|
|
21
22
|
showFooter?: boolean;
|
|
22
23
|
errors?: string[];
|
|
23
24
|
warnings?: string[];
|
|
25
|
+
/** Structured messages for the footer badge and message centre. */
|
|
26
|
+
diagnostics?: SolveDiagnostic[];
|
|
24
27
|
/** Fully overrides the footer copyright line. `{name}` and `{year}` are substituted. */
|
|
25
28
|
footerText?: string;
|
|
26
29
|
footerChildren?: Snippet;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
-
import { CircleAlert, TriangleAlert } from '@lucide/svelte';
|
|
3
|
+
import { CircleAlert, TriangleAlert, Info } from '@lucide/svelte';
|
|
4
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
4
5
|
import { useFooter } from '../../contexts/footerContext.svelte';
|
|
5
6
|
import ComputeMessagesDialog from '../compute/ComputeMessagesDialog.svelte';
|
|
6
7
|
import FooterItemRenderer from './FooterItemRenderer.svelte';
|
|
@@ -8,6 +9,8 @@
|
|
|
8
9
|
interface Props {
|
|
9
10
|
errors?: string[];
|
|
10
11
|
warnings?: string[];
|
|
12
|
+
/** Structured messages, including remarks. Preferred over the two arrays above. */
|
|
13
|
+
diagnostics?: SolveDiagnostic[];
|
|
11
14
|
copyrightName?: string;
|
|
12
15
|
/** Fully overrides the copyright line. `{name}` and `{year}` are substituted. */
|
|
13
16
|
footerText?: string;
|
|
@@ -17,6 +20,7 @@
|
|
|
17
20
|
let {
|
|
18
21
|
errors = [],
|
|
19
22
|
warnings = [],
|
|
23
|
+
diagnostics,
|
|
20
24
|
copyrightName = 'Selva',
|
|
21
25
|
footerText,
|
|
22
26
|
children
|
|
@@ -47,8 +51,19 @@
|
|
|
47
51
|
|
|
48
52
|
let _currentYear = new Date().getFullYear();
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
// Counts come from `diagnostics` when the host supplies it, so remarks are included and the
|
|
55
|
+
// badge matches what the dialog lists.
|
|
56
|
+
const errorCount = $derived(
|
|
57
|
+
diagnostics ? diagnostics.filter((d) => d.level === 'error').length : errors.length
|
|
58
|
+
);
|
|
59
|
+
const warningCount = $derived(
|
|
60
|
+
diagnostics ? diagnostics.filter((d) => d.level === 'warning').length : warnings.length
|
|
61
|
+
);
|
|
62
|
+
const remarkCount = $derived(
|
|
63
|
+
diagnostics ? diagnostics.filter((d) => d.level === 'remark').length : 0
|
|
64
|
+
);
|
|
65
|
+
const totalCount = $derived(errorCount + warningCount + remarkCount);
|
|
66
|
+
const hasMessages = $derived(totalCount > 0);
|
|
52
67
|
|
|
53
68
|
const copyrightLine = $derived(
|
|
54
69
|
footerText
|
|
@@ -66,28 +81,33 @@
|
|
|
66
81
|
{/each}
|
|
67
82
|
|
|
68
83
|
{#if hasMessages}
|
|
69
|
-
<ComputeMessagesDialog {errors} {warnings}>
|
|
84
|
+
<ComputeMessagesDialog {errors} {warnings} {diagnostics}>
|
|
70
85
|
{#snippet trigger()}
|
|
71
86
|
<div
|
|
72
|
-
class="gap-1.5 px-2 py-1 rounded flex cursor-pointer items-center transition-colors hover:bg-muted {
|
|
87
|
+
class="gap-1.5 px-2 py-1 rounded flex cursor-pointer items-center transition-colors hover:bg-muted {errorCount >
|
|
73
88
|
0
|
|
74
89
|
? 'text-destructive hover:bg-destructive/10'
|
|
75
|
-
:
|
|
76
|
-
|
|
90
|
+
: warningCount > 0
|
|
91
|
+
? 'text-warning hover:bg-warning/10'
|
|
92
|
+
: 'hover:bg-muted'}"
|
|
93
|
+
title={`${totalCount} ${totalCount === 1 ? 'message' : 'messages'} from the last solve`}
|
|
77
94
|
>
|
|
78
|
-
{#if
|
|
95
|
+
{#if errorCount > 0}
|
|
79
96
|
<CircleAlert class="h-3.5 w-3.5" />
|
|
80
|
-
<span class="font-medium">{
|
|
97
|
+
<span class="font-medium">{errorCount} Error{errorCount !== 1 ? 's' : ''}</span>
|
|
81
98
|
{/if}
|
|
82
99
|
|
|
83
|
-
{#if
|
|
84
|
-
{#if
|
|
100
|
+
{#if warningCount > 0}
|
|
101
|
+
{#if errorCount > 0}
|
|
85
102
|
<span class="text-border">•</span>
|
|
86
103
|
{/if}
|
|
87
104
|
<TriangleAlert class="h-3.5 w-3.5" />
|
|
88
|
-
<span class="font-medium"
|
|
89
|
-
|
|
90
|
-
|
|
105
|
+
<span class="font-medium">{warningCount} Warning{warningCount !== 1 ? 's' : ''}</span>
|
|
106
|
+
{/if}
|
|
107
|
+
|
|
108
|
+
{#if errorCount === 0 && warningCount === 0}
|
|
109
|
+
<Info class="h-3.5 w-3.5" />
|
|
110
|
+
<span class="font-medium">{remarkCount} Note{remarkCount !== 1 ? 's' : ''}</span>
|
|
91
111
|
{/if}
|
|
92
112
|
</div>
|
|
93
113
|
{/snippet}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { SolveDiagnostic } from '@selvajs/solve/shared';
|
|
2
3
|
interface Props {
|
|
3
4
|
errors?: string[];
|
|
4
5
|
warnings?: string[];
|
|
6
|
+
/** Structured messages, including remarks. Preferred over the two arrays above. */
|
|
7
|
+
diagnostics?: SolveDiagnostic[];
|
|
5
8
|
copyrightName?: string;
|
|
6
9
|
/** Fully overrides the copyright line. `{name}` and `{year}` are substituted. */
|
|
7
10
|
footerText?: string;
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
dynamicOptions = {}
|
|
43
43
|
}: Props = $props();
|
|
44
44
|
|
|
45
|
+
const visibleGroups = $derived(
|
|
46
|
+
tab.groups.filter((group) => evaluateGroupVisibility(group, values))
|
|
47
|
+
);
|
|
48
|
+
|
|
45
49
|
function getInputById(paramId: string): SchemaInput | undefined {
|
|
46
50
|
return inputs.find((i) => i.id === paramId);
|
|
47
51
|
}
|
|
@@ -82,25 +86,23 @@
|
|
|
82
86
|
<Tabs.Content value={tab.id} class="min-h-0 p-0 flex-1">
|
|
83
87
|
<ScrollArea class="h-full" orientation="vertical">
|
|
84
88
|
<div class="p-4 tab-content-container">
|
|
85
|
-
{#if
|
|
89
|
+
{#if visibleGroups.length === 0}
|
|
86
90
|
<StateDisplay type="empty" size="medium" message={t.tabNoGroups} />
|
|
87
91
|
{:else}
|
|
88
92
|
<div class="gap-8 flex flex-col">
|
|
89
|
-
{#each
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
/>
|
|
103
|
-
{/if}
|
|
93
|
+
{#each visibleGroups as group (group.id)}
|
|
94
|
+
<Group
|
|
95
|
+
label={group.label}
|
|
96
|
+
description={group.description}
|
|
97
|
+
items={group.items}
|
|
98
|
+
columns={group.columns ?? 1}
|
|
99
|
+
collapsed={collapsedGroups[group.id] ?? false}
|
|
100
|
+
{values}
|
|
101
|
+
onToggle={() => onToggleGroup(group.id)}
|
|
102
|
+
inputSnippet={renderInput}
|
|
103
|
+
outputSnippet={renderOutput}
|
|
104
|
+
flat={visibleGroups.length === 1}
|
|
105
|
+
/>
|
|
104
106
|
{/each}
|
|
105
107
|
</div>
|
|
106
108
|
{/if}
|
|
@@ -15,11 +15,16 @@ export function useSolveSession(args) {
|
|
|
15
15
|
// Reading this inside a getter is what registers the dependency; the value never matters.
|
|
16
16
|
let version = $state(0);
|
|
17
17
|
$effect(() => {
|
|
18
|
-
|
|
19
|
-
// dropping the subscription is the whole cleanup.
|
|
20
|
-
return session.subscribe(() => {
|
|
18
|
+
const unsubscribe = session.subscribe(() => {
|
|
21
19
|
version += 1;
|
|
22
20
|
});
|
|
21
|
+
// The session dies with the owning component. Its event source may not (the cloud
|
|
22
|
+
// stream belongs to the tab), so the session must stop listening to it, not only we to
|
|
23
|
+
// the session.
|
|
24
|
+
return () => {
|
|
25
|
+
unsubscribe();
|
|
26
|
+
session.dispose();
|
|
27
|
+
};
|
|
23
28
|
});
|
|
24
29
|
/** Registers the reactive dependency, then returns the live value. */
|
|
25
30
|
function track(read) {
|
|
@@ -39,6 +44,22 @@ export function useSolveSession(args) {
|
|
|
39
44
|
get computeWarnings() {
|
|
40
45
|
return track(() => session.computeWarnings);
|
|
41
46
|
},
|
|
47
|
+
get diagnostics() {
|
|
48
|
+
return track(() => session.diagnostics);
|
|
49
|
+
},
|
|
50
|
+
get blocked() {
|
|
51
|
+
return track(() => session.blocked);
|
|
52
|
+
},
|
|
53
|
+
get awaitingAck() {
|
|
54
|
+
return track(() => session.awaitingAck);
|
|
55
|
+
},
|
|
56
|
+
get live() {
|
|
57
|
+
return track(() => session.live);
|
|
58
|
+
},
|
|
59
|
+
get phase() {
|
|
60
|
+
return track(() => session.phase);
|
|
61
|
+
},
|
|
62
|
+
abort: () => session.abort(),
|
|
42
63
|
get meshes() {
|
|
43
64
|
return track(() => session.meshes);
|
|
44
65
|
},
|
|
@@ -54,6 +75,8 @@ export function useSolveSession(args) {
|
|
|
54
75
|
get isSolving() {
|
|
55
76
|
return track(() => session.isSolving);
|
|
56
77
|
},
|
|
78
|
+
acknowledge: () => session.acknowledge(),
|
|
79
|
+
discard: () => session.discard(),
|
|
57
80
|
setValue: (id, value, forceSolve) => session.setValue(id, value, forceSolve),
|
|
58
81
|
solve: () => session.solve(),
|
|
59
82
|
loadValues: (incoming) => session.loadValues(incoming),
|
|
@@ -61,6 +84,7 @@ export function useSolveSession(args) {
|
|
|
61
84
|
report: (result) => session.report(result),
|
|
62
85
|
reportError: (message) => session.reportError(message),
|
|
63
86
|
subscribe: (listener) => session.subscribe(listener),
|
|
64
|
-
notify: () => session.notify()
|
|
87
|
+
notify: () => session.notify(),
|
|
88
|
+
dispose: () => session.dispose()
|
|
65
89
|
};
|
|
66
90
|
}
|
package/dist/i18n/messages.d.ts
CHANGED
|
@@ -36,6 +36,15 @@ export interface ViewerMessages {
|
|
|
36
36
|
objectFallbackName: string;
|
|
37
37
|
noMetadata: string;
|
|
38
38
|
loadingSchema: string;
|
|
39
|
+
solveBlocked: string;
|
|
40
|
+
solveBlockedTitle: string;
|
|
41
|
+
solveMessagesTitle: string;
|
|
42
|
+
solveMessagesDescription: string;
|
|
43
|
+
solveMessagesConfirm: string;
|
|
44
|
+
solveMessagesDiscard: string;
|
|
45
|
+
solveMessagesAbort: string;
|
|
46
|
+
solveMessagesDismiss: string;
|
|
47
|
+
solveSolving: string;
|
|
39
48
|
parametersTab: string;
|
|
40
49
|
moreTab: string;
|
|
41
50
|
expandPanel: string;
|