better-rtplot 0.5.0__tar.gz → 0.6.1__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 (41) hide show
  1. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/PKG-INFO +4 -1
  2. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/README.md +3 -0
  3. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/pyproject.toml +1 -1
  4. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/client.py +53 -9
  5. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/server_browser.py +23 -6
  6. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/index.html +114 -33
  7. better_rtplot-0.6.1/rtplot/static/presentation.css +173 -0
  8. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/ui-view.js +51 -9
  9. better_rtplot-0.6.1/rtplot/ui_state.py +188 -0
  10. better_rtplot-0.5.0/rtplot/ui_state.py +0 -102
  11. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/LICENSE +0 -0
  12. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/interactive_test.py +0 -0
  13. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/saved_plots/.gitignore +0 -0
  14. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/server.py +0 -0
  15. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/server_browser_gui.py +0 -0
  16. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/auto-render.min.js +0 -0
  17. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  18. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  19. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  20. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  21. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  22. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  23. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  24. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  25. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  26. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  27. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  28. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  29. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  30. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  31. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  32. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  33. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  34. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  35. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  36. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  37. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/katex.min.css +0 -0
  38. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/katex/katex.min.js +0 -0
  39. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/uPlot.iife.min.js +0 -0
  40. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/rtplot/static/uPlot.min.css +0 -0
  41. {better_rtplot-0.5.0 → better_rtplot-0.6.1}/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.1
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.
@@ -158,3 +159,5 @@ A rising line now draws itself in the browser tab.
158
159
  Issues and feature requests:
159
160
  [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
160
161
 
162
+ Dropdown controls: [API and examples](docs/dropdowns.md).
163
+
@@ -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.
@@ -132,3 +133,5 @@ A rising line now draws itself in the browser tab.
132
133
 
133
134
  Issues and feature requests:
134
135
  [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
136
+
137
+ Dropdown controls: [API and examples](docs/dropdowns.md).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.5.0"
3
+ version = "0.6.1"
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
 
@@ -306,18 +310,34 @@ class TextInput:
306
310
  })
307
311
 
308
312
 
313
+ @dataclass
314
+ class Dropdown:
315
+ """Single choice control. Omitted value selects the first option."""
316
+ id: str
317
+ label: str
318
+ options: List[Union[str, dict]]
319
+ value: Optional[str] = None
320
+ height: Optional[float] = None
321
+
322
+ def to_dict(self):
323
+ return _drop_none({"type": "dropdown", "id": self.id, "label": self.label,
324
+ "options": self.options, "value": self.value, "height": self.height})
325
+
326
+
309
327
  @dataclass
310
328
  class ControlsRow:
311
329
  """A row of control widgets, rendered in place of a plot."""
312
- controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
330
+ controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, Dropdown, dict]] = field(default_factory=list)
313
331
 
314
332
  section: Optional[str] = None
333
+ title: Optional[str] = None
334
+ exclusive: Optional[bool] = None
315
335
 
316
336
  def to_dict(self):
317
337
  return _drop_none({"controls": [
318
338
  c.to_dict() if hasattr(c, "to_dict") else c
319
339
  for c in self.controls
320
- ], "section": self.section})
340
+ ], "section": self.section, "title": self.title, "exclusive": self.exclusive})
321
341
 
322
342
 
323
343
  @dataclass
@@ -328,6 +348,9 @@ class Section:
328
348
  collapsible: bool = False
329
349
  collapsed: bool = False
330
350
  visible: bool = True
351
+ navigation: str = "primary"
352
+ density: str = "normal"
353
+ show_heading: bool = True
331
354
 
332
355
  def to_dict(self):
333
356
  return dict(vars(self))
@@ -337,11 +360,12 @@ class Section:
337
360
  class View:
338
361
  sections: List[Union[Section, dict]]
339
362
  persistent_section: Optional[str] = None
363
+ essential_controls: Optional[List[str]] = None
340
364
 
341
365
  def to_dict(self):
342
366
  return _drop_none({"sections": [s.to_dict() if hasattr(s, "to_dict") else s
343
367
  for s in self.sections],
344
- "persistent_section": self.persistent_section})
368
+ "persistent_section": self.persistent_section, "essential_controls": self.essential_controls})
345
369
 
