@bramas/simulant-ui 0.1.2 → 0.2.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.
Files changed (38) hide show
  1. package/README.md +5 -0
  2. package/dist/SimulatorApp.jsx +782 -174
  3. package/dist/components/AlgorithmViewer.css +268 -74
  4. package/dist/components/AlgorithmViewer.jsx +171 -66
  5. package/dist/components/BranchPicker.css +74 -32
  6. package/dist/components/BranchPicker.jsx +9 -4
  7. package/dist/components/ConfigGraphGenerator.css +180 -38
  8. package/dist/components/ConfigGraphGenerator.jsx +155 -96
  9. package/dist/components/GenericEditor.jsx +18 -44
  10. package/dist/components/Icon.jsx +54 -0
  11. package/dist/components/ModelView.css +15 -15
  12. package/dist/components/SettingsLibrary.jsx +7 -5
  13. package/dist/components/Splitter.jsx +28 -0
  14. package/dist/components/StateInspector.css +59 -33
  15. package/dist/components/StateInspector.jsx +37 -4
  16. package/dist/components/theme.js +28 -0
  17. package/dist/pico.min.css +4 -4
  18. package/dist/renderers/ring.js +19 -10
  19. package/dist/simulator.css +1553 -363
  20. package/package.json +6 -6
  21. package/src/SimulatorApp.tsx +888 -219
  22. package/src/components/AlgorithmViewer.css +268 -74
  23. package/src/components/AlgorithmViewer.tsx +188 -75
  24. package/src/components/BranchPicker.css +74 -32
  25. package/src/components/BranchPicker.tsx +9 -4
  26. package/src/components/ConfigGraphGenerator.css +180 -38
  27. package/src/components/ConfigGraphGenerator.tsx +172 -114
  28. package/src/components/GenericEditor.tsx +25 -59
  29. package/src/components/Icon.tsx +76 -0
  30. package/src/components/ModelView.css +15 -15
  31. package/src/components/SettingsLibrary.tsx +7 -5
  32. package/src/components/Splitter.tsx +55 -0
  33. package/src/components/StateInspector.css +59 -33
  34. package/src/components/StateInspector.tsx +35 -4
  35. package/src/components/theme.ts +33 -0
  36. package/src/pico.min.css +4 -4
  37. package/src/renderers/ring.ts +22 -12
  38. package/src/simulator.css +1553 -363
@@ -4,11 +4,14 @@
4
4
  * more lightly, on the lines of the calls that led there. Below, the definitions the algorithm
5
5
  * uses, folded to their signatures (a click on a function name opens one); an agent is marked
6
6
  * (↩) on the lines its last step returned from. Clicking a cursor explains that agent's last
7
- * step: its view, and each definition it called with its arguments and result.
7
+ * step (its view, and each definition it called with its arguments and result) in a card
8
+ * anchored under that cursor: it covers the code without moving it, and follows the agent's line
9
+ * while stepping.
8
10
  */
9
- import { For, Show, createMemo, createSignal, onCleanup, type JSX } from 'solid-js';
11
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from 'solid-js';
10
12
  import type { AgentExecutionState, AlgorithmSource, TraceCall } from '@bramas/simulant-core/types/project';
11
13
  import { tokenizeAll, type Line } from './highlight';
14
+ import { Icon } from './Icon';
12
15
  import './AlgorithmViewer.css';
13
16
 
14
17
  export type { AgentExecutionState, AlgorithmSource } from '@bramas/simulant-core/types/project';
@@ -16,6 +19,14 @@ export type { AgentExecutionState, AlgorithmSource } from '@bramas/simulant-core
16
19
  export interface AlgorithmViewerProps {
17
20
  source: AlgorithmSource;
18
21
  agents?: AgentExecutionState[];
22
+ /** Buttons for the header's end (the page's panel controls). */
23
+ actions?: JSX.Element;
24
+ /** A press on the header (the page drags the panel from there). */
25
+ onHeadPointerDown?: (e: PointerEvent) => void;
26
+ /** The agent selected elsewhere (by name): its cursors are emphasized. */
27
+ highlighted?: string | null;
28
+ /** Called with the agent's name when one of its cursors is clicked. */
29
+ onSelectAgent?: (name: string) => void;
19
30
  }
