better-rtplot 0.5.0__tar.gz → 0.6.0__tar.gz

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 (40) hide show
  1. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/PKG-INFO +2 -1
  2. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/README.md +1 -0
  3. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/pyproject.toml +1 -1
  4. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/client.py +22 -7
  5. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/server_browser.py +5 -1
  6. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/index.html +90 -32
  7. better_rtplot-0.6.0/rtplot/static/presentation.css +168 -0
  8. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/ui-view.js +51 -9
  9. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/ui_state.py +64 -3
  10. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/LICENSE +0 -0
  11. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/interactive_test.py +0 -0
  12. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/saved_plots/.gitignore +0 -0
  13. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/server.py +0 -0
  14. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/server_browser_gui.py +0 -0
  15. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/auto-render.min.js +0 -0
  16. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  17. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  18. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  19. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  20. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  21. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  22. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  24. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  25. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  26. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  27. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  28. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  29. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  30. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  31. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  32. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  33. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  34. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  35. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  36. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/katex.min.css +0 -0
  37. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/katex/katex.min.js +0 -0
  38. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/uPlot.iife.min.js +0 -0
  39. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/uPlot.min.css +0 -0
  40. {better_rtplot-0.5.0 → better_rtplot-0.6.0}/rtplot/static/ui-view.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better-rtplot
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary:
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -132,6 +132,7 @@ A rising line now draws itself in the browser tab.
132
132
  - **Remote-friendly.** Sender or plot host can bind. Live Bind /
133
133
  Connect buttons retarget without restart.
134
134
  - **Config lives with the data.** The sender declares plot layout.
135
+ - **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
135
136
  - **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
136
137
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
137
138
  from your loop, no threads, no callbacks.
@@ -107,6 +107,7 @@ A rising line now draws itself in the browser tab.
107
107
  - **Remote-friendly.** Sender or plot host can bind. Live Bind /
108
108
  Connect buttons retarget without restart.
109
109
  - **Config lives with the data.** The sender declares plot layout.
110
+ - **Responsive presentation.** Native phone sizing, essential actions, compact navigation, and explicit semantic styles. See the [presentation guide](docs/presentation.md) and [before/after screenshots](docs/presentation-screenshots/README.md).
110
111
  - **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
111
112
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
112
113
  from your loop, no threads, no callbacks.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "MIT"
@@ -3,7 +3,7 @@ import numpy as np
3
3
  import time
4
4
  import uuid
5
5
  import warnings
6
- from .ui_state import META_KEY, declarations, merge_state
6
+ from .ui_state import META_KEY, declarations, merge_state, presentation_requested
7
7
  from collections import OrderedDict, namedtuple
8
8
  from dataclasses import dataclass, field
9
9
  from typing import List, Optional, Tuple, Union
@@ -162,6 +162,7 @@ class Plot:
162
162
  xrange: Optional[int] = None
163
163
  height: Optional[float] = None
164
164
  section: Optional[str] = None
165
+ min_height: Optional[int] = None
165
166
 
166
167
  def to_dict(self):
167
168
  return _drop_none({
@@ -176,7 +177,7 @@ class Plot:
176
177
  "yrange": _range_to_list(self.yrange),
177
178
  "xrange": self.xrange,
178
179
  "height": self.height,
179
- "section": self.section,
180
+ "section": self.section, "min_height": self.min_height,
180
181
  })
181
182
 
182
183
 
@@ -209,11 +210,12 @@ class Button:
209
210
  label: str
210
211
  color: Optional[str] = None
211
212
  height: Optional[float] = None
213
+ appearance: Optional[str] = None
212
214
 
213
215
  def to_dict(self):
214
216
  return _drop_none({
215
217
  "type": "button", "id": self.id, "label": self.label,
216
- "color": self.color, "height": self.height,
218
+ "color": self.color, "height": self.height, "appearance": self.appearance,
217
219
  })
218
220
 
219
221
 
@@ -267,11 +269,12 @@ class Display:
267
269
  label: str
268
270
  format: Optional[str] = None
269
271
  height: Optional[float] = None
272
+ appearance: Optional[str] = None
270
273
 
271
274
  def to_dict(self):
272
275
  return _drop_none({
273
276
  "type": "display", "id": self.id, "label": self.label,
274
- "format": self.format, "height": self.height,
277
+ "format": self.format, "height": self.height, "appearance": self.appearance,
275
278
  })
276
279
 
277
280
 
@@ -281,11 +284,12 @@ class Text:
281
284
  label: str
282
285
  value: str = ""
283
286
  height: Optional[float] = None
287
+ appearance: Optional[str] = None
284
288
 
285
289
  def to_dict(self):
286
290
  return _drop_none({
287
291
  "type": "text", "id": self.id, "label": self.label,
288
- "value": self.value, "height": self.height,
292
+ "value": self.value, "height": self.height, "appearance": self.appearance,
289
293
  })
290
294
 
291
295
 
@@ -312,12 +316,14 @@ class ControlsRow:
312
316
  controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
313
317
 
314
318
  section: Optional[str] = None
319
+ title: Optional[str] = None
320
+ exclusive: Optional[bool] = None
315
321
 
316
322
  def to_dict(self):
317
323
  return _drop_none({"controls": [
318
324
  c.to_dict() if hasattr(c, "to_dict") else c
319
325
  for c in self.controls
320
- ], "section": self.section})
326
+ ], "section": self.section, "title": self.title, "exclusive": self.exclusive})
321
327
 
322
328
 
323
329
  @dataclass
@@ -328,6 +334,9 @@ class Section:
328
334
  collapsible: bool = False
329
335
  collapsed: bool = False
330
336
  visible: bool = True
337
+ navigation: str = "primary"
338
+ density: str = "normal"
339
+ show_heading: bool = True
331
340
 
332
341
  def to_dict(self):
333
342
  return dict(vars(self))
@@ -337,11 +346,12 @@ class Section:
337
346
  class View:
338
347
  sections: List[Union[Section, dict]]
339
348
  persistent_section: Optional[str] = None
349
+ essential_controls: Optional[List[str]] = None
340
350
 
341
351
  def to_dict(self):
342
352
  return _drop_none({"sections": [s.to_dict() if hasattr(s, "to_dict") else s
343
353
  for s in self.sections],
344
- "persistent_section": self.persistent_section})
354
+ "persistent_section": self.persistent_section, "essential_controls": self.essential_controls})
345
355
 
346
356
 
347
357
  def local_plot():
@@ -600,6 +610,11 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0, *, view=None, u
600
610
  "confirm support: layout may be flat and disabled states are NOT enforced.",
601
611
  RuntimeWarning, stacklevel=2)
602
612
 
613
+ if presentation_requested(plot_desc_dict, _view) and "presentation_v1" not in _server_capabilities:
614
+ warnings.warn("Presentation options require rtplot browser server >= 0.6.0. This server did not "
615
+ "confirm support; essential placement, groups, and styling may be ignored.",
616
+ RuntimeWarning, stacklevel=2)
617
+
603
618
 
604
619
  def _send_initialize_with_handshake(cfg, timeout):
