@tellegen/svelte 0.3.0 → 0.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.
Files changed (71) hide show
  1. package/README.md +233 -0
  2. package/dist/Sparkline.svelte +2 -2
  3. package/dist/TellegenMap.svelte +239 -66
  4. package/dist/TellegenMap.svelte.d.ts +45 -0
  5. package/dist/TellegenProvider.svelte +36 -4
  6. package/dist/TellegenShell.svelte +14 -42
  7. package/dist/TellegenViewer.svelte +12 -2
  8. package/dist/api.d.ts +10 -2
  9. package/dist/api.js +2 -0
  10. package/dist/basemap.d.ts +192 -0
  11. package/dist/basemap.js +259 -0
  12. package/dist/colors.d.ts +55 -1
  13. package/dist/colors.js +89 -0
  14. package/dist/components/AppFooter.svelte +3 -2
  15. package/dist/components/AppHeader.svelte +21 -8
  16. package/dist/components/BindingLines.svelte +1 -1
  17. package/dist/components/BusPicker.svelte +3 -3
  18. package/dist/components/ControlPanel.svelte +4 -2
  19. package/dist/components/DemandSlider.svelte +16 -7
  20. package/dist/components/DiagramCanvas.svelte +17 -5
  21. package/dist/components/DisplayControls.svelte +5 -1
  22. package/dist/components/DropZone.svelte +1 -1
  23. package/dist/components/FormulationSelector.svelte +1 -1
  24. package/dist/components/LocalCaseDetails.svelte +6 -3
  25. package/dist/components/McResults.svelte +98 -35
  26. package/dist/components/McResults.svelte.d.ts +8 -2
  27. package/dist/components/ModelDetails.svelte +5 -5
  28. package/dist/components/MulticonductorDetails.svelte +9 -9
  29. package/dist/components/NotificationCenter.svelte +4 -2
  30. package/dist/components/PanelCard.svelte +72 -7
  31. package/dist/components/PanelHost.svelte +59 -34
  32. package/dist/components/PanelHost.svelte.d.ts +4 -1
  33. package/dist/components/PlacementCue.svelte +1 -1
  34. package/dist/components/PowerFlowDetails.svelte +1 -1
  35. package/dist/components/RatingSlider.svelte +14 -5
  36. package/dist/components/RestoreDefaultsButton.svelte +1 -1
  37. package/dist/components/SavedNetworkDetails.svelte +3 -3
  38. package/dist/components/SensitivityReadout.svelte +2 -2
  39. package/dist/components/ShellEnvironment.svelte +51 -0
  40. package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
  41. package/dist/components/SizeLegend.svelte +2 -2
  42. package/dist/components/SolveCard.svelte +3 -3
  43. package/dist/components/TopMovers.svelte +1 -1
  44. package/dist/components.d.ts +3 -0
  45. package/dist/components.js +3 -0
  46. package/dist/context.svelte.d.ts +8 -1
  47. package/dist/context.svelte.js +8 -3
  48. package/dist/controller.svelte.d.ts +65 -5
  49. package/dist/controller.svelte.js +266 -78
  50. package/dist/display.d.ts +3 -2
  51. package/dist/display.js +22 -4
  52. package/dist/format.d.ts +9 -0
  53. package/dist/format.js +13 -1
  54. package/dist/index.d.ts +6 -2
  55. package/dist/index.js +2 -0
  56. package/dist/map.d.ts +4 -2
  57. package/dist/map.js +2 -1
  58. package/dist/maplibre-worker.d.ts +5 -0
  59. package/dist/maplibre-worker.dev.d.ts +11 -0
  60. package/dist/maplibre-worker.dev.js +15 -0
  61. package/dist/maplibre-worker.js +8 -0
  62. package/dist/notices.svelte.js +5 -1
  63. package/dist/panels.svelte.d.ts +6 -0
  64. package/dist/panels.svelte.js +31 -1
  65. package/dist/slider.d.ts +12 -0
  66. package/dist/slider.js +31 -0
  67. package/dist/state.svelte.d.ts +25 -3
  68. package/dist/state.svelte.js +13 -0
  69. package/dist/styles.css +59 -6
  70. package/dist/types.d.ts +1 -1
  71. package/package.json +13 -8