20
31
 
21
32
  type Mark = { agent: AgentExecutionState; kind: 'at' | 'call' | 'return' };
@@ -74,7 +85,10 @@ export function AlgorithmViewer(props: AlgorithmViewerProps) {
74
85
  const [open, setOpen] = createSignal<Set<string>>(new Set());
75
86
  // The whole group of definitions is folded at first.
76
87
  const [defsOpen, setDefsOpen] = createSignal(false);
77
- const [selected, setSelected] = createSignal<number | undefined>();
88
+ // By name: an agent's index is its place in the configuration, which can change between steps.
89
+ const [selected, setSelected] = createSignal<string | undefined>();
90
+ /** The cursor the explanation is anchored to (`data-marker`). */
91
+ const [anchor, setAnchor] = createSignal<string | undefined>();
78
92
  const [flash, setFlash] = createSignal<string | undefined>();
79
93
  const defElements = new Map<string, HTMLElement>();
80
94
  let flashTimer: ReturnType<typeof setTimeout> | undefined;
@@ -94,7 +108,69 @@ export function AlgorithmViewer(props: AlgorithmViewerProps) {
94
108
  (target ?? el)?.scrollIntoView({ block: 'center', behavior: 'smooth' });
95
109
  });
96
110
  };
97
- const selectedAgent = createMemo(() => (props.agents ?? []).find(a => a.index !== undefined && a.index === selected()));
111
+ const selectedAgent = createMemo(() => (props.agents ?? []).find(a => a.agentId === selected()));
112
+ const markerKey = (m: Mark, def: string | undefined, line: number) => `${m.kind}-${m.agent.agentId}-${def ?? 'main'}-${line}`;
113
+ const markerAt = (key: string) => scroller?.querySelector<HTMLElement>(`[data-marker="${CSS.escape(key)}"]`);
114
+ /** Opens (or closes, from the same cursor) an agent's explanation, under the cursor `key`. */
115
+ const select = (a: AgentExecutionState, key?: string) => {
116
+ const same = selected() === a.agentId && (key === undefined || key === anchor());
117
+ setSelected(same ? undefined : a.agentId);
118
+ setAnchor(same ? undefined : key);
119
+ reveal = !same;
120
+ if (!same) props.onSelectAgent?.(a.agentId);
121
+ };
122
+ const close = () => { setSelected(undefined); setAnchor(undefined); };
123
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
124
+ window.addEventListener('keydown', onKey);
125
+ onCleanup(() => window.removeEventListener('keydown', onKey));
126
+
127
+ // The agents' lines stay in view while stepping: scroll to the first one when it is hidden.
128
+ let scroller: HTMLDivElement | undefined;
129
+ createEffect(() => {
130
+ mainMarks();
131
+ queueMicrotask(() => {
132
+ const line = scroller?.querySelector<HTMLElement>('.algorithm-body > .algorithm-line.highlighted');
133
+ if (!scroller || !line) return;
134
+ const view = scroller.getBoundingClientRect(), box = line.getBoundingClientRect();
135
+ if (box.top < view.top + 8 || box.bottom > view.bottom - 8) {
136
+ scroller.scrollTop += box.top - view.top - view.height / 3;
137
+ }
138
+ });
139
+ });
140
+
141
+ // The explanation's card: under its cursor (the agent's current line when that cursor is gone),
142
+ // in the scrolling content, so that it scrolls with the code.
143
+ let card: HTMLDivElement | undefined;
144
+ let reveal = false;
145
+ const [place, setPlace] = createSignal<{ top: number; left: number; width: number; arrow: number } | undefined>();
146
+ const reposition = () => {
147
+ const a = selectedAgent();
148
+ if (!scroller || !a) return setPlace(undefined);
149
+ const marker = (anchor() && markerAt(anchor()!)) || markerAt(`at-${a.agentId}-main-${lineOf(a)}`);
150
+ if (!marker) return setPlace(undefined);
151
+ const view = scroller.getBoundingClientRect(), box = marker.getBoundingClientRect();
152
+ const x = box.left + box.width / 2 - view.left + scroller.scrollLeft;
153
+ const width = Math.max(240, Math.min(400, scroller.clientWidth - 16));
154
+ const left = Math.max(scroller.scrollLeft + 8, Math.min(x - 48, scroller.scrollLeft + scroller.clientWidth - width - 8));
155
+ setPlace({ top: box.bottom - view.top + scroller.scrollTop + 6, left, width, arrow: x - left });
156
+ // When opened by a click, the card is scrolled into view.
157
+ if (reveal) queueMicrotask(() => {
158
+ reveal = false;
159
+ if (!card || !scroller) return;
160
+ const c = card.getBoundingClientRect(), v = scroller.getBoundingClientRect();
161
+ if (c.bottom > v.bottom - 8) scroller.scrollTop += Math.min(c.bottom - v.bottom + 8, c.top - v.top - 40);
162
+ });
163
+ };
164
+ createEffect(() => {
165
+ selected(); anchor(); mainMarks(); defMarks(); defsOpen(); open();
166
+ queueMicrotask(reposition);
167
+ });
168
+ onMount(() => {
169
+ if (!scroller) return;
170
+ const observer = new ResizeObserver(() => reposition());
171
+ observer.observe(scroller);
172
+ onCleanup(() => observer.disconnect());
173
+ });
98
174
 