346
370
 
347
371
  def local_plot():
@@ -600,6 +624,15 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0, *, view=None, u
600
624
  "confirm support: layout may be flat and disabled states are NOT enforced.",
601
625
  RuntimeWarning, stacklevel=2)
602
626
 
627
+ if any(c["type"] == "dropdown" for c in _control_registry.values()) and "dropdown_v1" not in _server_capabilities:
628
+ warnings.warn("Dropdowns require rtplot browser server >= 0.6.1. This server did not "
629
+ "confirm support; dropdown controls may be missing or unenforced.",
630
+ RuntimeWarning, stacklevel=2)
631
+ if presentation_requested(plot_desc_dict, _view) and "presentation_v1" not in _server_capabilities:
632
+ warnings.warn("Presentation options require rtplot browser server >= 0.6.0. This server did not "
633
+ "confirm support; essential placement, groups, and styling may be ignored.",
634
+ RuntimeWarning, stacklevel=2)
635
+
603
636
 
604
637
  def _send_initialize_with_handshake(cfg, timeout):
605
638
  """Publish ``cfg`` and block until the server acks it.
@@ -752,12 +785,23 @@ def set_text_input(input_id: str, value):
752
785
  socket.send_json({"id": str(input_id), "value": str(value)})
753
786
 
754
787
 
788
+ def set_dropdown(control_id: str, value: str):
789
+ """Select a declared option without rebuilding the layout (server >= 0.6.1)."""
790
+ control = _control_registry.get(control_id, {})
791
+ if control.get("type") != "dropdown" or value not in control["options"]:
792
+ raise ValueError("set_dropdown requires a dropdown ID and a declared string value")
793
+ if "dropdown_v1" not in _server_capabilities:
794
+ raise RuntimeError("set_dropdown requires rtplot browser server >= 0.6.1")
795
+ socket.send_string(SENDING_TEXT_INPUT, zmq.SNDMORE)
796
+ socket.send_json({"id": control_id, "value": value, "session": _session, "generation": _generation})
797
+
798
+
755
799
  def poll_controls():
756
800
  """Drain the return channel non-blocking and return current control state.
757
801
 
758
802
  Returns a ControlState(values, buttons) where:
759
803
  - values: dict of latest control values keyed by id. Sliders/dials
760
- remain floats; text_input controls remain strings.
804
+ remain floats; text_input and dropdown controls remain strings.
761
805
  - buttons: list of button ids that fired since the previous poll
762
806
  (cleared after this call).
763
807
 
@@ -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")})
@@ -673,8 +676,9 @@ def parse_config(tab: Tab, json_config):
673
676
  for element in row:
674
677
  if element.get("type") in ("slider", "dial") and "value" in element:
675
678
  slider_values[element["id"]] = float(element["value"])
676
- elif element.get("type") == "text_input":
677
- text_values[element["id"]] = str(element.get("value", ""))
679
+ elif element.get("type") in ("text_input", "dropdown"):
680
+ default = registry[element["id"]]["options"][0] if element["type"] == "dropdown" else ""
681
+ text_values[element["id"]] = str(element.get("value", default))
678
682
 
679
683
  for plot_counter, (_, plot_description) in enumerate(plot_entries):
680
684
  trace_names = plot_description["names"]
@@ -714,10 +718,11 @@ def parse_config(tab: Tab, json_config):
714
718
  el["id"]
715
719
  for row in control_rows
716
720
  for el in row
717
- if el.get("type") == "text_input"
721
+ if el.get("type") in ("text_input", "dropdown")
718
722
  }
719
723
  tab.text_values = {
720
- text_id: tab.text_values.get(text_id, default_value)
724
+ text_id: (default_value if registry[text_id]["type"] == "dropdown"
725
+ else tab.text_values.get(text_id, default_value))
721
726
  for text_id, default_value in text_values.items()
722
727
  if text_id in active_text_ids
723
728
  }
@@ -1261,6 +1266,12 @@ async def zmq_receiver(tab: Tab):
1261
1266
  if text_id is None:
1262
1267
  continue