605
620
  """Publish ``cfg`` and block until the server acks it.
@@ -622,6 +622,8 @@ def control_allowed(tab, payload, kind):
622
622
  state = {**CONTROL_DEFAULTS, **tab.ui_state.get("controls", {}).get(cid, {})}
623
623
  if not state["enabled"] or not state["visible"]:
624
624
  return False
625
+ if cid in tab.view.get("essential_controls", []):
626
+ return True
625
627
  sid = info.get("section")
626
628
  section = next((s for s in tab.view.get("sections", []) if s["id"] == sid), {})
627
629
  return tab.ui_state.get("sections", {}).get(sid, {}).get("visible", section.get("visible", True))
@@ -649,7 +651,8 @@ def _expand_config(config):
649
651
  plots.append((f"{key}/{i}", child))
650
652
  layout.append({"kind": "row", "columns": columns, "plots": indices, "section": entry.get("section")})
651
653
  elif "controls" in entry:
652
- layout.append({"kind": "controls", "index": len(controls), "section": entry.get("section")})
654
+ layout.append({"kind": "controls", "index": len(controls), "section": entry.get("section"),
655
+ "title": entry.get("title"), "exclusive": entry.get("exclusive", False)})
653
656
  controls.append(entry["controls"])
654
657
  else:
655
658
  layout.append({"kind": "plot", "index": len(plots), "section": entry.get("section")})
@@ -1574,6 +1577,7 @@ _INDEX_HTML = _read_static_asset("index.html")
1574
1577
  for _asset in (
1575
1578
  "ui-view.js",
1576
1579
  "ui-view.css",
1580
+ "presentation.css",
1577
1581
  "uPlot.min.css",
1578
1582
  "uPlot.iife.min.js",
1579
1583
  "katex/katex.min.css",
@@ -2,6 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
5
6
  <title>rtplot</title>
6
7
  <link rel="stylesheet" href="/static/uPlot.min.css" />
7
8
  <link rel="stylesheet" href="/static/ui-view.css" />
@@ -143,6 +144,7 @@
143
144
  .res-stats .rs-tab .rs-hz { color: #2a5db0; font-weight: 500; }
144
145
  .res-unavail { font-size: calc(11px * var(--ui-scale)); color: #999; font-style: italic; }
145
146
  </style>
147
+ <link rel="stylesheet" href="/static/presentation.css" />
146
148
  </head>
147
149
  <body>
148
150
  <div id="tabbar"></div>
@@ -154,12 +156,13 @@
154
156
  </div>
155
157
  <div id="header">
156
158
  <h1>rtplot</h1>
157
- <div id="status" class="green">Rate: -- Hz</div>
158
- <div id="zmq-mode">ZMQ: --</div>
159
159
  <div id="ws-status">connecting...</div>
160
160
  <button id="menu-btn" class="btn" title="Settings" aria-label="Settings">&#9776;</button>
161
161
  </div>
162
162
  <div id="menu-panel" aria-hidden="true">
163
+ <h2>Connection and performance</h2>
164
+ <div id="status" class="green">Rate: -- Hz</div>
165
+ <div id="zmq-mode">ZMQ: --</div>
163
166
  <h2>Settings</h2>
164
167
  <div class="menu-row">
165
168
  <label for="menu-font">UI font scale</label>
@@ -391,12 +394,25 @@
391
394
  }
392
395
  menuBtn.addEventListener('click', (e) => {
393
396
  e.stopPropagation();
394
- menuPanel.classList.toggle('open');
397
+ const open = menuPanel.classList.toggle('open');
398
+ const top = Math.max(document.getElementById('header').getBoundingClientRect().bottom,
399
+ viewManager?.sticky.getBoundingClientRect().bottom || 0) + 8;
400
+ menuPanel.style.top = `${top}px`;
401
+ menuPanel.style.maxHeight = `calc(100dvh - ${top + 8}px)`;
402
+ menuPanel.setAttribute('aria-hidden', String(!open));
403
+ menuBtn.setAttribute('aria-expanded', String(open));
404
+ });
405
+ document.addEventListener('keydown', e => {
406
+ if (e.key === 'Escape' && menuPanel.classList.contains('open')) {
407
+ menuPanel.classList.remove('open'); menuPanel.setAttribute('aria-hidden', 'true');
408
+ menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus();
409
+ }
395
410
  });
396
411
  document.addEventListener('click', (e) => {
397
412
  if (!menuPanel.classList.contains('open')) return;
398
413
  if (menuPanel.contains(e.target) || menuBtn.contains(e.target)) return;
399
414
  menuPanel.classList.remove('open');
415
+ menuPanel.setAttribute('aria-hidden', 'true'); menuBtn.setAttribute('aria-expanded', 'false');
400
416
  });
401
417
  menuFontInput.addEventListener('input', () => {
402
418
  settings.fontScale = Number(menuFontInput.value);
@@ -445,14 +461,22 @@
445
461
  let activeLayout = null;
446
462
  const controlElements = { displays: {}, displayFormats: {}, displayKinds: {}, sliders: {}, textInputs: {} };
447
463
 
464
+ const presentationTargets = '.section-nav,.section-header,#header,#menu-panel,#tabbar,.uplot';
465
+ let presentationPointer = false;
466
+ document.addEventListener('pointerdown', e => {
467
+ presentationPointer = Boolean(e.target.closest(presentationTargets));
468
+ setTimeout(() => { presentationPointer = false; }, 0);
469
+ }, true);
448
470
  function sendCtrl(msg) {
449
471
  if (msg.type.startsWith('control_')) {
450
- if (!viewManager || !viewManager.canInteract(msg.id)) return;
472
+ if (!viewManager || !viewManager.canInteract(msg.id)) return false;
451
473
  msg = {...msg, session: activeLayout?.session, generation: activeLayout?.generation};
452
474
  }
453
475
  if (socket && socket.readyState === WebSocket.OPEN) {
454
476
  socket.send(JSON.stringify(msg));
477
+ return true;
455
478
  }
479
+ return false;
456
480
  }
457
481
 
458
482
  function parseDisplayFormat(fmt) {
@@ -678,7 +702,7 @@
678
702
  const b = document.createElement('button');
679
703
  b.className = 'ctrl-btn';
680
704
  b.textContent = el.label || el.id;
681
- if (el.color != null) {
705
+ if (el.color != null && (!el.appearance || el.appearance === 'default')) {
682
706
  const c = resolveColor(el.color);
683
707
  b.style.backgroundColor = c;
684
708
  b.style.borderColor = c;
@@ -735,9 +759,16 @@
735
759
  if (el.max_length !== undefined && Number.isFinite(Number(el.max_length))) {
736
760
  input.maxLength = Number(el.max_length);
737
761
  }
738
- const commit = () => sendCtrl({ type: 'control_text', id: el.id, value: input.value });
739
- input.addEventListener('change', commit);
740
- input.addEventListener('blur', commit);
762
+ let committed = input.value;
763
+ const commit = () => {
764
+ if (input.value === committed) return;
765
+ if (sendCtrl({ type: 'control_text', id: el.id, value: input.value })) committed = input.value;
766
+ };
767
+ input.addEventListener('blur', e => {
768
+ // Moving into presentation UI must preserve a draft without sending it.
769
+ if (presentationPointer || e.relatedTarget?.closest(presentationTargets)) return;
770
+ commit();
771
+ });
741
772
  input.addEventListener('keydown', (e) => {
742
773
  if (e.key === 'Enter') {
743
774
  e.preventDefault();
@@ -765,12 +796,20 @@
765
796
  }
766
797
  }
767
798
 
768
- function buildControlRow(row, section) {
799
+ function buildControlRow(row, section, options={}) {
769
800
  const div = document.createElement('div');
770
801
  div.className = 'ctrl-row';
802
+ if (options.title) {
803
+ const title = document.createElement('h3'); title.className = 'control-group-title'; title.textContent = options.title;
804
+ div.appendChild(title); div.setAttribute('role', 'group'); div.setAttribute('aria-label', options.title);
805
+ }
806
+ if (options.exclusive) div.classList.add('exclusive-group');
771
807
  row.forEach(el => {
772
808
  const item = buildControlElement(el);
773
809
  applyElementSize(item, el);
810
+ item.dataset.appearance = el.appearance || 'default';
811
+ if (el.appearance === 'state') item.setAttribute('role', 'status');
812
+ item.querySelectorAll('input,svg.ctrl-dial').forEach(input => input.setAttribute('aria-label', el.label || el.id));
774
813
  div.appendChild(item);
775
814
  viewManager.register(el.id, item, section);
776
815
  });
@@ -850,11 +889,11 @@
850
889
 
851
890
  const series = [{}];
852
891
  for (let t = 0; t < traceCount; t++) {
853
- const dash = (styles[t] === '-') ? [10, 5] : null;
892
+ const dash = ({'-': [10, 5], dashed: [10, 5], dotted: [2, 4], dashdot: [10, 4, 2, 4]})[styles[t]] || null;
854
893
  series.push({
855
894
  label: pcfg.names[t],
856
- stroke: resolveColor(colors[t]),
857
- width: widths[t] || 1,
895
+ stroke: resolveColor(colors[t] || DEFAULT_COLORS[t % DEFAULT_COLORS.length]),
896
+ width: (Array.isArray(widths) ? widths[t] : widths) || 1,
858
897
  dash: dash || undefined,
859
898
  points: { show: false },
860
899
  spanGaps: true,
@@ -864,16 +903,15 @@
864
903
 
865
904
  const baseHeight = rowLayout ? 260 : 320;
866
905
  const heightMul = Number(pcfg.height);
867
- const plotHeight = (Number.isFinite(heightMul) && heightMul > 0)
868
- ? Math.round(baseHeight * heightMul)
869
- : baseHeight;
906
+ const plotHeight = Math.max(pcfg.min_height || 160, (Number.isFinite(heightMul) && heightMul > 0)
907
+ ? Math.round(baseHeight * heightMul) : baseHeight);
870
908
  const wrap = document.createElement('div');
871
909
  wrap.className = 'plot-wrap';
872
910
  parent.appendChild(wrap);
873
911
 
874
912
  let plotState = null;
875
913
  const opts = {
876
- width: Math.max(240, wrap.clientWidth - 16),
914
+ width: Math.max(120, wrap.clientWidth - 16),
877
915
  height: plotHeight,
878
916
  title: pcfg.title || '',
879
917
  scales: {
@@ -886,6 +924,14 @@
886
924
  ],
887
925
  series: series,
888
926
  legend: { show: true },
927
+ hooks: { ready: [u => u.root.querySelectorAll('.u-legend .u-series').forEach((tr, i) => {
928
+ if (!i) return;
929
+ if (traceCount === 1 && pcfg.title === pcfg.names[0]) tr.querySelector('.u-label').classList.add('sr-only');
930
+ tr.tabIndex = 0; tr.setAttribute('role', 'button'); tr.setAttribute('aria-pressed', String(u.series[i].show)); tr.setAttribute('aria-label', `Toggle ${pcfg.names[i-1]}`);
931
+ tr.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tr.querySelector('.u-label').click(); } });
932
+ })], setSeries: [u => u.root.querySelectorAll('.u-legend .u-series').forEach((tr, i) => {
933
+ if (i) tr.setAttribute('aria-pressed', String(u.series[i].show));
934
+ })] },
889
935
  cursor: { drag: { x: false, y: false } },
890
936
  };
891
937
 
@@ -929,7 +975,7 @@
929
975
  plots.forEach(p => {
930
976
  const visible = p.wrap.getClientRects().length > 0;
931
977
  if (visible) {
932
- const width = Math.max(240, p.wrap.clientWidth - 16);
978
+ const width = Math.max(120, p.wrap.clientWidth - 16);
933
979
  if (!p.visible || Math.abs(p.uplot.width - width) > 1) {
934
980
  p.uplot.setSize({width, height: p.height});
935
981
  p.dataDirty = true; p.forceKeepScales = true;
@@ -956,6 +1002,7 @@
956
1002
  function newRow(count, parent) {
957
1003
  const row = document.createElement('div'); row.className = 'plot-row';
958
1004
  row.style.setProperty('--plot-columns', Math.max(1, Math.floor(count)));
1005
+ row.style.setProperty('--compact-columns', Math.max(1, Math.min(2, Math.floor(count))));
959
1006
  parent.appendChild(row); return row;
960
1007
  }
961
1008
  // Trace offsets come ONLY from wire declaration order, never section/DOM order.
@@ -976,9 +1023,10 @@
976
1023
  entry.plots.forEach(index => buildOnePlot(cfg.plots[index], entry.columns === 1, offsets[index], row));
977
1024
  } else if (entry.kind === 'controls') {
978
1025
  implicitRows.delete(parent);
979
- parent.appendChild(buildControlRow(controls[entry.index], entry.section));
1026
+ parent.appendChild(buildControlRow(controls[entry.index], entry.section, entry));
980
1027
  }
981
1028
  });
1029
+ viewManager.finish();
982
1030
  applySliderValues(cfg.slider_values); applyTextValues(cfg.text_values); applyDisplayValues(cfg.display_values);
983
1031
  viewManager.apply(cfg.ui_state || {}, cfg.ui_revision ?? 0);
984
1032
  renderLatexIn(plotsDiv); applyVisibleSamples(); refreshPresentation(); scheduleRender();
@@ -1140,8 +1188,10 @@
1140
1188
 
1141
1189
  // Stacked labels: name on top, endpoint on a muted subline so
1142
1190
  // users can see at a glance which tab is wired to which device.
1143
- const labels = document.createElement('div');
1144
- labels.className = 'tab-labels';
1191
+ const labels = document.createElement('button');
1192
+ labels.className = 'tab-labels tab-select';
1193
+ labels.setAttribute('aria-label', `View ${t.name || id}`);
1194
+ labels.addEventListener('click', e => { e.stopPropagation(); if (id !== activeTab) switchTab(id); });
1145
1195
  const name = document.createElement('span');
1146
1196
  name.className = 'tab-name';
1147
1197
  name.textContent = t.name || id;
@@ -1154,10 +1204,10 @@
1154
1204
  labels.appendChild(ep);
1155
1205
  el.appendChild(labels);
1156
1206
 
1157
- const reconnect = document.createElement('span');
1207
+ const reconnect = document.createElement('button');
1158
1208
  reconnect.className = 'tab-reconnect';
1159
1209
  reconnect.textContent = '\u21BB';
1160
- reconnect.title = 'Reconnect';
1210
+ reconnect.title = 'Reconnect'; reconnect.setAttribute('aria-label', `Reconnect ${t.name || id}`);
1161
1211
  reconnect.addEventListener('click', (e) => {
1162
1212
  e.stopPropagation();
1163
1213
  sendCtrl({ type: 'tab_reconnect', id: id });
@@ -1171,17 +1221,17 @@
1171
1221
  el.appendChild(reconnect);
1172
1222
 
1173
1223
  if (id !== 'bind_me') {
1174
- const ren = document.createElement('span');
1224
+ const ren = document.createElement('button');
1175
1225
  ren.className = 'tab-rename';
1176
1226
  ren.textContent = '\u270E';
1177
- ren.title = 'Rename';
1227
+ ren.title = 'Rename'; ren.setAttribute('aria-label', `Rename ${t.name || id}`);
1178
1228
  ren.addEventListener('click', (e) => { e.stopPropagation(); beginRename(el, id, t.name); });
1179
1229
  el.appendChild(ren);
1180
1230
 
1181
- const close = document.createElement('span');
1231
+ const close = document.createElement('button');
1182
1232
  close.className = 'tab-close';
1183
1233
  close.textContent = '\u00D7';
1184
- close.title = 'Close tab';
1234
+ close.title = 'Close tab'; close.setAttribute('aria-label', `Close ${t.name || id}`);
1185
1235
  close.addEventListener('click', (e) => {
1186
1236
  e.stopPropagation();
1187
1237
  if (confirm(`Close tab "${t.name || id}"?`)) {
@@ -1216,6 +1266,7 @@
1216
1266
  add.className = 'tab-add';
1217
1267
  add.textContent = '+ new device';
1218
1268
  add.title = 'Add a tab that connects to a remote device';
1269
+ add.setAttribute('aria-label', 'Add data source');
1219
1270
  add.addEventListener('click', openCreateForm);
1220
1271
  tabbar.appendChild(add);
1221
1272
 
@@ -1231,10 +1282,15 @@
1231
1282
  input.type = 'text';
1232
1283
  input.className = 'tab-name-input';
1233
1284
  input.value = currentName || '';
1234
- nameSpan.replaceWith(input);
1285
+ const selector = nameSpan.closest('.tab-select');
1286
+ const original = selector || nameSpan;
1287
+ original.replaceWith(input);
1235
1288
  input.focus();
1236
1289
  input.select();
1290
+ let finished = false;
1237
1291
  const commit = () => {
1292
+ if (finished) return;
1293
+ finished = true;
1238
1294
  const newName = input.value.trim();
1239
1295
  if (newName && newName !== currentName) {
1240
1296
  sendCtrl({ type: 'tab_rename', id: id, name: newName });
@@ -1243,14 +1299,16 @@
1243
1299
  const span = document.createElement('span');
1244
1300
  span.className = 'tab-name';
1245
1301
  span.textContent = currentName || '';
1246
- input.replaceWith(span);
1302
+ if (selector) { nameSpan.textContent = currentName || ''; input.replaceWith(selector); } else input.replaceWith(span);
1247
1303
  }
1248
1304
  };
1249
1305
  const cancel = () => {
1306
+ if (finished) return;
1307
+ finished = true;
1250
1308
  const span = document.createElement('span');
1251
1309
  span.className = 'tab-name';
1252
1310
  span.textContent = currentName || '';
1253
- input.replaceWith(span);
1311
+ if (selector) { nameSpan.textContent = currentName || ''; input.replaceWith(selector); } else input.replaceWith(span);
1254
1312
  };
1255
1313
  input.addEventListener('keydown', (e) => {
1256
1314
  if (e.key === 'Enter') { e.preventDefault(); commit(); }
@@ -1339,13 +1397,13 @@
1339
1397
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1340
1398
  socket = new WebSocket(proto + '//' + location.host + '/ws');
1341
1399
  socket.binaryType = 'arraybuffer';
1342
- socket.onopen = () => { wsStatus.textContent = 'connected'; };
1400
+ socket.onopen = () => { wsStatus.textContent = 'Server connected'; wsStatus.dataset.health = 'connected'; };
1343
1401
  socket.onclose = () => {
1344
- wsStatus.textContent = 'disconnected, retrying...';
1402
+ wsStatus.textContent = 'Reconnecting…'; wsStatus.dataset.health = 'disconnected';
1345
1403
  if (viewManager) viewManager.connection(false);
1346
1404
  setTimeout(connect, 1000);
1347
1405
  };
1348
- socket.onerror = () => { wsStatus.textContent = 'error'; };
1406
+ socket.onerror = () => { wsStatus.textContent = 'Connection error'; wsStatus.dataset.health = 'disconnected'; };
1349
1407
  socket.onmessage = (ev) => {
1350
1408
  if (typeof ev.data === 'string') {
1351
1409
  let msg;
@@ -0,0 +1,168 @@
1
+ /* Responsive presentation. No element is duplicated across breakpoints. */
2
+ :root { --ctrl-unit-h: 44px; --persistent-budget: 144px; --presentation-offset: 120px; }
3
+ * { box-sizing: border-box; }
4
+ html { scroll-padding-top: var(--presentation-offset); }
5
+ body { min-width: 0; }
6
+ button, input, select, summary, [role=button], .section-nav a { min-width: 44px; min-height: 44px; }
7
+ button, input, select, textarea { font: inherit; }
8
+ :where(button,input,select,textarea,summary,a,[tabindex]):focus-visible { outline: 3px solid #185f9f; outline-offset: 2px; }
9
+ :where(button,input,select,textarea,summary,a,[tabindex]) { scroll-margin-top: var(--presentation-offset); scroll-margin-bottom: 12px; }
10
+ #header { min-height: 48px; gap: 8px; padding: 2px 12px; }
11
+ #header h1 { font-size: 17px; }
12
+ #ws-status { font-size: 13px; color: #35485a; overflow-wrap: anywhere; }
13
+ #menu-btn { width: 44px; height: 44px; margin: 0; flex: 0 0 44px; }
14
+ #menu-panel { max-width: calc(100vw - 16px); min-width: 0; width: 340px; max-height: calc(100dvh - 64px); overflow-y: auto; top: 52px; right: 8px; padding: 12px; }
15
+ #menu-panel #status, #menu-panel #zmq-mode { margin-bottom: 8px; overflow-wrap: anywhere; }
16
+ #menu-panel .menu-ctrl { min-width: 0; }
17
+ #menu-panel input[type=range] { min-width: 44px; width: 100%; }
18
+ #menu-panel input[type=number] { font-size: 16px; }
19
+ #menu-panel .menu-hint, #menu-panel label, #menu-panel .menu-reset { color: #465767; font-size: 13px; }
20
+ #tabbar { max-width: 100%; padding-top: 0; }
21
+ .tab { min-height: 44px; padding: 0 8px; gap: 4px; }
22
+ .tab .tab-endpoint { display: none; }
23
+ .tab .tab-reconnect, .tab .tab-rename, .tab .tab-close { opacity: 1; border: 0; background: transparent; font-size: 16px; color: #405569; padding: 0; flex: 0 0 44px; }
24
+ .tab .tab-name { max-width: min(260px, 55vw); }
25
+ #tab-create.open { flex-wrap: wrap; max-width: 100%; }
26
+ #tab-create input { max-width: 100%; min-width: 0; font-size: 16px; }
27
+ #plots { padding: 8px 12px 24px; min-width: 0; }
28
+ .plot-row { grid-template-columns: repeat(var(--plot-columns, 1), minmax(0, 1fr)); overflow: visible; }
29
+ .plot-wrap { box-sizing: content-box; }
30
+ .uplot { max-width: 100%; }
31
+ .uplot .u-title { white-space: normal; overflow-wrap: anywhere; font-size: 17px; }
32
+ .uplot .u-legend { display: block; white-space: normal; padding: 4px 0; font-size: 13px; }
33
+ .uplot .u-legend tbody { display: flex; justify-content: center; flex-wrap: wrap; gap: 2px 8px; }
34
+ .uplot .u-legend .u-series { display: inline-flex; align-items: center; min-height: 44px; max-width: 100%; margin: 0; }
35
+ .uplot .u-legend .u-label { white-space: normal; overflow-wrap: anywhere; max-width: 180px; }
36
+ .uplot .u-legend .u-value { overflow-wrap: anywhere; }
37
+ .ui-sticky { overflow: visible; max-height: none; background: #fafafa; }
38
+ .essential-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 4px 0; min-width: 0; border-bottom: 1px solid #ccd5de; }
39
+ .essential-controls .ctrl-item { flex: 0 1 auto; min-width: 0; margin: 0; }
40
+ .essential-controls .ctrl-text { flex: 1 1 150px; }
41
+ .essential-controls .ctrl-text label { font-size: 12px; }
42
+ .essential-controls .ctrl-textval { padding: 0; min-width: 0; }
43
+ .persistent-overflow { max-height: max(44px, calc(var(--persistent-budget) - 52px - var(--essential-height, 0px) - var(--source-height, 0px))); overflow-y: auto; overscroll-behavior: contain; }
44
+ .ui-source-status { padding: 4px 8px; font-size: 13px; color: #78260e; }
45
+ .section-nav { gap: 4px; padding: 0; align-items: flex-start; }
46
+ .section-nav a, .section-more summary { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; line-height: 1.2; padding: 6px 10px; color: #174f80; border: 0; border-radius: 4px; background: #eaf0f6; }
47
+ .section-nav a:hover, .section-more summary:hover { background: #dce8f3; }
48
+ .section-more summary { cursor: pointer; }
49
+ .section-more summary::after { content: ' ▾'; }
50
+ .section-more[open] summary::after { content: ' ▴'; }
51
+ .secondary-links { display: flex; flex-wrap: wrap; gap: 4px; padding-top: 4px; }
52
+ .nav-status { color: #435b6e; font-size: 12px; }
53
+ .section-nav [data-status=warning] .nav-status, .section-nav [data-status=error] .nav-status { color: #923616; }
54
+ .section-header h2 { font-size: 17px; line-height: 1.4; }
55
+ .heading-optional { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
56
+ .heading-optional:focus-within { position: static; width: auto; height: auto; clip-path: none; }
57
+ .section-description, .section-message { color: #435363; line-height: 1.5; overflow-wrap: anywhere; }
58
+ .view-section[data-density=compact] .ctrl-row { padding: 4px 8px; gap: 6px 10px; }
59
+ .view-section[data-density=compact] .section-body { gap: 6px; }
60
+ .ctrl-row { gap: 10px 12px; padding: 10px; min-width: 0; }
61
+ .control-group-title { flex: 0 0 100%; margin: 0; font-size: 14px; font-weight: 600; color: #35485a; }
62
+ .ctrl-item { min-width: 0; max-width: 100%; flex-wrap: wrap; line-height: 1.4; }
63
+ .ctrl-item.flex { min-width: 0; }
64
+ .ctrl-item label { font-size: 14px; color: #35485a; }
65
+ .ctrl-item input { font-size: 16px; }
66
+ .ctrl-textinput { min-width: 0; max-width: 100%; width: 100%; flex: 1 1 180px; }
67
+ .ctrl-numinput { width: 76px; flex: 0 0 76px; }
68
+ .ctrl-nudgebtn { width: 44px; height: 44px; flex: 0 0 44px; }
69
+ .ctrl-slider { flex-wrap: wrap; }
70
+ .ctrl-slider label { flex-basis: 100%; }
71
+ .ctrl-slider .ctrl-rangeinput { min-width: 80px; flex: 1 1 100px; max-width: 100%; }
72
+ .ctrl-btn { min-height: 44px; font-size: 15px; padding: 8px 14px; white-space: normal; overflow-wrap: anywhere; }
73
+ .ctrl-btn:disabled, .ctrl-nudgebtn:disabled { color: #616b75; background: #eff1f3; border-color: #b6c0c9; cursor: default; }
74
+ .ctrl-val, .ctrl-textval { min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
75
+ .ctrl-textval { font-size: 15px; }
76
+ .ctrl-reason { color: #7d3217; line-height: 1.4; }
77
+ .ctrl-busy, .ctrl-selected { color: #245779; }
78
+ .ctrl-item[data-appearance=help] { flex: 1 1 100%; }
79
+ .ctrl-item[data-appearance=help] .ctrl-textval { background: transparent; color: #465767; border: 0; padding: 0; font-size: 14px; }
80
+ .ctrl-item[data-appearance=state] .ctrl-textval { background: #e9f4ef; color: #175a3d; border: 0; font-weight: 600; }
81
+ .ctrl-item[data-appearance=primary] .ctrl-btn:not(:disabled) { background: #175f9d; border-color: #175f9d; color: white; }
82
+ .ctrl-item[data-appearance=warning] .ctrl-btn:not(:disabled) { background: #fff1c9; border-color: #9d6d00; color: #654200; }
83
+ .ctrl-item[data-appearance=danger] .ctrl-btn:not(:disabled) { background: #a32624; border-color: #a32624; color: white; font-weight: 650; }
84
+ .ctrl-item[data-appearance=choice] .ctrl-btn { border-color: #8195a8; background: #f3f6fa; color: #284862; }
85
+ .exclusive-group { gap: 4px; }
86
+ .exclusive-group .ctrl-item { flex: 0 1 auto; }
87
+ .exclusive-group .ui-selected, .ctrl-item[data-appearance=choice].ui-selected { outline: 0; }
88
+ .exclusive-group .ui-selected .ctrl-btn, .ctrl-item[data-appearance=choice].ui-selected .ctrl-btn { background: #d5e8f8; color: #123e64; border: 2px solid #235d8a; }
89
+ .exclusive-group .ctrl-selected, .ctrl-item[data-appearance=choice] .ctrl-selected { display: none; }
90
+ .exclusive-group .ui-selected .ctrl-btn::before, .ctrl-item[data-appearance=choice].ui-selected .ctrl-btn::before { content: '✓ '; margin-right: 4px; }
91
+ @media (min-width: 640px) and (max-width: 1023px) {
92
+ .plot-row { grid-template-columns: repeat(var(--compact-columns, 2), minmax(0, 1fr)); }
93
+ .ctrl-item.flex { flex-basis: 240px; }
94
+ }
95
+ @media (max-width: 639px) {
96
+ :root { --persistent-budget: 180px; }
97
+ #plots { padding: 6px 8px 24px; gap: 8px; }
98
+ #header { padding-inline: 8px; }
99
+ .plot-row { grid-template-columns: minmax(0, 1fr); }
100
+ .ctrl-row > .ctrl-item { flex: 1 1 100%; }
101
+ .exclusive-group > .ctrl-item { flex: 0 1 auto; }
102
+ .ctrl-slider .ctrl-rangeinput { flex-basis: 100%; }
103
+ .section-nav a, .section-more summary { font-size: 13px; padding: 6px; }
104
+ .nav-status { font-size: 11px; }
105
+ .section-nav a { flex-direction: column; gap: 1px; justify-content: center; }
106
+ .section-header { gap: 6px; }
107
+ }
108
+ @media (max-height: 480px) and (min-width: 640px) {
109
+ :root { --persistent-budget: 120px; }
110
+ #header { min-height: 44px; padding-block: 0; }
111
+ .essential-controls { padding-block: 2px; }
112
+ }
113
+ /* A quiet, consistent surface keeps live data and intentional actions prominent. */
114
+ :root { color-scheme: light; }
115
+ body { background: #f4f6f8; color: #223348; font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.45; }
116
+ #header { background: #fff; border-color: #dde4ec; box-shadow: 0 2px 8px #263b5110; }
117
+ #header h1 { color: #123f64; font-weight: 750; letter-spacing: -.5px; }
118
+ #menu-btn { border-color: transparent; border-radius: 8px; color: #36536d; }
119
+ #ws-status { display: flex; align-items: center; gap: 6px; }
120
+ #ws-status::before { content: ''; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: #a36b14; }
121
+ #ws-status[data-health=connected]::before { background: #22764e; }
122
+ #ws-status[data-health=disconnected]::before { background: #a32624; }
123
+ #tabbar { background: #e9eef3; border-color: #d6e0e9; }
124
+ .tab, .tab.bind-me { background: transparent; border-color: transparent; border-radius: 0; top: 0; }
125
+ .tab.active, .tab.bind-me.active { background: #fff; color: #174b75; box-shadow: inset 0 -3px #296a9e; border-color: transparent; }
126
+ .tab-add { border: 0; border-radius: 0; color: #40586f; }
127
+ .ui-sticky { background: #f4f6f8; }
128
+ .essential-controls { padding-inline: 8px; background: #fff; border: 1px solid #d9e3ec; border-radius: 9px; box-shadow: 0 2px 8px #253d5510; }
129
+ .essential-controls .ctrl-textval { background: transparent; }
130
+ .section-nav a { background: transparent; }
131
+ .section-more summary { background: #e8eef4; border-radius: 7px; }
132
+ .section-header h2 { color: #263f56; letter-spacing: -.2px; font-weight: 650; }
133
+ .section-header button { background: transparent; border-color: transparent; padding-left: 0; }
134
+ .section-status { color: #426078; }
135
+ .ctrl-row, .plot-wrap { border-color: #dce4ed; border-radius: 10px; box-shadow: 0 1px 3px #1d39530a; }
136
+ .ctrl-btn, .ctrl-textinput, .ctrl-numinput, .ctrl-nudgebtn { border-radius: 7px; }
137
+ .ctrl-textinput, .ctrl-numinput { border-color: #a9bacb; }
138
+ .ctrl-btn { transition: background-color .12s ease, border-color .12s ease; }
139
+ .ctrl-btn:not(:disabled):hover { box-shadow: 0 2px 5px #15395320; }
140
+ .ctrl-item[data-appearance=primary] .ctrl-btn:not(:disabled):hover { background: #124b7d; }
141
+ .ctrl-item[data-appearance=danger] .ctrl-btn:not(:disabled):hover { background: #851e1d; }
142
+ .ctrl-item[data-appearance=warning] .ctrl-btn:not(:disabled):hover { background: #ffe7a3; }
143
+ .ctrl-item[data-appearance=help] .ctrl-textval { line-height: 1.55; }
144
+ .uplot .u-title { color: #2c445b; font-weight: 600; }
145
+ #menu-panel { border-color: #d2dce7; border-radius: 10px; box-shadow: 0 8px 24px #15395322; }
146
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
147
+ #menu-panel { top: max(52px, var(--presentation-offset)); max-height: calc(100dvh - max(52px, var(--presentation-offset)) - 8px); }
148
+ .sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip-path: inset(50%); white-space: nowrap !important; border: 0 !important; }
149
+ .uplot .u-legend .u-series:first-child { display: none; }
150
+ .ctrl-item label, .control-group-title, .section-header h2 { overflow-wrap: anywhere; max-width: 100%; }
151
+ .ctrl-btn { font-size: max(15px, calc(15px * var(--ui-scale))); }
152
+ .ctrl-item input { font-size: max(16px, calc(16px * var(--ui-scale))); }
153
+ #tabbar { flex-wrap: wrap; overflow: visible; }
154
+ .tab { max-width: 100%; flex-wrap: wrap; }
155
+ .ctrl-item[data-appearance=state] .ctrl-val { background: #e9f4ef; color: #175a3d; border: 0; font-weight: 600; }
156
+ .tab .tab-select { border: 0; background: transparent; padding: 2px 4px; color: inherit; font: inherit; text-align: left; justify-content: center; }
157
+ @media (max-width: 639px) {
158
+ .tab-add { flex: 0 0 44px; width: 44px; padding: 0; font-size: 0; }
159
+ .tab-add::after { content: '+'; font-size: 22px; }
160
+ }
161
+ /* The page, not a viewport-height body box, contains the sticky header. */
162
+ body { height: auto; min-height: 100%; }
163
+ .ctrl-item[data-appearance=state] .ctrl-val { background: #eaf2fa; color: #244f74; }
164
+ .uplot .u-series:has(.sr-only) th::after { content: ''; }
165
+ #empty-state, .res-unavail { color: #586979; }
166
+ .ctrl-textinput, .ctrl-numinput, .ctrl-nudgebtn { border-color: #738799; }
167
+
168
+ @media (max-height: 480px) and (min-width: 640px) { .ui-source-status { padding-block: 2px; font-size: 12px; line-height: 1.2; } }
@@ -9,28 +9,41 @@
9
9
  this.state = {controls: {}, sections: {}}; this.revision = -1;
10
10
  this.available = cfg.source_available !== false; this.connected = true;
11
11
  this.reason = cfg.source_reason || '';
12
+ this.essentialIds = new Set(cfg.view?.essential_controls || []);
12
13
  this.key = `rtplotSections.v1:${cfg.tab}`;
13
14
  try { this.prefs = JSON.parse(sessionStorage.getItem(this.key) || '{}'); } catch (_) { this.prefs = {}; }
14
15
  this.sticky = document.createElement('div'); this.sticky.className = 'ui-sticky'; root.appendChild(this.sticky);
15
16
  this.banner = document.createElement('div'); this.banner.className = 'ui-source-status';
16
17
  this.banner.setAttribute('role', 'status'); this.sticky.appendChild(this.banner);
17
- this.nav = document.createElement('nav'); this.nav.className = 'section-nav'; this.nav.setAttribute('aria-label', 'Sections'); this.sticky.appendChild(this.nav);
18
+ this.nav = document.createElement('nav'); this.nav.className = 'section-nav'; this.nav.setAttribute('aria-label', 'Sections'); root.appendChild(this.nav);
19
+ this.more = document.createElement('details'); this.more.className = 'section-more';
20
+ const summary = document.createElement('summary'); summary.textContent = 'More'; this.more.appendChild(summary);
21
+ this.secondary = document.createElement('div'); this.secondary.className = 'secondary-links'; this.more.appendChild(this.secondary);
22
+ this.essentials = document.createElement('div'); this.essentials.className = 'essential-controls';
23
+ this.essentials.setAttribute('role', 'group'); this.essentials.setAttribute('aria-label', 'Essential actions and state');
24
+ this.sticky.appendChild(this.essentials);
25
+ this.overflow = document.createElement('div'); this.overflow.className = 'persistent-overflow'; this.sticky.appendChild(this.overflow);
18
26
  const view = cfg.view || {};
19
27
  (view.sections || []).forEach((s, index) => {
20
28
  const node = document.createElement('section'); node.className = 'view-section'; node.dataset.section = s.id;
21
29
  node.id = `rtplot-section-${index}`;
30
+ node.dataset.density = s.density || 'normal';
31
+ node.setAttribute('aria-label', s.title);
22
32
  const header = document.createElement('div'); header.className = 'section-header';
23
- const heading = document.createElement('h2');
33
+ const heading = document.createElement('h2'); heading.tabIndex = -1;
24
34
  const toggle = document.createElement(s.collapsible && s.id !== view.persistent_section ? 'button' : 'span');
25
35
  toggle.textContent = s.title; heading.appendChild(toggle); header.appendChild(heading); node.appendChild(header);
36
+ if (s.show_heading === false) header.classList.add('heading-optional');
26
37
  if (s.description) { const d = document.createElement('p'); d.className = 'section-description'; d.textContent = s.description; node.appendChild(d); }
27
38
  const status = document.createElement('span'); status.className = 'section-status'; header.appendChild(status);
28
39
  const message = document.createElement('p'); message.className = 'section-message'; message.setAttribute('role', 'status'); node.appendChild(message);
29
40
  const body = document.createElement('div'); body.className = 'section-body'; body.id = `${node.id}-body`; node.appendChild(body);
30
- const link = document.createElement('a'); link.textContent = s.title; link.href = `#${node.id}`; this.nav.appendChild(link);
31
- const section = {declaration: s, node, body, toggle, link, status, message, collapsed: false};
41
+ const link = document.createElement('a'); link.textContent = s.title; link.href = `#${node.id}`;
42
+ const navStatus = document.createElement('span'); navStatus.className = 'nav-status'; link.appendChild(navStatus);
43
+ (s.navigation === 'secondary' ? this.secondary : this.nav).appendChild(link);
44
+ const section = {declaration: s, node, body, toggle, link, navStatus, heading, status, message, collapsed: false};
32
45
  this.sections.set(s.id, section);
33
- if (s.id === view.persistent_section) { node.classList.add('persistent-section'); this.sticky.appendChild(node); }
46
+ if (s.id === view.persistent_section) { node.classList.add('persistent-section'); this.overflow.appendChild(node); }
34
47
  else root.appendChild(node);
35
48
  const canCollapse = toggle.tagName === 'BUTTON';
36
49
  if (canCollapse) {
@@ -41,19 +54,45 @@
41
54
  link.addEventListener('click', e => {
42
55
  e.preventDefault(); this.collapse(s.id, false, true);
43
56
  // A declaration/runtime-hidden section has no navigation link.
44
- this.changed(); requestAnimationFrame(() => node.scrollIntoView({block: 'start'}));
57
+ this.more.open = false;
58
+ this.changed(); requestAnimationFrame(() => {
59
+ if (s.id === view.persistent_section && this.overflow.hidden) {
60
+ this.essentials.tabIndex = -1; this.essentials.focus({preventScroll: true});
61
+ } else {
62
+ heading.focus({preventScroll: true}); node.scrollIntoView({block: 'start'});
63
+ }
64
+ });
45
65
  });
46
66
  });
67
+ this.nav.appendChild(this.more); this.more.hidden = !this.secondary.children.length;
47
68
  this.nav.hidden = !this.sections.size;
48
69
  this.refreshAvailability();
49
70
  this.observer = new ResizeObserver(() => this.offsets());
50
71
  this.observer.observe(this.sticky); this.observer.observe(document.getElementById('header'));
72
+ this.onFocus = e => {
73
+ if (!root.contains(e.target) || this.sticky.contains(e.target)) return;
74
+ const top = document.getElementById('header').getBoundingClientRect().bottom + this.sticky.getBoundingClientRect().height;
75
+ if (e.target.getBoundingClientRect().top < top) e.target.scrollIntoView({block: 'center'});
76
+ };
77
+ root.addEventListener('focusin', this.onFocus);
51
78
  this.offsets();
52
79
  }
80
+ finish() {
81
+ // Move the existing elements once, preserving declared control order.
82
+ // Resizing and state updates never reparent, clone, or replace them.
83
+ this.controls.forEach((c, id) => { if (this.essentialIds.has(id)) this.essentials.appendChild(c.item); });
84
+ this.essentials.hidden = !this.essentials.children.length;
85
+ this.overflow.querySelectorAll('.ctrl-row').forEach(row => { if (!row.querySelector('.ctrl-item')) row.hidden = true; });
86
+ this.overflow.hidden = !this.overflow.querySelector('.ctrl-row:not([hidden])');
87
+ this.refreshAvailability(); this.offsets();
88
+ }
53
89
  offsets() {
54
90
  const header = document.getElementById('header').getBoundingClientRect().height;
55
91
  this.sticky.style.top = `${header}px`;
56
92
  const margin = header + this.sticky.getBoundingClientRect().height + 12;
93
+ document.documentElement.style.setProperty('--presentation-offset', `${margin}px`);
94
+ this.sticky.style.setProperty('--source-height', `${this.banner.getBoundingClientRect().height}px`);
95
+ this.sticky.style.setProperty('--essential-height', `${this.essentials.getBoundingClientRect().height}px`);
57
96
  this.sections.forEach(s => { s.node.style.scrollMarginTop = `${margin}px`; });
58
97
  }
59
98
  parent(section) { return this.sections.get(section)?.body || this.root; }
@@ -78,7 +117,7 @@
78
117
  const c = this.controls.get(id); if (!c) return false;
79
118
  const v = {...defaults, ...this.state.controls?.[id]};
80
119
  const s = this.sections.get(c.section);
81
- return this.connected && this.available && v.enabled && v.visible && (!s || (!s.node.hidden && !s.collapsed));
120
+ return this.connected && this.available && v.enabled && v.visible && (this.essentialIds.has(id) || !s || (!s.node.hidden && !s.collapsed));
82
121
  }
83
122
  applyControl(id) {
84
123
  const c = this.controls.get(id); if (!c) return;
@@ -100,7 +139,10 @@
100
139
  const s = this.sections.get(id); if (!s) return;
101
140
  const v = {visible: s.declaration.visible, status: 'idle', message: '', ...this.state.sections?.[id]};
102
141
  s.node.hidden = !v.visible; s.link.hidden = !v.visible;
142
+ this.more.hidden = !Array.from(this.secondary.children).some(link => !link.hidden);
103
143
  s.status.textContent = statusLabels[v.status] || v.status;
144
+ s.navStatus.textContent = statusLabels[v.status] || v.status;
145
+ s.link.dataset.status = v.status;
104
146
  s.status.dataset.status = v.status; s.message.textContent = v.message; s.message.hidden = !v.message;
105
147
  }
106
148
  apply(snapshot, revision) {
@@ -124,7 +166,7 @@
124
166
  this.banner.hidden = this.connected && this.available;
125
167
  this.banner.textContent = !this.connected ? 'Disconnected from server — application controls unavailable' :
126
168
  this.reason || 'Source unavailable — application controls disabled';
127
- this.sticky.hidden = !this.sections.size && this.connected && this.available;
169
+ this.sticky.hidden = !this.sections.size && !this.essentialIds.size && this.connected && this.available;
128
170
  this.controls.forEach((_, id) => this.applyControl(id)); this.offsets();
129
171
  }
130
172
  source(available, reason) {
@@ -132,7 +174,7 @@
132
174
  this.available = available; this.reason = reason; this.refreshAvailability();
133
175
  }
134
176
  connection(connected) { this.connected = connected; this.refreshAvailability(); }
135
- destroy() { this.observer.disconnect(); }
177
+ destroy() { this.observer.disconnect(); this.root.removeEventListener('focusin', this.onFocus); }
136
178
  }
137
179
  window.RtplotView = RtplotView;
138
180
  })();
@@ -2,7 +2,7 @@
2
2
  from copy import deepcopy
3
3
 
4
4
  META_KEY = "__rtplot_view__"
5
- CAPABILITIES = ["sections", "ui_state_v1"]
5
+ CAPABILITIES = ["sections", "ui_state_v1", "presentation_v1"]
6
6
  CONTROL_DEFAULTS = {"enabled": True, "visible": True, "busy": False, "selected": False, "reason": ""}
7
7
  SECTION_DEFAULTS = {"visible": True, "status": "idle", "message": ""}
8
8
  STATUSES = {"idle", "ready", "busy", "complete", "warning", "error"}
@@ -29,13 +29,20 @@ def declarations(config, view=None):
29
29
  raise ValueError("Section IDs must be unique non-empty strings")
30
30
  ids.add(sid)
31
31
  s = {"id": sid, "title": sid, "description": "", "collapsible": False,
32
- "collapsed": False, "visible": True, **section}
32
+ "collapsed": False, "visible": True, "navigation": "primary", "density": "normal",
33
+ "show_heading": True, **section}
33
34
  for key in ("title", "description"):
34
35
  if not isinstance(s[key], str):
35
36
  raise ValueError(f"Section {key} must be a string")
36
37
  for key in ("collapsible", "collapsed", "visible"):
37
38
  if not isinstance(s[key], bool):
38
39
  raise ValueError(f"Section {key} must be boolean")
40
+ for key, default, allowed in (("navigation", "primary", {"primary", "secondary"}),
41
+ ("density", "normal", {"normal", "compact"})):
42
+ if s.get(key, default) not in allowed:
43
+ raise ValueError(f"Invalid section {key}")
44
+ if type(s.get("show_heading", True)) is not bool:
45
+ raise ValueError("show_heading must be boolean")
39
46
  if not s["collapsible"]:
40
47
  s["collapsed"] = False
41
48
  normalized.append(s)
@@ -53,14 +60,35 @@ def declarations(config, view=None):
53
60
  raise ValueError("The persistent section can contain only control rows")
54
61
  if "plots" in row and any(p.get("section") is not None for p in row["plots"]):
55
62
  raise ValueError("Assign section on the PlotRow, not its child plots")
63
+ if row.get("title") is not None and not isinstance(row["title"], str):
64
+ raise ValueError("Control group title must be a string")
65
+ if type(row.get("exclusive", False)) is not bool:
66
+ raise ValueError("Control group exclusive must be boolean")
67
+ for plot in row.get("plots", [row]):
68
+ if "min_height" in plot and (type(plot["min_height"]) is not int or plot["min_height"] < 160):
69
+ raise ValueError("Plot min_height must be an integer >= 160 CSS pixels")
56
70
  for control in row.get("controls", []):
57
71
  cid = control.get("id")
58
72
  if not isinstance(cid, str) or not cid:
59
73
  raise ValueError("Control IDs must be non-empty strings")
60
74
  if cid in controls:
61
75
  raise ValueError(f"Duplicate control ID: {cid}")
76
+ if control.get("appearance", "default") not in {"default", "help", "state", "primary", "choice", "warning", "danger"}:
77
+ raise ValueError("Unknown control appearance")
78
+ if row.get("exclusive") and control.get("type") != "button":
79
+ raise ValueError("Exclusive groups may contain only buttons")
62
80
  controls[cid] = {"type": control.get("type"), "section": sid}
63
- return ({"sections": normalized, "persistent_section": persistent} if view else {}), controls
81
+ if row.get("exclusive"):
82
+ controls[cid]["exclusive_group"] = key
83
+ essentials = view.get("essential_controls", [])
84
+ if not isinstance(essentials, list) or any(not isinstance(cid, str) or cid not in controls for cid in essentials):
85
+ raise ValueError("essential_controls must list existing control IDs")
86
+ if len(set(essentials)) != len(essentials):
87
+ raise ValueError("essential_controls must not contain duplicate IDs")
88
+ result = {"sections": normalized, "persistent_section": persistent} if view else {}
89
+ if essentials:
90
+ result["essential_controls"] = essentials
91
+ return result, controls
64
92
 
65
93
 
66
94
  def merge_state(current, patch, controls, view):
@@ -68,6 +96,19 @@ def merge_state(current, patch, controls, view):
68
96
  if not isinstance(patch, dict) or set(patch) - {"controls", "sections"}:
69
97
  raise ValueError("UI state must contain only controls and sections maps")
70
98
  result = deepcopy(current)
99
+ selected_groups = {}
100
+ for cid, values in patch.get("controls", {}).items() if isinstance(patch.get("controls", {}), dict) else []:
101
+ group = controls.get(cid, {}).get("exclusive_group")
102
+ if group and isinstance(values, dict) and values.get("selected") is True:
103
+ if group in selected_groups:
104
+ raise ValueError("An exclusive group may have only one selected choice")
105
+ selected_groups[group] = cid
106
+ for group, selected in selected_groups.items():
107
+ for cid, info in controls.items():
108
+ if cid != selected and info.get("exclusive_group") == group:
109
+ result.setdefault("controls", {}).get(cid, {}).pop("selected", None)
110
+ if not result["controls"].get(cid):
111
+ result["controls"].pop(cid, None)
71
112
  section_ids = {s["id"] for s in view.get("sections", [])}
72
113
  for group, changes in patch.items():
73
114
  if not isinstance(changes, dict):
@@ -100,3 +141,23 @@ def merge_state(current, patch, controls, view):
100
141
  else:
101
142
  target.pop(ident, None)
102
143
  return result
144
+
145
+
146
+ def presentation_requested(config, view):
147
+ """Whether a layout relies on options introduced in presentation_v1."""
148
+ if view.get("essential_controls"):
149
+ return True
150
+ if any(s.get("navigation", "primary") != "primary" or s.get("density", "normal") != "normal"
151
+ or not s.get("show_heading", True) for s in view.get("sections", [])):
152
+ return True
153
+ for key, row in config.items():
154
+ if key == META_KEY:
155
+ continue
156
+ if row.get("title") and "controls" in row or row.get("exclusive"):
157
+ return True
158
+ if any(c.get("appearance", "default") != "default" for c in row.get("controls", [])):
159
+ return True
160
+ for plot in row.get("plots", [row]):
161
+ if plot.get("min_height") or any(style in ("dashed", "dotted", "dashdot") for style in (plot.get("line_style") or [])):
162
+ return True
163
+ return False
File without changes