pythonfmu3 1.0.0__tar.gz → 1.1.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 (94) hide show
  1. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/MANIFEST.in +3 -0
  2. {pythonfmu3-1.0.0/pythonfmu3.egg-info → pythonfmu3-1.1.0}/PKG-INFO +9 -1
  3. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/README.md +8 -0
  4. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pyproject.toml +2 -0
  5. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/__main__.py +8 -0
  6. pythonfmu3-1.1.0/pythonfmu3/_version.py +1 -0
  7. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/resources/binaries/x86_64-windows/pythonfmu-export.dll +0 -0
  8. pythonfmu3-1.1.0/pythonfmu3/studio/__init__.py +1 -0
  9. pythonfmu3-1.1.0/pythonfmu3/studio/server.py +358 -0
  10. pythonfmu3-1.1.0/pythonfmu3/studio/static/app.css +179 -0
  11. pythonfmu3-1.1.0/pythonfmu3/studio/static/app.js +613 -0
  12. pythonfmu3-1.1.0/pythonfmu3/studio/static/index.html +141 -0
  13. pythonfmu3-1.1.0/pythonfmu3/studio/static/logo.svg +8 -0
  14. pythonfmu3-1.1.0/pythonfmu3/studio/templates/bouncing_ball.py +75 -0
  15. pythonfmu3-1.1.0/pythonfmu3/studio/templates/dahlquist.py +31 -0
  16. pythonfmu3-1.1.0/pythonfmu3/studio/templates/pid_controller.py +42 -0
  17. pythonfmu3-1.1.0/pythonfmu3/studio/templates/signal_generator.py +40 -0
  18. pythonfmu3-1.1.0/pythonfmu3/studio/templates/surrogate.py +37 -0
  19. pythonfmu3-1.1.0/pythonfmu3/studio/templates/thermostat.py +45 -0
  20. pythonfmu3-1.1.0/pythonfmu3/studio/templates/van_der_pol.py +34 -0
  21. pythonfmu3-1.1.0/pythonfmu3/studio/worker.py +519 -0
  22. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_builder.py +1 -1
  23. pythonfmu3-1.1.0/pythonfmu3/tests/test_example_pipe_cht.py +200 -0
  24. pythonfmu3-1.1.0/pythonfmu3/tests/test_migration_doc.py +34 -0
  25. pythonfmu3-1.1.0/pythonfmu3/tests/test_studio.py +149 -0
  26. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0/pythonfmu3.egg-info}/PKG-INFO +9 -1
  27. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/SOURCES.txt +17 -0
  28. pythonfmu3-1.0.0/pythonfmu3/_version.py +0 -1
  29. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/LICENSE +0 -0
  30. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/__init__.py +0 -0
  31. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/builder.py +0 -0
  32. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/cosimulation.py +0 -0
  33. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/default_experiment.py +0 -0
  34. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/deploy.py +0 -0
  35. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/enums.py +0 -0
  36. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/fmi3slave.py +0 -0
  37. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/logmsg.py +0 -0
  38. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/modelexchange.py +0 -0
  39. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/osutil.py +0 -0
  40. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/.clang-format +0 -0
  41. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/CMakeLists.txt +0 -0
  42. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/build_unix.sh +0 -0
  43. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/build_win.sh +0 -0
  44. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/generate_shim.py +0 -0
  45. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/CMakeLists.txt +0 -0
  46. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/cppfmu_common.hpp +0 -0
  47. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/cppfmu_cs.cpp +0 -0
  48. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/cppfmu_cs.hpp +0 -0
  49. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/fmi_functions.cpp +0 -0
  50. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/fmi/fmi3FunctionTypes.h +0 -0
  51. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/fmi/fmi3Functions.h +0 -0
  52. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/fmi/fmi3PlatformTypes.h +0 -0
  53. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/IPyState.hpp +0 -0
  54. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/PySlaveInstance.cpp +0 -0
  55. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/PySlaveInstance.hpp +0 -0
  56. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/PyState.hpp +0 -0
  57. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/shim/fmi3_forwarders.cpp +0 -0
  58. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/shim/shim_loader.cpp +0 -0
  59. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/shim/shim_loader.hpp +0 -0
  60. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/resources/binaries/x86_64-linux/libpythonfmu-backend.so +0 -0
  61. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/resources/binaries/x86_64-linux/libpythonfmu-export.so +0 -0
  62. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/__init__.py +0 -0
  63. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/conftest.py +0 -0
  64. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/data/csvdemo.csv +0 -0
  65. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/data/fmi3_host.c +0 -0
  66. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/data/hello.txt +0 -0
  67. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/fmi3_ctypes_driver.py +0 -0
  68. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/PythonSlaveWithException.py +0 -0
  69. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/localmodule.py +0 -0
  70. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslave.py +0 -0
  71. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslaveMX.py +0 -0
  72. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslaveMXCS.py +0 -0
  73. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslave_arraytypes.py +0 -0
  74. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslave_read_file.py +0 -0
  75. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/slavewithdep.py +0 -0
  76. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_deploy.py +0 -0
  77. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_docs.py +0 -0
  78. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_fmi3slave.py +0 -0
  79. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_integration.py +0 -0
  80. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_logger.py +0 -0
  81. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_multiple_fmus.py +0 -0
  82. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_non_python_host.py +0 -0
  83. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_osutil.py +0 -0
  84. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_variables.py +0 -0
  85. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_wrapper_ctypes.py +0 -0
  86. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/utils.py +0 -0
  87. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/unit.py +0 -0
  88. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/variable_types.py +0 -0
  89. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/variables.py +0 -0
  90. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/dependency_links.txt +0 -0
  91. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/entry_points.txt +0 -0
  92. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/requires.txt +0 -0
  93. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/top_level.txt +0 -0
  94. {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/setup.cfg +0 -0
@@ -7,5 +7,8 @@ recursive-include pythonfmu3/tests/slaves *
7
7
  recursive-include pythonfmu3/resources *
8
8
  recursive-include pythonfmu3/pythonfmu-export *
9
9
 
10
+ recursive-include pythonfmu3/studio/static *
11
+ recursive-include pythonfmu3/studio/templates *.py
12
+
10
13
  prune pythonfmu3/pythonfmu-export/build
11
14
  global-exclude __pycache__ *.py[cod]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonfmu3
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A lightweight framework that enables the packaging of Python 3 code as FMI 3.0 co-simulation and model exchange FMUs.
5
5
  Author: Stephen Smith
6
6
  License-Expression: MIT
@@ -74,6 +74,14 @@ On Windows, `python3.dll` must be on the `PATH`. See
74
74
  [Running FMUs outside Python](docs/install.md#running-fmus-outside-python-linux-and-macos)
75
75
  for the full lookup order.
76
76
 
77
+ ### PythonFMU3 Studio
78
+
79
+ `pythonfmu3 studio` opens a local web app to write, check, simulate, build and inspect
80
+ FMUs, with live plots, a model-description preview, an FMU inspector and templates. It
81
+ needs no extra dependencies. See the [Studio docs](docs/studio.md).
82
+
83
+ ![PythonFMU3 Studio](docs/assets/studio-simulate.png)
84
+
77
85
  ### How do I build an FMU from python code?
78
86
 
79
87
  1. Install `pythonfmu3` package:
@@ -39,6 +39,14 @@ On Windows, `python3.dll` must be on the `PATH`. See
39
39
  [Running FMUs outside Python](docs/install.md#running-fmus-outside-python-linux-and-macos)
40
40
  for the full lookup order.
41
41
 
42
+ ### PythonFMU3 Studio
43
+
44
+ `pythonfmu3 studio` opens a local web app to write, check, simulate, build and inspect
45
+ FMUs, with live plots, a model-description preview, an FMU inspector and templates. It
46
+ needs no extra dependencies. See the [Studio docs](docs/studio.md).
47
+
48
+ ![PythonFMU3 Studio](docs/assets/studio-simulate.png)
49
+
42
50
  ### How do I build an FMU from python code?
43
51
 
44
52
  1. Install `pythonfmu3` package:
@@ -58,6 +58,8 @@ pythonfmu3 = [
58
58
  "pythonfmu-export/**/*",
59
59
  "tests/data/*",
60
60
  "tests/slaves/*",
61
+ "studio/static/*",
62
+ "studio/templates/*.py",
61
63
  ]
62
64
 
63
65
  [tool.setuptools.exclude-package-data]
@@ -43,6 +43,14 @@ def cli_main():
43
43
  )
44
44
  deploy.create_command_parser(deploy_parser)
45
45
 
46
+ studio_parser = subparsers.add_parser(
47
+ "studio",
48
+ description="Open PythonFMU3 Studio, a local web app to edit, check, simulate, build and inspect FMUs.",
49
+ help="Open the PythonFMU3 Studio web app."
50
+ )
51
+ from .studio.server import create_command_parser as create_studio_parser
52
+ create_studio_parser(studio_parser)
53
+
46
54
  options = vars(parser.parse_args())
47
55
  execute = options.pop("execute")
48
56
  execute(**options)
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
@@ -0,0 +1 @@
1
+ """PythonFMU3 Studio: a local web app for building and checking FMUs (``pythonfmu3 studio``)."""
@@ -0,0 +1,358 @@
1
+ """PythonFMU3 Studio: a local web app to edit, check, simulate, build and inspect FMUs.
2
+
3
+ Start it with ``pythonfmu3 studio [folder]``. It uses only the standard library: a
4
+ threaded HTTP server on 127.0.0.1 serving the static app and a small JSON API. Model
5
+ code runs in a separate worker process per request (see worker.py).
6
+
7
+ Security: the server only listens on the loopback interface, only answers requests
8
+ whose Host header is the loopback address, and requires a random token (generated per
9
+ run and passed to the browser in the URL) on every API call, so other web pages open in
10
+ the browser can't drive it.
11
+ """
12
+ import argparse
13
+ import json
14
+ import mimetypes
15
+ import os
16
+ import re
17
+ import secrets
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import threading
23
+ import webbrowser
24
+ from http import HTTPStatus
25
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
+ from pathlib import Path
27
+ from typing import Any, Dict, Optional
28
+ from urllib.parse import parse_qs, quote, urlparse
29
+
30
+ from .._version import __version__
31
+
32
+ HERE = Path(__file__).parent
33
+ STATIC = HERE / "static"
34
+ TEMPLATES = HERE / "templates"
35
+ WORKER_TIMEOUT = 300
36
+ MAX_UPLOAD = 200 * 1024 * 1024
37
+ TEMPLATE_INFO = {
38
+ "pid_controller": ("PID controller", "Co-Simulation", "control",
39
+ "Setpoint and measurement in, actuator command out, with tunable gains and output limits.",
40
+ ["inputs", "tunable parameters"]),
41
+ "bouncing_ball": ("Bouncing ball", "Model Exchange", "me",
42
+ "Free fall with a state event at each impact and a restitution coefficient.",
43
+ ["events", "units"]),
44
+ "van_der_pol": ("Van der Pol", "Model Exchange", "me",
45
+ "A nonlinear oscillator with two continuous states, for testing solvers.",
46
+ ["continuous states"]),
47
+ "dahlquist": ("Dahlquist", "Model Exchange + Co-Simulation", "me cs",
48
+ "dx/dt = -k·x in both interfaces: the smallest model, with an exact solution.",
49
+ ["both interfaces"]),
50
+ "thermostat": ("Thermostat", "Co-Simulation", "control",
51
+ "A heated room with on/off control and hysteresis: Boolean output and a discrete state.",
52
+ ["Boolean", "discrete"]),
53
+ "surrogate": ("Polynomial surrogate", "Co-Simulation", "ml",
54
+ "A fitted NumPy polynomial exposed as an FMU: the pattern for ML surrogate models.",
55
+ ["numpy", "data-driven"]),
56
+ "signal_generator": ("Signal generator", "Co-Simulation", "cs",
57
+ "Sine, square and ramp signals selected by an Int32 mode: a test-bench source.",
58
+ ["Int32 mode", "outputs"]),
59
+ }
60
+
61
+
62
+ class StudioState:
63
+ def __init__(self, workspace: Path):
64
+ self.workspace = workspace.resolve()
65
+ self.token = secrets.token_urlsafe(24)
66
+ self.uploads = Path(tempfile.mkdtemp(prefix="pythonfmu3_studio_"))
67
+ self.last_build: Dict[str, str] = {}
68
+
69
+ def resolve(self, relative: str, must_exist: bool = True) -> Path:
70
+ """Resolve a workspace-relative path, refusing anything outside the workspace."""
71
+ if not relative or "\x00" in relative:
72
+ raise ValueError("empty path")
73
+ path = (self.workspace / relative).resolve()
74
+ if path != self.workspace and self.workspace not in path.parents:
75
+ raise PermissionError(f"{relative} is outside the workspace")
76
+ if must_exist and not path.exists():
77
+ raise FileNotFoundError(relative)
78
+ return path
79
+
80
+ def rel(self, path: Path) -> str:
81
+ return path.resolve().relative_to(self.workspace).as_posix()
82
+
83
+
84
+ def run_worker(request: Dict[str, Any], cwd: Path) -> Dict[str, Any]:
85
+ env = dict(os.environ)
86
+ package_root = str(HERE.parent.parent)
87
+ env["PYTHONPATH"] = package_root + os.pathsep + env.get("PYTHONPATH", "")
88
+ try:
89
+ proc = subprocess.run(
90
+ [sys.executable, "-m", "pythonfmu3.studio.worker"], input=json.dumps(request),
91
+ capture_output=True, text=True, timeout=WORKER_TIMEOUT, cwd=str(cwd), env=env,
92
+ )
93
+ except subprocess.TimeoutExpired:
94
+ return {"ok": False, "error": f"Timed out after {WORKER_TIMEOUT} s"}
95
+ try:
96
+ return json.loads(proc.stdout)
97
+ except json.JSONDecodeError:
98
+ return {"ok": False, "error": "The worker process failed", "traceback": (proc.stdout + proc.stderr)[-8000:]}
99
+
100
+
101
+ def environment() -> Dict[str, Any]:
102
+ from .worker import find_fmusim
103
+
104
+ def has(module):
105
+ import importlib.util
106
+ return importlib.util.find_spec(module) is not None
107
+
108
+ return {
109
+ "version": __version__,
110
+ "python": sys.version.split()[0],
111
+ "fmusim": find_fmusim(),
112
+ "fmpy": has("fmpy"),
113
+ "numpy": has("numpy"),
114
+ }
115
+
116
+
117
+ class Handler(BaseHTTPRequestHandler):
118
+ server_version = f"PythonFMU3Studio/{__version__}"
119
+ state: StudioState # set on the class by serve()
120
+
121
+ # -- plumbing ---------------------------------------------------------------------------
122
+
123
+ def log_message(self, fmt, *args): # keep the terminal quiet
124
+ if os.environ.get("PYTHONFMU3_STUDIO_DEBUG"):
125
+ super().log_message(fmt, *args)
126
+
127
+ def _send(self, status: int, body: bytes, content_type: str, extra: Optional[Dict[str, str]] = None):
128
+ self.send_response(status)
129
+ self.send_header("Content-Type", content_type)
130
+ self.send_header("Content-Length", str(len(body)))
131
+ self.send_header("Cache-Control", "no-store")
132
+ self.send_header("X-Content-Type-Options", "nosniff")
133
+ self.send_header("Referrer-Policy", "no-referrer")
134
+ for k, v in (extra or {}).items():
135
+ self.send_header(k, v)
136
+ self.end_headers()
137
+ self.wfile.write(body)
138
+
139
+ def _json(self, payload: Any, status: int = 200):
140
+ self._send(status, json.dumps(payload, default=str).encode("utf-8"), "application/json; charset=utf-8")
141
+
142
+ def _error(self, status: int, message: str, detail: str = ""):
143
+ self._json({"ok": False, "error": message, "traceback": detail}, status)
144
+
145
+ def _host_ok(self) -> bool:
146
+ host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]")
147
+ return host in ("127.0.0.1", "localhost", "::1")
148
+
149
+ def _token_ok(self, query: Dict[str, list]) -> bool:
150
+ supplied = self.headers.get("X-Studio-Token") or (query.get("token") or [""])[0]
151
+ return secrets.compare_digest(supplied, self.state.token)
152
+
153
+ def _body(self) -> bytes:
154
+ length = int(self.headers.get("Content-Length") or 0)
155
+ if length > MAX_UPLOAD:
156
+ raise ValueError("Upload too large")
157
+ return self.rfile.read(length) if length else b""
158
+
159
+ def _json_body(self) -> Dict[str, Any]:
160
+ data = self._body()
161
+ return json.loads(data or b"{}")
162
+
163
+ # -- routing ----------------------------------------------------------------------------
164
+
165
+ def do_GET(self):
166
+ self._dispatch("GET")
167
+
168
+ def do_POST(self):
169
+ self._dispatch("POST")
170
+
171
+ def _dispatch(self, method: str):
172
+ if not self._host_ok():
173
+ return self._error(HTTPStatus.FORBIDDEN, "Invalid Host header")
174
+ url = urlparse(self.path)
175
+ query = parse_qs(url.query)
176
+ if method == "GET" and not url.path.startswith("/api/"):
177
+ return self._static(url.path)
178
+ if not self._token_ok(query):
179
+ return self._error(HTTPStatus.FORBIDDEN, "Missing or invalid token")
180
+ route = getattr(self, f"api_{method.lower()}_{url.path[5:].replace('/', '_')}", None)
181
+ if route is None:
182
+ return self._error(HTTPStatus.NOT_FOUND, f"No such endpoint: {url.path}")
183
+ try:
184
+ route(query)
185
+ except (FileNotFoundError, PermissionError, ValueError, KeyError) as exc:
186
+ self._error(HTTPStatus.BAD_REQUEST, f"{type(exc).__name__}: {exc}")
187
+ except Exception as exc: # pragma: no cover - unexpected
188
+ import traceback
189
+ self._error(HTTPStatus.INTERNAL_SERVER_ERROR, f"{type(exc).__name__}: {exc}", traceback.format_exc())
190
+
191
+ def _static(self, path: str):
192
+ name = "index.html" if path in ("", "/") else path.lstrip("/")
193
+ target = (STATIC / name).resolve()
194
+ if STATIC.resolve() not in target.parents or not target.is_file():
195
+ return self._error(HTTPStatus.NOT_FOUND, "Not found")
196
+ ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
197
+ if ctype.startswith("text/") or ctype in ("application/javascript",):
198
+ ctype += "; charset=utf-8"
199
+ csp = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
200
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
201
+ self._send(200, target.read_bytes(), ctype, {"Content-Security-Policy": csp})
202
+
203
+ # -- API: environment and files ---------------------------------------------------------
204
+
205
+ def api_get_env(self, query):
206
+ info = environment()
207
+ info["workspace"] = str(self.state.workspace)
208
+ self._json({"ok": True, "result": info})
209
+
210
+ def api_get_files(self, query):
211
+ files = []
212
+ skip = {".git", "__pycache__", ".venv", "venv", "node_modules"}
213
+ for path in sorted(self.state.workspace.rglob("*")):
214
+ rel = path.relative_to(self.state.workspace)
215
+ if any(part in skip or part.startswith(".") for part in rel.parts[:-1]):
216
+ continue
217
+ if path.is_file() and path.suffix in (".py", ".fmu"):
218
+ files.append({"path": rel.as_posix(), "kind": path.suffix[1:], "size": path.stat().st_size})
219
+ if len(files) >= 500:
220
+ break
221
+ self._json({"ok": True, "result": files})
222
+
223
+ def api_get_file(self, query):
224
+ path = self.state.resolve(query["path"][0])
225
+ if path.suffix != ".py":
226
+ raise ValueError("Only .py files can be opened in the editor")
227
+ self._json({"ok": True, "result": {"path": self.state.rel(path), "content": path.read_text(encoding="utf-8")}})
228
+
229
+ def api_post_file(self, query):
230
+ body = self._json_body()
231
+ path = self.state.resolve(body["path"], must_exist=False)
232
+ if path.suffix != ".py":
233
+ raise ValueError("Only .py files can be saved")
234
+ path.parent.mkdir(parents=True, exist_ok=True)
235
+ path.write_text(body["content"], encoding="utf-8")
236
+ self._json({"ok": True, "result": {"path": self.state.rel(path)}})
237
+
238
+ # -- API: model operations (worker process) ---------------------------------------------
239
+
240
+ def _model_request(self, command: str, body: Dict[str, Any]):
241
+ path = self.state.resolve(body["path"])
242
+ request = dict(body, command=command, path=str(path))
243
+ return path, run_worker(request, path.parent)
244
+
245
+ def api_post_analyze(self, query):
246
+ _, response = self._model_request("analyze", self._json_body())
247
+ self._json(response)
248
+
249
+ def api_post_simulate(self, query):
250
+ _, response = self._model_request("simulate", self._json_body())
251
+ self._json(response)
252
+
253
+ def api_post_build(self, query):
254
+ body = self._json_body()
255
+ path = self.state.resolve(body["path"])
256
+ response = run_worker({"command": "build", "path": str(path),
257
+ "dest": str(path.parent / "build")}, path.parent)
258
+ if response.get("ok"):
259
+ fmu = Path(response["result"]["fmu"])
260
+ response["result"]["fmu"] = self.state.rel(fmu)
261
+ self.state.last_build[self.state.rel(path)] = self.state.rel(fmu)
262
+ self._json(response)
263
+
264
+ def api_post_validate(self, query):
265
+ body = self._json_body()
266
+ fmu = self.state.resolve(body["fmu"])
267
+ self._json(run_worker({"command": "validate", "fmu": str(fmu)}, self.state.workspace))
268
+
269
+ def api_get_download(self, query):
270
+ path = self.state.resolve(query["path"][0])
271
+ if path.suffix != ".fmu":
272
+ raise ValueError("Only FMUs can be downloaded")
273
+ self._send(200, path.read_bytes(), "application/octet-stream",
274
+ {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(path.name)}"})
275
+
276
+ def api_post_inspect(self, query):
277
+ if (self.headers.get("Content-Type") or "").startswith("application/json"):
278
+ body = self._json_body()
279
+ fmu = self.state.resolve(body["path"])
280
+ name = fmu.name
281
+ else:
282
+ name = re.sub(r"[^A-Za-z0-9._-]", "_", (query.get("name") or ["upload.fmu"])[0]) or "upload.fmu"
283
+ fmu = self.state.uploads / secrets.token_hex(8) / name
284
+ fmu.parent.mkdir(parents=True)
285
+ fmu.write_bytes(self._body())
286
+ response = run_worker({"command": "inspect", "path": str(fmu), "name": name}, self.state.workspace)
287
+ if response.get("ok") and environment()["fmusim"]:
288
+ response["result"]["validation"] = run_worker({"command": "validate", "fmu": str(fmu)},
289
+ self.state.workspace).get("result")
290
+ self._json(response)
291
+
292
+ # -- API: templates -----------------------------------------------------------------------
293
+
294
+ def api_get_templates(self, query):
295
+ items = []
296
+ for key, (title, iface, cats, desc, tags) in TEMPLATE_INFO.items():
297
+ if (TEMPLATES / f"{key}.py").exists():
298
+ items.append({"id": key, "title": title, "interface": iface, "categories": cats.split(),
299
+ "description": desc, "tags": tags})
300
+ self._json({"ok": True, "result": items})
301
+
302
+ def api_post_templates_use(self, query):
303
+ body = self._json_body()
304
+ key = body["id"]
305
+ if key not in TEMPLATE_INFO:
306
+ raise KeyError(key)
307
+ target = self.state.resolve(body.get("name") or f"{key}.py", must_exist=False)
308
+ if target.suffix != ".py":
309
+ raise ValueError("The file name must end in .py")
310
+ if target.exists():
311
+ raise ValueError(f"{self.state.rel(target)} already exists")
312
+ target.parent.mkdir(parents=True, exist_ok=True)
313
+ shutil.copyfile(TEMPLATES / f"{key}.py", target)
314
+ self._json({"ok": True, "result": {"path": self.state.rel(target)}})
315
+
316
+
317
+ def serve(workspace: Path, port: int = 0, open_browser: bool = True) -> ThreadingHTTPServer:
318
+ state = StudioState(workspace)
319
+ handler = type("StudioHandler", (Handler,), {"state": state})
320
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), handler)
321
+ httpd.studio_state = state
322
+ url = f"http://127.0.0.1:{httpd.server_address[1]}/?token={state.token}"
323
+ httpd.studio_url = url
324
+ if open_browser:
325
+ threading.Timer(0.3, lambda: webbrowser.open(url)).start()
326
+ return httpd
327
+
328
+
329
+ def main(folder: str = ".", port: int = 0, no_browser: bool = False, **_):
330
+ workspace = Path(folder).resolve()
331
+ if not workspace.is_dir():
332
+ raise SystemExit(f"{workspace} is not a folder")
333
+ httpd = serve(workspace, port, open_browser=not no_browser)
334
+ print(f"PythonFMU3 Studio {__version__}")
335
+ print(f" workspace: {workspace}")
336
+ print(f" open: {httpd.studio_url}")
337
+ print(" (Ctrl+C to stop)")
338
+ try:
339
+ httpd.serve_forever()
340
+ except KeyboardInterrupt:
341
+ pass
342
+ finally:
343
+ httpd.server_close()
344
+ shutil.rmtree(httpd.studio_state.uploads, ignore_errors=True)
345
+
346
+
347
+ def create_command_parser(parser: argparse.ArgumentParser):
348
+ parser.add_argument("folder", nargs="?", default=".", help="Workspace folder with your model scripts (default: current folder).")
349
+ parser.add_argument("-p", "--port", type=int, default=0, help="Port to listen on (default: a free port).")
350
+ parser.add_argument("--no-browser", action="store_true", help="Don't open a browser window.")
351
+ parser.set_defaults(execute=main)
352
+
353
+
354
+ if __name__ == "__main__":
355
+ p = argparse.ArgumentParser(prog="pythonfmu3 studio")
356
+ create_command_parser(p)
357
+ args = vars(p.parse_args())
358
+ args.pop("execute")(**args)
@@ -0,0 +1,179 @@
1
+ :root {
2
+ --blue: #2F5D8A; --blue-dark: #1F4468; --blue-soft: #E8EEF5;
3
+ --gold: #E8B11C; --gold-soft: #FEF6DD; --gold-ink: #6B4E00;
4
+ --ink: #1F2933; --ink-2: #46515E; --muted: #5B6673;
5
+ --bg: #F4F6F9; --panel: #FFFFFF; --line: #E3E8EF; --line-2: #C9D2DE;
6
+ --ok-bg: #ECFDF3; --ok: #05603A; --warn-bg: #FEF0C7; --warn: #7A2E0E; --err-bg: #FEE4E2; --err: #912018;
7
+ --code-bg: #0F1720; --code-fg: #D6DEEB; --code-gutter: #6B7A8A;
8
+ --mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
9
+ --sans: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
10
+ }
11
+ * { box-sizing: border-box; }
12
+ html, body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 14px; }
13
+ body { min-height: 100vh; display: flex; flex-direction: column; }
14
+ h1 { font-size: 26px; margin: 0 0 6px; }
15
+ h2 { font-size: 16px; margin: 0; }
16
+ h3 { font-size: 13px; margin: 14px 0 8px; color: var(--ink-2); }
17
+ button, input, select, textarea { font: inherit; color: inherit; }
18
+ a { color: var(--blue); }
19
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
20
+ .muted { color: var(--muted); }
21
+ .small { font-size: 12px; }
22
+ .spacer, .grow { flex: 1 1 auto; }
23
+ .push { margin-left: auto; }
24
+ [hidden] { display: none !important; }
25
+
26
+ /* top bar */
27
+ .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; padding: 10px 20px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 5; }
28
+ .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; }
29
+ .accent { color: var(--blue); }
30
+ .tabs { display: flex; flex-wrap: wrap; gap: 4px; }
31
+ .tab { min-height: 38px; padding: 0 14px; border: none; border-radius: 8px; background: transparent; color: var(--ink-2); font-weight: 500; cursor: pointer; }
32
+ .tab:hover { background: var(--bg); }
33
+ .tab[aria-current="page"] { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; }
34
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
35
+ .file-picker select, select, input[type=number], input[type=text], input[type=search] { min-height: 38px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 8px; background: #fff; }
36
+ .file-picker select { font-family: var(--mono); font-size: 13px; max-width: 260px; }
37
+ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 38px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--line-2); background: #fff; color: var(--ink); font-weight: 600; cursor: pointer; text-decoration: none; }
38
+ .btn:hover { border-color: var(--blue); }
39
+ .btn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
40
+ .btn.primary:hover { background: var(--blue-dark); }
41
+ .btn.gold { background: var(--gold); border-color: var(--gold); color: var(--ink); }
42
+ .btn:disabled { opacity: 0.55; cursor: progress; }
43
+ .btn-row { display: flex; flex-wrap: wrap; gap: 8px; }
44
+ :focus-visible { outline: 3px solid #7FB0DE; outline-offset: 2px; }
45
+
46
+ .banner { margin: 14px 20px 0; padding: 10px 14px; border-radius: 8px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
47
+ .banner.ok { background: #DCFAE6; color: var(--ok); }
48
+ .banner.error { background: var(--err-bg); color: var(--err); }
49
+ .banner.info { background: var(--blue-soft); color: var(--blue-dark); }
50
+ .banner pre { flex-basis: 100%; margin: 0; white-space: pre-wrap; font-family: var(--mono); font-size: 12px; max-height: 240px; overflow: auto; }
51
+
52
+ main { flex: 1 1 auto; padding: 16px 20px 20px; }
53
+ .view { display: flex; flex-direction: column; gap: 16px; }
54
+ .view.narrow { max-width: 1240px; margin: 0 auto; width: 100%; }
55
+ .row { display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch; }
56
+ .row.top { align-items: flex-start; }
57
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; min-width: 0; }
58
+ .panel-head { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
59
+
60
+ /* code editor */
61
+ .code-panel { flex: 999 1 560px; display: flex; flex-direction: column; background: var(--code-bg); border-color: var(--code-bg); height: min(72vh, 760px); min-height: 420px; }
62
+ .code-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid #22303D; color: #A9B4C2; font-size: 13px; font-family: var(--mono); }
63
+ .code-head .muted { color: #8A99A8; font-family: var(--sans); }
64
+ .dirty { color: var(--gold); font-family: var(--sans); font-weight: 600; }
65
+ .code-body { position: relative; display: flex; flex: 1 1 auto; min-height: 0; }
66
+ .gutter { margin: 0; padding: 14px 12px 14px 0; width: 48px; flex: none; text-align: right; color: var(--code-gutter); font: 13px/21px var(--mono); overflow: hidden; user-select: none; }
67
+ #code { flex: 1 1 auto; min-width: 0; border: none; resize: none; margin: 0; padding: 14px 14px 14px 4px; background: transparent; color: var(--code-fg); font: 13px/21px var(--mono); tab-size: 4; outline: none; white-space: pre; overflow: auto; }
68
+
69
+ /* preview */
70
+ .preview { flex: 1 1 420px; display: flex; flex-direction: column; height: min(72vh, 760px); min-height: 420px; }
71
+ .subtabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; border-bottom: 1px solid var(--line); }
72
+ .subtab { min-height: 38px; padding: 0 12px; border: none; border-radius: 8px; background: transparent; color: var(--ink-2); cursor: pointer; }
73
+ .subtab[aria-selected="true"] { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; }
74
+ .badge { margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 12px; background: #DCFAE6; color: var(--ok); }
75
+ .badge.bad { background: var(--warn-bg); color: var(--warn); }
76
+ .preview-body { flex: 1 1 auto; overflow: auto; padding: 16px; min-height: 0; }
77
+ .xml { margin: 0; font: 12px/20px var(--mono); background: #FAFBFC; white-space: pre; }
78
+ .checks { display: flex; flex-direction: column; gap: 10px; }
79
+ .check-item { display: flex; gap: 10px; padding: 10px 12px; border-radius: 8px; }
80
+ .check-item strong { width: 56px; flex: none; font-size: 12px; }
81
+ .check-item .title { font-weight: 600; }
82
+ .check-item.ok { background: var(--ok-bg); color: var(--ok); }
83
+ .check-item.warning { background: var(--warn-bg); color: var(--warn); }
84
+ .check-item.error { background: var(--err-bg); color: var(--err); }
85
+
86
+ /* FMU block */
87
+ .block-wrap { display: flex; flex-direction: column; align-items: center; gap: 18px; padding: 12px 0; }
88
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
89
+ .chip-param { padding: 5px 10px; border-radius: 999px; background: var(--gold-soft); color: var(--gold-ink); font-size: 13px; font-weight: 500; }
90
+ .block-row { display: flex; align-items: center; justify-content: center; width: 100%; max-width: 520px; }
91
+ .ports { display: flex; flex-direction: column; gap: 12px; flex: 1 1 0; min-width: 0; }
92
+ .ports.in { align-items: flex-end; }
93
+ .ports.out { align-items: flex-start; }
94
+ .port { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); }
95
+ .port i { width: 20px; height: 12px; border-radius: 3px; background: var(--gold); flex: none; }
96
+ .port span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
97
+ .block { width: 150px; min-height: 150px; flex: none; border-radius: 22px; background: var(--blue); color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 12px; text-align: center; }
98
+ .block b { font-size: 15px; word-break: break-word; }
99
+ .block small { color: #D6E2EE; }
100
+
101
+ /* tables */
102
+ .table-wrap { overflow-x: auto; }
103
+ .table { width: 100%; border-collapse: collapse; min-width: 760px; }
104
+ .table th { text-align: left; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; padding: 10px 8px; }
105
+ .table td { padding: 8px; border-top: 1px solid #EEF1F5; vertical-align: top; }
106
+ .table th:first-child, .table td:first-child { padding-left: 16px; }
107
+ .mono { font-family: var(--mono); font-size: 13px; }
108
+ .pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: var(--blue-soft); color: var(--blue-dark); }
109
+ .caus-input { background: var(--gold-soft); color: var(--gold-ink); }
110
+ .caus-output { background: #E3F2EA; color: var(--ok); }
111
+ .caus-structuralParameter { background: #EDE7F6; color: #4A2C83; }
112
+ .caus-independent, .caus-local { background: #F2F4F7; color: var(--ink-2); }
113
+ .flag { font-size: 12px; padding: 3px 8px; border-radius: 6px; }
114
+ .flag.warning { background: var(--warn-bg); color: var(--warn); }
115
+ .flag.error { background: var(--err-bg); color: var(--err); }
116
+ .flag.ok { color: var(--ok); }
117
+
118
+ /* simulate */
119
+ .settings { flex: 1 1 320px; max-width: 100%; padding: 18px; display: flex; flex-direction: column; gap: 14px; overflow: visible; }
120
+ fieldset { border: none; margin: 0; padding: 0; }
121
+ legend { font-size: 13px; font-weight: 600; color: var(--ink-2); margin-bottom: 8px; }
122
+ .grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
123
+ .grid2 select, .grid2 input[type=number] { width: 100%; }
124
+ .grid2 label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; font-weight: 600; color: var(--ink-2); }
125
+ .grid2 label.check { flex-direction: row; align-items: center; gap: 8px; font-weight: 500; }
126
+ .chip-btn { min-height: 38px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line-2); background: #fff; cursor: pointer; }
127
+ .chip-btn[aria-pressed="true"] { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; }
128
+ .chip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
129
+ .params { display: flex; flex-direction: column; gap: 12px; }
130
+ .param { display: flex; flex-direction: column; gap: 4px; }
131
+ .param-head { display: flex; align-items: center; gap: 8px; }
132
+ .param-head .mono { font-weight: 600; }
133
+ .param-head input { width: 110px; margin-left: auto; min-height: 32px; }
134
+ .param input[type=range] { width: 100%; accent-color: var(--blue); }
135
+ .results { flex: 999 1 600px; min-width: 0; display: flex; flex-direction: column; gap: 14px; }
136
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
137
+ .stat { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
138
+ .stat .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
139
+ .stat .v { font-size: 24px; font-weight: 700; margin-top: 4px; }
140
+ .series-picker { display: flex; flex-wrap: wrap; gap: 6px; }
141
+ .series-picker label { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 999px; background: #fff; font-family: var(--mono); font-size: 12px; cursor: pointer; }
142
+ .series-picker input { accent-color: var(--blue); }
143
+ .charts { display: flex; flex-direction: column; gap: 14px; }
144
+ .chart { margin: 0; background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px 8px; }
145
+ .chart figcaption { display: flex; gap: 10px; align-items: baseline; font-weight: 600; margin-bottom: 4px; }
146
+ .chart figcaption .mono { font-weight: 400; color: var(--muted); }
147
+ .chart svg { display: block; width: 100%; height: auto; }
148
+ .chart .readout { margin-left: auto; font-family: var(--mono); font-size: 12px; color: var(--ink-2); font-weight: 400; }
149
+ .empty { padding: 40px 16px; text-align: center; color: var(--muted); background: #fff; border: 1px dashed var(--line-2); border-radius: 12px; }
150
+ .log summary { padding: 12px 16px; cursor: pointer; font-weight: 600; }
151
+ .log pre { margin: 0; padding: 0 16px 16px; font: 12px/18px var(--mono); white-space: pre-wrap; max-height: 300px; overflow: auto; }
152
+
153
+ /* inspector */
154
+ .drop { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; padding: 16px 18px; border: 2px dashed #B8C3D0; border-radius: 12px; background: #fff; }
155
+ .drop.over { border-color: var(--blue); background: var(--blue-soft); }
156
+ .drop .grow { display: flex; flex-direction: column; gap: 2px; min-width: 220px; }
157
+ .summary { display: flex; flex-direction: column; gap: 12px; padding: 18px; }
158
+ .summary dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 24px; }
159
+ .summary dt { color: var(--muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
160
+ .summary dd { margin: 2px 0 0; word-break: break-word; }
161
+ .tag { padding: 4px 10px; border-radius: 999px; background: #F2F4F7; color: var(--ink-2); font-size: 13px; }
162
+ .tag.strong { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; }
163
+
164
+ /* templates */
165
+ .toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0; }
166
+ .search { flex: 1 1 260px; }
167
+ .search input { width: 100%; }
168
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
169
+ .card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 18px; }
170
+ .card h2 { font-size: 16px; }
171
+ .card p { margin: 0; color: var(--ink-2); flex: 1 1 auto; }
172
+
173
+ .statusbar { display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 20px; border-top: 1px solid var(--line); background: #fff; color: var(--muted); font-size: 12px; }
174
+
175
+ @media (max-width: 640px) {
176
+ .topbar { padding: 10px 16px; }
177
+ main { padding: 12px 16px; }
178
+ .grid2 { grid-template-columns: minmax(0, 1fr); }
179
+ }