1263
1268
  value = str(payload.get("value", ""))
1269
+ control = tab.control_registry.get(text_id, {})
1270
+ if control.get("type") == "dropdown":
1271
+ if (payload.get("session"), payload.get("generation")) != (tab.session, tab.generation):
1272
+ continue
1273
+ if payload.get("value") not in control["options"]:
1274
+ continue
1264
1275
  if tab.text_values.get(text_id) != value:
1265
1276
  tab.text_values[text_id] = value
1266
1277
  tab.text_dirty.add(text_id)
@@ -1574,6 +1585,7 @@ _INDEX_HTML = _read_static_asset("index.html")
1574
1585
  for _asset in (
1575
1586
  "ui-view.js",
1576
1587
  "ui-view.css",
1588
+ "presentation.css",
1577
1589
  "uPlot.min.css",
1578
1590
  "uPlot.iife.min.js",
1579
1591
  "katex/katex.min.css",
@@ -1697,8 +1709,13 @@ async def handle_ws(request):
1697
1709
  text_id = payload.get("id")
1698
1710
  tid = ws_tab.get(ws, BIND_ME_ID)
1699
1711
  t = tabs.get(tid)
1700
- if text_id and t is not None and control_allowed(t, payload, {"text_input"}):
1712
+ if text_id and t is not None and control_allowed(t, payload, {"text_input", "dropdown"}):
1701
1713
  value = str(payload.get("value", ""))
1714
+ control = t.control_registry.get(text_id, {})
1715
+ if control.get("type") == "dropdown":
1716
+ if payload.get("value") not in control["options"]:
1717
+ continue
1718
+ t.text_dirty.add(text_id)
1702
1719
  t.text_values[text_id] = value
1703
1720
  refresh_config_message(t)
1704
1721
  await send_control_event(
@@ -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;
@@ -718,6 +742,28 @@
718
742
  item.appendChild(val);
719
743
  controlElements.displays[el.id] = val;
720
744
  controlElements.displayKinds[el.id] = 'text';
745
+ } else if (el.type === 'dropdown') {
746
+ item.classList.add('ctrl-dropdown', 'flex');
747
+ const label = document.createElement('label');
748
+ label.textContent = el.label || el.id;
749
+ const select = document.createElement('select');
750
+ select.className = 'ctrl-textinput ctrl-select';
751
+ for (const choice of el.options) {
752
+ const option = document.createElement('option');
753
+ option.value = typeof choice === 'string' ? choice : choice.value;
754
+ option.textContent = typeof choice === 'string' ? choice : (choice.label ?? choice.value);
755
+ select.appendChild(option);
756
+ }
757
+ select.value = el.value ?? select.options[0].value;
758
+ let committed = select.value;
759
+ select.addEventListener('change', () => {
760
+ if (sendCtrl({type: 'control_text', id: el.id, value: select.value})) committed = select.value;
761
+ else select.value = committed;
762
+ });
763
+ select.setServerValue = value => { select.value = value; committed = value; };
764
+ label.appendChild(select);
765
+ item.appendChild(label);
766
+ controlElements.textInputs[el.id] = select;
721
767
  } else if (el.type === 'text_input') {
722
768
  item.classList.add('ctrl-text-input', 'flex');
723
769
  if (el.label) {
@@ -735,9 +781,16 @@
735
781
  if (el.max_length !== undefined && Number.isFinite(Number(el.max_length))) {
736
782
  input.maxLength = Number(el.max_length);
737
783
  }
738
- const commit = () => sendCtrl({ type: 'control_text', id: el.id, value: input.value });
739
- input.addEventListener('change', commit);
740
- input.addEventListener('blur', commit);
784
+ let committed = input.value;
785
+ const commit = () => {
786
+ if (input.value === committed) return;
787
+ if (sendCtrl({ type: 'control_text', id: el.id, value: input.value })) committed = input.value;
788
+ };
789
+ input.addEventListener('blur', e => {
790
+ // Moving into presentation UI must preserve a draft without sending it.
791
+ if (presentationPointer || e.relatedTarget?.closest(presentationTargets)) return;
792
+ commit();
793
+ });
741
794
  input.addEventListener('keydown', (e) => {
742
795
  if (e.key === 'Enter') {
743
796
  e.preventDefault();
@@ -765,12 +818,20 @@
765
818
  }
766
819
  }
767
820
 
768
- function buildControlRow(row, section) {
821
+ function buildControlRow(row, section, options={}) {
769
822
  const div = document.createElement('div');
770
823
  div.className = 'ctrl-row';
824
+ if (options.title) {
825
+ const title = document.createElement('h3'); title.className = 'control-group-title'; title.textContent = options.title;
826
+ div.appendChild(title); div.setAttribute('role', 'group'); div.setAttribute('aria-label', options.title);
827
+ }
828
+ if (options.exclusive) div.classList.add('exclusive-group');
771
829
  row.forEach(el => {
772
830
  const item = buildControlElement(el);
773
831
  applyElementSize(item, el);
832
+ item.dataset.appearance = el.appearance || 'default';
833
+ if (el.appearance === 'state') item.setAttribute('role', 'status');
834
+ item.querySelectorAll('input,select,svg.ctrl-dial').forEach(input => input.setAttribute('aria-label', el.label || el.id));
774
835
  div.appendChild(item);
775
836
  viewManager.register(el.id, item, section);
776
837
  });
@@ -789,7 +850,8 @@
789
850
  if (!values) return;
790
851
  for (const [id, val] of Object.entries(values)) {
791
852
  const input = controlElements.textInputs[id];
792
- if (input) input.value = (val !== undefined && val !== null) ? String(val) : '';
853
+ if (input?.setServerValue) input.setServerValue(String(val));
854
+ else if (input) input.value = (val !== undefined && val !== null) ? String(val) : '';
793
855
  }
794
856
  }
795
857
 
@@ -850,11 +912,11 @@
850
912
 
851
913
  const series = [{}];
852
914
  for (let t = 0; t < traceCount; t++) {
853
- const dash = (styles[t] === '-') ? [10, 5] : null;
915
+ const dash = ({'-': [10, 5], dashed: [10, 5], dotted: [2, 4], dashdot: [10, 4, 2, 4]})[styles[t]] || null;
854
916
  series.push({
855
917
  label: pcfg.names[t],
856
- stroke: resolveColor(colors[t]),
857
- width: widths[t] || 1,
918
+ stroke: resolveColor(colors[t] || DEFAULT_COLORS[t % DEFAULT_COLORS.length]),
919
+ width: (Array.isArray(widths) ? widths[t] : widths) || 1,
858
920
  dash: dash || undefined,
859
921
  points: { show: false },
860
922
  spanGaps: true,
@@ -864,16 +926,15 @@
864
926
 
865
927
  const baseHeight = rowLayout ? 260 : 320;
866
928
  const heightMul = Number(pcfg.height);
867
- const plotHeight = (Number.isFinite(heightMul) && heightMul > 0)
868
- ? Math.round(baseHeight * heightMul)
869
- : baseHeight;
929
+ const plotHeight = Math.max(pcfg.min_height || 160, (Number.isFinite(heightMul) && heightMul > 0)
930
+ ? Math.round(baseHeight * heightMul) : baseHeight);
870
931
  const wrap = document.createElement('div');
871
932
  wrap.className = 'plot-wrap';
872
933
  parent.appendChild(wrap);
873
934
 
874
935
  let plotState = null;
875
936
  const opts = {
876
- width: Math.max(240, wrap.clientWidth - 16),
937
+ width: Math.max(120, wrap.clientWidth - 16),
877
938
  height: plotHeight,
878
939
  title: pcfg.title || '',
879
940
  scales: {
@@ -886,6 +947,14 @@
886
947
  ],
887
948
  series: series,
888
949
  legend: { show: true },
950
+ hooks: { ready: [u => u.root.querySelectorAll('.u-legend .u-series').forEach((tr, i) => {
951
+ if (!i) return;
952
+ if (traceCount === 1 && pcfg.title === pcfg.names[0]) tr.querySelector('.u-label').classList.add('sr-only');
953
+ 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]}`);
954
+ tr.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tr.querySelector('.u-label').click(); } });
955
+ })], setSeries: [u => u.root.querySelectorAll('.u-legend .u-series').forEach((tr, i) => {
956
+ if (i) tr.setAttribute('aria-pressed', String(u.series[i].show));
957
+ })] },
889
958
  cursor: { drag: { x: false, y: false } },
890
959
  };
891
960
 
@@ -929,7 +998,7 @@
929
998
  plots.forEach(p => {
930
999
  const visible = p.wrap.getClientRects().length > 0;
931
1000
  if (visible) {
932
- const width = Math.max(240, p.wrap.clientWidth - 16);
1001
+ const width = Math.max(120, p.wrap.clientWidth - 16);
933
1002
  if (!p.visible || Math.abs(p.uplot.width - width) > 1) {
934
1003
  p.uplot.setSize({width, height: p.height});
935
1004
  p.dataDirty = true; p.forceKeepScales = true;
@@ -956,6 +1025,7 @@
956
1025
  function newRow(count, parent) {
957
1026
  const row = document.createElement('div'); row.className = 'plot-row';
958
1027
  row.style.setProperty('--plot-columns', Math.max(1, Math.floor(count)));
1028
+ row.style.setProperty('--compact-columns', Math.max(1, Math.min(2, Math.floor(count))));
959
1029
  parent.appendChild(row); return row;
960
1030
  }
961
1031
  // Trace offsets come ONLY from wire declaration order, never section/DOM order.
@@ -976,9 +1046,10 @@
976
1046
  entry.plots.forEach(index => buildOnePlot(cfg.plots[index], entry.columns === 1, offsets[index], row));
977
1047
  } else if (entry.kind === 'controls') {
978
1048
  implicitRows.delete(parent);
979
- parent.appendChild(buildControlRow(controls[entry.index], entry.section));
1049
+ parent.appendChild(buildControlRow(controls[entry.index], entry.section, entry));
980
1050
  }
981
1051
  });
1052
+ viewManager.finish();
982
1053
  applySliderValues(cfg.slider_values); applyTextValues(cfg.text_values); applyDisplayValues(cfg.display_values);
983
1054
  viewManager.apply(cfg.ui_state || {}, cfg.ui_revision ?? 0);
984
1055
  renderLatexIn(plotsDiv); applyVisibleSamples(); refreshPresentation(); scheduleRender();
@@ -1140,8 +1211,10 @@
1140
1211
 
1141
1212
  // Stacked labels: name on top, endpoint on a muted subline so
1142
1213
  // users can see at a glance which tab is wired to which device.
1143
- const labels = document.createElement('div');
1144
- labels.className = 'tab-labels';
1214
+ const labels = document.createElement('button');
1215
+ labels.className = 'tab-labels tab-select';
1216
+ labels.setAttribute('aria-label', `View ${t.name || id}`);
1217
+ labels.addEventListener('click', e => { e.stopPropagation(); if (id !== activeTab) switchTab(id); });
1145
1218
  const name = document.createElement('span');
1146
1219
  name.className = 'tab-name';
1147
1220
  name.textContent = t.name || id;
@@ -1154,10 +1227,10 @@
1154
1227
  labels.appendChild(ep);
1155
1228
  el.appendChild(labels);
1156
1229
 
1157
- const reconnect = document.createElement('span');
1230
+ const reconnect = document.createElement('button');
1158
1231
  reconnect.className = 'tab-reconnect';
1159
1232
  reconnect.textContent = '\u21BB';
1160
- reconnect.title = 'Reconnect';
1233
+ reconnect.title = 'Reconnect'; reconnect.setAttribute('aria-label', `Reconnect ${t.name || id}`);
1161
1234
  reconnect.addEventListener('click', (e) => {
1162
1235
  e.stopPropagation();
1163
1236
  sendCtrl({ type: 'tab_reconnect', id: id });
@@ -1171,17 +1244,17 @@
1171
1244
  el.appendChild(reconnect);
1172
1245
 
1173
1246
  if (id !== 'bind_me') {
1174
- const ren = document.createElement('span');
1247
+ const ren = document.createElement('button');
1175
1248
  ren.className = 'tab-rename';
1176
1249
  ren.textContent = '\u270E';
1177
- ren.title = 'Rename';
1250
+ ren.title = 'Rename'; ren.setAttribute('aria-label', `Rename ${t.name || id}`);
1178
1251
  ren.addEventListener('click', (e) => { e.stopPropagation(); beginRename(el, id, t.name); });
1179
1252
  el.appendChild(ren);
1180
1253
 
1181
- const close = document.createElement('span');
1254
+ const close = document.createElement('button');
1182
1255
  close.className = 'tab-close';
1183
1256
  close.textContent = '\u00D7';
1184
- close.title = 'Close tab';
1257
+ close.title = 'Close tab'; close.setAttribute('aria-label', `Close ${t.name || id}`);
1185
1258
  close.addEventListener('click', (e) => {
1186
1259
  e.stopPropagation();
1187
1260
  if (confirm(`Close tab "${t.name || id}"?`)) {
@@ -1216,6 +1289,7 @@
1216
1289
  add.className = 'tab-add';
1217
1290
  add.textContent = '+ new device';
1218
1291
  add.title = 'Add a tab that connects to a remote device';
1292
+ add.setAttribute('aria-label', 'Add data source');
1219
1293
  add.addEventListener('click', openCreateForm);
1220
1294
  tabbar.appendChild(add);
1221
1295
 
@@ -1231,10 +1305,15 @@
1231
1305
  input.type = 'text';
1232
1306
  input.className = 'tab-name-input';
1233
1307
  input.value = currentName || '';
1234
- nameSpan.replaceWith(input);
1308
+ const selector = nameSpan.closest('.tab-select');
1309
+ const original = selector || nameSpan;
1310
+ original.replaceWith(input);
1235
1311
  input.focus();
1236
1312
  input.select();
1313
+ let finished = false;
1237
1314
  const commit = () => {
1315
+ if (finished) return;
1316
+ finished = true;
1238
1317
  const newName = input.value.trim();
1239
1318
  if (newName && newName !== currentName) {
1240
1319
  sendCtrl({ type: 'tab_rename', id: id, name: newName });
@@ -1243,14 +1322,16 @@
1243
1322
  const span = document.createElement('span');
1244
1323
  span.className = 'tab-name';
1245
1324
  span.textContent = currentName || '';
1246
- input.replaceWith(span);
1325
+ if (selector) { nameSpan.textContent = currentName || ''; input.replaceWith(selector); } else input.replaceWith(span);
1247
1326
  }
1248
1327
  };
1249
1328
  const cancel = () => {
1329
+ if (finished) return;
1330
+ finished = true;
1250
1331
  const span = document.createElement('span');
1251
1332
  span.className = 'tab-name';
1252
1333
  span.textContent = currentName || '';
1253
- input.replaceWith(span);
1334
+ if (selector) { nameSpan.textContent = currentName || ''; input.replaceWith(selector); } else input.replaceWith(span);
1254
1335
  };
1255
1336
  input.addEventListener('keydown', (e) => {
1256
1337
  if (e.key === 'Enter') { e.preventDefault(); commit(); }
@@ -1339,13 +1420,13 @@
1339
1420
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1340
1421
  socket = new WebSocket(proto + '//' + location.host + '/ws');
1341
1422
  socket.binaryType = 'arraybuffer';
1342
- socket.onopen = () => { wsStatus.textContent = 'connected'; };
1423
+ socket.onopen = () => { wsStatus.textContent = 'Server connected'; wsStatus.dataset.health = 'connected'; };
1343
1424
  socket.onclose = () => {
1344
- wsStatus.textContent = 'disconnected, retrying...';
1425
+ wsStatus.textContent = 'Reconnecting…'; wsStatus.dataset.health = 'disconnected';
1345
1426
  if (viewManager) viewManager.connection(false);
1346
1427
  setTimeout(connect, 1000);
1347
1428
  };
1348
- socket.onerror = () => { wsStatus.textContent = 'error'; };
1429
+ socket.onerror = () => { wsStatus.textContent = 'Connection error'; wsStatus.dataset.health = 'disconnected'; };
1349
1430
  socket.onmessage = (ev) => {
1350
1431
  if (typeof ev.data === 'string') {
1351
1432
  let msg;