@@ -1,19 +1,43 @@
1
1
  <script lang="ts">
2
- import type { McPfResult, McComplex, DistGraph } from '@tellegen/engine';
2
+ import {
3
+ mcPfResultDetail,
4
+ summarizeMcPfResult,
5
+ type DistGraph,
6
+ type McComplex,
7
+ type McPfDetail,
8
+ type McPfDetailQuery,
9
+ type McPfResult,
10
+ type McPfSummary
11
+ } from '@tellegen/engine';
3
12
  import { neutralTerminal } from '../multiconductor.js';
13
+ import type { McEditTiming } from '../state.svelte.js';
4
14
  let {
5
15
  result,
16
+ detail,
17
+ timing = null,
6
18
  elapsedMs,
7
19
  selectedBus,
8
20
  selectedEdge,
9
21
  graph
10
22
  }: {
11
- result: McPfResult;
23
+ /** A live session's summary, or a stored full result. */
24
+ result: McPfSummary | McPfResult;
25
+ /** Fetches one bus's terminals and a page of equipment ports. A full
26
+ * `result` serves its own pages when this is omitted. */
27
+ detail?: (query: McPfDetailQuery) => Promise<McPfDetail | null>;
28
+ timing?: McEditTiming | null;
12
29
  elapsedMs: number | null;
13
30
  selectedBus: string | null;
14
31
  selectedEdge: string | null;
15
32
  graph: DistGraph | null;
16
33
  } = $props();
34
+ const summary = $derived('terminals' in result ? summarizeMcPfResult(result) : result);
35
+ const load = $derived.by(() => {
36
+ if (detail) return detail;
37
+ if (!('terminals' in result)) return null;
38
+ const full = result;
39
+ return (query: McPfDetailQuery) => Promise.resolve(mcPfResultDetail(full, query));
40
+ });
17
41
  let busChoice = $state('');
18
42
  let edgeChoice = $state('');
19
43
  const busId = $derived(
@@ -23,29 +47,48 @@
23
47
  );
24
48
  const bus = $derived(graph?.buses.find((b) => b.id === busId));
25
49
  const neutralName = $derived(bus ? neutralTerminal(bus.terminals, bus.neutral_terminal) : null);
26
- const neutral = $derived(
27
- result.terminals.find((t) => t.bus === busId && t.terminal === neutralName)?.voltage
28
- );
29
50
  const edgeId = $derived(selectedEdge ?? edgeChoice);
30
- const sourcePower = $derived(
31
- result.source_reactions.reduce(
32
- (sum, port) => ({
33
- re: sum.re + port.power_into_network.re,
34
- im: sum.im + port.power_into_network.im
35
- }),
36
- { re: 0, im: 0 }
37
- )
38
- );
39
- const passiveLoss = $derived(
40
- result.element_ports
41
- .filter((port) => !['load', 'generator', 'ibr'].includes(port.kind))
42
- .reduce((sum, port) => sum + port.power_into_element.re, 0)
43
- );
44
51
  let search = $state('');
45
52
  let page = $state(0);
46
53
  const size = 20;