99
175
  const Code = (p: { lines: Line[]; marks: Map<number, Mark[]>; def?: string }): JSX.Element => (
100
176
  <For each={p.lines}>
@@ -120,12 +196,13 @@ export function AlgorithmViewer(props: AlgorithmViewerProps) {
120
196
  <For each={marks()}>
121
197
  {(m) => (
122
198
  <span
123
- class={`agent-marker${m.kind === 'call' ? ' call-marker' : m.kind === 'return' ? ' return-marker' : ''}${selected() === m.agent.index ? ' selected' : ''}`}
124
- style={{ color: m.agent.color }}
199
+ class={`agent-marker${m.kind === 'call' ? ' call-marker' : m.kind === 'return' ? ' return-marker' : ''}${selected() === m.agent.agentId || props.highlighted === m.agent.agentId ? ' selected' : ''}`}
200
+ style={{ '--agent': m.agent.color }}
201
+ data-marker={markerKey(m, p.def, line.number)}
125
202
  title={m.kind === 'at' ? `${m.agent.agentId} is at this line: click to explain its last step`
126
203
  : m.kind === 'call' ? `${m.agent.agentId} is inside the procedure called here`
127
204
  : `${m.agent.agentId}'s last step returned from this line: click to explain it`}
128
- onClick={() => setSelected(s => s === m.agent.index ? undefined : m.agent.index)}
205
+ onClick={() => select(m.agent, markerKey(m, p.def, line.number))}
129
206
  >
130
207
  {m.kind === 'at' ? '◀' : m.kind === 'call' ? '◁' : '↩'} {m.agent.agentId}
131
208
  </span>
@@ -168,89 +245,125 @@ export function AlgorithmViewer(props: AlgorithmViewerProps) {
168
245
 
169
246
  return (
170
247
  <div class="algorithm-viewer">
171
- <div class="algorithm-header">
172
- <h3 class="algorithm-caption">{props.source.name}</h3>
248
+ <div class={`algorithm-header${props.onHeadPointerDown ? ' algorithm-header-draggable' : ''}`} onPointerDown={e => props.onHeadPointerDown?.(e)}>
249
+ <Icon name="file" />
173
250
  <Show when={props.source.file}>
174
251
  <span class="algorithm-file">{props.source.file!.split('/').pop()}</span>
252
+ <Icon name="chevronRight" size={12} class="algorithm-crumb" />
175
253
  </Show>
254
+ <h3 class="algorithm-caption">{props.source.name}</h3>
255
+ <span class="sim-spacer" />
256
+ <div class="algorithm-agents" aria-label="Agents">
257
+ <For each={props.agents ?? []}>
258
+ {(a) => (
259
+ <button
260
+ onPointerDown={e => e.stopPropagation()}
261
+ class={`algorithm-agent${selected() === a.agentId || props.highlighted === a.agentId ? ' selected' : ''}`}
262
+ style={{ '--agent': a.color }}
263
+ onClick={() => {
264
+ // The card opens under the agent's cursor, scrolled into view.
265
+ const key = `at-${a.agentId}-main-${lineOf(a)}`;
266
+ select(a, key);
267
+ queueMicrotask(() => markerAt(key)?.closest('.algorithm-line')?.scrollIntoView({ block: 'nearest' }));
268
+ }}
269
+ title={`${a.agentId}: line ${lineOf(a)}. Click to explain its last step`}
270
+ aria-pressed={selected() === a.agentId}
271
+ >
272
+ <span class="algorithm-agent-dot" />{a.agentId}<span class="algorithm-agent-line">{lineOf(a)}</span>
273
+ </button>
274
+ )}
275
+ </For>
276
+ </div>
277
+ {props.actions}
176
278
  </div>
177
279
 
178
- <Show when={selectedAgent()}>
179
- {a => (
180
- <div class="ex-panel">
280
+ <div class="algorithm-scroll" ref={scroller}>
281
+ <div class="algorithm-body">
282
+ <Code lines={lines()} marks={mainMarks()} />
283
+ </div>
284
+
285
+ <Show when={defLines().length > 0}>
286
+ <div class="algorithm-defs">
287
+ <div class="defs-title" onClick={() => setDefsOpen(v => !v)} title={defsOpen() ? 'Fold the definitions' : 'Show the definitions'}>
288
+ <Icon name="chevronRight" size={13} class={defsOpen() ? 'defs-chevron open' : 'defs-chevron'} /> Definitions <span class="sim-muted">{defLines().length} · a click on a function name opens its definition</span>
289
+ </div>
290
+ <Show when={defsOpen()}>
291
+ <For each={defLines()}>
292
+ {(d) => {
293
+ const marks = () => defMarks().get(d.name) ?? new Map<number, Mark[]>();
294
+ const agents = () => [...new Map([...marks().values()].flat().map(m => [m.agent.index, m.agent])).values()];
295
+ return (
296
+ <div class={`def${flash() === d.name ? ' flash' : ''}`} ref={el => defElements.set(d.name, el)}>
297
+ <Show when={open().has(d.name)} fallback={
298
+ <div class="algorithm-line def-folded" onClick={() => toggle(d.name)} title="Show the definition">
299
+ <span class="line-number">▸</span>
300
+ <span class="line-content">
301
+ <For each={d.tokens[0]?.tokens ?? []}>{(t) => t.kind ? <span class={`tok-${t.kind}`}>{t.text}</span> : t.text}</For>
302
+ </span>
303
+ <Show when={agents().length}>
304
+ <span class="agent-markers">
305
+ <For each={agents()}>{(a) => <span class="agent-marker return-marker" style={{ '--agent': a.color }}>↩ {a.agentId}</span>}</For>
306
+ </span>
307
+ </Show>
308
+ </div>
309
+ }>
310
+ <div class="def-open">
311
+ <div class="def-fold" onClick={() => toggle(d.name)} title="Fold">▾ {d.name}</div>
312
+ <Code lines={d.tokens} marks={marks()} def={d.name} />
313
+ </div>
314
+ </Show>
315
+ </div>
316
+ );
317
+ }}
318
+ </For>
319
+ </Show>
320
+ </div>
321
+ </Show>
322
+
323
+ <Show when={selectedAgent() && place()}>
324
+ <div
325
+ ref={card}
326
+ class="ex-card"
327
+ role="dialog"
328
+ aria-label={`Last step of ${selectedAgent()!.agentId}`}
329
+ style={{ top: `${place()!.top}px`, left: `${place()!.left}px`, width: `${place()!.width}px`, '--arrow': `${place()!.arrow}px`, '--agent': selectedAgent()!.color }}
330
+ >
181
331
  <div class="ex-head">
182
- <strong style={{ color: a().color }}>{a().agentId}</strong>
183
- <Show when={a().explanation} fallback={<span class="sim-muted">did not take a decision in the last step (not activated, or the initial configuration).</span>}>
184
- {e => (
185
- <span>
186
- last step: <span class="tok-action">move</span> {e().move}
187
- <Show when={e().line !== undefined}> · decided at line {e().line}</Show>
188
- <span class="sim-muted"> · frame: {e().frame}</span>
189
- </span>
190
- )}
191
- </Show>
192
- <span style={{ flex: 1 }} />
193
- <button class="sim-ghost sim-icon" onClick={() => setSelected(undefined)} title="Close">✕</button>
332
+ <span class="ex-agent"><span class="algorithm-agent-dot" />{selectedAgent()!.agentId}</span>
333
+ <span class="ex-title">Last step</span>
334
+ <span class="sim-spacer" />
335
+ <button class="sim-ghost sim-icon sim-sm" onClick={close} aria-label="Close the explanation" title="Close (Esc)"><Icon name="close" size={14} /></button>
194
336
  </div>
195
- <Show when={a().explanation}>
337
+ <Show when={selectedAgent()!.explanation} fallback={<p class="ex-none">No decision in the last step: the agent was not activated, or this is the initial configuration.</p>}>
196
338
  {e => (
197
339
  <>
198
- <div class="ex-view">
199
- <For each={Object.entries(e().view)}>
200
- {([k, v]) => <div><span class="si-key">{k}</span> <span class="ex-result" title={v}>{v}</span></div>}
201
- </For>
340
+ <div class="ex-meta">
341
+ <span><span class="tok-action">move</span> {e().move}</span>
342
+ <Show when={e().line !== undefined}><span>decided at line {e().line}</span></Show>
343
+ <span>frame: {e().frame}</span>
344
+ </div>
345
+ <div class="ex-section">
346
+ <h4>Sensors</h4>
347
+ <Show when={Object.keys(e().view).length} fallback={<p class="ex-none">No sensor.</p>}>
348
+ <dl class="ex-view">
349
+ <For each={Object.entries(e().view)}>
350
+ {([k, v]) => <><dt class="si-key">{k}</dt><dd class="ex-result">{v}</dd></>}
351
+ </For>
352
+ </dl>
353
+ </Show>
354
+ </div>
355
+ <div class="ex-section">
356
+ <h4>Calls</h4>
357
+ <Show when={e().calls.length} fallback={<p class="ex-none">No definition was called.</p>}>
358
+ <CallTree calls={e().calls} depth={0} />
359
+ </Show>
202
360
  </div>
203
- <Show when={e().calls.length} fallback={<p class="sim-muted ex-none">No definition was called.</p>}>
204
- <CallTree calls={e().calls} depth={0} />
205
- </Show>
206
361
  </>
207
362
  )}
208
363
  </Show>
209
364
  </div>
210
- )}
211
- </Show>
212
-
213
- <div class="algorithm-body">
214
- <Code lines={lines()} marks={mainMarks()} />
365
+ </Show>
215
366
  </div>
216
-
217
- <Show when={defLines().length > 0}>
218
- <div class="algorithm-defs">
219
- <div class="defs-title" onClick={() => setDefsOpen(v => !v)} title={defsOpen() ? 'Fold the definitions' : 'Show the definitions'}>
220
- {defsOpen() ? '▾' : '▸'} Definitions <span class="sim-muted">({defLines().length}; a click on a function name opens its definition)</span>
221
- </div>
222
- <Show when={defsOpen()}>
223
- <For each={defLines()}>
224
- {(d) => {
225
- const marks = () => defMarks().get(d.name) ?? new Map<number, Mark[]>();
226
- const agents = () => [...new Map([...marks().values()].flat().map(m => [m.agent.index, m.agent])).values()];
227
- return (
228
- <div class={`def${flash() === d.name ? ' flash' : ''}`} ref={el => defElements.set(d.name, el)}>
229
- <Show when={open().has(d.name)} fallback={
230
- <div class="algorithm-line def-folded" onClick={() => toggle(d.name)} title="Show the definition">
231
- <span class="line-number">▸</span>
232
- <span class="line-content">
233
- <For each={d.tokens[0]?.tokens ?? []}>{(t) => t.kind ? <span class={`tok-${t.kind}`}>{t.text}</span> : t.text}</For>
234
- </span>
235
- <Show when={agents().length}>
236
- <span class="agent-markers">
237
- <For each={agents()}>{(a) => <span class="agent-marker return-marker" style={{ color: a.color }}>↩ {a.agentId}</span>}</For>
238
- </span>
239
- </Show>
240
- </div>
241
- }>
242
- <div class="def-open">
243
- <div class="def-fold" onClick={() => toggle(d.name)} title="Fold">▾ {d.name}</div>
244
- <Code lines={d.tokens} marks={marks()} def={d.name} />
245
- </div>
246
- </Show>
247
- </div>
248
- );
249
- }}
250
- </For>
251
- </Show>
252
- </div>
253
- </Show>
254
367
  </div>
255
368
  );
256
369
  }
@@ -4,93 +4,135 @@
4
4
  position: fixed;
5
5
  inset: 0;
6
6
  background: rgba(0, 0, 0, 0.45);
7
+ backdrop-filter: blur(2px);
7
8
  z-index: 50;
8
9
  display: flex;
9
10
  align-items: center;
10
11
  justify-content: center;
11
12
  padding: 2rem;
13
+ animation: bp-fade 0.15s ease-out;
14
+ }
15
+
16
+ @keyframes bp-fade {
17
+ from { opacity: 0; }
12
18
  }
13
19
 
14
20
  .bp-window {
15
21
  background: var(--sim-surface);
16
22
  color: var(--sim-text);
17
23
  border: 1px solid var(--sim-border);
18
- border-radius: var(--sim-radius);
19
- box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
24
+ border-radius: 14px;
25
+ box-shadow: var(--sim-shadow-lg);
20
26
  width: min(1100px, 100%);
21
27
  max-height: 100%;
22
28
  display: flex;
23
29
  flex-direction: column;
24
- padding: 1rem 1.25rem;
30
+ overflow: hidden;
25
31
  }
26
32
 
27
33
  .bp-header {
28
34
  display: flex;
29
- align-items: center;
30
- gap: 0.75rem;
35
+ align-items: flex-start;
36
+ gap: 12px;
37
+ padding: 14px 14px 14px 18px;
38
+ border-bottom: 1px solid var(--sim-border);
31
39
  }
32
40
 
33
- .bp-header h3 {
34
- margin: 0;
35
- font-size: 1.05rem;
41
+ .bp-header > .sim-svg {
42
+ margin-top: 2px;
43
+ color: var(--sim-muted);
36
44
  }
37
45
 
38
- .bp-header input {
39
- max-width: 18rem;
46
+ .bp-title {
47
+ min-width: 0;
48
+ }
49
+
50
+ .bp-header h3 {
40
51
  margin: 0;
52
+ font-size: 15px;
41
53
  }
42
54
 
43
55
  .bp-help {
44
- margin: 0.4rem 0 0.75rem;
56
+ margin: 3px 0 0;
57
+ font-size: 12.5px;
58
+ color: var(--sim-muted);
59
+ max-width: 52rem;
60
+ }
61
+
62
+ .bp-header input {
63
+ max-width: 16rem;
45
64
  }
46
65
 
47
66
  .bp-grid {
48
67
  display: grid;
49
- grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
50
- gap: 0.75rem;
68
+ grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
69
+ gap: 12px;
51
70
  overflow: auto;
52
- padding: 2px;
71
+ padding: 16px 18px 18px;
72
+ background: var(--sim-bg);
53
73
  }
54
74
 
55
- .sim-app .bp-branch,
56
- .bp-branch {
75
+ .sim-app .bp-branch {
57
76
  display: flex;
58
77
  flex-direction: column;
59
- align-items: center;
60
- gap: 0.35rem;
78
+ align-items: stretch;
79
+ gap: 8px;
61
80
  width: 100%;
62
- padding: 0.5rem;
63
- background: var(--sim-surface-2);
81
+ padding: 8px;
82
+ background: var(--sim-surface);
64
83
  color: var(--sim-text);
65
- border: 2px solid var(--sim-border);
66
- border-radius: 8px;
67
- text-align: center;
68
- font-size: 0.8rem;
84
+ border: 1px solid var(--sim-border);
85
+ border-radius: 10px;
86
+ text-align: left;
87
+ font-size: 12.5px;
88
+ box-shadow: var(--sim-shadow-sm);
89
+ transition: border-color 0.12s, box-shadow 0.12s, transform 0.12s;
69
90
  }
70
91
 
71
- .bp-branch:hover {
92
+ .sim-app .bp-branch:hover {
72
93
  border-color: var(--sim-accent);
94
+ box-shadow: var(--sim-shadow-md);
95
+ transform: translateY(-1px);
73
96
  }
74
97
 
75
- .bp-branch.bp-current {
76
- border-color: var(--sim-highlight);
77
- background: var(--sim-highlight-soft);
98
+ .sim-app .bp-branch.bp-current {
99
+ border-color: var(--sim-accent);
100
+ box-shadow: 0 0 0 3px var(--sim-accent-soft);
78
101
  }
79
102
 
80
103
  .bp-thumb {
81
- background: white;
82
- border-radius: 6px;
104
+ align-self: center;
105
+ background-color: var(--sim-stage);
106
+ background-image: radial-gradient(var(--sim-stage-dot) 1px, transparent 1.2px);
107
+ background-size: 14px 14px;
108
+ border-radius: 7px;
109
+ border: 1px solid var(--sim-border);
83
110
  }
84
111
 
85
112
  .bp-caption {
86
113
  display: flex;
87
114
  align-items: center;
88
- gap: 0.4rem;
115
+ gap: 6px;
116
+ flex-wrap: wrap;
117
+ }
118
+
119
+ .bp-current-tag {
120
+ font-size: 10.5px;
121
+ font-weight: 600;
122
+ letter-spacing: 0.04em;
123
+ text-transform: uppercase;
124
+ color: var(--sim-accent);
125
+ }
126
+
127
+ .bp-caption .sim-chip {
128
+ margin-left: auto;
129
+ height: 20px;
130
+ font-size: 11px;
89
131
  }
90
132
 
91
133
  .bp-label {
92
134
  font-family: var(--sim-mono);
93
- font-size: 0.72rem;
135
+ font-size: 11.5px;
94
136
  color: var(--sim-muted);
95
137
  word-break: break-word;
96
138
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
7
7
  import type { ExecutionStatus } from '@bramas/simulant-core/types/project';
8
+ import { Icon } from './Icon';
8
9
  import './BranchPicker.css';
9
10
 
10
11
  export type Branch = {
@@ -62,14 +63,17 @@ export function BranchPicker(props: BranchPickerProps) {
62
63
  <div class="bp-backdrop" onClick={e => { if (e.target === e.currentTarget) props.onClose(); }}>
63
64
  <div class="bp-window" role="dialog" aria-modal="true" aria-label="Branches">
64
65
  <div class="bp-header">
65
- <h3>{props.branches.length} branches at this step</h3>
66
+ <Icon name="branch" />
67
+ <div class="bp-title">
68
+ <h3>{props.branches.length} branches at this step</h3>
69
+ <p class="bp-help">Each branch is one way the adversary can play this round; only the choices that differ between branches and are not the first option are listed.</p>
70
+ </div>
71
+ <span class="sim-spacer" />
66
72
  <Show when={props.branches.length > 8}>
67
73
  <input type="search" placeholder="Filter (index or choice)" value={filter()} onInput={e => setFilter(e.currentTarget.value)} aria-label="Filter the branches" />
68
74
  </Show>
69
- <span style={{ flex: 1 }} />
70
- <button class="sim-ghost sim-icon" onClick={props.onClose} title="Close (Esc)">✕</button>
75
+ <button class="sim-ghost sim-icon" onClick={props.onClose} aria-label="Close" title="Close (Esc)"><Icon name="close" /></button>
71
76
  </div>
72
- <p class="sim-muted bp-help">Each branch is one way the adversary can play this round; only the choices that differ between branches and are not the first option are listed.</p>
73
77
  <div class="bp-grid">
74
78
  <For each={shown()} fallback={<p class="sim-muted">No branch matches.</p>}>
75
79
  {b => (
@@ -77,6 +81,7 @@ export function BranchPicker(props: BranchPickerProps) {
77
81
  <Show when={props.draw}><Thumb index={b.index} /></Show>
78
82
  <span class="bp-caption">
79
83
  <strong>Branch {b.index}</strong>
84
+ <Show when={b.index === props.current}><span class="bp-current-tag">current</span></Show>
80
85
  <Show when={b.verdict}>{v => <span class={`sim-chip sim-${v()}`}>{VERDICT_TEXT[v()]}</span>}</Show>
81
86
  </span>
82
87
  <span class="bp-label">{b.short || b.label || '(no choice)'}</span>