@selvajs/ui 6.3.2 → 6.4.1

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.
@@ -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
- const hasMessages = $derived(errors.length > 0 || warnings.length > 0);
51
- const totalCount = $derived(errors.length + warnings.length);
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 {errors.length >
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
- : 'text-warning hover:bg-warning/10'}"
76
- title={`${totalCount} ${totalCount === 1 ? 'issue' : 'issues'}`}
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 errors.length > 0}
95
+ {#if errorCount > 0}
79
96
  <CircleAlert class="h-3.5 w-3.5" />
80
- <span class="font-medium">{errors.length} Error{errors.length !== 1 ? 's' : ''}</span>
97
+ <span class="font-medium">{errorCount} Error{errorCount !== 1 ? 's' : ''}</span>
81
98
  {/if}
82
99
 
83
- {#if warnings.length > 0}
84
- {#if errors.length > 0}
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
- >{warnings.length} Warning{warnings.length !== 1 ? 's' : ''}</span
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;
@@ -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
- // The session is created alongside this adapter and outlives no other component, so
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
  }
@@ -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;
@@ -41,6 +41,15 @@ const en = {
41
41
  objectFallbackName: 'Object',
42
42
  noMetadata: 'No metadata',
43
43
  loadingSchema: 'Loading schema...',
44
+ solveBlocked: 'The definition stopped this solve. No results were produced.',
45
+ solveBlockedTitle: 'Solve stopped',
46
+ solveMessagesTitle: 'Solve messages',
47
+ solveMessagesDescription: 'This solve reported the following. Continue to see the result.',
48
+ solveMessagesConfirm: 'Continue',
49
+ solveMessagesDiscard: 'Discard',
50
+ solveMessagesAbort: 'Abort',
51
+ solveMessagesDismiss: 'Dismiss',
52
+ solveSolving: 'Solving…',
44
53
  parametersTab: 'Parameters',
45
54
  moreTab: 'More',
46
55
  expandPanel: 'Expand panel',
@@ -92,6 +101,15 @@ const de = {
92
101
  objectFallbackName: 'Objekt',
93
102
  noMetadata: 'Keine Metadaten',
94
103
  loadingSchema: 'Schema wird geladen...',
104
+ solveBlocked: 'Die Definition hat diese Berechnung gestoppt. Es wurden keine Ergebnisse erzeugt.',
105
+ solveBlockedTitle: 'Berechnung gestoppt',
106
+ solveMessagesTitle: 'Meldungen zur Berechnung',
107
+ solveMessagesDescription: 'Diese Berechnung hat Folgendes gemeldet. Weiter, um das Ergebnis zu sehen.',
108
+ solveMessagesConfirm: 'Weiter',
109
+ solveMessagesDiscard: 'Verwerfen',
110
+ solveMessagesAbort: 'Abbrechen',
111
+ solveMessagesDismiss: 'Schliessen',
112
+ solveSolving: 'Berechnung läuft…',
95
113
  parametersTab: 'Parameter',
96
114
  moreTab: 'Mehr',
97
115
  expandPanel: 'Bereich aufklappen',