54
+ let portPage = $state(0);
55
+ // Only the selected bus and one equipment page are fetched; the previous
56
+ // rows stay visible until the page for a new operating point arrives.
57
+ let current = $state.raw<McPfDetail | null>(null);
58
+ let requested = 0;
59
+ $effect(() => {
60
+ const fetchDetail = load;
61
+ const query: McPfDetailQuery = {
62
+ bus: busId || null,
63
+ element: edgeId || null,
64
+ port_offset: portPage * size,
65
+ port_limit: size
66
+ };
67
+ void result;
68
+ if (!fetchDetail) {
69
+ current = null;
70
+ return;
71
+ }
72
+ const seq = ++requested;
73
+ fetchDetail(query).then(
74
+ (next) => {
75
+ if (seq !== requested || !next) return;
76
+ if (next.element_port_total > 0 && query.port_offset! >= next.element_port_total) {
77
+ portPage = 0;
78
+ return;
79
+ }
80
+ current = next;
81
+ },
82
+ () => {}
83
+ );
84
+ });
85
+ const neutral = $derived(
86
+ current?.terminals.find((t) => t.bus === busId && t.terminal === neutralName)?.voltage
87
+ );
88
+ const sourcePower = $derived(summary.source_power_into_network);
89
+ const passiveLoss = $derived(summary.passive_loss.re);
47
90
  const terminals = $derived(
48
- result.terminals.filter(
91
+ (current?.terminals ?? []).filter(
49
92
  (row) =>
50
93
  (!busId || row.bus === busId) &&
51
94
  `${row.bus} ${row.terminal}`.toLowerCase().includes(search.toLowerCase())
@@ -54,13 +97,13 @@
54
97
  const pageCount = $derived(Math.max(1, Math.ceil(terminals.length / size)));
55
98
  const currentPage = $derived(Math.min(page, pageCount - 1));
56
99
  const visible = $derived(terminals.slice(currentPage * size, (currentPage + 1) * size));
57
- const ports = $derived(result.element_ports.filter((row) => !edgeId || row.element === edgeId));
58
- let portPage = $state(0);
59
- const portPages = $derived(Math.max(1, Math.ceil(ports.length / size)));
100
+ const portTotal = $derived(current?.element_port_total ?? 0);
101
+ const portPages = $derived(Math.max(1, Math.ceil(portTotal / size)));
60
102
  const currentPortPage = $derived(Math.min(portPage, portPages - 1));
61
- const visiblePorts = $derived(ports.slice(currentPortPage * size, (currentPortPage + 1) * size));
103
+ const visiblePorts = $derived(current?.element_ports ?? []);
62
104
  const magnitude = (value: McComplex) => Math.hypot(value.re, value.im);
63
105
  const fixed = (value: number) => (Number.isFinite(value) ? value.toFixed(3) : '-');
106
+ const ms = (value: number | null) => (value === null ? '-' : `${value.toFixed(1)} ms`);
64
107
  const angle = (value: McComplex) =>
65
108
  magnitude(value) > 1e-12 ? ((Math.atan2(value.im, value.re) * 180) / Math.PI).toFixed(2) : '-';
66
109
  </script>
@@ -68,7 +111,7 @@
68
111
  <section aria-label="AC power flow results" class="mc-results">
69
112
  <div class="result-heading">
70
113
  <strong>AC power flow</strong><span
71
- >{result.iterations} iterations{elapsedMs === null
114
+ >{summary.iterations} iterations{elapsedMs === null
72
115
  ? ''
73
116
  : `, ${Math.round(elapsedMs)} ms`}</span
74
117
  >
@@ -76,15 +119,15 @@
76
119
  <dl class="totals">
77
120
  <div>
78
121
  <dt>Status</dt>
79
- <dd>{result.converged ? 'Converged' : 'Not converged'}</dd>
122
+ <dd>{summary.converged ? 'Converged' : 'Not converged'}</dd>
80
123
  </div>
81
124
  <div>
82
125
  <dt>Voltage band</dt>
83
- <dd>{result.voltage_valid ? 'Valid' : `${result.voltage_violations.length} violations`}</dd>
126
+ <dd>{summary.voltage_valid ? 'Valid' : `${summary.voltage_violation_count} violations`}</dd>
84
127
  </div>
85
128
  <div>
86
129
  <dt>Load voltage range</dt>
87
- <dd>{result.min_voltage_pu == null || result.max_voltage_pu == null ? 'n/a' : `${fixed(result.min_voltage_pu)}–${fixed(result.max_voltage_pu)} pu`}</dd>
130
+ <dd>{summary.min_voltage_pu == null || summary.max_voltage_pu == null ? 'n/a' : `${fixed(summary.min_voltage_pu)}–${fixed(summary.max_voltage_pu)} pu`}</dd>
88
131
  </div>
89
132
  <div>
90
133
  <dt>Source P / Q</dt>
@@ -212,7 +255,7 @@
212
255
  </table>
213
256
  </div>
214
257
  <div class="pagination">
215
- <span>{ports.length} connections</span>{#if portPages > 1}<button
258
+ <span>{portTotal} connections</span>{#if portPages > 1}<button
216
259
  disabled={currentPortPage === 0}
217
260
  onclick={() => (portPage = currentPortPage - 1)}
218
261
  aria-label="Previous currents">Previous</button
@@ -228,28 +271,48 @@
228
271
  <dl>
229
272
  <div>
230
273
  <dt>Maximum KCL residual</dt>
231
- <dd>{result.physical_kcl_residual.toExponential(3)} A</dd>
274
+ <dd>{summary.physical_kcl_residual.toExponential(3)} A</dd>
232
275
  </div>
233
276
  <div>
234
277
  <dt>Scaled KCL residual</dt>
235
- <dd>{result.scaled_kcl_residual.toExponential(3)}</dd>
278
+ <dd>{summary.scaled_kcl_residual.toExponential(3)}</dd>
236
279
  </div>
237
280
  <div>
238
281
  <dt>Voltage change</dt>
239
- <dd>{result.voltage_change.toExponential(3)} V</dd>
282
+ <dd>{summary.voltage_change.toExponential(3)} V</dd>
240
283
  </div>
241
284
  <div>
242
285
  <dt>Matrix size</dt>
243
- <dd>{result.matrix_dimension}</dd>
286
+ <dd>{summary.matrix_dimension}</dd>
244
287
  </div>
245
288
  <div>
246
289
  <dt>Nonzero entries</dt>
247
- <dd>{result.matrix_nonzeros}</dd>
290
+ <dd>{summary.matrix_nonzeros}</dd>
248
291
  </div>
249
292
  <div>
250
293
  <dt>Factorizations</dt>
251
- <dd>{result.factorization_count}</dd>
294
+ <dd>{summary.factorization_count}</dd>
252
295
  </div>
296
+ {#if timing}
297
+ <div>
298
+ <dt>Engine solve</dt>
299
+ <dd>{ms(timing.engine_ms)}</dd>
300
+ </div>
301
+ <div>
302
+ <dt>Transfer and parse</dt>
303
+ <dd>
304
+ {ms(
305
+ timing.engine_ms === null
306
+ ? null
307
+ : timing.round_trip_ms - timing.engine_ms + timing.parse_ms
308
+ )}
309
+ </dd>
310
+ </div>
311
+ <div>
312
+ <dt>Total update</dt>
313
+ <dd>{ms(timing.total_ms)}</dd>
314
+ </div>
315
+ {/if}
253
316
  </dl>
254
317
  <p>
255
318
  Voltage is measured to ground. Net current is the injection into the network. The calculation
@@ -1,6 +1,12 @@
1
- import type { McPfResult, DistGraph } from '@tellegen/engine';
1
+ import { type DistGraph, type McPfDetail, type McPfDetailQuery, type McPfResult, type McPfSummary } from '@tellegen/engine';
2
+ import type { McEditTiming } from '../state.svelte.js';
2
3
  type $$ComponentProps = {
3
- result: McPfResult;
4
+ /** A live session's summary, or a stored full result. */
5
+ result: McPfSummary | McPfResult;
6
+ /** Fetches one bus's terminals and a page of equipment ports. A full
7
+ * `result` serves its own pages when this is omitted. */
8
+ detail?: (query: McPfDetailQuery) => Promise<McPfDetail | null>;
9
+ timing?: McEditTiming | null;
4
10
  elapsedMs: number | null;
5
11
  selectedBus: string | null;
6
12
  selectedEdge: string | null;
@@ -138,7 +138,7 @@
138
138
  display: grid;
139
139
  gap: 6px;
140
140
  margin: 12px 0;
141
- font-size: 11px;
141
+ font-size: 13px;
142
142
  }
143
143
  input {
144
144
  min-width: 0;
@@ -158,7 +158,7 @@
158
158
  table {
159
159
  width: 100%;
160
160
  border-collapse: collapse;
161
- font-size: 10px;
161
+ font-size: 12px;
162
162
  }
163
163
  caption {
164
164
  text-align: left;
@@ -194,7 +194,7 @@
194
194
  justify-content: space-between;
195
195
  gap: 8px;
196
196
  margin: 10px 0;
197
- font-size: 10px;
197
+ font-size: 12px;
198
198
  color: var(--text-secondary);
199
199
  }
200
200
  button {
@@ -218,13 +218,13 @@
218
218
  }
219
219
  .references {
220
220
  margin-top: 12px;
221
- font-size: 10px;
221
+ font-size: 12px;
222
222
  }
223
223
  .references dl > div {
224
224
  grid-template-columns: 42px minmax(0, 1fr);
225
225
  }
226
226
  code {
227
227
  overflow-wrap: anywhere;
228
- font: 10px/1.5 var(--font-mono);
228
+ font: 12px/1.5 var(--font-mono);
229
229
  }
230
230
  </style>
@@ -306,7 +306,7 @@
306
306
 
307
307
  .tag {
308
308
  margin: 0 0 10px;
309
- font-size: 10px;
309
+ font-size: 12px;
310
310
  color: var(--text-secondary);
311
311
  letter-spacing: 0;
312
312
  text-transform: uppercase;
@@ -353,7 +353,7 @@
353
353
  background: color-mix(in srgb, var(--tc) 26%, transparent);
354
354
  color: var(--ink);
355
355
  font-family: var(--font-mono);
356
- font-size: 11px;
356
+ font-size: 13px;
357
357
  }
358
358
 
359
359
  .term.grounded {
@@ -377,7 +377,7 @@
377
377
  border-radius: 3px;
378
378
  background: var(--bc);
379
379
  color: #fff;
380
- font-size: 10px;
380
+ font-size: 12px;
381
381
  letter-spacing: 0;
382
382
  }
383
383
 
@@ -389,7 +389,7 @@
389
389
 
390
390
  .load-editor h4 {
391
391
  margin: 0 0 8px;
392
- font-size: 11px;
392
+ font-size: 13px;
393
393
  }
394
394
 
395
395
  .load-row {
@@ -404,7 +404,7 @@
404
404
  display: flex;
405
405
  flex-direction: column;
406
406
  gap: 2px;
407
- font-size: 9px;
407
+ font-size: 12px;
408
408
  color: var(--text-secondary);
409
409
  }
410
410
 
@@ -412,11 +412,11 @@
412
412
  min-width: 0;
413
413
  width: 100%;
414
414
  box-sizing: border-box;
415
- font: 10px var(--font-mono);
415
+ font: 12px var(--font-mono);
416
416
  }
417
417
 
418
418
  .load-row button {
419
- font-size: 9px;
419
+ font-size: 12px;
420
420
  }
421
421
 
422
422
  /* The global stylesheet's .legend is the 6px color-ramp strip; its height
@@ -426,7 +426,7 @@
426
426
  flex-direction: column;
427
427
  gap: 4px;
428
428
  margin-top: 12px;
429
- font-size: 10px;
429
+ font-size: 12px;
430
430
  color: var(--text-secondary);
431
431
  }
432
432
 
@@ -469,7 +469,7 @@
469
469
 
470
470
  .footnote {
471
471
  margin: 8px 0 0;
472
- font-size: 10px;
472
+ font-size: 12px;
473
473
  color: var(--text-tertiary);
474
474
  letter-spacing: 0;
475
475
  }
@@ -12,14 +12,16 @@
12
12
  const observed = new WeakSet<object>();
13
13
  $effect(() => {
14
14
  const message = app.error;
15
+ const detail = app.errorDetail;
15
16
  const revision = app.errorRevision;
16
17
  const retry = app.errorRetry;
17
18
  if (message)
18
19
  untrack(() =>
19
20
  notices.push({
20
21
  kind: 'error',
21
- title: errorNoticeTitle(message),
22
- details: message,
22
+ // A message with its own detail is already written for people.
23
+ title: detail ? message : errorNoticeTitle(message),
24
+ details: detail ?? message,
23
25
  retry: () => ctrl.retryError(),
24
26
  canRetry: () =>
25
27
  app.errorRevision === revision && app.error === message && app.errorRetry === retry
@@ -1,6 +1,11 @@
1
1
  <script lang="ts">
2
2
  import { tick } from 'svelte';
3
- import { getPanelLayout, type PanelRegistration, type PanelSide } from '../panels.svelte.js';
3
+ import {
4
+ focusFirst,
5
+ getPanelLayout,
6
+ type PanelRegistration,
7
+ type PanelSide
8
+ } from '../panels.svelte.js';
4
9
  let {
5
10
  panel,
6
11
  floating = false,
@@ -12,6 +17,7 @@
12
17
  } = $props();
13
18
  const layout = getPanelLayout();
14
19
  let menu = $state(false);
20
+ let menuAnchor: HTMLDivElement | undefined = $state();
15
21
  let card: HTMLElement;
16
22
  const bounds = $derived(layout.bounds(panel));
17
23
  const preferred = $derived(!floating && !mobile && layout.preferred(panel));
@@ -86,11 +92,40 @@
86
92
  }
87
93
  async function close() {
88
94
  const title = panel.title;
95
+ const position = layout.position(panel);
96
+ const dock = position === 'floating' ? panel.side : position;
97
+ const opener = layout.opener(panel);
89
98
  layout.close(panel);
90
99
  await tick();
91
- document
92
- .querySelector<HTMLElement>(`.panel-toolbar button[aria-label="${CSS.escape(title)}"]`)
93
- ?.focus();
100
+ // The toolbar button for the panel; a host without the toolbar gets the
101
+ // control that opened it, else the panel's dock (the drawer when narrow).
102
+ focusFirst([
103
+ document.querySelector<HTMLElement>(
104
+ `.panel-toolbar button[aria-label="${CSS.escape(title)}"]`
105
+ ),
106
+ opener,
107
+ document.querySelector<HTMLElement>(`[data-panel-dock="${dock}"], [data-panel-drawer]`)
108
+ ]);
109
+ }
110
+ // Keyboard users can scroll an overflowing panel only through focus, so the
111
+ // content becomes a labelled, focusable region exactly while it overflows.
112
+ let overflowing = $state(false);
113
+ function trackOverflow(content: HTMLElement) {
114
+ const sync = () => (overflowing = content.scrollHeight > content.clientHeight + 1);
115
+ const resize = new ResizeObserver(sync);
116
+ const observe = () => {
117
+ resize.disconnect();
118
+ resize.observe(content);
119
+ for (const child of content.children) resize.observe(child);
120
+ sync();
121
+ };
122
+ const children = new MutationObserver(observe);
123
+ children.observe(content, { childList: true });
124
+ observe();
125
+ return () => {
126
+ resize.disconnect();
127
+ children.disconnect();
128
+ };
94
129
  }
95
130
  </script>
96
131
 
@@ -98,6 +133,9 @@
98
133
  onkeydown={(event) => {
99
134
  if (event.key === 'Escape') menu = false;
100
135
  }}
136
+ onpointerdown={(event) => {
137
+ if (menu && !menuAnchor?.contains(event.target as Node)) menu = false;
138
+ }}
101
139
  />
102
140
  <aside
103
141
  bind:this={card}
@@ -127,7 +165,7 @@
127
165
  >
128
166
  {/if}
129
167
  <div class="header-actions">{@render panel.headerActions?.()}</div>
130
- {#if !mobile}<div class="menu-anchor">
168
+ {#if !mobile}<div class="menu-anchor" bind:this={menuAnchor}>
131
169
  <button
132
170
  class="icon"
133
171
  aria-label={`${panel.title} panel options`}
@@ -150,7 +188,7 @@
150
188
  </div>{/if}
151
189
  </div>{/if}
152
190
  <button
153
- class="icon"
191
+ class="icon close"
154
192
  aria-label={`Close ${panel.title.toLowerCase()} panel`}
155
193
  title="Close panel"
156
194
  onclick={close}
@@ -160,7 +198,18 @@
160
198
  >
161
199
  </button>
162
200
  </header>
163
- <div class="panel-content">{@render panel.children()}</div>
201
+ <!-- A scroll container must take focus for keyboard scrolling, and this one
202
+ takes it only while it overflows. -->
203
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
204
+ <div
205
+ class="panel-content"
206
+ {@attach trackOverflow}
207
+ tabindex={overflowing ? 0 : undefined}
208
+ role={overflowing ? 'region' : undefined}
209
+ aria-label={overflowing ? `${panel.title} panel content` : undefined}
210
+ >
211
+ {@render panel.children()}
212
+ </div>
164
213
  {#if floating}<button
165
214
  class="resize"
166
215
  aria-label={`Resize ${panel.title} panel`}
@@ -238,6 +287,19 @@
238
287
  .icon svg {
239
288
  fill: currentColor;
240
289
  }
290
+ /* Coarse pointers: a 44px hit area around the 26x28 close button. The
291
+ pseudo-element reaches into the header padding and the gap, so nothing
292
+ moves. */
293
+ @media (pointer: coarse) {
294
+ .close {
295
+ position: relative;
296
+ }
297
+ .close::after {
298
+ content: '';
299
+ position: absolute;
300
+ inset: -8px -10px -8px -8px;
301
+ }
302
+ }
241
303
  .header-actions {
242
304
  display: flex;
243
305
  align-items: center;
@@ -258,6 +320,9 @@
258
320
  flex-direction: column;
259
321
  border-radius: 0 0 5px 5px;
260
322
  }
323
+ .panel-content:focus-visible {
324
+ outline-offset: -2px;
325
+ }
261
326
  .menu-anchor {
262
327
  position: relative;
263
328
  }
@@ -4,6 +4,12 @@
4
4
  import { getPanelLayout } from '../panels.svelte.js';
5
5
  import { getNoticeCenter } from '../notices.svelte.js';
6
6
  import PanelCard from './PanelCard.svelte';
7
+
8
+ // The dock insets come from `--tellegen-dock-left/right/bottom` on an
9
+ // ancestor (defaults 20px, 20px, 100px), so a custom shell with its own
10
+ // rail or status bar can move the docks without restyling them.
11
+ let { showToolbar = true }: { showToolbar?: boolean } = $props();
12
+
7
13
  const app = getAppState();
8
14
  const layout = getPanelLayout();
9
15
  const notices = getNoticeCenter();
@@ -55,43 +61,59 @@
55
61
  onpointerup={() => layout.finishGesture()}
56
62
  onpointercancel={() => layout.finishGesture()}
57
63
  />
58
- <div class="panel-toolbar" style:top="{toolbarTop}px" aria-label="Panels">
59
- <div class="panel-buttons">
60
- {#each layout.ordered().filter((p) => p.side === 'left') as panel (panel.id)}
61
- <button
62
- aria-label={panel.title}
63
- aria-pressed={layout.compact ? layout.drawer === panel.id : panel.open}
64
- onclick={() => layout.toggle(panel)}>{panel.title}</button
65
- >
66
- {/each}
67
- </div>
68
- <div class="panel-buttons right">
69
- {#each layout.ordered().filter((p) => p.side === 'right') as panel (panel.id)}
64
+ {#if showToolbar}
65
+ <div class="panel-toolbar" style:top="{toolbarTop}px" aria-label="Panels">
66
+ <div class="panel-buttons">
67
+ {#each layout.ordered().filter((p) => p.side === 'left') as panel (panel.id)}
68
+ <button
69
+ aria-label={panel.title}
70
+ aria-pressed={layout.compact ? layout.drawer === panel.id : panel.open}
71
+ onclick={() => layout.toggle(panel)}>{panel.title}</button
72
+ >
73
+ {/each}
74
+ </div>
75
+ <div class="panel-buttons right">
76
+ {#each layout.ordered().filter((p) => p.side === 'right') as panel (panel.id)}
77
+ <button
78
+ aria-label={panel.title}
79
+ aria-pressed={layout.compact ? layout.drawer === panel.id : panel.open}
80
+ onclick={() => layout.toggle(panel)}>{panel.title}</button
81
+ >
82
+ {/each}
70
83
  <button
71
- aria-label={panel.title}
72
- aria-pressed={layout.compact ? layout.drawer === panel.id : panel.open}
73
- onclick={() => layout.toggle(panel)}>{panel.title}</button
84
+ class="reset-layout"
85
+ onclick={() => layout.reset()}
86
+ title="Restore the default panel positions">Reset layout</button
74
87
  >
75
- {/each}
76
- <button
77
- class="reset-layout"
78
- onclick={() => layout.reset()}
79
- title="Restore the default panel positions">Reset layout</button
80
- >
88
+ </div>
81
89
  </div>
82
- </div>
90
+ {/if}
83
91
  {#if layout.storageError}<p class="layout-error" role="status" style:top="{panelTop}px">
84
92
  {layout.storageError}
85
93
  </p>{/if}
94
+ <!-- The docks and the drawer take focus (tabindex -1) when a closed panel has
95
+ no toolbar button or opener to hand it back to. The drawer stays rendered,
96
+ at zero height, while no panel is open in it. -->
86
97
  {#if layout.compact}
87
- {#if drawer}<div class="drawer" style:bottom="{bottomReserve}px" style:height="{drawerHeight}px">
88
- <PanelCard panel={drawer} mobile />
89
- </div>{/if}
98
+ <div
99
+ class="drawer"
100
+ data-panel-drawer
101
+ role="region"
102
+ aria-label="Panels"
103
+ tabindex="-1"
104
+ style:bottom="{bottomReserve}px"
105
+ style:height="{drawer ? drawerHeight : 0}px"
106
+ >
107
+ {#if drawer}<PanelCard panel={drawer} mobile />{/if}
108
+ </div>
90
109
  {:else}
91
110
  <div
92
111
  class="dock left"
93
- style:bottom="{Math.max(100, bottomReserve)}px"
112
+ style:bottom="max(var(--tellegen-dock-bottom, 100px), {bottomReserve}px)"
94
113
  data-panel-dock="left"
114
+ role="region"
115
+ aria-label="Left panels"
116
+ tabindex="-1"
95
117
  style:top="{panelTop}px"
96
118
  style:width="{layout.dockWidth('left')}px"
97
119
  >
@@ -99,8 +121,11 @@
99
121
  </div>
100
122
  <div
101
123
  class="dock right"
102
- style:bottom="{Math.max(100, bottomReserve)}px"
124
+ style:bottom="max(var(--tellegen-dock-bottom, 100px), {bottomReserve}px)"
103
125
  data-panel-dock="right"
126
+ role="region"
127
+ aria-label="Right panels"
128
+ tabindex="-1"
104
129
  style:top="{panelTop}px"
105
130
  style:width="{layout.dockWidth('right')}px"
106
131
  >
@@ -113,8 +138,8 @@
113
138
  .panel-toolbar {
114
139
  position: fixed;
115
140
  z-index: 30;
116
- left: 20px;
117
- right: 20px;
141
+ left: var(--tellegen-dock-left, 20px);
142
+ right: var(--tellegen-dock-right, 20px);
118
143
  display: flex;
119
144
  justify-content: space-between;
120
145
  align-items: center;
@@ -159,7 +184,7 @@
159
184
  .dock {
160
185
  position: fixed;
161
186
  z-index: 25;
162
- bottom: 100px;
187
+ bottom: var(--tellegen-dock-bottom, 100px);
163
188
  display: flex;
164
189
  flex-direction: column;
165
190
  align-items: stretch;
@@ -167,10 +192,10 @@
167
192
  pointer-events: none;
168
193
  }
169
194
  .dock.left {
170
- left: 20px;
195
+ left: var(--tellegen-dock-left, 20px);
171
196
  }
172
197
  .dock.right {
173
- right: 20px;
198
+ right: var(--tellegen-dock-right, 20px);
174
199
  }
175
200
  .drawer {
176
201
  position: fixed;
@@ -194,8 +219,8 @@
194
219
  }
195
220
  @media (max-width: 960px) {
196
221
  .panel-toolbar {
197
- left: 12px;
198
- right: 12px;
222
+ left: var(--tellegen-dock-left, 12px);
223
+ right: var(--tellegen-dock-right, 12px);
199
224
  gap: 6px;
200
225
  }
201
226
  .panel-buttons {
@@ -1,3 +1,6 @@
1
- declare const PanelHost: import("svelte").Component<Record<string, never>, {}, "">;
1
+ type $$ComponentProps = {
2
+ showToolbar?: boolean;
3
+ };
4
+ declare const PanelHost: import("svelte").Component<$$ComponentProps, {}, "">;
2
5
  type PanelHost = ReturnType<typeof PanelHost>;
3
6
  export default PanelHost;
@@ -21,7 +21,7 @@
21
21
  border-radius: 3px;
22
22
  color: var(--text-accent);
23
23
  font-size: 11px;
24
- box-shadow: 0 4px 18px rgba(32, 36, 43, 0.1);
24
+ box-shadow: var(--elev-cue);
25
25
  pointer-events: none;
26
26
  }
27
27