adtserapio commited on
Commit
c2858c1
·
0 Parent(s):

Deploy EHRGym to Hugging Face Space

Browse files
.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ DATABASE_URL="file:./dev.db"
2
+ EHR_BASE_URL="http://127.0.0.1:3000"
3
+ PLAYWRIGHT_HEADLESS="true"
4
+ OPENENV_DEFAULT_WAIT_MS="350"
.gitignore ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MacOS
2
+ .DS_Store
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[codz]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ node_modules/
16
+ .next/
17
+ *.tsbuildinfo
18
+ prisma/dev.db
19
+ prisma/dev.db-journal
20
+ develop-eggs/
21
+ dist/
22
+ downloads/
23
+ eggs/
24
+ .eggs/
25
+ lib/
26
+ lib64/
27
+ parts/
28
+ sdist/
29
+ var/
30
+ wheels/
31
+ share/python-wheels/
32
+ *.egg-info/
33
+ .installed.cfg
34
+ *.egg
35
+ MANIFEST
36
+
37
+ # PyInstaller
38
+ # Usually these files are written by a python script from a template
39
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
40
+ *.manifest
41
+ *.spec
42
+
43
+ # Installer logs
44
+ pip-log.txt
45
+ pip-delete-this-directory.txt
46
+
47
+ # Unit test / coverage reports
48
+ htmlcov/
49
+ .tox/
50
+ .nox/
51
+ .coverage
52
+ .coverage.*
53
+ .cache
54
+ nosetests.xml
55
+ coverage.xml
56
+ *.cover
57
+ *.py.cover
58
+ .hypothesis/
59
+ .pytest_cache/
60
+ cover/
61
+
62
+ # Translations
63
+ *.mo
64
+ *.pot
65
+
66
+ # Django stuff:
67
+ *.log
68
+ local_settings.py
69
+ db.sqlite3
70
+ db.sqlite3-journal
71
+
72
+ # Flask stuff:
73
+ instance/
74
+ .webassets-cache
75
+
76
+ # Scrapy stuff:
77
+ .scrapy
78
+
79
+ # Sphinx documentation
80
+ docs/_build/
81
+
82
+ # PyBuilder
83
+ .pybuilder/
84
+ target/
85
+
86
+ # Jupyter Notebook
87
+ .ipynb_checkpoints
88
+
89
+ # IPython
90
+ profile_default/
91
+ ipython_config.py
92
+
93
+ # pyenv
94
+ # For a library or package, you might want to ignore these files since the code is
95
+ # intended to run in multiple environments; otherwise, check them in:
96
+ # .python-version
97
+
98
+ # pipenv
99
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
100
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
101
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
102
+ # install all needed dependencies.
103
+ #Pipfile.lock
104
+
105
+ # UV
106
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
107
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
108
+ # commonly ignored for libraries.
109
+ #uv.lock
110
+
111
+ # poetry
112
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
113
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
114
+ # commonly ignored for libraries.
115
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
116
+ #poetry.lock
117
+ #poetry.toml
118
+
119
+ # pdm
120
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
121
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
122
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
123
+ #pdm.lock
124
+ #pdm.toml
125
+ .pdm-python
126
+ .pdm-build/
127
+
128
+ # pixi
129
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
130
+ #pixi.lock
131
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
132
+ # in the .venv directory. It is recommended not to include this directory in version control.
133
+ .pixi
134
+
135
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
136
+ __pypackages__/
137
+
138
+ # Celery stuff
139
+ celerybeat-schedule
140
+ celerybeat.pid
141
+
142
+ # SageMath parsed files
143
+ *.sage.py
144
+
145
+ # Environments
146
+ .env
147
+ .envrc
148
+ .venv
149
+ env/
150
+ venv/
151
+ ENV/
152
+ env.bak/
153
+ venv.bak/
154
+
155
+ # Spyder project settings
156
+ .spyderproject
157
+ .spyproject
158
+
159
+ # Rope project settings
160
+ .ropeproject
161
+
162
+ # mkdocs documentation
163
+ /site
164
+
165
+ # mypy
166
+ .mypy_cache/
167
+ .dmypy.json
168
+ dmypy.json
169
+
170
+ # Pyre type checker
171
+ .pyre/
172
+
173
+ # pytype static type analyzer
174
+ .pytype/
175
+
176
+ # Cython debug symbols
177
+ cython_debug/
178
+
179
+ # PyCharm
180
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
181
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
182
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
183
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
184
+ #.idea/
185
+
186
+ # Abstra
187
+ # Abstra is an AI-powered process automation framework.
188
+ # Ignore directories containing user credentials, local state, and settings.
189
+ # Learn more at https://abstra.io/docs
190
+ .abstra/
191
+
192
+ # Visual Studio Code
193
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
194
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
195
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
196
+ # you could uncomment the following to ignore the entire vscode folder
197
+ # .vscode/
198
+
199
+ # Ruff stuff:
200
+ .ruff_cache/
201
+
202
+ # PyPI configuration file
203
+ .pypirc
204
+
205
+ # Cursor
206
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
207
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
208
+ # refer to https://docs.cursor.com/context/ignore-files
209
+ .cursorignore
210
+ .cursorindexingignore
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
.vscode/settings.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "python-envs.defaultEnvManager": "ms-python.python:conda",
3
+ "python-envs.defaultPackageManager": "ms-python.python:conda"
4
+ }
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-bookworm
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+ COPY . /app
9
+
10
+ ENV PORT=7860 \
11
+ DATABASE_URL=file:/app/prisma/dev.db \
12
+ EHR_BASE_URL=http://127.0.0.1:7860 \
13
+ PLAYWRIGHT_HEADLESS=true \
14
+ OPENENV_DEFAULT_WAIT_MS=350
15
+
16
+ RUN npm install \
17
+ && python3 -m pip install --no-cache-dir . \
18
+ && python3 -m playwright install --with-deps chromium \
19
+ && npx prisma generate \
20
+ && npx prisma db push \
21
+ && npx prisma db seed \
22
+ && npm run build:ehr
23
+
24
+ EXPOSE 7860
25
+ ENTRYPOINT ["./docker/entrypoint.sh"]
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EHRGym
2
+
3
+ **EHRGym** is a containerized environment for training and evaluating computer-use agents in an Epic-like electronic health record (EHR) workflow.
4
+
5
+ It combines:
6
+
7
+ - A web-based EHR built with **Next.js + TypeScript**
8
+ - An **OpenEnv-compliant environment server** built with **FastAPI + Playwright**
9
+
10
+ The environment exposes `reset()`, `step(action)`, and a `state` object so an agent can interact with the EHR through a real browser.
11
+
12
+ > **Note:** This project uses **synthetic data only** (no PHI).
13
+ > Not affiliated with or endorsed by Epic Systems.
14
+
15
+ ---
16
+
17
+ ## Table of contents
18
+
19
+ - [Clinical focus (initial)](#clinical-focus-initial)
20
+ - [What you get](#what-you-get)
21
+ - [Goals](#goals)
22
+ - [Non-goals (initial)](#non-goals-initial)
23
+ - [Architecture (one environment instance)](#architecture-one-environment-instance)
24
+ - [EHR UI layout (Epic-like)](#ehr-ui-layout-epic-like)
25
+ - [OpenEnv interface](#openenv-interface)
26
+ - [Tasks (provider-focused)](#tasks-provider-focused)
27
+ - [Synthetic patients](#synthetic-patients)
28
+ - [Performance & training approach](#performance--training-approach)
29
+ - [Logging & evaluation](#logging--evaluation)
30
+ - [Repository layout (proposed)](#repository-layout-proposed)
31
+ - [Quickstart (placeholder)](#quickstart-placeholder)
32
+ - [Contributing](#contributing)
33
+ - [License](#license)
34
+
35
+ ---
36
+
37
+ ## Clinical focus (initial)
38
+
39
+ Provider workflows:
40
+
41
+ - Reviewing the chart (encounters, labs, prior notes)
42
+ - Writing progress and encounter notes
43
+ - Placing and signing orders
44
+
45
+ ---
46
+
47
+ ## What you get
48
+
49
+ - **Epic-like charting UI**
50
+ - Chart Review (Encounters / Labs / Clinical Notes)
51
+ - Notes authoring
52
+ - Orders with signing workflow
53
+ - Encounter sign/close
54
+
55
+ - **OpenEnv-compliant RL environment**
56
+ - Typed `Action`, `Observation`, `State`
57
+ - `reset()` / `step()` / `state()`
58
+ - Real browser interaction (Playwright)
59
+
60
+ - **Task library**
61
+ - Chart review → note → orders → sign/close
62
+
63
+ - **Synthetic patient pipeline**
64
+ - Baseline: **Synthea + FHIR-shaped ingest**
65
+
66
+ ---
67
+
68
+ ## Goals
69
+
70
+ - OpenEnv compliance with typed `Action` / `Observation` / `State` models
71
+ - Docker-first deployment and reproducible containers
72
+ - Next.js EHR interface supporting:
73
+ - chart review (encounters, labs, clinical notes)
74
+ - order entry (labs / meds / imaging) with sign workflow
75
+ - note authoring (progress & encounter notes)
76
+ - Task-based RL episodes (patient + scenario + objective + scoring rubric)
77
+ - Synthetic patients only (no PHI), with realistic longitudinal timelines and standard coding where feasible
78
+
79
+ ---
80
+
81
+ ## Out-of-Scope
82
+
83
+ - Pixel-perfect Epic cloning (We emulate workflows & info layout)
84
+ - Full enterprise EHR scope on day one (MAR, billing, scheduling, in-basket, prior auth, etc.)
85
+
86
+ ---
87
+
88
+ ## Architecture
89
+
90
+ A single container runs two processes:
91
+
92
+ 1. **Next.js EHR app (port 3000)**
93
+ - Serves the UI and required API routes (patient data, notes, orders, signing)
94
+ 2. **OpenEnv environment server (port 8000)**
95
+ - FastAPI server exposing OpenEnv API
96
+ - Launches and controls headless Chromium via Playwright
97
+ - Implements `reset()`, `step()`, `state`, scenario sampling, and reward computation
98
+
99
+ **Data layer**
100
+ - SQLite via Prisma (portable and fast)
101
+ - On `reset()`, the environment recreates/truncates the DB and reseeds patients, encounters, labs, notes, orders, and scenario ground truth. Optionally use a DB snapshot + copy-on-reset for speed.
102
+
103
+ ---
104
+
105
+ ## EHR UI layout (Epic-like)
106
+
107
+ - **Entry view:** patient list / schedule-like page → select patient → open chart
108
+ - **Chart shell**
109
+ - Activity sidebar: Summary, Chart Review, Orders, Notes (optional), Encounter (close/sign)
110
+ - Patient banner: synthetic demographics and key flags (synthetic ID, age/sex, allergies)
111
+ - **Chart Review tabs**
112
+ - Encounters: timeline, encounter detail, linked notes/orders
113
+ - Labs: table + trend view, filtering, abnormal flags
114
+ - Clinical Notes: list by type/date/author, open note
115
+ - **Notes**
116
+ - Create Progress Note tied to current encounter
117
+ - Structured sections (SOAP)
118
+ - Problem-oriented A/P that links naturally to orders
119
+ - **Orders**
120
+ - Search/select from constrained preference list
121
+ - Configure parameters (dose/frequency, lab timing)
122
+ - Statuses: Draft → Pending Signature → Signed
123
+
124
+ **RL instrumentation**
125
+ - Stable selectors (`data-testid` / `data-qa`) for tabs, lab rows, order rows, note controls
126
+ - Accessible labels (`aria-label`) so agents can use the accessibility tree
127
+
128
+ ---
129
+
130
+ ## OpenEnv Interface
131
+
132
+ **Actions**
133
+ - Low-level computer-use actions (mouse clicks, drag, scroll, keypress, type, wait)
134
+ - Optional high-level actions for curriculum/debug (e.g., `click(selector)`, `fill(selector,text)`, `goto(path)`, `select_patient(patient_id)`)
135
+
136
+ **Observations**
137
+ - Goal/instruction text
138
+ - Downscaled screenshot (base64 PNG)
139
+ - Current route/URL and active activity context
140
+ - Optional DOM snapshot and/or accessibility tree
141
+ - Metadata (timing, action success, structured errors)
142
+
143
+ **State**
144
+ - `episode_id`, `step_count` + environment fields:
145
+ - `patient_id`, `encounter_id`, `scenario_id`
146
+ - `rubric_progress`
147
+ - `cumulative_reward`
148
+
149
+ **Rewarding**
150
+ - Terminal success when objective is satisfied (e.g., correct note signed + correct orders signed)
151
+ - Shaping rewards for meaningful substeps (navigate, find target lab, place required order, sign)
152
+ - Penalties for invalid actions, navigation errors, unsafe/irrelevant orders, excessive steps
153
+
154
+ ---
155
+
156
+ ## Tasks
157
+
158
+ Scenarios are packaged as specs and optionally generated at reset. Example task families:
159
+
160
+ - **Chart Review → Labs**
161
+ - Find most recent creatinine; evaluate AKI criteria
162
+ - Trend hemoglobin over last 3 values; document in progress note
163
+ - **Chart Review → Encounters**
164
+ - Locate discharge summary; extract follow-up plan
165
+ - Identify prior antibiotic exposure from previous encounter orders
166
+ - **Clinical Notes**
167
+ - Open most recent consult; summarize recommendations
168
+ - **Progress note authoring**
169
+ - Complete SOAP note with required elements and grounded facts
170
+ - **Orders**
171
+ - Place specific orders with correct parameters; sign
172
+ - **Close/finish encounter**
173
+ - Signed note + signed orders + required fields
174
+
175
+ **Curriculum**
176
+ - Phase 0: unit skills (navigate, open tabs, filter labs, open note)
177
+ - Phase 1: single objective (place one order, sign one note)
178
+ - Phase 2: multi-step (review → note → orders → sign/close)
179
+
180
+ ---
181
+
182
+ ## Synthetic patients
183
+
184
+ Baseline approach:
185
+
186
+ - Use Synthea to generate longitudinal synthetic records (encounters, conditions, meds, labs/vitals, procedures, etc.), exportable as FHIR
187
+ - Treat FHIR R4 concepts as the internal “shape” even if stored relationally
188
+ - Use standard coding when feasible:
189
+ - LOINC for labs
190
+ - SNOMED CT for problems/findings/procedures
191
+ - RxNorm for meds
192
+
193
+ **Notes gap (free-text)**
194
+ - Template-based notes from structured facts (easy to score, less diverse)
195
+ - Constrained LLM-generated notes grounded strictly in chart facts (more realistic, needs guardrails)
196
+ - Hybrid: deterministic skeleton + constrained paraphrase
197
+
198
+ **Scenarios** layer on top of base patients as teaching cases (e.g., DKA, CHF, pneumonia, AKI, GI bleed) with explicit ground truth objectives:
199
+ - required orders
200
+ - required note elements
201
+ - critical facts that must appear in the note
202
+
203
+ ---
204
+
205
+ ## Performance and Training Approach
206
+
207
+ - Browser simulation throughput is usually the bottleneck, not GPU
208
+ - Start with demonstrations (scripted Playwright expert) → supervised behavioral cloning
209
+ - Move to RL after BC reliably solves simpler tasks
210
+ - Run a modest number of env containers concurrently (e.g., 4–16)
211
+ - Keep observations efficient (downscale screenshots; optionally omit DOM/a11y on “easy mode”)
212
+
213
+ ---
214
+
215
+ ## Logging and Evaluation
216
+
217
+ **Logging per step**
218
+ - Action, success/failure, reward components, UI errors
219
+
220
+ **Episode artifacts**
221
+ - Final note text
222
+ - Orders placed/signed
223
+ - Optional screenshots for debugging
224
+
225
+ **Evaluation**
226
+ - Deterministic test suites with fixed seeds
227
+ - Metrics: task success rate, steps-to-completion, unsafe/irrelevant order rate, note completeness/grounding
228
+
229
+ **Safety**
230
+ - Synthetic data only (no PHI)
231
+ - Constrained formulary and order catalog
232
+ - If LLM-generated notes are used, enforce grounding checks (facts must be supported by chart)
233
+
234
+ ---
235
+
236
+ ## Repository layout
237
+
238
+ ```
239
+ apps/ehr/ Next.js EHR UI (TypeScript)
240
+ env_server/ FastAPI OpenEnv server + Playwright control
241
+ tasks/ scenario specs, rubrics, fixtures
242
+ synthetic/ Synthea generation + FHIR ingest + seed tooling
243
+ prisma/ schema + migrations
244
+ docker/ Dockerfiles + entrypoints
245
+ shared/ synthetic seed definitions + reset helpers
246
+ scripts/ example agent loop and local helpers
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Quickstart
252
+
253
+ The initial scaffold is now wired end-to-end.
254
+
255
+ ### What is included
256
+
257
+ - **Next.js EHR UI** in [apps/ehr](apps/ehr)
258
+ - patient list / chart entry
259
+ - chart review with encounters, labs, notes
260
+ - progress note authoring
261
+ - order drafting and signing
262
+ - encounter sign workflow
263
+ - **FastAPI environment server** in [env_server](env_server)
264
+ - `POST /reset`
265
+ - `POST /step`
266
+ - `GET /state`
267
+ - `GET /healthz`
268
+ - **Prisma + SQLite** schema and seed data in [prisma](prisma) and [shared](shared)
269
+ - **Docker** single-container startup files in [docker](docker) and [docker-compose.yml](docker-compose.yml)
270
+
271
+ ### Local development
272
+
273
+ Prerequisites:
274
+
275
+ - Node.js 20+
276
+ - Python 3.9+
277
+
278
+ 1. Install Node dependencies:
279
+
280
+ `npm install`
281
+
282
+ 2. Install the Python environment server package:
283
+
284
+ `python3 -m pip install .`
285
+
286
+ If you use a virtual environment or conda environment, activate it before running the remaining commands.
287
+
288
+ 3. Install the browser runtime for Playwright:
289
+
290
+ `python3 -m playwright install chromium`
291
+
292
+ 4. Copy environment variables if needed:
293
+
294
+ `cp .env.example .env`
295
+
296
+ 5. Initialize the SQLite database:
297
+
298
+ `npx prisma generate && npx prisma db push && npx prisma db seed`
299
+
300
+ 6. Start both processes:
301
+
302
+ `npm run dev`
303
+
304
+ Available endpoints:
305
+
306
+ - EHR UI: http://127.0.0.1:3000
307
+ - Env server: http://127.0.0.1:8000
308
+
309
+ ### Docker
310
+
311
+ Build and run the combined container:
312
+
313
+ `docker compose up --build`
314
+
315
+ This launches:
316
+
317
+ - the Next.js EHR app on port `3000`
318
+ - the FastAPI environment server on port `8000`
319
+
320
+ ### Minimal API flow
321
+
322
+ 1. `POST /reset`
323
+ 2. Read `observation` and `state`
324
+ 3. Send browser-style actions to `POST /step`
325
+ 4. Inspect `GET /state` for episode progress
326
+
327
+ A starter agent loop is included in [scripts/example_agent.py](scripts/example_agent.py).
328
+
329
+ ---
330
+
331
+ ## Contributing
332
+
333
+ - Keep all data synthetic
334
+ - Add `data-testid` / `aria-label` for any new interactive UI element
335
+ - New tasks should include:
336
+ - objective text
337
+ - ground truth artifacts (required orders/note fields)
338
+ - rubric scoring rules
339
+ - deterministic seed behavior
340
+
341
+ ---
342
+
343
+ ## License
344
+
345
+ Apache License
346
+ Version 2.0, January 2004
347
+
348
+ This project is licensed under the Apache License, Version 2.0.
349
+ You should include the full license text in a file named `LICENSE` at the repository root.
350
+
351
+ Copyright [2026] [Adrian Serapio]
352
+
353
+ Licensed under the Apache License, Version 2.0 (the "License");
354
+ you may not use this file except in compliance with the License.
355
+ You may obtain a copy of the License at
356
+
357
+ http://www.apache.org/licenses/LICENSE-2.0
apps/ehr/app/api/dev/reset/route.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { prisma } from "../../../../lib/db";
4
+ import { resetDatabase } from "../../../../../../shared/reset-database";
5
+
6
+ export async function POST() {
7
+ await resetDatabase(prisma);
8
+
9
+ const patientCount = await prisma.patient.count();
10
+
11
+ return NextResponse.json({
12
+ ok: true,
13
+ patientCount
14
+ });
15
+ }
apps/ehr/app/api/patients/[id]/route.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { parseJsonValue } from "../../../../lib/chart";
4
+ import { prisma } from "../../../../lib/db";
5
+
6
+ type ApiPatient = {
7
+ id: string;
8
+ mrn: string;
9
+ fullName: string;
10
+ age: number;
11
+ sex: string;
12
+ allergiesJson: string;
13
+ bannerFlagsJson: string;
14
+ summary: string;
15
+ encounters: Array<{
16
+ id: string;
17
+ type: string;
18
+ reasonForVisit: string;
19
+ provider: string;
20
+ startedAt: Date;
21
+ status: string;
22
+ labs: Array<{
23
+ id: string;
24
+ name: string;
25
+ loinc: string | null;
26
+ value: string;
27
+ unit: string;
28
+ referenceRange: string;
29
+ abnormal: boolean;
30
+ collectedAt: Date;
31
+ }>;
32
+ notes: Array<{
33
+ id: string;
34
+ type: string;
35
+ title: string;
36
+ author: string;
37
+ content: string;
38
+ signed: boolean;
39
+ createdAt: Date;
40
+ }>;
41
+ orders: Array<{
42
+ id: string;
43
+ name: string;
44
+ category: string;
45
+ parametersJson: string;
46
+ status: string;
47
+ rationale: string;
48
+ createdAt: Date;
49
+ }>;
50
+ }>;
51
+ scenarios: Array<{
52
+ id: string;
53
+ encounterId: string;
54
+ title: string;
55
+ objective: string;
56
+ rubricJson: string;
57
+ requiredOrdersJson: string;
58
+ requiredNoteElementsJson: string;
59
+ }>;
60
+ };
61
+
62
+ export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
63
+ const { id } = await context.params;
64
+
65
+ const patient: ApiPatient | null = await prisma.patient.findUnique({
66
+ where: { id },
67
+ include: {
68
+ encounters: {
69
+ orderBy: { startedAt: "desc" },
70
+ include: {
71
+ labs: { orderBy: { collectedAt: "desc" } },
72
+ notes: { orderBy: { createdAt: "desc" } },
73
+ orders: { orderBy: { createdAt: "desc" } }
74
+ }
75
+ },
76
+ scenarios: {
77
+ orderBy: { createdAt: "desc" }
78
+ }
79
+ }
80
+ });
81
+
82
+ if (!patient) {
83
+ return NextResponse.json({ error: "Patient not found" }, { status: 404 });
84
+ }
85
+
86
+ return NextResponse.json({
87
+ patient: {
88
+ id: patient.id,
89
+ mrn: patient.mrn,
90
+ fullName: patient.fullName,
91
+ age: patient.age,
92
+ sex: patient.sex,
93
+ allergies: parseJsonValue<string[]>(patient.allergiesJson),
94
+ bannerFlags: parseJsonValue<string[]>(patient.bannerFlagsJson),
95
+ summary: patient.summary,
96
+ encounters: patient.encounters.map((encounter: ApiPatient["encounters"][number]) => ({
97
+ ...encounter,
98
+ orders: encounter.orders.map((order: ApiPatient["encounters"][number]["orders"][number]) => ({
99
+ ...order,
100
+ parameters: parseJsonValue<Record<string, string>>(order.parametersJson)
101
+ }))
102
+ })),
103
+ scenarios: patient.scenarios.map((scenario: ApiPatient["scenarios"][number]) => ({
104
+ ...scenario,
105
+ rubric: parseJsonValue<string[]>(scenario.rubricJson),
106
+ requiredOrders: parseJsonValue<string[]>(scenario.requiredOrdersJson),
107
+ requiredNoteElements: parseJsonValue<string[]>(scenario.requiredNoteElementsJson)
108
+ }))
109
+ }
110
+ });
111
+ }
apps/ehr/app/api/patients/route.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+
3
+ import { parseJsonValue } from "../../../lib/chart";
4
+ import { prisma } from "../../../lib/db";
5
+
6
+ export async function GET() {
7
+ const patients = await prisma.patient.findMany({
8
+ orderBy: { fullName: "asc" },
9
+ include: {
10
+ encounters: {
11
+ take: 1,
12
+ orderBy: { startedAt: "desc" }
13
+ },
14
+ scenarios: {
15
+ take: 1,
16
+ orderBy: { createdAt: "desc" }
17
+ }
18
+ }
19
+ });
20
+
21
+ return NextResponse.json({
22
+ patients: patients.map((patient) => ({
23
+ id: patient.id,
24
+ mrn: patient.mrn,
25
+ fullName: patient.fullName,
26
+ age: patient.age,
27
+ sex: patient.sex,
28
+ allergies: parseJsonValue<string[]>(patient.allergiesJson),
29
+ bannerFlags: parseJsonValue<string[]>(patient.bannerFlagsJson),
30
+ summary: patient.summary,
31
+ encounter: patient.encounters[0] ?? null,
32
+ scenario: patient.scenarios[0] ?? null
33
+ }))
34
+ });
35
+ }
apps/ehr/app/globals.css ADDED
@@ -0,0 +1,1646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ font-family: var(--font-body), "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
4
+ background: #ffffff;
5
+ color: #000000;
6
+ }
7
+
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ html,
13
+ body {
14
+ margin: 0;
15
+ min-height: 100%;
16
+ }
17
+
18
+ body {
19
+ background: #ffffff;
20
+ font-family: var(--font-body), "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
21
+ color: #000000;
22
+ }
23
+
24
+ h1,
25
+ h2,
26
+ h3,
27
+ h4,
28
+ h5,
29
+ h6,
30
+ button,
31
+ input,
32
+ textarea,
33
+ select,
34
+ label,
35
+ nav,
36
+ .app-brand,
37
+ .badge,
38
+ .status-pill,
39
+ .summary-flag,
40
+ .workspace-sidebar,
41
+ .workspace-header,
42
+ .workspace-hero,
43
+ .patient-hero,
44
+ .workspace-toggle,
45
+ .workspace-backlink,
46
+ .workspace-icon-button,
47
+ .workspace-profile,
48
+ .table th,
49
+ .ehr-table__header,
50
+ .problem-list__status,
51
+ .patient-hero__eyebrow {
52
+ font-family: var(--font-ui), "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
53
+ }
54
+
55
+ a {
56
+ color: inherit;
57
+ text-decoration: none;
58
+ }
59
+
60
+ button,
61
+ input,
62
+ textarea,
63
+ select {
64
+ font: inherit;
65
+ }
66
+
67
+ button {
68
+ cursor: pointer;
69
+ }
70
+
71
+ .epic-shell {
72
+ min-height: 100vh;
73
+ max-width: 1600px;
74
+ margin: 0 auto;
75
+ padding: 18px 20px 28px;
76
+ }
77
+
78
+ .epic-topbar {
79
+ display: grid;
80
+ grid-template-columns: auto 1fr auto;
81
+ gap: 20px;
82
+ align-items: center;
83
+ padding: 4px 0 18px;
84
+ border: none;
85
+ border-bottom: 1px solid #e5e7eb;
86
+ border-radius: 0;
87
+ background: transparent;
88
+ }
89
+
90
+ .epic-topbar__brand,
91
+ .epic-topbar__meta,
92
+ .section-card h2,
93
+ .section-card h3 {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 10px;
97
+ margin: 0;
98
+ }
99
+
100
+ .app-brand {
101
+ display: inline-flex;
102
+ align-items: center;
103
+ gap: 14px;
104
+ min-width: 0;
105
+ text-decoration: none;
106
+ }
107
+
108
+ .app-brand__logo {
109
+ width: 48px;
110
+ height: 48px;
111
+ display: inline-flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ flex: 0 0 auto;
115
+ user-select: none;
116
+ -webkit-user-select: none;
117
+ }
118
+
119
+ .app-brand__logo--compact {
120
+ width: 42px;
121
+ height: 42px;
122
+ }
123
+
124
+ .app-brand__logo img {
125
+ width: 100%;
126
+ height: 100%;
127
+ object-fit: contain;
128
+ user-select: none;
129
+ -webkit-user-drag: none;
130
+ }
131
+
132
+ .app-brand__copy {
133
+ display: grid;
134
+ gap: 2px;
135
+ min-width: 0;
136
+ }
137
+
138
+ .app-brand__copy--compact {
139
+ gap: 1px;
140
+ }
141
+
142
+ .app-brand__copy strong {
143
+ font-size: 1.05rem;
144
+ line-height: 1.1;
145
+ color: #000000;
146
+ }
147
+
148
+ .app-brand__copy span {
149
+ font-size: 0.86rem;
150
+ color: #000000;
151
+ }
152
+
153
+ .epic-topbar__brand p,
154
+ .epic-topbar__meta,
155
+ .section-card p,
156
+ .muted {
157
+ color: #000000;
158
+ }
159
+
160
+ .epic-topbar__tabs {
161
+ display: flex;
162
+ gap: 8px;
163
+ flex-wrap: wrap;
164
+ justify-content: center;
165
+ }
166
+
167
+ .nav-link {
168
+ text-decoration: none;
169
+ }
170
+
171
+ .epic-tab,
172
+ .epic-topbar__tabs .nav-link {
173
+ padding: 10px 14px;
174
+ border: 1px solid #e5e7eb;
175
+ border-radius: 999px;
176
+ background: #ffffff;
177
+ font-size: 0.9rem;
178
+ font-weight: 600;
179
+ color: #4b5563;
180
+ }
181
+
182
+ .epic-tab--active,
183
+ .epic-topbar__tabs .nav-link.is-active {
184
+ background: #eef2ff;
185
+ border-color: #c7d2fe;
186
+ color: #4338ca;
187
+ }
188
+
189
+ .epic-contextbar,
190
+ .chart-activity-bar {
191
+ display: flex;
192
+ justify-content: space-between;
193
+ align-items: center;
194
+ gap: 12px;
195
+ padding: 14px 20px;
196
+ border: 1px solid #e5e7eb;
197
+ background: #ffffff;
198
+ }
199
+
200
+ .epic-contextbar {
201
+ margin-bottom: 16px;
202
+ border-radius: 18px;
203
+ }
204
+
205
+ .epic-contextbar p {
206
+ margin: 2px 0 0;
207
+ }
208
+
209
+ .epic-contextbar__actions {
210
+ display: flex;
211
+ gap: 8px;
212
+ flex-wrap: wrap;
213
+ }
214
+
215
+ .chart-activity-bar {
216
+ justify-content: flex-start;
217
+ flex-wrap: wrap;
218
+ margin-bottom: 16px;
219
+ border-radius: 18px;
220
+ }
221
+
222
+ .chart-activity-bar a {
223
+ padding: 9px 14px;
224
+ border: 1px solid #e5e7eb;
225
+ border-radius: 999px;
226
+ background: #f9fafb;
227
+ font-size: 0.9rem;
228
+ font-weight: 600;
229
+ color: #4b5563;
230
+ }
231
+
232
+ .chart-activity-bar a.is-active,
233
+ .sidebar-nav a.is-active {
234
+ background: #eef2ff;
235
+ border-color: #c7d2fe;
236
+ color: #4338ca;
237
+ }
238
+
239
+ .grid {
240
+ display: grid;
241
+ gap: 14px;
242
+ }
243
+
244
+ .grid--2 {
245
+ grid-template-columns: 1.2fr 0.8fr;
246
+ }
247
+
248
+ .grid--3 {
249
+ grid-template-columns: repeat(3, minmax(0, 1fr));
250
+ }
251
+
252
+ .workspace-grid {
253
+ display: grid;
254
+ gap: 16px;
255
+ }
256
+
257
+ .workspace-grid--home {
258
+ grid-template-columns: 260px minmax(0, 1fr) 320px;
259
+ }
260
+
261
+ .workspace-grid--chart {
262
+ grid-template-columns: 250px minmax(0, 1fr) 300px;
263
+ }
264
+
265
+ .center-pane,
266
+ .right-rail,
267
+ .left-rail,
268
+ .patient-list,
269
+ .sidebar-nav,
270
+ .info-chips,
271
+ .timeline,
272
+ .data-list,
273
+ .order-list,
274
+ .note-list,
275
+ .rail-list,
276
+ .problem-list {
277
+ display: grid;
278
+ gap: 12px;
279
+ }
280
+
281
+ .patient-card,
282
+ .sidebar-nav a,
283
+ .info-chip,
284
+ .list-row,
285
+ .order-row,
286
+ .note-row,
287
+ .lab-row,
288
+ .section-card {
289
+ background: #ffffff;
290
+ border: 1px solid #e5e7eb;
291
+ border-radius: 18px;
292
+ }
293
+
294
+ .patient-card,
295
+ .list-row,
296
+ .order-row,
297
+ .note-row,
298
+ .lab-row,
299
+ .section-card {
300
+ padding: 16px;
301
+ }
302
+
303
+ .patient-card:hover,
304
+ .sidebar-nav a:hover {
305
+ border-color: #d1d5db;
306
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
307
+ }
308
+
309
+ .patient-card header,
310
+ .list-row header,
311
+ .order-row header,
312
+ .note-row header,
313
+ .section-card__header,
314
+ .banner,
315
+ .patient-header,
316
+ .patient-header__identity,
317
+ .patient-header__facts {
318
+ display: flex;
319
+ justify-content: space-between;
320
+ gap: 12px;
321
+ align-items: flex-start;
322
+ }
323
+
324
+ .badge,
325
+ .status-pill {
326
+ display: inline-flex;
327
+ align-items: center;
328
+ gap: 6px;
329
+ padding: 6px 10px;
330
+ border-radius: 999px;
331
+ font-size: 0.78rem;
332
+ font-weight: 700;
333
+ }
334
+
335
+ .badge {
336
+ background: #f9fafb;
337
+ color: #4b5563;
338
+ border: 1px solid #e5e7eb;
339
+ }
340
+
341
+ .status-pill[data-status="OPEN"],
342
+ .status-pill[data-status="DRAFT"] {
343
+ background: #fff7ed;
344
+ color: #c2410c;
345
+ }
346
+
347
+ .status-pill[data-status="PENDING_SIGNATURE"] {
348
+ background: #eff6ff;
349
+ color: #2563eb;
350
+ }
351
+
352
+ .status-pill[data-status="SIGNED"],
353
+ .status-pill[data-status="CLOSED"] {
354
+ background: #ecfdf5;
355
+ color: #059669;
356
+ }
357
+
358
+ .info-chip {
359
+ padding: 10px 12px;
360
+ }
361
+
362
+ .sidebar-nav a {
363
+ padding: 12px 14px;
364
+ font-weight: 600;
365
+ background: #f9fafb;
366
+ }
367
+
368
+ .section-stack {
369
+ display: grid;
370
+ gap: 10px;
371
+ }
372
+
373
+ .section-card__header {
374
+ margin-bottom: 12px;
375
+ padding-bottom: 10px;
376
+ border-bottom: 1px solid #f1f5f9;
377
+ }
378
+
379
+ .section-card__header h2 {
380
+ font-size: 1rem;
381
+ color: #111827;
382
+ }
383
+
384
+ .section-card__header p {
385
+ margin: 4px 0 0;
386
+ font-size: 0.86rem;
387
+ }
388
+
389
+ textarea,
390
+ input,
391
+ select {
392
+ border: 1px solid #e5e7eb;
393
+ border-radius: 12px;
394
+ padding: 10px 12px;
395
+ background: #ffffff;
396
+ }
397
+
398
+ textarea,
399
+ input:not([type="checkbox"]):not([type="radio"]),
400
+ select {
401
+ width: 100%;
402
+ }
403
+
404
+ input[type="checkbox"],
405
+ input[type="radio"] {
406
+ width: auto;
407
+ margin: 0;
408
+ accent-color: #35638c;
409
+ }
410
+
411
+ textarea {
412
+ min-height: 140px;
413
+ resize: vertical;
414
+ }
415
+
416
+ .field {
417
+ display: grid;
418
+ gap: 6px;
419
+ }
420
+
421
+ .field > span {
422
+ display: inline-block;
423
+ }
424
+
425
+ .form-grid {
426
+ display: grid;
427
+ gap: 10px;
428
+ }
429
+
430
+ .form-actions {
431
+ display: flex;
432
+ align-items: center;
433
+ gap: 10px;
434
+ flex-wrap: wrap;
435
+ }
436
+
437
+ .checkbox-field {
438
+ display: inline-flex;
439
+ align-items: center;
440
+ gap: 8px;
441
+ width: fit-content;
442
+ padding: 6px 0;
443
+ color: #42586d;
444
+ }
445
+
446
+ .checkbox-field__control {
447
+ flex: 0 0 auto;
448
+ }
449
+
450
+ .form-grid--2 {
451
+ grid-template-columns: repeat(2, minmax(0, 1fr));
452
+ }
453
+
454
+ .primary-button,
455
+ .secondary-button {
456
+ border: 1px solid #e5e7eb;
457
+ border-radius: 12px;
458
+ padding: 10px 14px;
459
+ font-weight: 700;
460
+ width: auto;
461
+ min-width: 144px;
462
+ }
463
+
464
+ .primary-button {
465
+ background: #4f46e5;
466
+ color: #ffffff;
467
+ border-color: #4f46e5;
468
+ }
469
+
470
+ .secondary-button {
471
+ background: #ffffff;
472
+ color: #111827;
473
+ }
474
+
475
+ .table {
476
+ width: 100%;
477
+ border-collapse: collapse;
478
+ }
479
+
480
+ .table th,
481
+ .table td {
482
+ padding: 10px 8px;
483
+ text-align: left;
484
+ border-bottom: 1px solid #d9e3ea;
485
+ }
486
+
487
+ .table th {
488
+ font-size: 0.82rem;
489
+ text-transform: uppercase;
490
+ letter-spacing: 0.04em;
491
+ color: #6b7280;
492
+ background: #f9fafb;
493
+ }
494
+
495
+ .table tr:last-child td {
496
+ border-bottom: none;
497
+ }
498
+
499
+ .abnormal {
500
+ color: #b22222;
501
+ font-weight: 700;
502
+ }
503
+
504
+ .toolbar {
505
+ display: flex;
506
+ justify-content: space-between;
507
+ gap: 12px;
508
+ align-items: center;
509
+ margin-top: 10px;
510
+ }
511
+
512
+ .patient-header {
513
+ margin-bottom: 16px;
514
+ padding: 20px;
515
+ border: 1px solid #e5e7eb;
516
+ border-radius: 22px;
517
+ background: #ffffff;
518
+ }
519
+
520
+ .patient-header__identity {
521
+ align-items: center;
522
+ }
523
+
524
+ .patient-header__identity h1 {
525
+ margin: 2px 0;
526
+ font-size: 1.45rem;
527
+ }
528
+
529
+ .patient-header__identity p,
530
+ .patient-header__meta {
531
+ margin: 0;
532
+ color: #6b7280;
533
+ }
534
+
535
+ .patient-avatar,
536
+ .patient-summary-card__avatar {
537
+ width: 54px;
538
+ height: 54px;
539
+ border-radius: 50%;
540
+ background: #f3f4f6;
541
+ border: 1px solid #e5e7eb;
542
+ display: inline-flex;
543
+ align-items: center;
544
+ justify-content: center;
545
+ font-weight: 700;
546
+ color: #4b5563;
547
+ }
548
+
549
+ .patient-header__facts {
550
+ align-items: center;
551
+ flex-wrap: wrap;
552
+ }
553
+
554
+ .patient-fact {
555
+ min-width: 180px;
556
+ display: grid;
557
+ gap: 3px;
558
+ }
559
+
560
+ .patient-fact strong {
561
+ font-size: 0.78rem;
562
+ color: #9ca3af;
563
+ text-transform: uppercase;
564
+ letter-spacing: 0.04em;
565
+ }
566
+
567
+ .patient-fact--priority span {
568
+ color: #111827;
569
+ font-weight: 600;
570
+ }
571
+
572
+ .left-rail,
573
+ .right-rail {
574
+ align-content: start;
575
+ }
576
+
577
+ .patient-summary-card,
578
+ .patient-sidebar-card {
579
+ padding: 18px;
580
+ border: 1px solid #e5e7eb;
581
+ border-radius: 18px;
582
+ background: #ffffff;
583
+ }
584
+
585
+ .patient-summary-card {
586
+ display: grid;
587
+ grid-template-columns: 54px 1fr;
588
+ gap: 12px;
589
+ align-items: center;
590
+ }
591
+
592
+ .patient-summary-card h2,
593
+ .patient-sidebar-card h2 {
594
+ margin: 0;
595
+ font-size: 1rem;
596
+ }
597
+
598
+ .patient-sidebar-card__label {
599
+ font-size: 0.75rem;
600
+ text-transform: uppercase;
601
+ letter-spacing: 0.08em;
602
+ color: #9ca3af;
603
+ margin-bottom: 6px;
604
+ }
605
+
606
+ .rail-list__item,
607
+ .summary-panel__row,
608
+ .problem-list__item {
609
+ display: flex;
610
+ justify-content: space-between;
611
+ gap: 10px;
612
+ align-items: flex-start;
613
+ }
614
+
615
+ .rail-list__item span,
616
+ .summary-panel__row span,
617
+ .problem-list__item span:last-child {
618
+ color: #6b7280;
619
+ }
620
+
621
+ .ehr-table__header,
622
+ .patient-card--table {
623
+ display: grid;
624
+ grid-template-columns: 1.2fr 0.7fr 1.3fr 0.6fr 1fr;
625
+ gap: 12px;
626
+ align-items: start;
627
+ }
628
+
629
+ .ehr-table__header {
630
+ padding: 0 8px 12px;
631
+ border-bottom: 1px solid #f1f5f9;
632
+ font-size: 0.78rem;
633
+ font-weight: 700;
634
+ color: #9ca3af;
635
+ text-transform: uppercase;
636
+ letter-spacing: 0.04em;
637
+ }
638
+
639
+ .patient-list--table {
640
+ margin-top: 10px;
641
+ }
642
+
643
+ .patient-card--table {
644
+ color: inherit;
645
+ }
646
+
647
+ .patient-card--table p,
648
+ .patient-card--table strong {
649
+ margin: 0;
650
+ }
651
+
652
+ .section-card--rail,
653
+ .section-card--summary {
654
+ background: #ffffff;
655
+ }
656
+
657
+ .section-card--worklist {
658
+ border-top: 1px solid #d8e0ea;
659
+ }
660
+
661
+ .section-card--chart {
662
+ border-top: 1px solid #d8e0ea;
663
+ }
664
+
665
+ .section-card--notes {
666
+ border-top: 1px solid #d8e0ea;
667
+ }
668
+
669
+ .section-card--orders {
670
+ border-top: 1px solid #d8e0ea;
671
+ }
672
+
673
+ .section-card--wrapup {
674
+ border-top: 1px solid #d8e0ea;
675
+ }
676
+
677
+ .section-card--problem-list {
678
+ border-top: 1px solid #d8e0ea;
679
+ }
680
+
681
+ .section-card--diagnosis-list {
682
+ border-top: 1px solid #d8e0ea;
683
+ }
684
+
685
+ .summary-panel,
686
+ .summary-flags {
687
+ display: grid;
688
+ gap: 8px;
689
+ }
690
+
691
+ .summary-flag {
692
+ display: inline-flex;
693
+ padding: 6px 10px;
694
+ border: 1px solid #e5e7eb;
695
+ border-radius: 999px;
696
+ background: #f9fafb;
697
+ color: #4b5563;
698
+ }
699
+
700
+ .summary-flag--accent {
701
+ background: #eef2ff;
702
+ border-color: #c7d2fe;
703
+ color: #4338ca;
704
+ }
705
+
706
+ .subtab-strip {
707
+ display: flex;
708
+ gap: 8px;
709
+ flex-wrap: wrap;
710
+ }
711
+
712
+ .subtab-strip__item {
713
+ appearance: none;
714
+ padding: 9px 14px;
715
+ border: 1px solid #e5e7eb;
716
+ border-radius: 999px;
717
+ background: #f9fafb;
718
+ font-size: 0.88rem;
719
+ color: #4b5563;
720
+ cursor: pointer;
721
+ }
722
+
723
+ .subtab-strip__item--active {
724
+ background: #eef2ff;
725
+ border-color: #c7d2fe;
726
+ color: #4338ca;
727
+ font-weight: 700;
728
+ }
729
+
730
+ .problem-list__item {
731
+ padding: 8px 10px;
732
+ border: 1px solid #f1f5f9;
733
+ border-radius: 12px;
734
+ background: #ffffff;
735
+ }
736
+
737
+ .problem-list__status {
738
+ color: #4f46e5;
739
+ font-weight: 700;
740
+ }
741
+
742
+ .problem-list__status--muted {
743
+ color: #6b7280;
744
+ }
745
+
746
+ .list-row--compact {
747
+ gap: 6px;
748
+ }
749
+
750
+ .list-row--compact p {
751
+ margin: 4px 0 0;
752
+ }
753
+
754
+ .primary-button:focus-visible,
755
+ .secondary-button:focus-visible,
756
+ .subtab-strip__item:focus-visible,
757
+ .chart-activity-bar a:focus-visible,
758
+ .sidebar-nav a:focus-visible,
759
+ .patient-card:focus-visible,
760
+ input:focus-visible,
761
+ textarea:focus-visible,
762
+ select:focus-visible {
763
+ outline: 2px solid #2563eb;
764
+ outline-offset: 2px;
765
+ }
766
+
767
+ @media (max-width: 1080px) {
768
+ .grid--2,
769
+ .form-grid--2,
770
+ .grid--3 {
771
+ grid-template-columns: 1fr;
772
+ }
773
+
774
+ .workspace-grid--home,
775
+ .workspace-grid--chart,
776
+ .ehr-table__header,
777
+ .patient-card--table,
778
+ .patient-header,
779
+ .patient-header__facts,
780
+ .epic-topbar,
781
+ .epic-contextbar {
782
+ grid-template-columns: 1fr;
783
+ flex-direction: column;
784
+ align-items: flex-start;
785
+ }
786
+ }
787
+
788
+ .dashboard-shell {
789
+ min-height: 100vh;
790
+ display: grid;
791
+ grid-template-columns: 280px minmax(0, 1fr);
792
+ gap: 24px;
793
+ padding: 20px;
794
+ }
795
+
796
+ .dashboard-main {
797
+ display: grid;
798
+ gap: 18px;
799
+ align-content: start;
800
+ }
801
+
802
+ .workspace-sidebar {
803
+ gap: 16px;
804
+ position: sticky;
805
+ top: 20px;
806
+ align-self: start;
807
+ min-height: calc(100vh - 40px);
808
+ display: grid;
809
+ grid-template-rows: auto 1fr auto;
810
+ gap: 24px;
811
+ padding: 18px;
812
+ border: 1px solid #e5e7eb;
813
+ border-radius: 28px;
814
+ background: rgba(255, 255, 255, 0.92);
815
+ }
816
+
817
+ .workspace-sidebar__brand {
818
+ padding-bottom: 8px;
819
+ padding-bottom: 2px;
820
+ border-bottom: 1px solid #eef2f7;
821
+ }
822
+
823
+ .workspace-sidebar__sections {
824
+ display: grid;
825
+ gap: 14px;
826
+ gap: 10px;
827
+ align-content: start;
828
+ }
829
+
830
+ .workspace-sidebar__section {
831
+ display: grid;
832
+ gap: 6px;
833
+ gap: 6px;
834
+ }
835
+
836
+ .workspace-sidebar__heading,
837
+ .patient-hero__eyebrow {
838
+ margin: 0;
839
+ font-size: 0.78rem;
840
+ font-weight: 700;
841
+ letter-spacing: 0.08em;
842
+ text-transform: uppercase;
843
+ color: #9ca3af;
844
+ }
845
+
846
+ .workspace-sidebar__nav,
847
+ .task-list,
848
+ .content-stack,
849
+ .metric-card__stack,
850
+ .workspace-header__breadcrumbs,
851
+ .workspace-header__actions,
852
+ .patient-hero__meta,
853
+ .workspace-search,
854
+ .note-list,
855
+ .order-list {
856
+ display: grid;
857
+ gap: 8px;
858
+ }
859
+
860
+ .workspace-sidebar__nav {
861
+ gap: 3px;
862
+ }
863
+
864
+ .workspace-sidebar__item {
865
+ display: flex;
866
+ align-items: center;
867
+ gap: 10px;
868
+ padding: 8px 10px;
869
+ border: 1px solid transparent;
870
+ border-radius: 10px;
871
+ color: #4b5563;
872
+ background: transparent;
873
+ font-weight: 600;
874
+ }
875
+
876
+ .workspace-sidebar__item:hover {
877
+ background: #f8fafc;
878
+ border-color: #e5e7eb;
879
+ }
880
+
881
+ .workspace-sidebar__item--active {
882
+ background: #ffffff;
883
+ border-color: #dbe3ee;
884
+ color: #1f3b63;
885
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
886
+ }
887
+
888
+ .workspace-sidebar__icon,
889
+ .workspace-icon-button,
890
+ .task-list__check {
891
+ width: 18px;
892
+ width: 16px;
893
+ height: 16px;
894
+ border-radius: 0;
895
+ display: inline-flex;
896
+ align-items: center;
897
+ justify-content: center;
898
+ background: transparent;
899
+ color: #55708f;
900
+ flex: 0 0 auto;
901
+ }
902
+
903
+ .workspace-sidebar__icon svg {
904
+ width: 16px;
905
+ height: 16px;
906
+ display: block;
907
+ }
908
+
909
+ .workspace-sidebar__footer,
910
+ .workspace-hero,
911
+ .patient-hero,
912
+ .metric-card,
913
+ .note-row,
914
+ .order-row,
915
+ .list-row,
916
+ .section-card,
917
+ .patient-card,
918
+ .patient-hero__panel,
919
+ .workspace-header {
920
+ border: 1px solid #e5e7eb;
921
+ background: #ffffff;
922
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.04);
923
+ }
924
+
925
+ .workspace-sidebar__footer {
926
+ padding: 16px;
927
+ border-radius: 20px;
928
+ display: grid;
929
+ gap: 10px;
930
+ }
931
+
932
+ .workspace-sidebar__footer p,
933
+ .workspace-profile p,
934
+ .metric-card p,
935
+ .patient-hero p,
936
+ .workspace-hero p {
937
+ margin: 0;
938
+ color: #6b7280;
939
+ }
940
+
941
+ .workspace-header {
942
+ display: flex;
943
+ justify-content: space-between;
944
+ align-items: center;
945
+ gap: 16px;
946
+ padding: 12px 16px;
947
+ border-radius: 24px;
948
+ }
949
+
950
+ .workspace-header--home {
951
+ justify-content: space-between;
952
+ }
953
+
954
+ .workspace-header--chart {
955
+ align-items: flex-start;
956
+ }
957
+
958
+ .workspace-search {
959
+ grid-template-columns: auto minmax(260px, 1fr);
960
+ align-items: center;
961
+ width: min(520px, 100%);
962
+ padding: 12px 14px;
963
+ border: 1px solid #e5e7eb;
964
+ border-radius: 18px;
965
+ background: #f9fafc;
966
+ }
967
+
968
+ .workspace-search input {
969
+ border: none;
970
+ padding: 0;
971
+ background: transparent;
972
+ }
973
+
974
+ .workspace-search input:focus-visible {
975
+ outline: none;
976
+ }
977
+
978
+ .workspace-search__icon {
979
+ color: #9ca3af;
980
+ }
981
+
982
+ .workspace-header__actions {
983
+ grid-auto-flow: column;
984
+ align-items: center;
985
+ justify-content: end;
986
+ }
987
+
988
+ .workspace-profile {
989
+ display: flex;
990
+ align-items: center;
991
+ gap: 10px;
992
+ padding-left: 4px;
993
+ }
994
+
995
+ .workspace-profile--panel {
996
+ padding: 8px 12px;
997
+ padding-left: 12px;
998
+ border: 1px solid #111111;
999
+ border-radius: 14px;
1000
+ }
1001
+
1002
+ .workspace-profile--compact {
1003
+ padding-left: 0;
1004
+ }
1005
+
1006
+ .workspace-profile__avatar {
1007
+ width: 38px;
1008
+ height: 38px;
1009
+ border-radius: 14px;
1010
+ display: inline-flex;
1011
+ align-items: center;
1012
+ justify-content: center;
1013
+ background: #e8eef7;
1014
+ color: #35526f;
1015
+ font-weight: 700;
1016
+ }
1017
+
1018
+ .workspace-toggle,
1019
+ .workspace-backlink {
1020
+ display: inline-flex;
1021
+ align-items: center;
1022
+ gap: 8px;
1023
+ padding: 9px 12px;
1024
+ border: 1px solid #e5e7eb;
1025
+ border-radius: 999px;
1026
+ background: #f9fafc;
1027
+ color: #4b5563;
1028
+ font-weight: 600;
1029
+ }
1030
+
1031
+ .workspace-pills {
1032
+ display: flex;
1033
+ gap: 8px;
1034
+ flex-wrap: wrap;
1035
+ }
1036
+
1037
+ .workspace-pills .nav-link,
1038
+ .workspace-pills--wide a {
1039
+ padding: 10px 14px;
1040
+ border: 1px solid #e5e7eb;
1041
+ border-radius: 999px;
1042
+ background: #f9fafc;
1043
+ font-size: 0.9rem;
1044
+ font-weight: 600;
1045
+ color: #4b5563;
1046
+ }
1047
+
1048
+ .workspace-pills .nav-link.is-active,
1049
+ .workspace-pills--wide a.is-active {
1050
+ background: #eef4ff;
1051
+ border-color: #cddcf4;
1052
+ color: #27476f;
1053
+ }
1054
+
1055
+ .workspace-hero,
1056
+ .patient-hero {
1057
+ border-radius: 28px;
1058
+ padding: 24px;
1059
+ }
1060
+
1061
+ .workspace-hero {
1062
+ display: flex;
1063
+ justify-content: space-between;
1064
+ align-items: center;
1065
+ gap: 18px;
1066
+ }
1067
+
1068
+ .workspace-hero h1,
1069
+ .patient-hero h1 {
1070
+ margin: 0 0 6px;
1071
+ font-size: 2rem;
1072
+ letter-spacing: -0.04em;
1073
+ }
1074
+
1075
+ .metric-grid {
1076
+ display: grid;
1077
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1078
+ gap: 16px;
1079
+ }
1080
+
1081
+ .metric-grid--chart {
1082
+ align-items: stretch;
1083
+ }
1084
+
1085
+ .metric-card {
1086
+ border-radius: 22px;
1087
+ }
1088
+
1089
+ .metric-card--wide {
1090
+ grid-column: span 2;
1091
+ }
1092
+
1093
+ .metric-card__value {
1094
+ font-size: 2rem;
1095
+ font-weight: 700;
1096
+ letter-spacing: -0.04em;
1097
+ color: #172554;
1098
+ }
1099
+
1100
+ .content-grid {
1101
+ display: grid;
1102
+ gap: 16px;
1103
+ }
1104
+
1105
+ .content-grid--home {
1106
+ grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.85fr);
1107
+ }
1108
+
1109
+ .content-grid--chart {
1110
+ grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.85fr);
1111
+ }
1112
+
1113
+ .content-stack {
1114
+ align-content: start;
1115
+ }
1116
+
1117
+ .task-list__item {
1118
+ display: grid;
1119
+ grid-template-columns: auto 1fr;
1120
+ gap: 12px;
1121
+ align-items: start;
1122
+ }
1123
+
1124
+ .task-list__check::before {
1125
+ content: "✓";
1126
+ font-weight: 700;
1127
+ }
1128
+
1129
+ .patient-hero {
1130
+ display: grid;
1131
+ grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.9fr);
1132
+ gap: 18px;
1133
+ }
1134
+
1135
+ .patient-hero__identity {
1136
+ display: flex;
1137
+ gap: 16px;
1138
+ align-items: center;
1139
+ }
1140
+
1141
+ .patient-hero__panel {
1142
+ padding: 16px;
1143
+ border-radius: 20px;
1144
+ display: grid;
1145
+ gap: 6px;
1146
+ }
1147
+
1148
+ .patient-hero__panel--status {
1149
+ align-content: start;
1150
+ }
1151
+
1152
+ .patient-hero__panel strong {
1153
+ font-size: 0.78rem;
1154
+ text-transform: uppercase;
1155
+ letter-spacing: 0.08em;
1156
+ color: #9ca3af;
1157
+ }
1158
+
1159
+ .list-row--split {
1160
+ display: flex;
1161
+ justify-content: space-between;
1162
+ align-items: center;
1163
+ gap: 16px;
1164
+ }
1165
+
1166
+ .list-row--split p {
1167
+ margin: 0;
1168
+ }
1169
+
1170
+ .section-card,
1171
+ .patient-card,
1172
+ .note-row,
1173
+ .order-row,
1174
+ .list-row {
1175
+ border-radius: 22px;
1176
+ }
1177
+
1178
+ .section-card__header {
1179
+ padding-bottom: 14px;
1180
+ border-bottom: 1px solid #edf2f7;
1181
+ }
1182
+
1183
+ .section-card__header h2 {
1184
+ font-size: 1.02rem;
1185
+ }
1186
+
1187
+ .patient-card:hover,
1188
+ .workspace-backlink:hover,
1189
+ .workspace-icon-button:hover,
1190
+ .workspace-toggle:hover,
1191
+ .workspace-pills .nav-link:hover,
1192
+ .workspace-pills--wide a:hover {
1193
+ border-color: #d4dde8;
1194
+ }
1195
+
1196
+ @media (max-width: 1240px) {
1197
+ .dashboard-shell,
1198
+ .patient-hero,
1199
+ .content-grid--home,
1200
+ .content-grid--chart,
1201
+ .metric-grid {
1202
+ grid-template-columns: 1fr;
1203
+ }
1204
+
1205
+ .workspace-sidebar {
1206
+ position: static;
1207
+ min-height: auto;
1208
+ }
1209
+
1210
+ .metric-card--wide {
1211
+ grid-column: auto;
1212
+ }
1213
+ }
1214
+
1215
+ @media (max-width: 900px) {
1216
+ .dashboard-shell {
1217
+ padding: 14px;
1218
+ }
1219
+
1220
+ .workspace-header,
1221
+ .workspace-hero,
1222
+ .patient-hero,
1223
+ .list-row--split {
1224
+ flex-direction: column;
1225
+ align-items: flex-start;
1226
+ }
1227
+
1228
+ .workspace-header__actions {
1229
+ grid-auto-flow: row;
1230
+ justify-items: start;
1231
+ }
1232
+
1233
+ .workspace-search {
1234
+ width: 100%;
1235
+ grid-template-columns: auto 1fr;
1236
+ }
1237
+
1238
+ .ehr-table__header,
1239
+ .patient-card--table {
1240
+ grid-template-columns: 1fr;
1241
+ }
1242
+ }
1243
+
1244
+ .dashboard-shell {
1245
+ position: relative;
1246
+ isolation: isolate;
1247
+ background: #f3f4f6 !important;
1248
+ }
1249
+
1250
+ .dashboard-shell::before {
1251
+ content: none;
1252
+ }
1253
+
1254
+ .dashboard-shell::after {
1255
+ content: "";
1256
+ position: fixed;
1257
+ inset: 0;
1258
+ background-image: linear-gradient(rgba(75, 85, 99, 0.18) 1px, transparent 1px), linear-gradient(90deg, rgba(75, 85, 99, 0.18) 1px, transparent 1px);
1259
+ background-size: 28px 28px;
1260
+ opacity: 0.04;
1261
+ z-index: -3;
1262
+ pointer-events: none;
1263
+ }
1264
+
1265
+ body,
1266
+ .dashboard-main,
1267
+ .workspace-sidebar,
1268
+ .workspace-header,
1269
+ .workspace-hero,
1270
+ .patient-hero,
1271
+ .metric-card,
1272
+ .section-card,
1273
+ .note-row,
1274
+ .order-row,
1275
+ .list-row,
1276
+ .patient-card,
1277
+ .patient-hero__panel,
1278
+ .workspace-sidebar__footer,
1279
+ .patient-summary-card,
1280
+ .patient-sidebar-card,
1281
+ .patient-header,
1282
+ .epic-contextbar,
1283
+ .chart-activity-bar,
1284
+ .workspace-search,
1285
+ .problem-list__item,
1286
+ .table th,
1287
+ .table td,
1288
+ .subtab-strip__item,
1289
+ .workspace-sidebar__item,
1290
+ .workspace-toggle,
1291
+ .workspace-backlink,
1292
+ .workspace-icon-button,
1293
+ .summary-flag,
1294
+ .badge,
1295
+ .status-pill,
1296
+ .epic-tab,
1297
+ .epic-topbar__tabs .nav-link,
1298
+ .workspace-pills .nav-link,
1299
+ .workspace-pills--wide a,
1300
+ .sidebar-nav a,
1301
+ .secondary-button {
1302
+ color: #111111 !important;
1303
+ border-color: #111111 !important;
1304
+ box-shadow: none !important;
1305
+ }
1306
+
1307
+ body,
1308
+ .dashboard-main {
1309
+ background: transparent !important;
1310
+ }
1311
+
1312
+ .workspace-sidebar,
1313
+ .workspace-header,
1314
+ .workspace-hero,
1315
+ .patient-hero,
1316
+ .metric-card,
1317
+ .section-card,
1318
+ .note-row,
1319
+ .order-row,
1320
+ .list-row,
1321
+ .patient-card,
1322
+ .patient-hero__panel,
1323
+ .workspace-sidebar__footer,
1324
+ .patient-summary-card,
1325
+ .patient-sidebar-card,
1326
+ .patient-header,
1327
+ .epic-contextbar,
1328
+ .chart-activity-bar,
1329
+ .workspace-search,
1330
+ .problem-list__item,
1331
+ .table th,
1332
+ .table td,
1333
+ .subtab-strip__item,
1334
+ .workspace-sidebar__item,
1335
+ .workspace-toggle,
1336
+ .workspace-backlink,
1337
+ .workspace-icon-button,
1338
+ .summary-flag,
1339
+ .badge,
1340
+ .status-pill,
1341
+ .epic-tab,
1342
+ .epic-topbar__tabs .nav-link,
1343
+ .workspace-pills .nav-link,
1344
+ .workspace-pills--wide a,
1345
+ .sidebar-nav a,
1346
+ .secondary-button,
1347
+ input,
1348
+ textarea,
1349
+ select {
1350
+ color: #111111 !important;
1351
+ border-color: #111111 !important;
1352
+ background: #ffffff !important;
1353
+ }
1354
+
1355
+ .workspace-header,
1356
+ .workspace-sidebar,
1357
+ .workspace-hero,
1358
+ .patient-hero {
1359
+ border-radius: 30px !important;
1360
+ }
1361
+
1362
+ .workspace-sidebar__icon,
1363
+ .task-list__check,
1364
+ .workspace-profile__avatar,
1365
+ .patient-avatar,
1366
+ .patient-summary-card__avatar {
1367
+ background: transparent !important;
1368
+ color: #111111 !important;
1369
+ border: none !important;
1370
+ }
1371
+
1372
+ .primary-button,
1373
+ .subtab-strip__item--active,
1374
+ .chart-activity-bar a.is-active,
1375
+ .sidebar-nav a.is-active,
1376
+ .workspace-sidebar__item--active,
1377
+ .workspace-pills .nav-link.is-active,
1378
+ .workspace-pills--wide a.is-active,
1379
+ .epic-tab--active,
1380
+ .epic-topbar__tabs .nav-link.is-active,
1381
+ .summary-flag--accent,
1382
+ .problem-list__status {
1383
+ background: #111111 !important;
1384
+ color: #ffffff !important;
1385
+ border-color: #111111 !important;
1386
+ }
1387
+
1388
+ .status-pill[data-status="OPEN"],
1389
+ .status-pill[data-status="DRAFT"],
1390
+ .status-pill[data-status="PENDING_SIGNATURE"],
1391
+ .status-pill[data-status="SIGNED"],
1392
+ .status-pill[data-status="CLOSED"],
1393
+ .problem-list__status--muted {
1394
+ background: transparent !important;
1395
+ color: #111111 !important;
1396
+ border: 1px solid #111111 !important;
1397
+ }
1398
+
1399
+ .muted,
1400
+ .section-card p,
1401
+ .workspace-profile p,
1402
+ .metric-card p,
1403
+ .patient-hero p,
1404
+ .workspace-hero p,
1405
+ .summary-panel__row span,
1406
+ .rail-list__item span,
1407
+ .problem-list__item span:last-child,
1408
+ .table th,
1409
+ .ehr-table__header,
1410
+ .patient-fact strong,
1411
+ .workspace-sidebar__heading,
1412
+ .patient-hero__eyebrow,
1413
+ .patient-sidebar-card__label {
1414
+ color: #525252 !important;
1415
+ }
1416
+
1417
+ .app-brand__copy strong,
1418
+ .app-brand__copy span,
1419
+ .workspace-hero h1,
1420
+ .patient-hero h1,
1421
+ .patient-hero__title,
1422
+ .metric-card__value,
1423
+ .section-card__header h2,
1424
+ .note-row h3,
1425
+ .order-row h3,
1426
+ .patient-card strong,
1427
+ .workspace-hero__location {
1428
+ color: #111111 !important;
1429
+ }
1430
+
1431
+ .workspace-hero {
1432
+ align-items: flex-start !important;
1433
+ min-height: 240px;
1434
+ min-height: 200px;
1435
+ }
1436
+
1437
+ .workspace-hero__copy,
1438
+ .workspace-hero__aside {
1439
+ display: grid;
1440
+ gap: 14px;
1441
+ }
1442
+
1443
+ .workspace-hero h1 {
1444
+ display: block;
1445
+ font-size: clamp(1.6rem, 3vw, 2.2rem) !important;
1446
+ line-height: 1.05 !important;
1447
+ letter-spacing: -0.03em;
1448
+ text-transform: none;
1449
+ }
1450
+
1451
+ .workspace-hero__eyebrow,
1452
+ .workspace-hero__location,
1453
+ .workspace-sidebar__heading,
1454
+ .workspace-toggle,
1455
+ .workspace-backlink,
1456
+ .badge,
1457
+ .status-pill,
1458
+ .summary-flag,
1459
+ .workspace-pills .nav-link,
1460
+ .workspace-pills--wide a,
1461
+ .workspace-sidebar__item,
1462
+ .sidebar-nav a,
1463
+ .epic-tab,
1464
+ .epic-topbar__tabs .nav-link,
1465
+ .subtab-strip__item,
1466
+ .section-card__header p,
1467
+ .app-brand__copy span {
1468
+ text-transform: uppercase;
1469
+ letter-spacing: 0.08em;
1470
+ font-size: 0.76rem !important;
1471
+ }
1472
+
1473
+ .workspace-hero__lede,
1474
+ .patient-hero__lede,
1475
+ .note-row p,
1476
+ .order-row p,
1477
+ .list-row p,
1478
+ .summary-panel,
1479
+ .task-list,
1480
+ .table td,
1481
+ textarea,
1482
+ input,
1483
+ select {
1484
+ font-family: var(--font-ui), "IBM Plex Sans", "Segoe UI", system-ui, sans-serif !important;
1485
+ }
1486
+
1487
+ .workspace-hero__location {
1488
+ justify-self: start;
1489
+ padding: 8px 10px;
1490
+ border: 1px solid #111111;
1491
+ border-radius: 999px;
1492
+ }
1493
+
1494
+ .workspace-hero__aside {
1495
+ justify-items: start;
1496
+ align-self: end;
1497
+ align-self: start;
1498
+ }
1499
+
1500
+ .patient-hero {
1501
+ grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.9fr) !important;
1502
+ }
1503
+
1504
+ .patient-hero__identity {
1505
+ align-items: start !important;
1506
+ }
1507
+
1508
+ .patient-hero__title {
1509
+ font-size: clamp(1.85rem, 3.4vw, 2.8rem) !important;
1510
+ line-height: 1.02 !important;
1511
+ letter-spacing: -0.03em;
1512
+ text-transform: none;
1513
+ margin: 0;
1514
+ }
1515
+
1516
+ .workspace-search__icon,
1517
+ .workspace-icon-button,
1518
+ .workspace-sidebar__icon {
1519
+ color: #111111 !important;
1520
+ }
1521
+
1522
+ .workspace-icon-button,
1523
+ .workspace-sidebar__icon {
1524
+ background: transparent !important;
1525
+ }
1526
+
1527
+ .patient-card:hover,
1528
+ .workspace-backlink:hover,
1529
+ .workspace-icon-button:hover,
1530
+ .workspace-toggle:hover,
1531
+ .workspace-pills .nav-link:hover,
1532
+ .workspace-pills--wide a:hover,
1533
+ .workspace-sidebar__item:hover,
1534
+ .sidebar-nav a:hover,
1535
+ .primary-button:hover,
1536
+ .secondary-button:hover {
1537
+ background: #111111 !important;
1538
+ color: #ffffff !important;
1539
+ border-color: #111111 !important;
1540
+ }
1541
+
1542
+ .workspace-icon-button:hover,
1543
+ .workspace-toggle:hover,
1544
+ .workspace-backlink:hover,
1545
+ .workspace-pills .nav-link:hover,
1546
+ .workspace-pills--wide a:hover,
1547
+ .workspace-sidebar__item:hover,
1548
+ .sidebar-nav a:hover,
1549
+ .secondary-button:hover,
1550
+ .primary-button:hover {
1551
+ text-decoration: none;
1552
+ }
1553
+
1554
+ .workspace-sidebar__item:hover .workspace-sidebar__icon,
1555
+ .workspace-sidebar__item--active .workspace-sidebar__icon,
1556
+ .workspace-icon-button:hover,
1557
+ .workspace-toggle:hover,
1558
+ .workspace-backlink:hover,
1559
+ .workspace-pills .nav-link:hover,
1560
+ .workspace-pills--wide a:hover,
1561
+ .sidebar-nav a:hover {
1562
+ color: #ffffff !important;
1563
+ }
1564
+
1565
+ .workspace-sidebar__item:hover .workspace-sidebar__icon {
1566
+ background: transparent !important;
1567
+ border-color: transparent !important;
1568
+ }
1569
+
1570
+ .patient-card:hover {
1571
+ background: #111111 !important;
1572
+ color: #ffffff !important;
1573
+ border-color: #111111 !important;
1574
+ }
1575
+
1576
+ .workspace-toggle,
1577
+ .workspace-backlink,
1578
+ .workspace-icon-button,
1579
+ .primary-button,
1580
+ .secondary-button {
1581
+ padding: 8px 11px !important;
1582
+ min-height: 0 !important;
1583
+ border-radius: 10px !important;
1584
+ }
1585
+
1586
+ .workspace-toggle--static {
1587
+ display: inline-flex;
1588
+ align-items: center;
1589
+ gap: 8px;
1590
+ cursor: default;
1591
+ }
1592
+
1593
+ .workspace-toggle--static:hover {
1594
+ background: #ffffff !important;
1595
+ color: #111111 !important;
1596
+ border-color: #111111 !important;
1597
+ }
1598
+
1599
+ .workspace-toggle__icon {
1600
+ width: 14px;
1601
+ height: 14px;
1602
+ display: inline-flex;
1603
+ align-items: center;
1604
+ justify-content: center;
1605
+ flex: 0 0 auto;
1606
+ padding: 3px;
1607
+ border: 1px solid currentColor;
1608
+ border-radius: 999px;
1609
+ box-sizing: content-box;
1610
+ }
1611
+
1612
+ .workspace-toggle__icon svg {
1613
+ width: 14px;
1614
+ height: 14px;
1615
+ display: block;
1616
+ }
1617
+
1618
+ .workspace-profile--panel .workspace-profile__avatar {
1619
+ border: 1px solid #111111 !important;
1620
+ border-radius: 999px;
1621
+ }
1622
+
1623
+ .workspace-sidebar__icon {
1624
+ font-size: 0.78rem !important;
1625
+ font-weight: 700;
1626
+ line-height: 1;
1627
+ }
1628
+
1629
+ .patient-card:hover .muted,
1630
+ .patient-card:hover .status-pill,
1631
+ .patient-card:hover .summary-flag,
1632
+ .patient-card:hover strong,
1633
+ .patient-card:hover p {
1634
+ color: #ffffff !important;
1635
+ border-color: #ffffff !important;
1636
+ }
1637
+
1638
+ .patient-card:hover .status-pill {
1639
+ background: transparent !important;
1640
+ }
1641
+
1642
+ @media (max-width: 900px) {
1643
+ .dashboard-shell::after {
1644
+ background-size: 20px 20px;
1645
+ }
1646
+ }
apps/ehr/app/layout.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { Metadata } from "next";
4
+ import { IBM_Plex_Sans, Source_Serif_4 } from "next/font/google";
5
+ import ehrgymIcon from "../../../ehrgym_icon.png";
6
+
7
+ import "./globals.css";
8
+
9
+ const uiFont = IBM_Plex_Sans({
10
+ subsets: ["latin"],
11
+ weight: ["400", "500", "600", "700"],
12
+ variable: "--font-ui"
13
+ });
14
+
15
+ const bodyFont = Source_Serif_4({
16
+ subsets: ["latin"],
17
+ weight: ["400", "500", "600", "700"],
18
+ variable: "--font-body"
19
+ });
20
+
21
+ export const metadata: Metadata = {
22
+ title: "EHRGym",
23
+ description: "Clinical charting workspace for computer-use agents",
24
+ icons: {
25
+ icon: ehrgymIcon.src,
26
+ shortcut: ehrgymIcon.src,
27
+ apple: ehrgymIcon.src
28
+ }
29
+ };
30
+
31
+ export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
32
+ return (
33
+ <html lang="en">
34
+ <body className={`${uiFont.variable} ${bodyFont.variable}`}>{children}</body>
35
+ </html>
36
+ );
37
+ }
apps/ehr/app/page.tsx ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+
3
+ import { AppBrand } from "../components/app-brand";
4
+ import { SectionCard } from "../components/section-card";
5
+ import { WorkspaceSidebar } from "../components/workspace-sidebar";
6
+ import { formatDateTime, parseJsonValue } from "../lib/chart";
7
+ import { prisma } from "../lib/db";
8
+
9
+ type HomePatient = {
10
+ id: string;
11
+ mrn: string;
12
+ fullName: string;
13
+ age: number;
14
+ sex: string;
15
+ summary: string;
16
+ bannerFlagsJson: string;
17
+ encounters: Array<{
18
+ status: string;
19
+ reasonForVisit: string;
20
+ startedAt: Date;
21
+ }>;
22
+ scenarios: Array<{
23
+ objective: string;
24
+ }>;
25
+ };
26
+
27
+ export default async function HomePage() {
28
+ const patients: HomePatient[] = await prisma.patient.findMany({
29
+ orderBy: { fullName: "asc" },
30
+ include: {
31
+ encounters: {
32
+ take: 1,
33
+ orderBy: { startedAt: "desc" }
34
+ },
35
+ scenarios: {
36
+ take: 1,
37
+ orderBy: { createdAt: "desc" }
38
+ }
39
+ }
40
+ });
41
+
42
+ const leadPatient = patients[0];
43
+ const leadEncounter = leadPatient?.encounters[0];
44
+ const leadFlags = leadPatient ? parseJsonValue<string[]>(leadPatient.bannerFlagsJson) : [];
45
+ const openCount = patients.filter((patient) => patient.encounters[0]?.status === "OPEN").length;
46
+
47
+ return (
48
+ <main className="dashboard-shell">
49
+ <WorkspaceSidebar
50
+ brand={<AppBrand title="EHRGym" subtitle="Clinical workspace" href="/" />}
51
+ sections={[
52
+ {
53
+ title: "Navigation",
54
+ items: [
55
+ { label: "Dashboard", icon: "dashboard", href: "/" },
56
+ { label: "Patient list", icon: "patients", href: "#patient-list-section" },
57
+ { label: "Recent Activity", icon: "activity", href: "#recent-activity" },
58
+ { label: "Snapshot", icon: "snapshot", href: "#selected-chart" }
59
+ ]
60
+ }
61
+ ]}
62
+ footerTitle="Operational View"
63
+ footerText="Prepared for rounding, documentation, and order entry from a single dashboard."
64
+ />
65
+
66
+ <div className="dashboard-main">
67
+ <header className="workspace-header workspace-header--home" data-testid="patient-list-hero">
68
+ <span className="workspace-toggle workspace-toggle--static">
69
+ <span className="workspace-toggle__icon" aria-hidden="true">
70
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
71
+ <path d="M10 17s4.5-4.7 4.5-8A4.5 4.5 0 1 0 5.5 9c0 3.3 4.5 8 4.5 8Z" />
72
+ <circle cx="10" cy="9" r="1.6" />
73
+ </svg>
74
+ </span>
75
+ <span>5 West Med-Surg</span>
76
+ </span>
77
+ <div className="workspace-profile workspace-profile--panel">
78
+ <div className="workspace-profile__avatar">PS</div>
79
+ <div>
80
+ <strong>Patrick Sullivan</strong>
81
+ <p>RN</p>
82
+ </div>
83
+ </div>
84
+ </header>
85
+
86
+ <section className="metric-grid">
87
+ <SectionCard title="Open Charts" subtitle="Ready for review" className="metric-card">
88
+ <div className="metric-card__value">{openCount}</div>
89
+ <p>{patients.length} total patients on service</p>
90
+ </SectionCard>
91
+ <SectionCard title="Orders Pending" subtitle="Awaiting attention" className="metric-card">
92
+ <div className="metric-card__value">{leadEncounter?.status === "OPEN" ? "1/3" : "0/3"}</div>
93
+ <p>Prioritize chart completion before sign-off.</p>
94
+ </SectionCard>
95
+ <SectionCard title="Next Review" subtitle="Current focus" className="metric-card metric-card--wide">
96
+ <div className="metric-card__stack">
97
+ <strong>{leadPatient?.fullName ?? "No patient selected"}</strong>
98
+ <p>{leadEncounter?.reasonForVisit ?? "No active encounter"}</p>
99
+ <span className="summary-flag summary-flag--accent">{leadEncounter ? formatDateTime(leadEncounter.startedAt) : "Pending"}</span>
100
+ </div>
101
+ </SectionCard>
102
+ </section>
103
+
104
+ <div className="content-grid content-grid--home">
105
+ <div className="content-stack">
106
+ <SectionCard title="Patient List" subtitle="Select a patient to enter the chart" testId="patient-list">
107
+ <div id="patient-list-section" />
108
+ <div className="ehr-table__header">
109
+ <span>Name</span>
110
+ <span>MRN</span>
111
+ <span>Visit</span>
112
+ <span>Status</span>
113
+ <span>Last update</span>
114
+ </div>
115
+ <div className="patient-list patient-list--table">
116
+ {patients.map((patient: HomePatient) => {
117
+ const encounter = patient.encounters[0];
118
+ const scenario = patient.scenarios[0];
119
+ const flags = parseJsonValue<string[]>(patient.bannerFlagsJson);
120
+
121
+ return (
122
+ <Link
123
+ key={patient.id}
124
+ className="patient-card patient-card--table"
125
+ href={`/patient/${patient.id}`}
126
+ data-testid={`patient-card-${patient.id}`}
127
+ >
128
+ <div>
129
+ <strong>{patient.fullName}</strong>
130
+ <p className="muted">
131
+ {patient.age} y/o {patient.sex}
132
+ </p>
133
+ </div>
134
+ <div>
135
+ <strong>{patient.mrn}</strong>
136
+ <p className="muted">General medicine</p>
137
+ </div>
138
+ <div>
139
+ <strong>{encounter?.reasonForVisit ?? "No encounter"}</strong>
140
+ <p className="muted">{patient.summary}</p>
141
+ </div>
142
+ <div>
143
+ <span className="status-pill" data-status={encounter?.status ?? "OPEN"}>
144
+ {encounter?.status ?? "OPEN"}
145
+ </span>
146
+ </div>
147
+ <div>
148
+ <strong>{encounter ? formatDateTime(encounter.startedAt) : "Pending"}</strong>
149
+ <p className="muted">{scenario ? scenario.objective : flags.join(" · ")}</p>
150
+ </div>
151
+ </Link>
152
+ );
153
+ })}
154
+ </div>
155
+ </SectionCard>
156
+
157
+ <SectionCard title="Recent Chart Activity" subtitle="Review recent notes and care context">
158
+ <div id="recent-activity" />
159
+ <div className="note-list">
160
+ {patients.slice(0, 2).map((patient) => {
161
+ const encounter = patient.encounters[0];
162
+ const scenario = patient.scenarios[0];
163
+
164
+ return (
165
+ <article key={patient.id} className="note-row">
166
+ <header>
167
+ <div>
168
+ <h3>{patient.fullName}</h3>
169
+ <p className="muted">{encounter?.reasonForVisit ?? "No encounter"}</p>
170
+ </div>
171
+ <span className="muted">{encounter ? formatDateTime(encounter.startedAt) : "Pending"}</span>
172
+ </header>
173
+ <p>{patient.summary}</p>
174
+ {scenario ? <span className="summary-flag">{scenario.objective}</span> : null}
175
+ </article>
176
+ );
177
+ })}
178
+ </div>
179
+ </SectionCard>
180
+ </div>
181
+
182
+ <aside className="content-stack">
183
+ <SectionCard title="Selected Chart Snapshot" subtitle={leadPatient?.fullName ?? "No patient selected"} className="section-card--summary">
184
+ <div id="selected-chart" />
185
+ {leadPatient && leadEncounter ? (
186
+ <div className="summary-panel">
187
+ <div className="summary-panel__row">
188
+ <strong>MRN</strong>
189
+ <span>{leadPatient.mrn}</span>
190
+ </div>
191
+ <div className="summary-panel__row">
192
+ <strong>Visit</strong>
193
+ <span>{leadEncounter.reasonForVisit}</span>
194
+ </div>
195
+ <div className="summary-panel__row">
196
+ <strong>Provider</strong>
197
+ <span>Open chart to continue workflow</span>
198
+ </div>
199
+ <div className="summary-flags">
200
+ {leadFlags.map((flag) => (
201
+ <span key={flag} className="summary-flag">
202
+ {flag}
203
+ </span>
204
+ ))}
205
+ </div>
206
+ </div>
207
+ ) : null}
208
+ </SectionCard>
209
+
210
+ <SectionCard title="Today’s Tasks" subtitle="Priority items for chart completion" className="section-card--summary">
211
+ <div id="today-tasks" />
212
+ <div className="task-list">
213
+ <div className="task-list__item">
214
+ <span className="task-list__check" />
215
+ <div>
216
+ <strong>Review latest labs before signing</strong>
217
+ <p className="muted">Abnormal values are highlighted in chart review.</p>
218
+ </div>
219
+ </div>
220
+ <div className="task-list__item">
221
+ <span className="task-list__check" />
222
+ <div>
223
+ <strong>Complete progress note and order entry</strong>
224
+ <p className="muted">Orders can remain draft or move directly to signature.</p>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </SectionCard>
229
+ </aside>
230
+ </div>
231
+ </div>
232
+ </main>
233
+ );
234
+ }
apps/ehr/app/patient/[id]/actions.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ import { revalidatePath } from "next/cache";
6
+
7
+ import { prisma } from "../../../lib/db";
8
+
9
+ type OrderCategoryValue = "LAB" | "MED" | "IMAGING";
10
+ type OrderStatusValue = "DRAFT" | "PENDING_SIGNATURE" | "SIGNED";
11
+
12
+ function getRequiredField(formData: FormData, key: string) {
13
+ const value = formData.get(key);
14
+ if (!value || typeof value !== "string") {
15
+ throw new Error(`Missing field: ${key}`);
16
+ }
17
+
18
+ return value.trim();
19
+ }
20
+
21
+ export async function createProgressNoteAction(formData: FormData) {
22
+ const patientId = getRequiredField(formData, "patientId");
23
+ const encounterId = getRequiredField(formData, "encounterId");
24
+ const author = getRequiredField(formData, "author");
25
+ const title = getRequiredField(formData, "title");
26
+ const content = getRequiredField(formData, "content");
27
+
28
+ await prisma.clinicalNote.create({
29
+ data: {
30
+ id: randomUUID(),
31
+ encounterId,
32
+ type: "PROGRESS",
33
+ title,
34
+ author,
35
+ content,
36
+ signed: false
37
+ }
38
+ });
39
+
40
+ revalidatePath(`/patient/${patientId}`);
41
+ }
42
+
43
+ export async function createOrderAction(formData: FormData) {
44
+ const patientId = getRequiredField(formData, "patientId");
45
+ const encounterId = getRequiredField(formData, "encounterId");
46
+ const name = getRequiredField(formData, "name");
47
+ const category = getRequiredField(formData, "category") as OrderCategoryValue;
48
+ const parameters = getRequiredField(formData, "parameters");
49
+ const rationale = getRequiredField(formData, "rationale");
50
+ const submitForSignature = formData.get("submitForSignature") === "on";
51
+ const status: OrderStatusValue = submitForSignature ? "PENDING_SIGNATURE" : "DRAFT";
52
+
53
+ await prisma.order.create({
54
+ data: {
55
+ id: randomUUID(),
56
+ encounterId,
57
+ name,
58
+ category,
59
+ parametersJson: JSON.stringify({ freeText: parameters }),
60
+ rationale,
61
+ status
62
+ }
63
+ });
64
+
65
+ revalidatePath(`/patient/${patientId}`);
66
+ }
67
+
68
+ export async function signOrderAction(formData: FormData) {
69
+ const patientId = getRequiredField(formData, "patientId");
70
+ const orderId = getRequiredField(formData, "orderId");
71
+
72
+ await prisma.order.update({
73
+ where: { id: orderId },
74
+ data: { status: "SIGNED" }
75
+ });
76
+
77
+ revalidatePath(`/patient/${patientId}`);
78
+ }
79
+
80
+ export async function signEncounterAction(formData: FormData) {
81
+ const patientId = getRequiredField(formData, "patientId");
82
+ const encounterId = getRequiredField(formData, "encounterId");
83
+
84
+ await prisma.encounter.update({
85
+ where: { id: encounterId },
86
+ data: { status: "SIGNED" }
87
+ });
88
+
89
+ await prisma.clinicalNote.updateMany({
90
+ where: { encounterId },
91
+ data: { signed: true }
92
+ });
93
+
94
+ await prisma.order.updateMany({
95
+ where: {
96
+ encounterId,
97
+ status: {
98
+ in: ["DRAFT", "PENDING_SIGNATURE"]
99
+ }
100
+ },
101
+ data: { status: "SIGNED" }
102
+ });
103
+
104
+ revalidatePath(`/patient/${patientId}`);
105
+ }
apps/ehr/app/patient/[id]/page.tsx ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import { notFound } from "next/navigation";
3
+
4
+ import { ActivityNav } from "../../../components/activity-nav";
5
+ import { AppBrand } from "../../../components/app-brand";
6
+ import { ChartReviewTabs } from "../../../components/chart-review-tabs";
7
+ import { SectionCard } from "../../../components/section-card";
8
+ import { WorkspaceSidebar } from "../../../components/workspace-sidebar";
9
+ import { formatDateTime, parseJsonValue } from "../../../lib/chart";
10
+ import { prisma } from "../../../lib/db";
11
+ import {
12
+ createOrderAction,
13
+ createProgressNoteAction,
14
+ signEncounterAction,
15
+ signOrderAction
16
+ } from "./actions";
17
+
18
+ type PatientPageData = {
19
+ id: string;
20
+ mrn: string;
21
+ fullName: string;
22
+ age: number;
23
+ sex: string;
24
+ allergiesJson: string;
25
+ bannerFlagsJson: string;
26
+ summary: string;
27
+ encounters: Array<{
28
+ id: string;
29
+ type: string;
30
+ reasonForVisit: string;
31
+ provider: string;
32
+ startedAt: Date;
33
+ status: string;
34
+ labs: Array<{
35
+ id: string;
36
+ name: string;
37
+ loinc: string | null;
38
+ value: string;
39
+ unit: string;
40
+ referenceRange: string;
41
+ abnormal: boolean;
42
+ collectedAt: Date;
43
+ }>;
44
+ notes: Array<{
45
+ id: string;
46
+ type: string;
47
+ title: string;
48
+ author: string;
49
+ content: string;
50
+ signed: boolean;
51
+ createdAt: Date;
52
+ }>;
53
+ orders: Array<{
54
+ id: string;
55
+ name: string;
56
+ category: string;
57
+ parametersJson: string;
58
+ status: string;
59
+ rationale: string;
60
+ createdAt: Date;
61
+ }>;
62
+ }>;
63
+ scenarios: Array<{
64
+ id: string;
65
+ encounterId: string;
66
+ title: string;
67
+ objective: string;
68
+ rubricJson: string;
69
+ requiredOrdersJson: string;
70
+ requiredNoteElementsJson: string;
71
+ }>;
72
+ };
73
+
74
+ type PatientPageProps = {
75
+ params: Promise<{
76
+ id: string;
77
+ }>;
78
+ };
79
+
80
+ export default async function PatientPage({ params }: PatientPageProps) {
81
+ const { id } = await params;
82
+
83
+ const patient: PatientPageData | null = await prisma.patient.findUnique({
84
+ where: { id },
85
+ include: {
86
+ encounters: {
87
+ orderBy: { startedAt: "desc" },
88
+ include: {
89
+ labs: {
90
+ orderBy: { collectedAt: "desc" }
91
+ },
92
+ notes: {
93
+ orderBy: { createdAt: "desc" }
94
+ },
95
+ orders: {
96
+ orderBy: { createdAt: "desc" }
97
+ }
98
+ }
99
+ },
100
+ scenarios: {
101
+ orderBy: { createdAt: "desc" }
102
+ }
103
+ }
104
+ });
105
+
106
+ if (!patient) {
107
+ notFound();
108
+ }
109
+
110
+ const activeEncounter = patient.encounters[0];
111
+ const scenario =
112
+ patient.scenarios.find((candidate: PatientPageData["scenarios"][number]) => candidate.encounterId === activeEncounter?.id) ??
113
+ patient.scenarios[0];
114
+
115
+ if (!activeEncounter) {
116
+ notFound();
117
+ }
118
+
119
+ const allergies = parseJsonValue<string[]>(patient.allergiesJson);
120
+ const flags = parseJsonValue<string[]>(patient.bannerFlagsJson);
121
+ const rubric = scenario ? parseJsonValue<string[]>(scenario.rubricJson) : [];
122
+ const requiredOrders = scenario ? parseJsonValue<string[]>(scenario.requiredOrdersJson) : [];
123
+ const requiredNoteElements = scenario ? parseJsonValue<string[]>(scenario.requiredNoteElementsJson) : [];
124
+ const problemList = Array.from(new Set([activeEncounter.reasonForVisit, ...flags]));
125
+ const visitDiagnoses = Array.from(new Set([scenario?.title ?? activeEncounter.reasonForVisit, activeEncounter.type, patient.summary]));
126
+ const globalNavItems = [
127
+ { label: "Chart Review", href: "#chart-review" },
128
+ { label: "Synopsis", href: "#summary" },
129
+ { label: "Orders", href: "#orders" },
130
+ { label: "Notes", href: "#notes" },
131
+ { label: "Plan", href: "#summary" },
132
+ { label: "Wrap-Up", href: "#encounter" }
133
+ ];
134
+ const activityNavItems = [
135
+ { label: "Summary", href: "#summary", testId: "activity-summary" },
136
+ { label: "Chart Review", href: "#chart-review", testId: "activity-chart-review" },
137
+ { label: "Notes", href: "#notes", testId: "activity-notes" },
138
+ { label: "Orders", href: "#orders", testId: "activity-orders" },
139
+ { label: "Wrap-Up", href: "#encounter", testId: "activity-encounter" }
140
+ ];
141
+ const sidebarNavItems = [
142
+ { label: "Summary", href: "#summary" },
143
+ { label: "Labs & encounters", href: "#chart-review" },
144
+ { label: "Documentation", href: "#notes" },
145
+ { label: "Order Entry", href: "#orders" },
146
+ { label: "Sign / close", href: "#encounter" }
147
+ ];
148
+
149
+ return (
150
+ <main className="dashboard-shell">
151
+ <WorkspaceSidebar
152
+ brand={<AppBrand title="EHRGym" subtitle="Chart workspace" href="/" />}
153
+ sections={[
154
+ {
155
+ title: "Navigation",
156
+ items: [
157
+ { label: "Dashboard", icon: "dashboard", href: "/" },
158
+ { label: "Chart", icon: "chart", href: "#summary" }
159
+ ]
160
+ },
161
+ {
162
+ title: "Sections",
163
+ items: [
164
+ { label: "Summary", icon: "summary", href: "#summary" },
165
+ { label: "Review", icon: "review", href: "#chart-review" },
166
+ { label: "Orders", icon: "orders", href: "#orders" },
167
+ { label: "Notes", icon: "notes", href: "#notes" }
168
+ ]
169
+ }
170
+ ]}
171
+ footerTitle="Active Visit"
172
+ footerText={`${activeEncounter.reasonForVisit} · ${activeEncounter.provider}`}
173
+ footerAction="Return to Worklist"
174
+ footerHref="/"
175
+ />
176
+
177
+ <div className="dashboard-main">
178
+ <header className="workspace-header workspace-header--chart">
179
+ <div className="workspace-header__breadcrumbs">
180
+ <Link href="/" className="workspace-backlink">
181
+ ← All patients
182
+ </Link>
183
+ <ActivityNav items={globalNavItems} className="workspace-pills" ariaLabel="Chart navigation" defaultHref="#chart-review" />
184
+ </div>
185
+ <div className="workspace-header__actions">
186
+ <a href="#encounter" className="workspace-toggle">
187
+ {activeEncounter.status}
188
+ </a>
189
+ <div className="workspace-profile workspace-profile--compact">
190
+ <div className="workspace-profile__avatar">{patient.fullName.slice(0, 1)}</div>
191
+ <div>
192
+ <strong>{activeEncounter.provider}</strong>
193
+ <p>{formatDateTime(activeEncounter.startedAt)}</p>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </header>
198
+
199
+ <section className="patient-hero" data-testid="patient-banner">
200
+ <div className="patient-hero__identity">
201
+ <div>
202
+ <p className="patient-hero__eyebrow">Active Chart · MRN {patient.mrn}</p>
203
+ <h1 className="patient-hero__title">{patient.fullName}</h1>
204
+ <p className="patient-hero__lede">
205
+ {patient.age} y/o {patient.sex} · {activeEncounter.reasonForVisit}
206
+ </p>
207
+ </div>
208
+ </div>
209
+ <div className="patient-hero__meta">
210
+ <div className="patient-hero__panel" data-testid="allergies-card">
211
+ <strong>Allergies</strong>
212
+ <span>{allergies.join(", ")}</span>
213
+ </div>
214
+ <div className="patient-hero__panel" data-testid="flags-card">
215
+ <strong>Chart flags</strong>
216
+ <span>{flags.join(" · ")}</span>
217
+ </div>
218
+ <div className="patient-hero__panel" data-testid="encounter-card">
219
+ <strong>Visit focus</strong>
220
+ <span>{scenario?.title ?? activeEncounter.type}</span>
221
+ </div>
222
+ <div className="patient-hero__panel patient-hero__panel--status">
223
+ <strong>Status</strong>
224
+ <span className="status-pill" data-status={activeEncounter.status}>
225
+ {activeEncounter.status}
226
+ </span>
227
+ </div>
228
+ </div>
229
+ </section>
230
+
231
+ <ActivityNav items={activityNavItems} className="chart-activity-bar workspace-pills workspace-pills--wide" ariaLabel="Activity navigation" defaultHref="#summary" />
232
+
233
+ <section className="metric-grid metric-grid--chart">
234
+ <SectionCard title="Visit Goal" subtitle="Immediate objective" testId="scenario-brief" className="metric-card metric-card--wide">
235
+ <div id="summary" className="metric-card__stack">
236
+ <strong>{scenario?.objective ?? "Continue chart review and complete documentation."}</strong>
237
+ <p>{patient.summary}</p>
238
+ </div>
239
+ </SectionCard>
240
+ <SectionCard title="Pending Orders" subtitle="Expected for this visit" className="metric-card">
241
+ <div className="metric-card__value">{requiredOrders.length}</div>
242
+ <p>{requiredOrders.slice(0, 2).join(" · ") || "No required orders"}</p>
243
+ </SectionCard>
244
+ <SectionCard title="Documentation" subtitle="Expected note elements" className="metric-card">
245
+ <div className="metric-card__value">{requiredNoteElements.length}</div>
246
+ <p>{requiredNoteElements[0] ?? "Note ready for completion"}</p>
247
+ </SectionCard>
248
+ </section>
249
+
250
+ <div className="content-grid content-grid--chart">
251
+ <div className="content-stack">
252
+ <SectionCard title="Chart Review" subtitle="Encounter timeline and laboratory review" testId="chart-review-panel" className="section-card--chart">
253
+ <div id="chart-review">
254
+ <ChartReviewTabs encounters={patient.encounters} labs={activeEncounter.labs} notes={activeEncounter.notes} />
255
+ </div>
256
+ </SectionCard>
257
+
258
+ <SectionCard title="Notes" subtitle="Progress and clinical documentation" testId="notes-panel" className="section-card--notes">
259
+ <div id="notes" className="grid grid--2">
260
+ <form action={createProgressNoteAction} className="list-row" data-testid="note-form">
261
+ <input type="hidden" name="patientId" value={patient.id} />
262
+ <input type="hidden" name="encounterId" value={activeEncounter.id} />
263
+ <div className="form-grid">
264
+ <label className="field">
265
+ <span className="muted">Author</span>
266
+ <input aria-label="Note author" name="author" defaultValue="Resident Physician" required />
267
+ </label>
268
+ <label className="field">
269
+ <span className="muted">Title</span>
270
+ <input aria-label="Note title" name="title" defaultValue="Progress Note" required />
271
+ </label>
272
+ <label className="field">
273
+ <span className="muted">Progress note</span>
274
+ <textarea
275
+ aria-label="Progress note content"
276
+ name="content"
277
+ defaultValue={`S: \nO: Reviewed interval history and latest results.\nA: ${scenario?.title ?? activeEncounter.reasonForVisit}.\nP: `}
278
+ required
279
+ />
280
+ </label>
281
+ <div className="form-actions">
282
+ <button className="primary-button" type="submit" data-testid="save-note-button">
283
+ File progress note
284
+ </button>
285
+ </div>
286
+ </div>
287
+ </form>
288
+
289
+ <div className="note-list">
290
+ {activeEncounter.notes.map((note: PatientPageData["encounters"][number]["notes"][number]) => (
291
+ <article key={note.id} className="note-row" data-testid={`note-row-${note.id}`}>
292
+ <header>
293
+ <div>
294
+ <h3>{note.title}</h3>
295
+ <p className="muted">
296
+ {note.type} · {note.author} · {formatDateTime(note.createdAt)}
297
+ </p>
298
+ </div>
299
+ <span className="status-pill" data-status={note.signed ? "SIGNED" : "OPEN"}>
300
+ {note.signed ? "SIGNED" : "DRAFT"}
301
+ </span>
302
+ </header>
303
+ <p style={{ whiteSpace: "pre-wrap" }}>{note.content}</p>
304
+ </article>
305
+ ))}
306
+ </div>
307
+ </div>
308
+ </SectionCard>
309
+
310
+ <SectionCard title="Orders" subtitle="Medication, lab, and imaging entry" testId="orders-panel" className="section-card--orders">
311
+ <div id="orders" className="grid grid--2">
312
+ <form action={createOrderAction} className="list-row" data-testid="order-form">
313
+ <input type="hidden" name="patientId" value={patient.id} />
314
+ <input type="hidden" name="encounterId" value={activeEncounter.id} />
315
+ <div className="form-grid">
316
+ <label className="field">
317
+ <span className="muted">Order name</span>
318
+ <input aria-label="Order name" name="name" placeholder="Normal saline bolus" required />
319
+ </label>
320
+ <label className="field">
321
+ <span className="muted">Category</span>
322
+ <select aria-label="Order category" name="category" defaultValue="LAB">
323
+ <option value="LAB">Lab</option>
324
+ <option value="MED">Medication</option>
325
+ <option value="IMAGING">Imaging</option>
326
+ </select>
327
+ </label>
328
+ <label className="field">
329
+ <span className="muted">Parameters</span>
330
+ <input aria-label="Order parameters" name="parameters" placeholder="1 L IV once" required />
331
+ </label>
332
+ <label className="field">
333
+ <span className="muted">Rationale</span>
334
+ <textarea aria-label="Order rationale" name="rationale" placeholder="Why is this order needed?" required />
335
+ </label>
336
+ <label className="checkbox-field">
337
+ <input className="checkbox-field__control" aria-label="Submit order for signature" name="submitForSignature" type="checkbox" />
338
+ <span>Send directly for signature</span>
339
+ </label>
340
+ <div className="form-actions">
341
+ <button className="primary-button" type="submit" data-testid="save-order-button">
342
+ Accept order
343
+ </button>
344
+ </div>
345
+ </div>
346
+ </form>
347
+
348
+ <div className="order-list">
349
+ {activeEncounter.orders.map((order: PatientPageData["encounters"][number]["orders"][number]) => {
350
+ const parameters = parseJsonValue<Record<string, string>>(order.parametersJson);
351
+
352
+ return (
353
+ <article key={order.id} className="order-row" data-testid={`order-row-${order.id}`}>
354
+ <header>
355
+ <div>
356
+ <h3>{order.name}</h3>
357
+ <p className="muted">
358
+ {order.category} · {formatDateTime(order.createdAt)}
359
+ </p>
360
+ </div>
361
+ <span className="status-pill" data-status={order.status}>
362
+ {order.status}
363
+ </span>
364
+ </header>
365
+ <p>
366
+ <strong>Parameters:</strong> {Object.values(parameters).join(", ")}
367
+ </p>
368
+ <p>
369
+ <strong>Rationale:</strong> {order.rationale}
370
+ </p>
371
+ {order.status !== "SIGNED" ? (
372
+ <form action={signOrderAction} className="form-actions">
373
+ <input type="hidden" name="patientId" value={patient.id} />
374
+ <input type="hidden" name="orderId" value={order.id} />
375
+ <button className="secondary-button" type="submit" data-testid={`sign-order-${order.id}`}>
376
+ Sign order
377
+ </button>
378
+ </form>
379
+ ) : null}
380
+ </article>
381
+ );
382
+ })}
383
+ </div>
384
+ </div>
385
+ </SectionCard>
386
+
387
+ <SectionCard title="Wrap-Up" subtitle="Finalize documentation and orders" testId="encounter-panel" className="section-card--wrapup">
388
+ <div id="encounter" className="list-row list-row--split">
389
+ <p>Signing this visit finalizes notes and promotes all remaining draft or pending orders to signed.</p>
390
+ <form action={signEncounterAction}>
391
+ <input type="hidden" name="patientId" value={patient.id} />
392
+ <input type="hidden" name="encounterId" value={activeEncounter.id} />
393
+ <div className="form-actions">
394
+ <button className="primary-button" type="submit" data-testid="sign-encounter-button">
395
+ Sign visit
396
+ </button>
397
+ </div>
398
+ </form>
399
+ </div>
400
+ </SectionCard>
401
+ </div>
402
+
403
+ <aside className="content-stack">
404
+ <SectionCard title="Review Status" subtitle="Visit workflow" className="section-card--summary">
405
+ <div className="rail-list">
406
+ {rubric.map((item) => (
407
+ <div key={item} className="rail-list__item">
408
+ <strong>{item}</strong>
409
+ <span>Pending review</span>
410
+ </div>
411
+ ))}
412
+ </div>
413
+ </SectionCard>
414
+
415
+ <SectionCard title="Chart Navigation" subtitle="Common activities" className="section-card--summary">
416
+ <ActivityNav items={sidebarNavItems} className="sidebar-nav" ariaLabel="Chart sections" defaultHref="#summary" />
417
+ </SectionCard>
418
+
419
+ <SectionCard title="Problem List" subtitle="Active charted issues" className="section-card--problem-list">
420
+ <div className="problem-list">
421
+ {problemList.map((problem) => (
422
+ <div key={problem} className="problem-list__item">
423
+ <span>{problem}</span>
424
+ <span className="problem-list__status">Active</span>
425
+ </div>
426
+ ))}
427
+ </div>
428
+ </SectionCard>
429
+
430
+ <SectionCard title="Visit Diagnoses" subtitle="Current encounter associations" className="section-card--diagnosis-list">
431
+ <div className="problem-list">
432
+ {visitDiagnoses.map((diagnosis) => (
433
+ <div key={diagnosis} className="problem-list__item">
434
+ <span>{diagnosis}</span>
435
+ <span className="problem-list__status problem-list__status--muted">Visit</span>
436
+ </div>
437
+ ))}
438
+ </div>
439
+ </SectionCard>
440
+
441
+ <SectionCard title="Orders to Review" subtitle="Expected for this visit" className="section-card--summary">
442
+ <div className="summary-flags">
443
+ {requiredOrders.map((item) => (
444
+ <span key={item} className="summary-flag summary-flag--accent">
445
+ {item}
446
+ </span>
447
+ ))}
448
+ </div>
449
+ </SectionCard>
450
+ </aside>
451
+ </div>
452
+ </div>
453
+ </main>
454
+ );
455
+ }
apps/ehr/components/activity-nav.tsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ type NavItem = {
6
+ label: string;
7
+ href: string;
8
+ testId?: string;
9
+ };
10
+
11
+ type ActivityNavProps = {
12
+ items: NavItem[];
13
+ className?: string;
14
+ defaultHref?: string;
15
+ ariaLabel: string;
16
+ };
17
+
18
+ export function ActivityNav({ items, className, defaultHref, ariaLabel }: ActivityNavProps) {
19
+ const fallbackHref = useMemo(() => defaultHref ?? items[0]?.href ?? "#summary", [defaultHref, items]);
20
+ const [activeHref, setActiveHref] = useState(fallbackHref);
21
+
22
+ useEffect(() => {
23
+ const updateActiveHref = () => {
24
+ const currentHash = window.location.hash || fallbackHref;
25
+ setActiveHref(currentHash);
26
+ };
27
+
28
+ updateActiveHref();
29
+ window.addEventListener("hashchange", updateActiveHref);
30
+ return () => window.removeEventListener("hashchange", updateActiveHref);
31
+ }, [fallbackHref]);
32
+
33
+ return (
34
+ <nav className={className} aria-label={ariaLabel}>
35
+ {items.map((item) => (
36
+ <a
37
+ key={`${item.href}-${item.label}`}
38
+ href={item.href}
39
+ className={activeHref === item.href ? "nav-link is-active" : "nav-link"}
40
+ aria-current={activeHref === item.href ? "page" : undefined}
41
+ data-testid={item.testId}
42
+ onClick={() => setActiveHref(item.href)}
43
+ >
44
+ {item.label}
45
+ </a>
46
+ ))}
47
+ </nav>
48
+ );
49
+ }
apps/ehr/components/app-brand.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Image from "next/image";
2
+ import Link from "next/link";
3
+ import type { Route } from "next";
4
+ import ehrgymIcon from "../../../ehrgym_icon.png";
5
+
6
+ type AppBrandProps = {
7
+ title: string;
8
+ subtitle: string;
9
+ href?: Route;
10
+ compact?: boolean;
11
+ };
12
+
13
+ export function AppBrand({ title, subtitle, href = "/", compact = false }: AppBrandProps) {
14
+ const content = (
15
+ <>
16
+ <span className={compact ? "app-brand__logo app-brand__logo--compact" : "app-brand__logo"} aria-hidden="true">
17
+ <Image src={ehrgymIcon} alt="" draggable={false} priority={compact ? false : true} />
18
+ </span>
19
+ <span className={compact ? "app-brand__copy app-brand__copy--compact" : "app-brand__copy"}>
20
+ <strong>{title}</strong>
21
+ <span>{subtitle}</span>
22
+ </span>
23
+ </>
24
+ );
25
+
26
+ if (!href) {
27
+ return <div className="app-brand">{content}</div>;
28
+ }
29
+
30
+ return (
31
+ <Link href={href} className="app-brand" aria-label={title}>
32
+ {content}
33
+ </Link>
34
+ );
35
+ }
apps/ehr/components/chart-review-tabs.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+
5
+ import { formatDateTime } from "../lib/chart";
6
+
7
+ type EncounterItem = {
8
+ id: string;
9
+ type: string;
10
+ reasonForVisit: string;
11
+ provider: string;
12
+ startedAt: Date | string;
13
+ status: string;
14
+ };
15
+
16
+ type LabItem = {
17
+ id: string;
18
+ name: string;
19
+ loinc: string | null;
20
+ value: string;
21
+ unit: string;
22
+ referenceRange: string;
23
+ abnormal: boolean;
24
+ collectedAt: Date | string;
25
+ };
26
+
27
+ type NoteItem = {
28
+ id: string;
29
+ type: string;
30
+ title: string;
31
+ author: string;
32
+ content: string;
33
+ signed: boolean;
34
+ createdAt: Date | string;
35
+ };
36
+
37
+ type ChartReviewTabsProps = {
38
+ encounters: EncounterItem[];
39
+ labs: LabItem[];
40
+ notes: NoteItem[];
41
+ };
42
+
43
+ type TabKey = "encounters" | "labs" | "notes";
44
+
45
+ export function ChartReviewTabs({ encounters, labs, notes }: ChartReviewTabsProps) {
46
+ const [activeTab, setActiveTab] = useState<TabKey>("encounters");
47
+
48
+ const tabs = useMemo(
49
+ () => [
50
+ { key: "encounters" as const, label: "Encounters" },
51
+ { key: "labs" as const, label: "Labs" },
52
+ { key: "notes" as const, label: "Clinical Notes" }
53
+ ],
54
+ []
55
+ );
56
+
57
+ return (
58
+ <div className="section-stack">
59
+ <div className="subtab-strip" aria-label="Chart review tabs" role="tablist">
60
+ {tabs.map((tab) => (
61
+ <button
62
+ key={tab.key}
63
+ type="button"
64
+ role="tab"
65
+ aria-selected={activeTab === tab.key}
66
+ aria-controls={`chart-tab-panel-${tab.key}`}
67
+ id={`chart-tab-${tab.key}`}
68
+ className={activeTab === tab.key ? "subtab-strip__item subtab-strip__item--active" : "subtab-strip__item"}
69
+ data-testid={`chart-tab-${tab.key}`}
70
+ onClick={() => setActiveTab(tab.key)}
71
+ >
72
+ {tab.label}
73
+ </button>
74
+ ))}
75
+ </div>
76
+
77
+ <section
78
+ id="chart-tab-panel-encounters"
79
+ role="tabpanel"
80
+ aria-labelledby="chart-tab-encounters"
81
+ hidden={activeTab !== "encounters"}
82
+ className="list-row"
83
+ data-testid="encounter-timeline"
84
+ >
85
+ <header>
86
+ <div>
87
+ <h3>Encounter timeline</h3>
88
+ <p className="muted">Linked visit history and responsible clinicians.</p>
89
+ </div>
90
+ </header>
91
+ <div className="timeline">
92
+ {encounters.map((encounter) => (
93
+ <div key={encounter.id} className="list-row">
94
+ <header>
95
+ <div>
96
+ <strong>{encounter.type}</strong>
97
+ <p className="muted">
98
+ {encounter.reasonForVisit} · {encounter.provider}
99
+ </p>
100
+ </div>
101
+ <span className="status-pill" data-status={encounter.status}>
102
+ {encounter.status}
103
+ </span>
104
+ </header>
105
+ <p className="muted">Started {formatDateTime(new Date(encounter.startedAt))}</p>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ </section>
110
+
111
+ <section
112
+ id="chart-tab-panel-labs"
113
+ role="tabpanel"
114
+ aria-labelledby="chart-tab-labs"
115
+ hidden={activeTab !== "labs"}
116
+ className="list-row"
117
+ data-testid="labs-table-wrapper"
118
+ >
119
+ <header>
120
+ <div>
121
+ <h3>Labs</h3>
122
+ <p className="muted">Recent resulted values for the active encounter.</p>
123
+ </div>
124
+ </header>
125
+ <table className="table" aria-label="Recent labs" data-testid="labs-table">
126
+ <thead>
127
+ <tr>
128
+ <th>Collected</th>
129
+ <th>Test</th>
130
+ <th>Value</th>
131
+ <th>Reference</th>
132
+ <th>LOINC</th>
133
+ </tr>
134
+ </thead>
135
+ <tbody>
136
+ {labs.map((lab) => (
137
+ <tr key={lab.id} className="lab-row" data-testid={`lab-row-${lab.id}`}>
138
+ <td>{formatDateTime(new Date(lab.collectedAt))}</td>
139
+ <td>{lab.name}</td>
140
+ <td className={lab.abnormal ? "abnormal" : undefined}>
141
+ {lab.value} {lab.unit}
142
+ </td>
143
+ <td>{lab.referenceRange}</td>
144
+ <td>{lab.loinc ?? "—"}</td>
145
+ </tr>
146
+ ))}
147
+ </tbody>
148
+ </table>
149
+ </section>
150
+
151
+ <section
152
+ id="chart-tab-panel-notes"
153
+ role="tabpanel"
154
+ aria-labelledby="chart-tab-notes"
155
+ hidden={activeTab !== "notes"}
156
+ className="note-list"
157
+ data-testid="chart-review-notes"
158
+ >
159
+ {notes.map((note) => (
160
+ <article key={note.id} className="note-row" data-testid={`chart-note-row-${note.id}`}>
161
+ <header>
162
+ <div>
163
+ <h3>{note.title}</h3>
164
+ <p className="muted">
165
+ {note.type} · {note.author} · {formatDateTime(new Date(note.createdAt))}
166
+ </p>
167
+ </div>
168
+ <span className="status-pill" data-status={note.signed ? "SIGNED" : "OPEN"}>
169
+ {note.signed ? "SIGNED" : "DRAFT"}
170
+ </span>
171
+ </header>
172
+ <p style={{ whiteSpace: "pre-wrap" }}>{note.content}</p>
173
+ </article>
174
+ ))}
175
+ </section>
176
+ </div>
177
+ );
178
+ }
apps/ehr/components/section-card.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ type SectionCardProps = {
4
+ title: string;
5
+ subtitle?: string;
6
+ children: ReactNode;
7
+ testId?: string;
8
+ className?: string;
9
+ };
10
+
11
+ export function SectionCard({ title, subtitle, children, testId, className }: SectionCardProps) {
12
+ return (
13
+ <section className={["section-card", className].filter(Boolean).join(" ")} data-testid={testId}>
14
+ <div className="section-card__header">
15
+ <div>
16
+ <h2>{title}</h2>
17
+ {subtitle ? <p>{subtitle}</p> : null}
18
+ </div>
19
+ </div>
20
+ {children}
21
+ </section>
22
+ );
23
+ }
apps/ehr/components/workspace-sidebar.tsx ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import type { Route } from "next";
3
+ import type { ReactNode, SVGProps } from "react";
4
+
5
+ type SidebarIconName = "dashboard" | "patients" | "activity" | "snapshot" | "chart" | "summary" | "review" | "orders" | "notes";
6
+
7
+ type SidebarItem = {
8
+ label: string;
9
+ icon: SidebarIconName;
10
+ active?: boolean;
11
+ href?: string;
12
+ };
13
+
14
+ type SidebarSection = {
15
+ title: string;
16
+ items: SidebarItem[];
17
+ };
18
+
19
+ type WorkspaceSidebarProps = {
20
+ brand: ReactNode;
21
+ sections: SidebarSection[];
22
+ footerTitle: string;
23
+ footerText: string;
24
+ footerAction?: string;
25
+ footerHref?: string;
26
+ };
27
+
28
+ function SidebarGlyph({ name, ...props }: { name: SidebarIconName } & SVGProps<SVGSVGElement>) {
29
+ switch (name) {
30
+ case "dashboard":
31
+ return (
32
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" {...props}>
33
+ <rect x="3" y="3" width="5" height="5" rx="1" />
34
+ <rect x="12" y="3" width="5" height="5" rx="1" />
35
+ <rect x="3" y="12" width="5" height="5" rx="1" />
36
+ <rect x="12" y="12" width="5" height="5" rx="1" />
37
+ </svg>
38
+ );
39
+ case "patients":
40
+ return (
41
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...props}>
42
+ <path d="M5 5.5h10" />
43
+ <path d="M5 10h10" />
44
+ <path d="M5 14.5h10" />
45
+ <circle cx="3.5" cy="5.5" r="0.9" fill="currentColor" stroke="none" />
46
+ <circle cx="3.5" cy="10" r="0.9" fill="currentColor" stroke="none" />
47
+ <circle cx="3.5" cy="14.5" r="0.9" fill="currentColor" stroke="none" />
48
+ </svg>
49
+ );
50
+ case "activity":
51
+ return (
52
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...props}>
53
+ <path d="M3 12h3l2-4 3.2 7 2.1-4H17" />
54
+ </svg>
55
+ );
56
+ case "snapshot":
57
+ return (
58
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...props}>
59
+ <rect x="3" y="4" width="14" height="12" rx="2" />
60
+ <path d="M7 8h6" />
61
+ <path d="M7 12h4" />
62
+ </svg>
63
+ );
64
+ case "chart":
65
+ return (
66
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...props}>
67
+ <path d="M6 3.5h6l3 3V16a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-11a1 1 0 0 1 1-1Z" />
68
+ <path d="M12 3.5V7h3" />
69
+ </svg>
70
+ );
71
+ case "summary":
72
+ return (
73
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...props}>
74
+ <path d="M5 6h10" />
75
+ <path d="M5 10h10" />
76
+ <path d="M5 14h6" />
77
+ </svg>
78
+ );
79
+ case "review":
80
+ return (
81
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...props}>
82
+ <circle cx="9" cy="9" r="4.5" />
83
+ <path d="m13 13 3.5 3.5" />
84
+ </svg>
85
+ );
86
+ case "orders":
87
+ return (
88
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...props}>
89
+ <rect x="5" y="3.5" width="10" height="13" rx="1.5" />
90
+ <path d="M8 3.5h4" />
91
+ <path d="M7.5 8h5" />
92
+ <path d="M7.5 11h5" />
93
+ </svg>
94
+ );
95
+ case "notes":
96
+ return (
97
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...props}>
98
+ <path d="M6 3.5h8a1 1 0 0 1 1 1V16l-3-2-3 2-3-2-1 .7V4.5a1 1 0 0 1 1-1Z" />
99
+ <path d="M7.5 8h5" />
100
+ <path d="M7.5 10.8h5" />
101
+ </svg>
102
+ );
103
+ }
104
+ }
105
+
106
+ export function WorkspaceSidebar({ brand, sections, footerTitle, footerText, footerAction, footerHref }: WorkspaceSidebarProps) {
107
+ return (
108
+ <aside className="workspace-sidebar">
109
+ <div className="workspace-sidebar__brand">{brand}</div>
110
+
111
+ <div className="workspace-sidebar__sections">
112
+ {sections.map((section) => (
113
+ <section key={section.title} className="workspace-sidebar__section">
114
+ <p className="workspace-sidebar__heading">{section.title}</p>
115
+ <div className="workspace-sidebar__nav">
116
+ {section.items.map((item) => {
117
+ const className = item.active ? "workspace-sidebar__item workspace-sidebar__item--active" : "workspace-sidebar__item";
118
+ const content = (
119
+ <>
120
+ <span className="workspace-sidebar__icon" aria-hidden="true">
121
+ <SidebarGlyph name={item.icon} />
122
+ </span>
123
+ <span>{item.label}</span>
124
+ </>
125
+ );
126
+
127
+ if (item.href?.startsWith("#")) {
128
+ return (
129
+ <a key={item.label} href={item.href} className={className}>
130
+ {content}
131
+ </a>
132
+ );
133
+ }
134
+
135
+ if (item.href) {
136
+ return (
137
+ <Link key={item.label} href={item.href as Route} className={className}>
138
+ {content}
139
+ </Link>
140
+ );
141
+ }
142
+
143
+ return (
144
+ <button key={item.label} type="button" className={className}>
145
+ {content}
146
+ </button>
147
+ );
148
+ })}
149
+ </div>
150
+ </section>
151
+ ))}
152
+ </div>
153
+
154
+ <div className="workspace-sidebar__footer">
155
+ <strong>{footerTitle}</strong>
156
+ <p>{footerText}</p>
157
+ {footerAction
158
+ ? footerHref?.startsWith("#")
159
+ ? (
160
+ <a href={footerHref} className="secondary-button workspace-sidebar__footer-action">
161
+ {footerAction}
162
+ </a>
163
+ )
164
+ : footerHref
165
+ ? (
166
+ <Link href={footerHref as Route} className="secondary-button workspace-sidebar__footer-action">
167
+ {footerAction}
168
+ </Link>
169
+ )
170
+ : (
171
+ <button type="button" className="secondary-button workspace-sidebar__footer-action">
172
+ {footerAction}
173
+ </button>
174
+ )
175
+ : null}
176
+ </div>
177
+ </aside>
178
+ );
179
+ }
apps/ehr/next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ /// <reference path="./.next/types/routes.d.ts" />
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
apps/ehr/next.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ typedRoutes: true
5
+ };
6
+
7
+ export default nextConfig;
apps/ehr/package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@ehrgym/ehr",
3
+ "private": true,
4
+ "scripts": {
5
+ "clean": "node -e \"require('fs').rmSync('.next', { recursive: true, force: true })\"",
6
+ "dev": "npm run clean && next dev --hostname 0.0.0.0 --port 3000",
7
+ "build": "npm run clean && next build",
8
+ "start": "next start --hostname 0.0.0.0 --port 3000",
9
+ "typecheck": "tsc --project tsconfig.json --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@prisma/client": "^6.5.0",
13
+ "next": "^15.2.0",
14
+ "react": "^19.0.0",
15
+ "react-dom": "^19.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^19.0.10",
19
+ "@types/react-dom": "^19.0.4"
20
+ }
21
+ }
apps/ehr/tsconfig.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "plugins": [
5
+ {
6
+ "name": "next"
7
+ }
8
+ ]
9
+ },
10
+ "include": [
11
+ "next-env.d.ts",
12
+ "**/*.ts",
13
+ "**/*.tsx",
14
+ ".next/types/**/*.ts"
15
+ ],
16
+ "exclude": ["node_modules"]
17
+ }
docker-compose.yml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ services:
4
+ ehrgym:
5
+ build:
6
+ context: .
7
+ dockerfile: docker/Dockerfile
8
+ environment:
9
+ DATABASE_URL: file:/app/prisma/dev.db
10
+ EHR_BASE_URL: http://127.0.0.1:3000
11
+ PORT: "3000"
12
+ PLAYWRIGHT_HEADLESS: "true"
13
+ OPENENV_DEFAULT_WAIT_MS: "350"
14
+ ports:
15
+ - "3000:3000"
16
+ - "8000:8000"
docker/Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-bookworm
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+ COPY . /app
9
+
10
+ RUN npm install \
11
+ && python3 -m pip install --no-cache-dir . \
12
+ && python3 -m playwright install --with-deps chromium \
13
+ && npx prisma generate \
14
+ && npx prisma db push \
15
+ && npx prisma db seed \
16
+ && npm run build:ehr
17
+
18
+ EXPOSE 3000 8000
19
+ ENTRYPOINT ["./docker/entrypoint.sh"]
docker/entrypoint.sh ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cleanup() {
5
+ if [[ -n "${ENV_SERVER_PID:-}" ]]; then
6
+ kill "$ENV_SERVER_PID" >/dev/null 2>&1 || true
7
+ fi
8
+ }
9
+ trap cleanup EXIT INT TERM
10
+
11
+ export DATABASE_URL="${DATABASE_URL:-file:/app/prisma/dev.db}"
12
+ export PORT="${PORT:-3000}"
13
+ export EHR_BASE_URL="${EHR_BASE_URL:-http://127.0.0.1:${PORT}}"
14
+
15
+ npx prisma generate
16
+ npx prisma db push
17
+ npx prisma db seed
18
+
19
+ uvicorn env_server.app.main:app --host 0.0.0.0 --port 8000 &
20
+ ENV_SERVER_PID=$!
21
+
22
+ npm run start --workspace @ehrgym/ehr -- --hostname 0.0.0.0 --port "$PORT"
env_server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Environment server package for EHRGym."""
env_server/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FastAPI app for the EHRGym environment server."""
env_server/app/browser.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ from typing import Any, Optional
6
+ from urllib.parse import urljoin
7
+
8
+ from playwright.async_api import Browser, Page, Playwright, async_playwright
9
+
10
+ from .models import Action, Observation
11
+
12
+
13
+ class BrowserSession:
14
+ def __init__(self) -> None:
15
+ self._playwright: Optional[Playwright] = None
16
+ self._browser: Optional[Browser] = None
17
+ self.page: Optional[Page] = None
18
+
19
+ async def ensure_started(self, *, headless: bool) -> None:
20
+ if self._browser and self.page:
21
+ return
22
+
23
+ self._playwright = await async_playwright().start()
24
+ self._browser = await self._playwright.chromium.launch(headless=headless)
25
+ context = await self._browser.new_context(viewport={"width": 1440, "height": 1024})
26
+ self.page = await context.new_page()
27
+
28
+ async def close(self) -> None:
29
+ if self.page:
30
+ await self.page.context.close()
31
+ self.page = None
32
+
33
+ if self._browser:
34
+ await self._browser.close()
35
+ self._browser = None
36
+
37
+ if self._playwright:
38
+ await self._playwright.stop()
39
+ self._playwright = None
40
+
41
+ async def reset(self, base_url: str) -> None:
42
+ if not self.page:
43
+ raise RuntimeError("Browser session has not been started.")
44
+
45
+ await self.page.goto(base_url, wait_until="networkidle")
46
+
47
+ async def perform(self, action: Action, *, default_wait_ms: int) -> dict[str, Any]:
48
+ if not self.page:
49
+ raise RuntimeError("Browser session has not been started.")
50
+
51
+ metadata: dict[str, Any] = {"action_type": action.type, "success": True}
52
+
53
+ if action.type == "goto":
54
+ target_url = action.url or "/"
55
+ if not target_url.startswith("http"):
56
+ current_origin = self.page.url.split("/", 3)
57
+ base_origin = "/".join(current_origin[:3]) if len(current_origin) >= 3 else "http://127.0.0.1:3000"
58
+ target_url = urljoin(f"{base_origin}/", target_url.lstrip("/"))
59
+ await self.page.goto(target_url, wait_until="networkidle")
60
+ elif action.type == "click":
61
+ if not action.selector:
62
+ raise ValueError("click action requires selector")
63
+ await self.page.locator(action.selector).click()
64
+ elif action.type == "fill":
65
+ if not action.selector:
66
+ raise ValueError("fill action requires selector")
67
+ await self.page.locator(action.selector).fill(action.text or "")
68
+ elif action.type == "keypress":
69
+ if not action.key:
70
+ raise ValueError("keypress action requires key")
71
+ await self.page.keyboard.press(action.key)
72
+ elif action.type == "wait":
73
+ await asyncio.sleep((action.milliseconds or default_wait_ms) / 1000)
74
+ else:
75
+ metadata["success"] = False
76
+ metadata["error"] = f"Unsupported action: {action.type}"
77
+
78
+ return metadata
79
+
80
+ async def observe(self, *, goal: str, metadata: dict[str, Any]) -> Observation:
81
+ if not self.page:
82
+ raise RuntimeError("Browser session has not been started.")
83
+
84
+ screenshot = await self.page.screenshot(type="png", full_page=True)
85
+ screenshot_b64 = base64.b64encode(screenshot).decode("utf-8")
86
+ current_url = self.page.url
87
+ active_activity = "/" if current_url.endswith(":3000/") else current_url.rsplit("/", 1)[-1]
88
+
89
+ return Observation(
90
+ goal=goal,
91
+ screenshot_b64=screenshot_b64,
92
+ current_url=current_url,
93
+ active_activity=active_activity,
94
+ metadata=metadata,
95
+ )
env_server/app/main.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any, Optional
6
+ from uuid import uuid4
7
+
8
+ import httpx
9
+ from fastapi import FastAPI, HTTPException
10
+
11
+ from .browser import BrowserSession
12
+ from .models import Action, EnvironmentState, ResetRequest, ResetResponse, StepResponse
13
+
14
+ EHR_BASE_URL = os.getenv("EHR_BASE_URL", "http://127.0.0.1:3000")
15
+ HEADLESS = os.getenv("PLAYWRIGHT_HEADLESS", "true").lower() != "false"
16
+ DEFAULT_WAIT_MS = int(os.getenv("OPENENV_DEFAULT_WAIT_MS", "350"))
17
+
18
+ browser = BrowserSession()
19
+ state = EnvironmentState(episode_id="bootstrap")
20
+ goal_text = "Open the chart and complete the assigned workflow."
21
+
22
+
23
+ async def _post_reset() -> None:
24
+ async with httpx.AsyncClient(timeout=30.0) as client:
25
+ response = await client.post(f"{EHR_BASE_URL}/api/dev/reset")
26
+ response.raise_for_status()
27
+
28
+
29
+ async def _fetch_patients() -> list[dict[str, Any]]:
30
+ async with httpx.AsyncClient(timeout=30.0) as client:
31
+ response = await client.get(f"{EHR_BASE_URL}/api/patients")
32
+ response.raise_for_status()
33
+ return response.json()["patients"]
34
+
35
+
36
+ async def _fetch_patient(patient_id: str) -> dict[str, Any]:
37
+ async with httpx.AsyncClient(timeout=30.0) as client:
38
+ response = await client.get(f"{EHR_BASE_URL}/api/patients/{patient_id}")
39
+ response.raise_for_status()
40
+ return response.json()["patient"]
41
+
42
+
43
+ async def _refresh_progress() -> tuple[list[str], bool]:
44
+ if not state.patient_id:
45
+ return [], False
46
+
47
+ patient = await _fetch_patient(state.patient_id)
48
+ scenario = next((item for item in patient["scenarios"] if item["id"] == state.scenario_id), None)
49
+ encounter = next((item for item in patient["encounters"] if item["id"] == state.encounter_id), None)
50
+
51
+ if not scenario or not encounter:
52
+ return [], False
53
+
54
+ completed: list[str] = []
55
+ order_names = {order["name"] for order in encounter["orders"] if order["status"] == "SIGNED"}
56
+ if set(scenario["requiredOrders"]).issubset(order_names):
57
+ completed.append("required_orders")
58
+
59
+ note_text = "\n".join(note["content"] for note in encounter["notes"])
60
+ if all(element.lower() in note_text.lower() for element in scenario["requiredNoteElements"]):
61
+ completed.append("required_note_elements")
62
+
63
+ if encounter["status"] == "SIGNED":
64
+ completed.append("encounter_signed")
65
+
66
+ return completed, len(completed) == 3
67
+
68
+
69
+ @asynccontextmanager
70
+ async def lifespan(_: FastAPI):
71
+ await browser.ensure_started(headless=HEADLESS)
72
+ yield
73
+ await browser.close()
74
+
75
+
76
+ app = FastAPI(title="EHRGym Environment Server", version="0.1.0", lifespan=lifespan)
77
+
78
+
79
+ @app.get("/healthz")
80
+ async def healthz() -> dict[str, str]:
81
+ return {"status": "ok"}
82
+
83
+
84
+ @app.post("/reset", response_model=ResetResponse)
85
+ async def reset(request: Optional[ResetRequest] = None) -> ResetResponse:
86
+ global state, goal_text
87
+
88
+ try:
89
+ await _post_reset()
90
+ patients = await _fetch_patients()
91
+ except httpx.HTTPError as error:
92
+ raise HTTPException(status_code=502, detail=f"Failed to reset EHR app: {error}") from error
93
+
94
+ patient = next((item for item in patients if item["id"] == request.patient_id), None) if request else None
95
+ patient = patient or patients[0]
96
+ if not patient:
97
+ raise HTTPException(status_code=500, detail="No synthetic patients available after reset")
98
+
99
+ scenario = patient.get("scenario")
100
+ encounter = patient.get("encounter")
101
+
102
+ state = EnvironmentState(
103
+ episode_id=str(uuid4()),
104
+ patient_id=patient["id"],
105
+ encounter_id=encounter["id"] if encounter else None,
106
+ scenario_id=scenario["id"] if scenario else None,
107
+ rubric_progress=[],
108
+ cumulative_reward=0.0,
109
+ step_count=0,
110
+ )
111
+ goal_text = scenario["objective"] if scenario else "Open the chart and complete the assigned workflow."
112
+
113
+ await browser.reset(EHR_BASE_URL)
114
+ observation = await browser.observe(goal=goal_text, metadata={"reset": True})
115
+ return ResetResponse(observation=observation, state=state)
116
+
117
+
118
+ @app.post("/step", response_model=StepResponse)
119
+ async def step(action: Action) -> StepResponse:
120
+ global state
121
+
122
+ try:
123
+ metadata = await browser.perform(action, default_wait_ms=DEFAULT_WAIT_MS)
124
+ except Exception as error: # noqa: BLE001
125
+ metadata = {"success": False, "error": str(error), "action_type": action.type}
126
+
127
+ state.step_count += 1
128
+ reward = 0.02 if metadata.get("success") else -0.05
129
+
130
+ try:
131
+ rubric_progress, done = await _refresh_progress()
132
+ except httpx.HTTPError as error:
133
+ rubric_progress, done = [], False
134
+ metadata["progress_error"] = str(error)
135
+
136
+ if rubric_progress:
137
+ reward += 0.1 * len(rubric_progress)
138
+
139
+ state.rubric_progress = rubric_progress
140
+ state.cumulative_reward += reward
141
+
142
+ observation = await browser.observe(goal=goal_text, metadata=metadata)
143
+ return StepResponse(
144
+ observation=observation,
145
+ state=state,
146
+ reward=reward,
147
+ done=done,
148
+ info={"rubric_progress": rubric_progress},
149
+ )
150
+
151
+
152
+ @app.get("/state", response_model=EnvironmentState)
153
+ async def get_state() -> EnvironmentState:
154
+ return state
env_server/app/models.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ ActionType = Literal["goto", "click", "fill", "keypress", "wait"]
9
+
10
+
11
+ class Action(BaseModel):
12
+ type: ActionType
13
+ selector: Optional[str] = None
14
+ text: Optional[str] = None
15
+ url: Optional[str] = None
16
+ key: Optional[str] = None
17
+ milliseconds: Optional[int] = Field(default=None, ge=0)
18
+ metadata: dict[str, Any] = Field(default_factory=dict)
19
+
20
+
21
+ class Observation(BaseModel):
22
+ goal: str
23
+ screenshot_b64: str
24
+ current_url: str
25
+ active_activity: str
26
+ metadata: dict[str, Any] = Field(default_factory=dict)
27
+
28
+
29
+ class EnvironmentState(BaseModel):
30
+ episode_id: str
31
+ step_count: int = 0
32
+ patient_id: Optional[str] = None
33
+ encounter_id: Optional[str] = None
34
+ scenario_id: Optional[str] = None
35
+ rubric_progress: list[str] = Field(default_factory=list)
36
+ cumulative_reward: float = 0.0
37
+
38
+
39
+ class ResetRequest(BaseModel):
40
+ patient_id: Optional[str] = None
41
+ scenario_id: Optional[str] = None
42
+
43
+
44
+ class ResetResponse(BaseModel):
45
+ observation: Observation
46
+ state: EnvironmentState
47
+
48
+
49
+ class StepResponse(BaseModel):
50
+ observation: Observation
51
+ state: EnvironmentState
52
+ reward: float
53
+ done: bool
54
+ info: dict[str, Any] = Field(default_factory=dict)
package-lock.json ADDED
@@ -0,0 +1,2249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ehrgym",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "ehrgym",
8
+ "workspaces": [
9
+ "apps/ehr"
10
+ ],
11
+ "devDependencies": {
12
+ "@types/node": "^22.13.14",
13
+ "concurrently": "^9.1.2",
14
+ "prisma": "^6.5.0",
15
+ "tsx": "^4.19.3",
16
+ "typescript": "^5.8.2"
17
+ }
18
+ },
19
+ "apps/ehr": {
20
+ "name": "@ehrgym/ehr",
21
+ "dependencies": {
22
+ "@prisma/client": "^6.5.0",
23
+ "next": "^15.2.0",
24
+ "react": "^19.0.0",
25
+ "react-dom": "^19.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "^19.0.10",
29
+ "@types/react-dom": "^19.0.4"
30
+ }
31
+ },
32
+ "node_modules/@ehrgym/ehr": {
33
+ "resolved": "apps/ehr",
34
+ "link": true
35
+ },
36
+ "node_modules/@emnapi/runtime": {
37
+ "version": "1.8.1",
38
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
39
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
40
+ "license": "MIT",
41
+ "optional": true,
42
+ "dependencies": {
43
+ "tslib": "^2.4.0"
44
+ }
45
+ },
46
+ "node_modules/@esbuild/aix-ppc64": {
47
+ "version": "0.27.3",
48
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
49
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
50
+ "cpu": [
51
+ "ppc64"
52
+ ],
53
+ "dev": true,
54
+ "license": "MIT",
55
+ "optional": true,
56
+ "os": [
57
+ "aix"
58
+ ],
59
+ "engines": {
60
+ "node": ">=18"
61
+ }
62
+ },
63
+ "node_modules/@esbuild/android-arm": {
64
+ "version": "0.27.3",
65
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
66
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
67
+ "cpu": [
68
+ "arm"
69
+ ],
70
+ "dev": true,
71
+ "license": "MIT",
72
+ "optional": true,
73
+ "os": [
74
+ "android"
75
+ ],
76
+ "engines": {
77
+ "node": ">=18"
78
+ }
79
+ },
80
+ "node_modules/@esbuild/android-arm64": {
81
+ "version": "0.27.3",
82
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
83
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
84
+ "cpu": [
85
+ "arm64"
86
+ ],
87
+ "dev": true,
88
+ "license": "MIT",
89
+ "optional": true,
90
+ "os": [
91
+ "android"
92
+ ],
93
+ "engines": {
94
+ "node": ">=18"
95
+ }
96
+ },
97
+ "node_modules/@esbuild/android-x64": {
98
+ "version": "0.27.3",
99
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
100
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
101
+ "cpu": [
102
+ "x64"
103
+ ],
104
+ "dev": true,
105
+ "license": "MIT",
106
+ "optional": true,
107
+ "os": [
108
+ "android"
109
+ ],
110
+ "engines": {
111
+ "node": ">=18"
112
+ }
113
+ },
114
+ "node_modules/@esbuild/darwin-arm64": {
115
+ "version": "0.27.3",
116
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
117
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
118
+ "cpu": [
119
+ "arm64"
120
+ ],
121
+ "dev": true,
122
+ "license": "MIT",
123
+ "optional": true,
124
+ "os": [
125
+ "darwin"
126
+ ],
127
+ "engines": {
128
+ "node": ">=18"
129
+ }
130
+ },
131
+ "node_modules/@esbuild/darwin-x64": {
132
+ "version": "0.27.3",
133
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
134
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
135
+ "cpu": [
136
+ "x64"
137
+ ],
138
+ "dev": true,
139
+ "license": "MIT",
140
+ "optional": true,
141
+ "os": [
142
+ "darwin"
143
+ ],
144
+ "engines": {
145
+ "node": ">=18"
146
+ }
147
+ },
148
+ "node_modules/@esbuild/freebsd-arm64": {
149
+ "version": "0.27.3",
150
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
151
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
152
+ "cpu": [
153
+ "arm64"
154
+ ],
155
+ "dev": true,
156
+ "license": "MIT",
157
+ "optional": true,
158
+ "os": [
159
+ "freebsd"
160
+ ],
161
+ "engines": {
162
+ "node": ">=18"
163
+ }
164
+ },
165
+ "node_modules/@esbuild/freebsd-x64": {
166
+ "version": "0.27.3",
167
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
168
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
169
+ "cpu": [
170
+ "x64"
171
+ ],
172
+ "dev": true,
173
+ "license": "MIT",
174
+ "optional": true,
175
+ "os": [
176
+ "freebsd"
177
+ ],
178
+ "engines": {
179
+ "node": ">=18"
180
+ }
181
+ },
182
+ "node_modules/@esbuild/linux-arm": {
183
+ "version": "0.27.3",
184
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
185
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
186
+ "cpu": [
187
+ "arm"
188
+ ],
189
+ "dev": true,
190
+ "license": "MIT",
191
+ "optional": true,
192
+ "os": [
193
+ "linux"
194
+ ],
195
+ "engines": {
196
+ "node": ">=18"
197
+ }
198
+ },
199
+ "node_modules/@esbuild/linux-arm64": {
200
+ "version": "0.27.3",
201
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
202
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
203
+ "cpu": [
204
+ "arm64"
205
+ ],
206
+ "dev": true,
207
+ "license": "MIT",
208
+ "optional": true,
209
+ "os": [
210
+ "linux"
211
+ ],
212
+ "engines": {
213
+ "node": ">=18"
214
+ }
215
+ },
216
+ "node_modules/@esbuild/linux-ia32": {
217
+ "version": "0.27.3",
218
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
219
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
220
+ "cpu": [
221
+ "ia32"
222
+ ],
223
+ "dev": true,
224
+ "license": "MIT",
225
+ "optional": true,
226
+ "os": [
227
+ "linux"
228
+ ],
229
+ "engines": {
230
+ "node": ">=18"
231
+ }
232
+ },
233
+ "node_modules/@esbuild/linux-loong64": {
234
+ "version": "0.27.3",
235
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
236
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
237
+ "cpu": [
238
+ "loong64"
239
+ ],
240
+ "dev": true,
241
+ "license": "MIT",
242
+ "optional": true,
243
+ "os": [
244
+ "linux"
245
+ ],
246
+ "engines": {
247
+ "node": ">=18"
248
+ }
249
+ },
250
+ "node_modules/@esbuild/linux-mips64el": {
251
+ "version": "0.27.3",
252
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
253
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
254
+ "cpu": [
255
+ "mips64el"
256
+ ],
257
+ "dev": true,
258
+ "license": "MIT",
259
+ "optional": true,
260
+ "os": [
261
+ "linux"
262
+ ],
263
+ "engines": {
264
+ "node": ">=18"
265
+ }
266
+ },
267
+ "node_modules/@esbuild/linux-ppc64": {
268
+ "version": "0.27.3",
269
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
270
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
271
+ "cpu": [
272
+ "ppc64"
273
+ ],
274
+ "dev": true,
275
+ "license": "MIT",
276
+ "optional": true,
277
+ "os": [
278
+ "linux"
279
+ ],
280
+ "engines": {
281
+ "node": ">=18"
282
+ }
283
+ },
284
+ "node_modules/@esbuild/linux-riscv64": {
285
+ "version": "0.27.3",
286
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
287
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
288
+ "cpu": [
289
+ "riscv64"
290
+ ],
291
+ "dev": true,
292
+ "license": "MIT",
293
+ "optional": true,
294
+ "os": [
295
+ "linux"
296
+ ],
297
+ "engines": {
298
+ "node": ">=18"
299
+ }
300
+ },
301
+ "node_modules/@esbuild/linux-s390x": {
302
+ "version": "0.27.3",
303
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
304
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
305
+ "cpu": [
306
+ "s390x"
307
+ ],
308
+ "dev": true,
309
+ "license": "MIT",
310
+ "optional": true,
311
+ "os": [
312
+ "linux"
313
+ ],
314
+ "engines": {
315
+ "node": ">=18"
316
+ }
317
+ },
318
+ "node_modules/@esbuild/linux-x64": {
319
+ "version": "0.27.3",
320
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
321
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
322
+ "cpu": [
323
+ "x64"
324
+ ],
325
+ "dev": true,
326
+ "license": "MIT",
327
+ "optional": true,
328
+ "os": [
329
+ "linux"
330
+ ],
331
+ "engines": {
332
+ "node": ">=18"
333
+ }
334
+ },
335
+ "node_modules/@esbuild/netbsd-arm64": {
336
+ "version": "0.27.3",
337
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
338
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
339
+ "cpu": [
340
+ "arm64"
341
+ ],
342
+ "dev": true,
343
+ "license": "MIT",
344
+ "optional": true,
345
+ "os": [
346
+ "netbsd"
347
+ ],
348
+ "engines": {
349
+ "node": ">=18"
350
+ }
351
+ },
352
+ "node_modules/@esbuild/netbsd-x64": {
353
+ "version": "0.27.3",
354
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
355
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
356
+ "cpu": [
357
+ "x64"
358
+ ],
359
+ "dev": true,
360
+ "license": "MIT",
361
+ "optional": true,
362
+ "os": [
363
+ "netbsd"
364
+ ],
365
+ "engines": {
366
+ "node": ">=18"
367
+ }
368
+ },
369
+ "node_modules/@esbuild/openbsd-arm64": {
370
+ "version": "0.27.3",
371
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
372
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
373
+ "cpu": [
374
+ "arm64"
375
+ ],
376
+ "dev": true,
377
+ "license": "MIT",
378
+ "optional": true,
379
+ "os": [
380
+ "openbsd"
381
+ ],
382
+ "engines": {
383
+ "node": ">=18"
384
+ }
385
+ },
386
+ "node_modules/@esbuild/openbsd-x64": {
387
+ "version": "0.27.3",
388
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
389
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
390
+ "cpu": [
391
+ "x64"
392
+ ],
393
+ "dev": true,
394
+ "license": "MIT",
395
+ "optional": true,
396
+ "os": [
397
+ "openbsd"
398
+ ],
399
+ "engines": {
400
+ "node": ">=18"
401
+ }
402
+ },
403
+ "node_modules/@esbuild/openharmony-arm64": {
404
+ "version": "0.27.3",
405
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
406
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
407
+ "cpu": [
408
+ "arm64"
409
+ ],
410
+ "dev": true,
411
+ "license": "MIT",
412
+ "optional": true,
413
+ "os": [
414
+ "openharmony"
415
+ ],
416
+ "engines": {
417
+ "node": ">=18"
418
+ }
419
+ },
420
+ "node_modules/@esbuild/sunos-x64": {
421
+ "version": "0.27.3",
422
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
423
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
424
+ "cpu": [
425
+ "x64"
426
+ ],
427
+ "dev": true,
428
+ "license": "MIT",
429
+ "optional": true,
430
+ "os": [
431
+ "sunos"
432
+ ],
433
+ "engines": {
434
+ "node": ">=18"
435
+ }
436
+ },
437
+ "node_modules/@esbuild/win32-arm64": {
438
+ "version": "0.27.3",
439
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
440
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
441
+ "cpu": [
442
+ "arm64"
443
+ ],
444
+ "dev": true,
445
+ "license": "MIT",
446
+ "optional": true,
447
+ "os": [
448
+ "win32"
449
+ ],
450
+ "engines": {
451
+ "node": ">=18"
452
+ }
453
+ },
454
+ "node_modules/@esbuild/win32-ia32": {
455
+ "version": "0.27.3",
456
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
457
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
458
+ "cpu": [
459
+ "ia32"
460
+ ],
461
+ "dev": true,
462
+ "license": "MIT",
463
+ "optional": true,
464
+ "os": [
465
+ "win32"
466
+ ],
467
+ "engines": {
468
+ "node": ">=18"
469
+ }
470
+ },
471
+ "node_modules/@esbuild/win32-x64": {
472
+ "version": "0.27.3",
473
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
474
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
475
+ "cpu": [
476
+ "x64"
477
+ ],
478
+ "dev": true,
479
+ "license": "MIT",
480
+ "optional": true,
481
+ "os": [
482
+ "win32"
483
+ ],
484
+ "engines": {
485
+ "node": ">=18"
486
+ }
487
+ },
488
+ "node_modules/@img/colour": {
489
+ "version": "1.1.0",
490
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
491
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
492
+ "license": "MIT",
493
+ "optional": true,
494
+ "engines": {
495
+ "node": ">=18"
496
+ }
497
+ },
498
+ "node_modules/@img/sharp-darwin-arm64": {
499
+ "version": "0.34.5",
500
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
501
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
502
+ "cpu": [
503
+ "arm64"
504
+ ],
505
+ "license": "Apache-2.0",
506
+ "optional": true,
507
+ "os": [
508
+ "darwin"
509
+ ],
510
+ "engines": {
511
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
512
+ },
513
+ "funding": {
514
+ "url": "https://opencollective.com/libvips"
515
+ },
516
+ "optionalDependencies": {
517
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
518
+ }
519
+ },
520
+ "node_modules/@img/sharp-darwin-x64": {
521
+ "version": "0.34.5",
522
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
523
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
524
+ "cpu": [
525
+ "x64"
526
+ ],
527
+ "license": "Apache-2.0",
528
+ "optional": true,
529
+ "os": [
530
+ "darwin"
531
+ ],
532
+ "engines": {
533
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
534
+ },
535
+ "funding": {
536
+ "url": "https://opencollective.com/libvips"
537
+ },
538
+ "optionalDependencies": {
539
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
540
+ }
541
+ },
542
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
543
+ "version": "1.2.4",
544
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
545
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
546
+ "cpu": [
547
+ "arm64"
548
+ ],
549
+ "license": "LGPL-3.0-or-later",
550
+ "optional": true,
551
+ "os": [
552
+ "darwin"
553
+ ],
554
+ "funding": {
555
+ "url": "https://opencollective.com/libvips"
556
+ }
557
+ },
558
+ "node_modules/@img/sharp-libvips-darwin-x64": {
559
+ "version": "1.2.4",
560
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
561
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
562
+ "cpu": [
563
+ "x64"
564
+ ],
565
+ "license": "LGPL-3.0-or-later",
566
+ "optional": true,
567
+ "os": [
568
+ "darwin"
569
+ ],
570
+ "funding": {
571
+ "url": "https://opencollective.com/libvips"
572
+ }
573
+ },
574
+ "node_modules/@img/sharp-libvips-linux-arm": {
575
+ "version": "1.2.4",
576
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
577
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
578
+ "cpu": [
579
+ "arm"
580
+ ],
581
+ "license": "LGPL-3.0-or-later",
582
+ "optional": true,
583
+ "os": [
584
+ "linux"
585
+ ],
586
+ "funding": {
587
+ "url": "https://opencollective.com/libvips"
588
+ }
589
+ },
590
+ "node_modules/@img/sharp-libvips-linux-arm64": {
591
+ "version": "1.2.4",
592
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
593
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
594
+ "cpu": [
595
+ "arm64"
596
+ ],
597
+ "license": "LGPL-3.0-or-later",
598
+ "optional": true,
599
+ "os": [
600
+ "linux"
601
+ ],
602
+ "funding": {
603
+ "url": "https://opencollective.com/libvips"
604
+ }
605
+ },
606
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
607
+ "version": "1.2.4",
608
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
609
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
610
+ "cpu": [
611
+ "ppc64"
612
+ ],
613
+ "license": "LGPL-3.0-or-later",
614
+ "optional": true,
615
+ "os": [
616
+ "linux"
617
+ ],
618
+ "funding": {
619
+ "url": "https://opencollective.com/libvips"
620
+ }
621
+ },
622
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
623
+ "version": "1.2.4",
624
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
625
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
626
+ "cpu": [
627
+ "riscv64"
628
+ ],
629
+ "license": "LGPL-3.0-or-later",
630
+ "optional": true,
631
+ "os": [
632
+ "linux"
633
+ ],
634
+ "funding": {
635
+ "url": "https://opencollective.com/libvips"
636
+ }
637
+ },
638
+ "node_modules/@img/sharp-libvips-linux-s390x": {
639
+ "version": "1.2.4",
640
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
641
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
642
+ "cpu": [
643
+ "s390x"
644
+ ],
645
+ "license": "LGPL-3.0-or-later",
646
+ "optional": true,
647
+ "os": [
648
+ "linux"
649
+ ],
650
+ "funding": {
651
+ "url": "https://opencollective.com/libvips"
652
+ }
653
+ },
654
+ "node_modules/@img/sharp-libvips-linux-x64": {
655
+ "version": "1.2.4",
656
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
657
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
658
+ "cpu": [
659
+ "x64"
660
+ ],
661
+ "license": "LGPL-3.0-or-later",
662
+ "optional": true,
663
+ "os": [
664
+ "linux"
665
+ ],
666
+ "funding": {
667
+ "url": "https://opencollective.com/libvips"
668
+ }
669
+ },
670
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
671
+ "version": "1.2.4",
672
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
673
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
674
+ "cpu": [
675
+ "arm64"
676
+ ],
677
+ "license": "LGPL-3.0-or-later",
678
+ "optional": true,
679
+ "os": [
680
+ "linux"
681
+ ],
682
+ "funding": {
683
+ "url": "https://opencollective.com/libvips"
684
+ }
685
+ },
686
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
687
+ "version": "1.2.4",
688
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
689
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
690
+ "cpu": [
691
+ "x64"
692
+ ],
693
+ "license": "LGPL-3.0-or-later",
694
+ "optional": true,
695
+ "os": [
696
+ "linux"
697
+ ],
698
+ "funding": {
699
+ "url": "https://opencollective.com/libvips"
700
+ }
701
+ },
702
+ "node_modules/@img/sharp-linux-arm": {
703
+ "version": "0.34.5",
704
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
705
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
706
+ "cpu": [
707
+ "arm"
708
+ ],
709
+ "license": "Apache-2.0",
710
+ "optional": true,
711
+ "os": [
712
+ "linux"
713
+ ],
714
+ "engines": {
715
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
716
+ },
717
+ "funding": {
718
+ "url": "https://opencollective.com/libvips"
719
+ },
720
+ "optionalDependencies": {
721
+ "@img/sharp-libvips-linux-arm": "1.2.4"
722
+ }
723
+ },
724
+ "node_modules/@img/sharp-linux-arm64": {
725
+ "version": "0.34.5",
726
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
727
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
728
+ "cpu": [
729
+ "arm64"
730
+ ],
731
+ "license": "Apache-2.0",
732
+ "optional": true,
733
+ "os": [
734
+ "linux"
735
+ ],
736
+ "engines": {
737
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
738
+ },
739
+ "funding": {
740
+ "url": "https://opencollective.com/libvips"
741
+ },
742
+ "optionalDependencies": {
743
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
744
+ }
745
+ },
746
+ "node_modules/@img/sharp-linux-ppc64": {
747
+ "version": "0.34.5",
748
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
749
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
750
+ "cpu": [
751
+ "ppc64"
752
+ ],
753
+ "license": "Apache-2.0",
754
+ "optional": true,
755
+ "os": [
756
+ "linux"
757
+ ],
758
+ "engines": {
759
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
760
+ },
761
+ "funding": {
762
+ "url": "https://opencollective.com/libvips"
763
+ },
764
+ "optionalDependencies": {
765
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
766
+ }
767
+ },
768
+ "node_modules/@img/sharp-linux-riscv64": {
769
+ "version": "0.34.5",
770
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
771
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
772
+ "cpu": [
773
+ "riscv64"
774
+ ],
775
+ "license": "Apache-2.0",
776
+ "optional": true,
777
+ "os": [
778
+ "linux"
779
+ ],
780
+ "engines": {
781
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
782
+ },
783
+ "funding": {
784
+ "url": "https://opencollective.com/libvips"
785
+ },
786
+ "optionalDependencies": {
787
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
788
+ }
789
+ },
790
+ "node_modules/@img/sharp-linux-s390x": {
791
+ "version": "0.34.5",
792
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
793
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
794
+ "cpu": [
795
+ "s390x"
796
+ ],
797
+ "license": "Apache-2.0",
798
+ "optional": true,
799
+ "os": [
800
+ "linux"
801
+ ],
802
+ "engines": {
803
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
804
+ },
805
+ "funding": {
806
+ "url": "https://opencollective.com/libvips"
807
+ },
808
+ "optionalDependencies": {
809
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
810
+ }
811
+ },
812
+ "node_modules/@img/sharp-linux-x64": {
813
+ "version": "0.34.5",
814
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
815
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
816
+ "cpu": [
817
+ "x64"
818
+ ],
819
+ "license": "Apache-2.0",
820
+ "optional": true,
821
+ "os": [
822
+ "linux"
823
+ ],
824
+ "engines": {
825
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
826
+ },
827
+ "funding": {
828
+ "url": "https://opencollective.com/libvips"
829
+ },
830
+ "optionalDependencies": {
831
+ "@img/sharp-libvips-linux-x64": "1.2.4"
832
+ }
833
+ },
834
+ "node_modules/@img/sharp-linuxmusl-arm64": {
835
+ "version": "0.34.5",
836
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
837
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
838
+ "cpu": [
839
+ "arm64"
840
+ ],
841
+ "license": "Apache-2.0",
842
+ "optional": true,
843
+ "os": [
844
+ "linux"
845
+ ],
846
+ "engines": {
847
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
848
+ },
849
+ "funding": {
850
+ "url": "https://opencollective.com/libvips"
851
+ },
852
+ "optionalDependencies": {
853
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
854
+ }
855
+ },
856
+ "node_modules/@img/sharp-linuxmusl-x64": {
857
+ "version": "0.34.5",
858
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
859
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
860
+ "cpu": [
861
+ "x64"
862
+ ],
863
+ "license": "Apache-2.0",
864
+ "optional": true,
865
+ "os": [
866
+ "linux"
867
+ ],
868
+ "engines": {
869
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
870
+ },
871
+ "funding": {
872
+ "url": "https://opencollective.com/libvips"
873
+ },
874
+ "optionalDependencies": {
875
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
876
+ }
877
+ },
878
+ "node_modules/@img/sharp-wasm32": {
879
+ "version": "0.34.5",
880
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
881
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
882
+ "cpu": [
883
+ "wasm32"
884
+ ],
885
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
886
+ "optional": true,
887
+ "dependencies": {
888
+ "@emnapi/runtime": "^1.7.0"
889
+ },
890
+ "engines": {
891
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
892
+ },
893
+ "funding": {
894
+ "url": "https://opencollective.com/libvips"
895
+ }
896
+ },
897
+ "node_modules/@img/sharp-win32-arm64": {
898
+ "version": "0.34.5",
899
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
900
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
901
+ "cpu": [
902
+ "arm64"
903
+ ],
904
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
905
+ "optional": true,
906
+ "os": [
907
+ "win32"
908
+ ],
909
+ "engines": {
910
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
911
+ },
912
+ "funding": {
913
+ "url": "https://opencollective.com/libvips"
914
+ }
915
+ },
916
+ "node_modules/@img/sharp-win32-ia32": {
917
+ "version": "0.34.5",
918
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
919
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
920
+ "cpu": [
921
+ "ia32"
922
+ ],
923
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
924
+ "optional": true,
925
+ "os": [
926
+ "win32"
927
+ ],
928
+ "engines": {
929
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
930
+ },
931
+ "funding": {
932
+ "url": "https://opencollective.com/libvips"
933
+ }
934
+ },
935
+ "node_modules/@img/sharp-win32-x64": {
936
+ "version": "0.34.5",
937
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
938
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
939
+ "cpu": [
940
+ "x64"
941
+ ],
942
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
943
+ "optional": true,
944
+ "os": [
945
+ "win32"
946
+ ],
947
+ "engines": {
948
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
949
+ },
950
+ "funding": {
951
+ "url": "https://opencollective.com/libvips"
952
+ }
953
+ },
954
+ "node_modules/@next/env": {
955
+ "version": "15.5.12",
956
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
957
+ "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
958
+ "license": "MIT"
959
+ },
960
+ "node_modules/@next/swc-darwin-arm64": {
961
+ "version": "15.5.12",
962
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
963
+ "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
964
+ "cpu": [
965
+ "arm64"
966
+ ],
967
+ "license": "MIT",
968
+ "optional": true,
969
+ "os": [
970
+ "darwin"
971
+ ],
972
+ "engines": {
973
+ "node": ">= 10"
974
+ }
975
+ },
976
+ "node_modules/@next/swc-darwin-x64": {
977
+ "version": "15.5.12",
978
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
979
+ "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
980
+ "cpu": [
981
+ "x64"
982
+ ],
983
+ "license": "MIT",
984
+ "optional": true,
985
+ "os": [
986
+ "darwin"
987
+ ],
988
+ "engines": {
989
+ "node": ">= 10"
990
+ }
991
+ },
992
+ "node_modules/@next/swc-linux-arm64-gnu": {
993
+ "version": "15.5.12",
994
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
995
+ "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
996
+ "cpu": [
997
+ "arm64"
998
+ ],
999
+ "license": "MIT",
1000
+ "optional": true,
1001
+ "os": [
1002
+ "linux"
1003
+ ],
1004
+ "engines": {
1005
+ "node": ">= 10"
1006
+ }
1007
+ },
1008
+ "node_modules/@next/swc-linux-arm64-musl": {
1009
+ "version": "15.5.12",
1010
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
1011
+ "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
1012
+ "cpu": [
1013
+ "arm64"
1014
+ ],
1015
+ "license": "MIT",
1016
+ "optional": true,
1017
+ "os": [
1018
+ "linux"
1019
+ ],
1020
+ "engines": {
1021
+ "node": ">= 10"
1022
+ }
1023
+ },
1024
+ "node_modules/@next/swc-linux-x64-gnu": {
1025
+ "version": "15.5.12",
1026
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
1027
+ "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
1028
+ "cpu": [
1029
+ "x64"
1030
+ ],
1031
+ "license": "MIT",
1032
+ "optional": true,
1033
+ "os": [
1034
+ "linux"
1035
+ ],
1036
+ "engines": {
1037
+ "node": ">= 10"
1038
+ }
1039
+ },
1040
+ "node_modules/@next/swc-linux-x64-musl": {
1041
+ "version": "15.5.12",
1042
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
1043
+ "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
1044
+ "cpu": [
1045
+ "x64"
1046
+ ],
1047
+ "license": "MIT",
1048
+ "optional": true,
1049
+ "os": [
1050
+ "linux"
1051
+ ],
1052
+ "engines": {
1053
+ "node": ">= 10"
1054
+ }
1055
+ },
1056
+ "node_modules/@next/swc-win32-arm64-msvc": {
1057
+ "version": "15.5.12",
1058
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
1059
+ "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
1060
+ "cpu": [
1061
+ "arm64"
1062
+ ],
1063
+ "license": "MIT",
1064
+ "optional": true,
1065
+ "os": [
1066
+ "win32"
1067
+ ],
1068
+ "engines": {
1069
+ "node": ">= 10"
1070
+ }
1071
+ },
1072
+ "node_modules/@next/swc-win32-x64-msvc": {
1073
+ "version": "15.5.12",
1074
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
1075
+ "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
1076
+ "cpu": [
1077
+ "x64"
1078
+ ],
1079
+ "license": "MIT",
1080
+ "optional": true,
1081
+ "os": [
1082
+ "win32"
1083
+ ],
1084
+ "engines": {
1085
+ "node": ">= 10"
1086
+ }
1087
+ },
1088
+ "node_modules/@prisma/client": {
1089
+ "version": "6.19.2",
1090
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
1091
+ "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==",
1092
+ "hasInstallScript": true,
1093
+ "license": "Apache-2.0",
1094
+ "engines": {
1095
+ "node": ">=18.18"
1096
+ },
1097
+ "peerDependencies": {
1098
+ "prisma": "*",
1099
+ "typescript": ">=5.1.0"
1100
+ },
1101
+ "peerDependenciesMeta": {
1102
+ "prisma": {
1103
+ "optional": true
1104
+ },
1105
+ "typescript": {
1106
+ "optional": true
1107
+ }
1108
+ }
1109
+ },
1110
+ "node_modules/@prisma/config": {
1111
+ "version": "6.19.2",
1112
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz",
1113
+ "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==",
1114
+ "devOptional": true,
1115
+ "license": "Apache-2.0",
1116
+ "dependencies": {
1117
+ "c12": "3.1.0",
1118
+ "deepmerge-ts": "7.1.5",
1119
+ "effect": "3.18.4",
1120
+ "empathic": "2.0.0"
1121
+ }
1122
+ },
1123
+ "node_modules/@prisma/debug": {
1124
+ "version": "6.19.2",
1125
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz",
1126
+ "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==",
1127
+ "devOptional": true,
1128
+ "license": "Apache-2.0"
1129
+ },
1130
+ "node_modules/@prisma/engines": {
1131
+ "version": "6.19.2",
1132
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz",
1133
+ "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==",
1134
+ "devOptional": true,
1135
+ "hasInstallScript": true,
1136
+ "license": "Apache-2.0",
1137
+ "dependencies": {
1138
+ "@prisma/debug": "6.19.2",
1139
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
1140
+ "@prisma/fetch-engine": "6.19.2",
1141
+ "@prisma/get-platform": "6.19.2"
1142
+ }
1143
+ },
1144
+ "node_modules/@prisma/engines-version": {
1145
+ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
1146
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
1147
+ "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
1148
+ "devOptional": true,
1149
+ "license": "Apache-2.0"
1150
+ },
1151
+ "node_modules/@prisma/fetch-engine": {
1152
+ "version": "6.19.2",
1153
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz",
1154
+ "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==",
1155
+ "devOptional": true,
1156
+ "license": "Apache-2.0",
1157
+ "dependencies": {
1158
+ "@prisma/debug": "6.19.2",
1159
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
1160
+ "@prisma/get-platform": "6.19.2"
1161
+ }
1162
+ },
1163
+ "node_modules/@prisma/get-platform": {
1164
+ "version": "6.19.2",
1165
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz",
1166
+ "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==",
1167
+ "devOptional": true,
1168
+ "license": "Apache-2.0",
1169
+ "dependencies": {
1170
+ "@prisma/debug": "6.19.2"
1171
+ }
1172
+ },
1173
+ "node_modules/@standard-schema/spec": {
1174
+ "version": "1.1.0",
1175
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1176
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1177
+ "devOptional": true,
1178
+ "license": "MIT"
1179
+ },
1180
+ "node_modules/@swc/helpers": {
1181
+ "version": "0.5.15",
1182
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
1183
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
1184
+ "license": "Apache-2.0",
1185
+ "dependencies": {
1186
+ "tslib": "^2.8.0"
1187
+ }
1188
+ },
1189
+ "node_modules/@types/node": {
1190
+ "version": "22.19.15",
1191
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
1192
+ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
1193
+ "dev": true,
1194
+ "license": "MIT",
1195
+ "dependencies": {
1196
+ "undici-types": "~6.21.0"
1197
+ }
1198
+ },
1199
+ "node_modules/@types/react": {
1200
+ "version": "19.2.14",
1201
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
1202
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
1203
+ "dev": true,
1204
+ "license": "MIT",
1205
+ "dependencies": {
1206
+ "csstype": "^3.2.2"
1207
+ }
1208
+ },
1209
+ "node_modules/@types/react-dom": {
1210
+ "version": "19.2.3",
1211
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
1212
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
1213
+ "dev": true,
1214
+ "license": "MIT",
1215
+ "peerDependencies": {
1216
+ "@types/react": "^19.2.0"
1217
+ }
1218
+ },
1219
+ "node_modules/ansi-regex": {
1220
+ "version": "5.0.1",
1221
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
1222
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1223
+ "dev": true,
1224
+ "license": "MIT",
1225
+ "engines": {
1226
+ "node": ">=8"
1227
+ }
1228
+ },
1229
+ "node_modules/ansi-styles": {
1230
+ "version": "4.3.0",
1231
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
1232
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
1233
+ "dev": true,
1234
+ "license": "MIT",
1235
+ "dependencies": {
1236
+ "color-convert": "^2.0.1"
1237
+ },
1238
+ "engines": {
1239
+ "node": ">=8"
1240
+ },
1241
+ "funding": {
1242
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
1243
+ }
1244
+ },
1245
+ "node_modules/c12": {
1246
+ "version": "3.1.0",
1247
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
1248
+ "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
1249
+ "devOptional": true,
1250
+ "license": "MIT",
1251
+ "dependencies": {
1252
+ "chokidar": "^4.0.3",
1253
+ "confbox": "^0.2.2",
1254
+ "defu": "^6.1.4",
1255
+ "dotenv": "^16.6.1",
1256
+ "exsolve": "^1.0.7",
1257
+ "giget": "^2.0.0",
1258
+ "jiti": "^2.4.2",
1259
+ "ohash": "^2.0.11",
1260
+ "pathe": "^2.0.3",
1261
+ "perfect-debounce": "^1.0.0",
1262
+ "pkg-types": "^2.2.0",
1263
+ "rc9": "^2.1.2"
1264
+ },
1265
+ "peerDependencies": {
1266
+ "magicast": "^0.3.5"
1267
+ },
1268
+ "peerDependenciesMeta": {
1269
+ "magicast": {
1270
+ "optional": true
1271
+ }
1272
+ }
1273
+ },
1274
+ "node_modules/caniuse-lite": {
1275
+ "version": "1.0.30001777",
1276
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
1277
+ "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
1278
+ "funding": [
1279
+ {
1280
+ "type": "opencollective",
1281
+ "url": "https://opencollective.com/browserslist"
1282
+ },
1283
+ {
1284
+ "type": "tidelift",
1285
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1286
+ },
1287
+ {
1288
+ "type": "github",
1289
+ "url": "https://github.com/sponsors/ai"
1290
+ }
1291
+ ],
1292
+ "license": "CC-BY-4.0"
1293
+ },
1294
+ "node_modules/chalk": {
1295
+ "version": "4.1.2",
1296
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
1297
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
1298
+ "dev": true,
1299
+ "license": "MIT",
1300
+ "dependencies": {
1301
+ "ansi-styles": "^4.1.0",
1302
+ "supports-color": "^7.1.0"
1303
+ },
1304
+ "engines": {
1305
+ "node": ">=10"
1306
+ },
1307
+ "funding": {
1308
+ "url": "https://github.com/chalk/chalk?sponsor=1"
1309
+ }
1310
+ },
1311
+ "node_modules/chalk/node_modules/supports-color": {
1312
+ "version": "7.2.0",
1313
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
1314
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
1315
+ "dev": true,
1316
+ "license": "MIT",
1317
+ "dependencies": {
1318
+ "has-flag": "^4.0.0"
1319
+ },
1320
+ "engines": {
1321
+ "node": ">=8"
1322
+ }
1323
+ },
1324
+ "node_modules/chokidar": {
1325
+ "version": "4.0.3",
1326
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
1327
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
1328
+ "devOptional": true,
1329
+ "license": "MIT",
1330
+ "dependencies": {
1331
+ "readdirp": "^4.0.1"
1332
+ },
1333
+ "engines": {
1334
+ "node": ">= 14.16.0"
1335
+ },
1336
+ "funding": {
1337
+ "url": "https://paulmillr.com/funding/"
1338
+ }
1339
+ },
1340
+ "node_modules/citty": {
1341
+ "version": "0.1.6",
1342
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
1343
+ "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
1344
+ "devOptional": true,
1345
+ "license": "MIT",
1346
+ "dependencies": {
1347
+ "consola": "^3.2.3"
1348
+ }
1349
+ },
1350
+ "node_modules/client-only": {
1351
+ "version": "0.0.1",
1352
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
1353
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
1354
+ "license": "MIT"
1355
+ },
1356
+ "node_modules/cliui": {
1357
+ "version": "8.0.1",
1358
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
1359
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
1360
+ "dev": true,
1361
+ "license": "ISC",
1362
+ "dependencies": {
1363
+ "string-width": "^4.2.0",
1364
+ "strip-ansi": "^6.0.1",
1365
+ "wrap-ansi": "^7.0.0"
1366
+ },
1367
+ "engines": {
1368
+ "node": ">=12"
1369
+ }
1370
+ },
1371
+ "node_modules/color-convert": {
1372
+ "version": "2.0.1",
1373
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
1374
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
1375
+ "dev": true,
1376
+ "license": "MIT",
1377
+ "dependencies": {
1378
+ "color-name": "~1.1.4"
1379
+ },
1380
+ "engines": {
1381
+ "node": ">=7.0.0"
1382
+ }
1383
+ },
1384
+ "node_modules/color-name": {
1385
+ "version": "1.1.4",
1386
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
1387
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
1388
+ "dev": true,
1389
+ "license": "MIT"
1390
+ },
1391
+ "node_modules/concurrently": {
1392
+ "version": "9.2.1",
1393
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
1394
+ "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
1395
+ "dev": true,
1396
+ "license": "MIT",
1397
+ "dependencies": {
1398
+ "chalk": "4.1.2",
1399
+ "rxjs": "7.8.2",
1400
+ "shell-quote": "1.8.3",
1401
+ "supports-color": "8.1.1",
1402
+ "tree-kill": "1.2.2",
1403
+ "yargs": "17.7.2"
1404
+ },
1405
+ "bin": {
1406
+ "conc": "dist/bin/concurrently.js",
1407
+ "concurrently": "dist/bin/concurrently.js"
1408
+ },
1409
+ "engines": {
1410
+ "node": ">=18"
1411
+ },
1412
+ "funding": {
1413
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
1414
+ }
1415
+ },
1416
+ "node_modules/confbox": {
1417
+ "version": "0.2.4",
1418
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
1419
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
1420
+ "devOptional": true,
1421
+ "license": "MIT"
1422
+ },
1423
+ "node_modules/consola": {
1424
+ "version": "3.4.2",
1425
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
1426
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
1427
+ "devOptional": true,
1428
+ "license": "MIT",
1429
+ "engines": {
1430
+ "node": "^14.18.0 || >=16.10.0"
1431
+ }
1432
+ },
1433
+ "node_modules/csstype": {
1434
+ "version": "3.2.3",
1435
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1436
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1437
+ "dev": true,
1438
+ "license": "MIT"
1439
+ },
1440
+ "node_modules/deepmerge-ts": {
1441
+ "version": "7.1.5",
1442
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
1443
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
1444
+ "devOptional": true,
1445
+ "license": "BSD-3-Clause",
1446
+ "engines": {
1447
+ "node": ">=16.0.0"
1448
+ }
1449
+ },
1450
+ "node_modules/defu": {
1451
+ "version": "6.1.4",
1452
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
1453
+ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
1454
+ "devOptional": true,
1455
+ "license": "MIT"
1456
+ },
1457
+ "node_modules/destr": {
1458
+ "version": "2.0.5",
1459
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
1460
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
1461
+ "devOptional": true,
1462
+ "license": "MIT"
1463
+ },
1464
+ "node_modules/detect-libc": {
1465
+ "version": "2.1.2",
1466
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1467
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1468
+ "license": "Apache-2.0",
1469
+ "optional": true,
1470
+ "engines": {
1471
+ "node": ">=8"
1472
+ }
1473
+ },
1474
+ "node_modules/dotenv": {
1475
+ "version": "16.6.1",
1476
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
1477
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
1478
+ "devOptional": true,
1479
+ "license": "BSD-2-Clause",
1480
+ "engines": {
1481
+ "node": ">=12"
1482
+ },
1483
+ "funding": {
1484
+ "url": "https://dotenvx.com"
1485
+ }
1486
+ },
1487
+ "node_modules/effect": {
1488
+ "version": "3.18.4",
1489
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
1490
+ "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
1491
+ "devOptional": true,
1492
+ "license": "MIT",
1493
+ "dependencies": {
1494
+ "@standard-schema/spec": "^1.0.0",
1495
+ "fast-check": "^3.23.1"
1496
+ }
1497
+ },
1498
+ "node_modules/emoji-regex": {
1499
+ "version": "8.0.0",
1500
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
1501
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1502
+ "dev": true,
1503
+ "license": "MIT"
1504
+ },
1505
+ "node_modules/empathic": {
1506
+ "version": "2.0.0",
1507
+ "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
1508
+ "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
1509
+ "devOptional": true,
1510
+ "license": "MIT",
1511
+ "engines": {
1512
+ "node": ">=14"
1513
+ }
1514
+ },
1515
+ "node_modules/esbuild": {
1516
+ "version": "0.27.3",
1517
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
1518
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
1519
+ "dev": true,
1520
+ "hasInstallScript": true,
1521
+ "license": "MIT",
1522
+ "bin": {
1523
+ "esbuild": "bin/esbuild"
1524
+ },
1525
+ "engines": {
1526
+ "node": ">=18"
1527
+ },
1528
+ "optionalDependencies": {
1529
+ "@esbuild/aix-ppc64": "0.27.3",
1530
+ "@esbuild/android-arm": "0.27.3",
1531
+ "@esbuild/android-arm64": "0.27.3",
1532
+ "@esbuild/android-x64": "0.27.3",
1533
+ "@esbuild/darwin-arm64": "0.27.3",
1534
+ "@esbuild/darwin-x64": "0.27.3",
1535
+ "@esbuild/freebsd-arm64": "0.27.3",
1536
+ "@esbuild/freebsd-x64": "0.27.3",
1537
+ "@esbuild/linux-arm": "0.27.3",
1538
+ "@esbuild/linux-arm64": "0.27.3",
1539
+ "@esbuild/linux-ia32": "0.27.3",
1540
+ "@esbuild/linux-loong64": "0.27.3",
1541
+ "@esbuild/linux-mips64el": "0.27.3",
1542
+ "@esbuild/linux-ppc64": "0.27.3",
1543
+ "@esbuild/linux-riscv64": "0.27.3",
1544
+ "@esbuild/linux-s390x": "0.27.3",
1545
+ "@esbuild/linux-x64": "0.27.3",
1546
+ "@esbuild/netbsd-arm64": "0.27.3",
1547
+ "@esbuild/netbsd-x64": "0.27.3",
1548
+ "@esbuild/openbsd-arm64": "0.27.3",
1549
+ "@esbuild/openbsd-x64": "0.27.3",
1550
+ "@esbuild/openharmony-arm64": "0.27.3",
1551
+ "@esbuild/sunos-x64": "0.27.3",
1552
+ "@esbuild/win32-arm64": "0.27.3",
1553
+ "@esbuild/win32-ia32": "0.27.3",
1554
+ "@esbuild/win32-x64": "0.27.3"
1555
+ }
1556
+ },
1557
+ "node_modules/escalade": {
1558
+ "version": "3.2.0",
1559
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1560
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1561
+ "dev": true,
1562
+ "license": "MIT",
1563
+ "engines": {
1564
+ "node": ">=6"
1565
+ }
1566
+ },
1567
+ "node_modules/exsolve": {
1568
+ "version": "1.0.8",
1569
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
1570
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
1571
+ "devOptional": true,
1572
+ "license": "MIT"
1573
+ },
1574
+ "node_modules/fast-check": {
1575
+ "version": "3.23.2",
1576
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
1577
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
1578
+ "devOptional": true,
1579
+ "funding": [
1580
+ {
1581
+ "type": "individual",
1582
+ "url": "https://github.com/sponsors/dubzzz"
1583
+ },
1584
+ {
1585
+ "type": "opencollective",
1586
+ "url": "https://opencollective.com/fast-check"
1587
+ }
1588
+ ],
1589
+ "license": "MIT",
1590
+ "dependencies": {
1591
+ "pure-rand": "^6.1.0"
1592
+ },
1593
+ "engines": {
1594
+ "node": ">=8.0.0"
1595
+ }
1596
+ },
1597
+ "node_modules/fsevents": {
1598
+ "version": "2.3.3",
1599
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1600
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1601
+ "dev": true,
1602
+ "hasInstallScript": true,
1603
+ "license": "MIT",
1604
+ "optional": true,
1605
+ "os": [
1606
+ "darwin"
1607
+ ],
1608
+ "engines": {
1609
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1610
+ }
1611
+ },
1612
+ "node_modules/get-caller-file": {
1613
+ "version": "2.0.5",
1614
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
1615
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
1616
+ "dev": true,
1617
+ "license": "ISC",
1618
+ "engines": {
1619
+ "node": "6.* || 8.* || >= 10.*"
1620
+ }
1621
+ },
1622
+ "node_modules/get-tsconfig": {
1623
+ "version": "4.13.6",
1624
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
1625
+ "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
1626
+ "dev": true,
1627
+ "license": "MIT",
1628
+ "dependencies": {
1629
+ "resolve-pkg-maps": "^1.0.0"
1630
+ },
1631
+ "funding": {
1632
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
1633
+ }
1634
+ },
1635
+ "node_modules/giget": {
1636
+ "version": "2.0.0",
1637
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
1638
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
1639
+ "devOptional": true,
1640
+ "license": "MIT",
1641
+ "dependencies": {
1642
+ "citty": "^0.1.6",
1643
+ "consola": "^3.4.0",
1644
+ "defu": "^6.1.4",
1645
+ "node-fetch-native": "^1.6.6",
1646
+ "nypm": "^0.6.0",
1647
+ "pathe": "^2.0.3"
1648
+ },
1649
+ "bin": {
1650
+ "giget": "dist/cli.mjs"
1651
+ }
1652
+ },
1653
+ "node_modules/has-flag": {
1654
+ "version": "4.0.0",
1655
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
1656
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
1657
+ "dev": true,
1658
+ "license": "MIT",
1659
+ "engines": {
1660
+ "node": ">=8"
1661
+ }
1662
+ },
1663
+ "node_modules/is-fullwidth-code-point": {
1664
+ "version": "3.0.0",
1665
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
1666
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
1667
+ "dev": true,
1668
+ "license": "MIT",
1669
+ "engines": {
1670
+ "node": ">=8"
1671
+ }
1672
+ },
1673
+ "node_modules/jiti": {
1674
+ "version": "2.6.1",
1675
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
1676
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
1677
+ "devOptional": true,
1678
+ "license": "MIT",
1679
+ "bin": {
1680
+ "jiti": "lib/jiti-cli.mjs"
1681
+ }
1682
+ },
1683
+ "node_modules/nanoid": {
1684
+ "version": "3.3.11",
1685
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1686
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1687
+ "funding": [
1688
+ {
1689
+ "type": "github",
1690
+ "url": "https://github.com/sponsors/ai"
1691
+ }
1692
+ ],
1693
+ "license": "MIT",
1694
+ "bin": {
1695
+ "nanoid": "bin/nanoid.cjs"
1696
+ },
1697
+ "engines": {
1698
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1699
+ }
1700
+ },
1701
+ "node_modules/next": {
1702
+ "version": "15.5.12",
1703
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
1704
+ "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
1705
+ "license": "MIT",
1706
+ "dependencies": {
1707
+ "@next/env": "15.5.12",
1708
+ "@swc/helpers": "0.5.15",
1709
+ "caniuse-lite": "^1.0.30001579",
1710
+ "postcss": "8.4.31",
1711
+ "styled-jsx": "5.1.6"
1712
+ },
1713
+ "bin": {
1714
+ "next": "dist/bin/next"
1715
+ },
1716
+ "engines": {
1717
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
1718
+ },
1719
+ "optionalDependencies": {
1720
+ "@next/swc-darwin-arm64": "15.5.12",
1721
+ "@next/swc-darwin-x64": "15.5.12",
1722
+ "@next/swc-linux-arm64-gnu": "15.5.12",
1723
+ "@next/swc-linux-arm64-musl": "15.5.12",
1724
+ "@next/swc-linux-x64-gnu": "15.5.12",
1725
+ "@next/swc-linux-x64-musl": "15.5.12",
1726
+ "@next/swc-win32-arm64-msvc": "15.5.12",
1727
+ "@next/swc-win32-x64-msvc": "15.5.12",
1728
+ "sharp": "^0.34.3"
1729
+ },
1730
+ "peerDependencies": {
1731
+ "@opentelemetry/api": "^1.1.0",
1732
+ "@playwright/test": "^1.51.1",
1733
+ "babel-plugin-react-compiler": "*",
1734
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1735
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
1736
+ "sass": "^1.3.0"
1737
+ },
1738
+ "peerDependenciesMeta": {
1739
+ "@opentelemetry/api": {
1740
+ "optional": true
1741
+ },
1742
+ "@playwright/test": {
1743
+ "optional": true
1744
+ },
1745
+ "babel-plugin-react-compiler": {
1746
+ "optional": true
1747
+ },
1748
+ "sass": {
1749
+ "optional": true
1750
+ }
1751
+ }
1752
+ },
1753
+ "node_modules/node-fetch-native": {
1754
+ "version": "1.6.7",
1755
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
1756
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
1757
+ "devOptional": true,
1758
+ "license": "MIT"
1759
+ },
1760
+ "node_modules/nypm": {
1761
+ "version": "0.6.5",
1762
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
1763
+ "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
1764
+ "devOptional": true,
1765
+ "license": "MIT",
1766
+ "dependencies": {
1767
+ "citty": "^0.2.0",
1768
+ "pathe": "^2.0.3",
1769
+ "tinyexec": "^1.0.2"
1770
+ },
1771
+ "bin": {
1772
+ "nypm": "dist/cli.mjs"
1773
+ },
1774
+ "engines": {
1775
+ "node": ">=18"
1776
+ }
1777
+ },
1778
+ "node_modules/nypm/node_modules/citty": {
1779
+ "version": "0.2.1",
1780
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
1781
+ "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
1782
+ "devOptional": true,
1783
+ "license": "MIT"
1784
+ },
1785
+ "node_modules/ohash": {
1786
+ "version": "2.0.11",
1787
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
1788
+ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
1789
+ "devOptional": true,
1790
+ "license": "MIT"
1791
+ },
1792
+ "node_modules/pathe": {
1793
+ "version": "2.0.3",
1794
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1795
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1796
+ "devOptional": true,
1797
+ "license": "MIT"
1798
+ },
1799
+ "node_modules/perfect-debounce": {
1800
+ "version": "1.0.0",
1801
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
1802
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
1803
+ "devOptional": true,
1804
+ "license": "MIT"
1805
+ },
1806
+ "node_modules/picocolors": {
1807
+ "version": "1.1.1",
1808
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1809
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1810
+ "license": "ISC"
1811
+ },
1812
+ "node_modules/pkg-types": {
1813
+ "version": "2.3.0",
1814
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
1815
+ "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
1816
+ "devOptional": true,
1817
+ "license": "MIT",
1818
+ "dependencies": {
1819
+ "confbox": "^0.2.2",
1820
+ "exsolve": "^1.0.7",
1821
+ "pathe": "^2.0.3"
1822
+ }
1823
+ },
1824
+ "node_modules/postcss": {
1825
+ "version": "8.4.31",
1826
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
1827
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
1828
+ "funding": [
1829
+ {
1830
+ "type": "opencollective",
1831
+ "url": "https://opencollective.com/postcss/"
1832
+ },
1833
+ {
1834
+ "type": "tidelift",
1835
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1836
+ },
1837
+ {
1838
+ "type": "github",
1839
+ "url": "https://github.com/sponsors/ai"
1840
+ }
1841
+ ],
1842
+ "license": "MIT",
1843
+ "dependencies": {
1844
+ "nanoid": "^3.3.6",
1845
+ "picocolors": "^1.0.0",
1846
+ "source-map-js": "^1.0.2"
1847
+ },
1848
+ "engines": {
1849
+ "node": "^10 || ^12 || >=14"
1850
+ }
1851
+ },
1852
+ "node_modules/prisma": {
1853
+ "version": "6.19.2",
1854
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
1855
+ "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
1856
+ "devOptional": true,
1857
+ "hasInstallScript": true,
1858
+ "license": "Apache-2.0",
1859
+ "dependencies": {
1860
+ "@prisma/config": "6.19.2",
1861
+ "@prisma/engines": "6.19.2"
1862
+ },
1863
+ "bin": {
1864
+ "prisma": "build/index.js"
1865
+ },
1866
+ "engines": {
1867
+ "node": ">=18.18"
1868
+ },
1869
+ "peerDependencies": {
1870
+ "typescript": ">=5.1.0"
1871
+ },
1872
+ "peerDependenciesMeta": {
1873
+ "typescript": {
1874
+ "optional": true
1875
+ }
1876
+ }
1877
+ },
1878
+ "node_modules/pure-rand": {
1879
+ "version": "6.1.0",
1880
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
1881
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
1882
+ "devOptional": true,
1883
+ "funding": [
1884
+ {
1885
+ "type": "individual",
1886
+ "url": "https://github.com/sponsors/dubzzz"
1887
+ },
1888
+ {
1889
+ "type": "opencollective",
1890
+ "url": "https://opencollective.com/fast-check"
1891
+ }
1892
+ ],
1893
+ "license": "MIT"
1894
+ },
1895
+ "node_modules/rc9": {
1896
+ "version": "2.1.2",
1897
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
1898
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
1899
+ "devOptional": true,
1900
+ "license": "MIT",
1901
+ "dependencies": {
1902
+ "defu": "^6.1.4",
1903
+ "destr": "^2.0.3"
1904
+ }
1905
+ },
1906
+ "node_modules/react": {
1907
+ "version": "19.2.4",
1908
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
1909
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
1910
+ "license": "MIT",
1911
+ "engines": {
1912
+ "node": ">=0.10.0"
1913
+ }
1914
+ },
1915
+ "node_modules/react-dom": {
1916
+ "version": "19.2.4",
1917
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
1918
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
1919
+ "license": "MIT",
1920
+ "dependencies": {
1921
+ "scheduler": "^0.27.0"
1922
+ },
1923
+ "peerDependencies": {
1924
+ "react": "^19.2.4"
1925
+ }
1926
+ },
1927
+ "node_modules/readdirp": {
1928
+ "version": "4.1.2",
1929
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
1930
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
1931
+ "devOptional": true,
1932
+ "license": "MIT",
1933
+ "engines": {
1934
+ "node": ">= 14.18.0"
1935
+ },
1936
+ "funding": {
1937
+ "type": "individual",
1938
+ "url": "https://paulmillr.com/funding/"
1939
+ }
1940
+ },
1941
+ "node_modules/require-directory": {
1942
+ "version": "2.1.1",
1943
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
1944
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
1945
+ "dev": true,
1946
+ "license": "MIT",
1947
+ "engines": {
1948
+ "node": ">=0.10.0"
1949
+ }
1950
+ },
1951
+ "node_modules/resolve-pkg-maps": {
1952
+ "version": "1.0.0",
1953
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
1954
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
1955
+ "dev": true,
1956
+ "license": "MIT",
1957
+ "funding": {
1958
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
1959
+ }
1960
+ },
1961
+ "node_modules/rxjs": {
1962
+ "version": "7.8.2",
1963
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
1964
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
1965
+ "dev": true,
1966
+ "license": "Apache-2.0",
1967
+ "dependencies": {
1968
+ "tslib": "^2.1.0"
1969
+ }
1970
+ },
1971
+ "node_modules/scheduler": {
1972
+ "version": "0.27.0",
1973
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1974
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1975
+ "license": "MIT"
1976
+ },
1977
+ "node_modules/semver": {
1978
+ "version": "7.7.4",
1979
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
1980
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
1981
+ "license": "ISC",
1982
+ "optional": true,
1983
+ "bin": {
1984
+ "semver": "bin/semver.js"
1985
+ },
1986
+ "engines": {
1987
+ "node": ">=10"
1988
+ }
1989
+ },
1990
+ "node_modules/sharp": {
1991
+ "version": "0.34.5",
1992
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
1993
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
1994
+ "hasInstallScript": true,
1995
+ "license": "Apache-2.0",
1996
+ "optional": true,
1997
+ "dependencies": {
1998
+ "@img/colour": "^1.0.0",
1999
+ "detect-libc": "^2.1.2",
2000
+ "semver": "^7.7.3"
2001
+ },
2002
+ "engines": {
2003
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
2004
+ },
2005
+ "funding": {
2006
+ "url": "https://opencollective.com/libvips"
2007
+ },
2008
+ "optionalDependencies": {
2009
+ "@img/sharp-darwin-arm64": "0.34.5",
2010
+ "@img/sharp-darwin-x64": "0.34.5",
2011
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
2012
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
2013
+ "@img/sharp-libvips-linux-arm": "1.2.4",
2014
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
2015
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
2016
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
2017
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
2018
+ "@img/sharp-libvips-linux-x64": "1.2.4",
2019
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
2020
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
2021
+ "@img/sharp-linux-arm": "0.34.5",
2022
+ "@img/sharp-linux-arm64": "0.34.5",
2023
+ "@img/sharp-linux-ppc64": "0.34.5",
2024
+ "@img/sharp-linux-riscv64": "0.34.5",
2025
+ "@img/sharp-linux-s390x": "0.34.5",
2026
+ "@img/sharp-linux-x64": "0.34.5",
2027
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
2028
+ "@img/sharp-linuxmusl-x64": "0.34.5",
2029
+ "@img/sharp-wasm32": "0.34.5",
2030
+ "@img/sharp-win32-arm64": "0.34.5",
2031
+ "@img/sharp-win32-ia32": "0.34.5",
2032
+ "@img/sharp-win32-x64": "0.34.5"
2033
+ }
2034
+ },
2035
+ "node_modules/shell-quote": {
2036
+ "version": "1.8.3",
2037
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
2038
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
2039
+ "dev": true,
2040
+ "license": "MIT",
2041
+ "engines": {
2042
+ "node": ">= 0.4"
2043
+ },
2044
+ "funding": {
2045
+ "url": "https://github.com/sponsors/ljharb"
2046
+ }
2047
+ },
2048
+ "node_modules/source-map-js": {
2049
+ "version": "1.2.1",
2050
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2051
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2052
+ "license": "BSD-3-Clause",
2053
+ "engines": {
2054
+ "node": ">=0.10.0"
2055
+ }
2056
+ },
2057
+ "node_modules/string-width": {
2058
+ "version": "4.2.3",
2059
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
2060
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
2061
+ "dev": true,
2062
+ "license": "MIT",
2063
+ "dependencies": {
2064
+ "emoji-regex": "^8.0.0",
2065
+ "is-fullwidth-code-point": "^3.0.0",
2066
+ "strip-ansi": "^6.0.1"
2067
+ },
2068
+ "engines": {
2069
+ "node": ">=8"
2070
+ }
2071
+ },
2072
+ "node_modules/strip-ansi": {
2073
+ "version": "6.0.1",
2074
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
2075
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
2076
+ "dev": true,
2077
+ "license": "MIT",
2078
+ "dependencies": {
2079
+ "ansi-regex": "^5.0.1"
2080
+ },
2081
+ "engines": {
2082
+ "node": ">=8"
2083
+ }
2084
+ },
2085
+ "node_modules/styled-jsx": {
2086
+ "version": "5.1.6",
2087
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
2088
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
2089
+ "license": "MIT",
2090
+ "dependencies": {
2091
+ "client-only": "0.0.1"
2092
+ },
2093
+ "engines": {
2094
+ "node": ">= 12.0.0"
2095
+ },
2096
+ "peerDependencies": {
2097
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
2098
+ },
2099
+ "peerDependenciesMeta": {
2100
+ "@babel/core": {
2101
+ "optional": true
2102
+ },
2103
+ "babel-plugin-macros": {
2104
+ "optional": true
2105
+ }
2106
+ }
2107
+ },
2108
+ "node_modules/supports-color": {
2109
+ "version": "8.1.1",
2110
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
2111
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
2112
+ "dev": true,
2113
+ "license": "MIT",
2114
+ "dependencies": {
2115
+ "has-flag": "^4.0.0"
2116
+ },
2117
+ "engines": {
2118
+ "node": ">=10"
2119
+ },
2120
+ "funding": {
2121
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
2122
+ }
2123
+ },
2124
+ "node_modules/tinyexec": {
2125
+ "version": "1.0.2",
2126
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
2127
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
2128
+ "devOptional": true,
2129
+ "license": "MIT",
2130
+ "engines": {
2131
+ "node": ">=18"
2132
+ }
2133
+ },
2134
+ "node_modules/tree-kill": {
2135
+ "version": "1.2.2",
2136
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
2137
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
2138
+ "dev": true,
2139
+ "license": "MIT",
2140
+ "bin": {
2141
+ "tree-kill": "cli.js"
2142
+ }
2143
+ },
2144
+ "node_modules/tslib": {
2145
+ "version": "2.8.1",
2146
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2147
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2148
+ "license": "0BSD"
2149
+ },
2150
+ "node_modules/tsx": {
2151
+ "version": "4.21.0",
2152
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
2153
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
2154
+ "dev": true,
2155
+ "license": "MIT",
2156
+ "dependencies": {
2157
+ "esbuild": "~0.27.0",
2158
+ "get-tsconfig": "^4.7.5"
2159
+ },
2160
+ "bin": {
2161
+ "tsx": "dist/cli.mjs"
2162
+ },
2163
+ "engines": {
2164
+ "node": ">=18.0.0"
2165
+ },
2166
+ "optionalDependencies": {
2167
+ "fsevents": "~2.3.3"
2168
+ }
2169
+ },
2170
+ "node_modules/typescript": {
2171
+ "version": "5.9.3",
2172
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2173
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2174
+ "devOptional": true,
2175
+ "license": "Apache-2.0",
2176
+ "bin": {
2177
+ "tsc": "bin/tsc",
2178
+ "tsserver": "bin/tsserver"
2179
+ },
2180
+ "engines": {
2181
+ "node": ">=14.17"
2182
+ }
2183
+ },
2184
+ "node_modules/undici-types": {
2185
+ "version": "6.21.0",
2186
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
2187
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
2188
+ "dev": true,
2189
+ "license": "MIT"
2190
+ },
2191
+ "node_modules/wrap-ansi": {
2192
+ "version": "7.0.0",
2193
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
2194
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
2195
+ "dev": true,
2196
+ "license": "MIT",
2197
+ "dependencies": {
2198
+ "ansi-styles": "^4.0.0",
2199
+ "string-width": "^4.1.0",
2200
+ "strip-ansi": "^6.0.0"
2201
+ },
2202
+ "engines": {
2203
+ "node": ">=10"
2204
+ },
2205
+ "funding": {
2206
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
2207
+ }
2208
+ },
2209
+ "node_modules/y18n": {
2210
+ "version": "5.0.8",
2211
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
2212
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
2213
+ "dev": true,
2214
+ "license": "ISC",
2215
+ "engines": {
2216
+ "node": ">=10"
2217
+ }
2218
+ },
2219
+ "node_modules/yargs": {
2220
+ "version": "17.7.2",
2221
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
2222
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
2223
+ "dev": true,
2224
+ "license": "MIT",
2225
+ "dependencies": {
2226
+ "cliui": "^8.0.1",
2227
+ "escalade": "^3.1.1",
2228
+ "get-caller-file": "^2.0.5",
2229
+ "require-directory": "^2.1.1",
2230
+ "string-width": "^4.2.3",
2231
+ "y18n": "^5.0.5",
2232
+ "yargs-parser": "^21.1.1"
2233
+ },
2234
+ "engines": {
2235
+ "node": ">=12"
2236
+ }
2237
+ },
2238
+ "node_modules/yargs-parser": {
2239
+ "version": "21.1.1",
2240
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
2241
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
2242
+ "dev": true,
2243
+ "license": "ISC",
2244
+ "engines": {
2245
+ "node": ">=12"
2246
+ }
2247
+ }
2248
+ }
2249
+ }
package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ehrgym",
3
+ "private": true,
4
+ "type": "module",
5
+ "workspaces": [
6
+ "apps/ehr"
7
+ ],
8
+ "scripts": {
9
+ "dev": "concurrently -k -n ehr,env \"npm run dev:ehr\" \"npm run dev:env\"",
10
+ "dev:ehr": "npm run dev --workspace @ehrgym/ehr",
11
+ "dev:env": "python3 -m uvicorn env_server.app.main:app --reload --host 0.0.0.0 --port 8000",
12
+ "clean:ehr": "npm run clean --workspace @ehrgym/ehr",
13
+ "build:ehr": "npm run build --workspace @ehrgym/ehr",
14
+ "typecheck": "npm run typecheck --workspace @ehrgym/ehr",
15
+ "db:generate": "prisma generate",
16
+ "db:push": "prisma db push",
17
+ "db:seed": "prisma db seed"
18
+ },
19
+ "prisma": {
20
+ "seed": "tsx prisma/seed.ts"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.13.14",
24
+ "concurrently": "^9.1.2",
25
+ "prisma": "^6.5.0",
26
+ "tsx": "^4.19.3",
27
+ "typescript": "^5.8.2"
28
+ }
29
+ }
prisma/schema.prisma ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ }
4
+
5
+ datasource db {
6
+ provider = "sqlite"
7
+ url = env("DATABASE_URL")
8
+ }
9
+
10
+ enum EncounterStatus {
11
+ OPEN
12
+ SIGNED
13
+ CLOSED
14
+ }
15
+
16
+ enum NoteType {
17
+ PROGRESS
18
+ CONSULT
19
+ DISCHARGE
20
+ }
21
+
22
+ enum OrderCategory {
23
+ LAB
24
+ MED
25
+ IMAGING
26
+ }
27
+
28
+ enum OrderStatus {
29
+ DRAFT
30
+ PENDING_SIGNATURE
31
+ SIGNED
32
+ }
33
+
34
+ model Patient {
35
+ id String @id
36
+ mrn String @unique
37
+ fullName String
38
+ age Int
39
+ sex String
40
+ allergiesJson String
41
+ bannerFlagsJson String
42
+ summary String
43
+ createdAt DateTime @default(now())
44
+ updatedAt DateTime @updatedAt
45
+ encounters Encounter[]
46
+ scenarios Scenario[]
47
+ }
48
+
49
+ model Encounter {
50
+ id String @id
51
+ patientId String
52
+ type String
53
+ reasonForVisit String
54
+ provider String
55
+ startedAt DateTime
56
+ status EncounterStatus @default(OPEN)
57
+ createdAt DateTime @default(now())
58
+ updatedAt DateTime @updatedAt
59
+ patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
60
+ labs LabResult[]
61
+ notes ClinicalNote[]
62
+ orders Order[]
63
+ scenarios Scenario[]
64
+
65
+ @@index([patientId, startedAt])
66
+ }
67
+
68
+ model LabResult {
69
+ id String @id
70
+ encounterId String
71
+ name String
72
+ loinc String?
73
+ value String
74
+ unit String
75
+ referenceRange String
76
+ abnormal Boolean @default(false)
77
+ collectedAt DateTime
78
+ encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
79
+
80
+ @@index([encounterId, collectedAt])
81
+ }
82
+
83
+ model ClinicalNote {
84
+ id String @id
85
+ encounterId String
86
+ type NoteType
87
+ title String
88
+ author String
89
+ content String
90
+ signed Boolean @default(false)
91
+ createdAt DateTime @default(now())
92
+ encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
93
+
94
+ @@index([encounterId, createdAt])
95
+ }
96
+
97
+ model Order {
98
+ id String @id
99
+ encounterId String
100
+ name String
101
+ category OrderCategory
102
+ parametersJson String
103
+ status OrderStatus @default(DRAFT)
104
+ rationale String
105
+ createdAt DateTime @default(now())
106
+ encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
107
+
108
+ @@index([encounterId, createdAt])
109
+ }
110
+
111
+ model Scenario {
112
+ id String @id
113
+ patientId String
114
+ encounterId String
115
+ title String
116
+ objective String
117
+ rubricJson String
118
+ requiredOrdersJson String
119
+ requiredNoteElementsJson String
120
+ createdAt DateTime @default(now())
121
+ patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
122
+ encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
123
+
124
+ @@index([patientId, encounterId])
125
+ }
prisma/seed.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PrismaClient } from "@prisma/client";
2
+
3
+ import { resetDatabase } from "../shared/reset-database";
4
+
5
+ const prisma = new PrismaClient();
6
+
7
+ async function main() {
8
+ await resetDatabase(prisma);
9
+ const patientCount = await prisma.patient.count();
10
+ console.log(`Seeded ${patientCount} synthetic patients.`);
11
+ }
12
+
13
+ main()
14
+ .catch((error) => {
15
+ console.error("Failed to seed database", error);
16
+ process.exitCode = 1;
17
+ })
18
+ .finally(async () => {
19
+ await prisma.$disconnect();
20
+ });
pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ehrgym-env-server"
7
+ version = "0.1.0"
8
+ description = "OpenEnv-style FastAPI + Playwright environment server for EHRGym"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "fastapi>=0.115.0",
13
+ "httpx>=0.28.1",
14
+ "playwright>=1.51.0",
15
+ "pydantic>=2.10.6",
16
+ "uvicorn[standard]>=0.34.0"
17
+ ]
18
+
19
+ [tool.setuptools]
20
+ include-package-data = true
21
+
22
+ [tool.setuptools.packages.find]
23
+ include = ["env_server*"]
scripts/diagnose_server_actions.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from playwright.async_api import async_playwright
6
+
7
+
8
+ async def main() -> None:
9
+ async with async_playwright() as playwright:
10
+ browser = await playwright.chromium.launch()
11
+ page = await browser.new_page()
12
+ events: list[tuple[str, str, int] | tuple[str, str, str]] = []
13
+
14
+ page.on(
15
+ "response",
16
+ lambda response: events.append((response.request.method, response.url, response.status))
17
+ if response.request.method == "POST"
18
+ else None,
19
+ )
20
+ page.on(
21
+ "console",
22
+ lambda message: events.append(("console", message.type, message.text)) if message.type == "error" else None,
23
+ )
24
+
25
+ await page.goto("http://127.0.0.1:3000/patient/pat-1001", wait_until="networkidle")
26
+ await page.get_by_label("Note author").fill("Dr. Test User")
27
+ await page.get_by_label("Note title").fill("Progress Note Follow-up")
28
+ await page.get_by_label("Progress note content").fill("S\nO\nA\nP")
29
+ await page.get_by_test_id("save-note-button").click()
30
+ await page.wait_for_timeout(1500)
31
+
32
+ print("events", events)
33
+ print("url", page.url)
34
+ print("count", await page.get_by_text("Progress Note Follow-up").count())
35
+
36
+ await browser.close()
37
+
38
+
39
+ if __name__ == "__main__":
40
+ asyncio.run(main())
scripts/example_agent.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import httpx
6
+
7
+ ENV_SERVER_URL = "http://127.0.0.1:8000"
8
+
9
+
10
+ def main() -> None:
11
+ with httpx.Client(base_url=ENV_SERVER_URL, timeout=30.0) as client:
12
+ reset = client.post("/reset").json()
13
+ print("Goal:", reset["observation"]["goal"])
14
+ print("State:", json.dumps(reset["state"], indent=2))
15
+
16
+ actions = [
17
+ {"type": "click", "selector": "[data-testid='patient-card-pat-1001']"},
18
+ {"type": "wait", "milliseconds": 500},
19
+ {"type": "click", "selector": "[data-testid='activity-orders']"}
20
+ ]
21
+
22
+ for action in actions:
23
+ step = client.post("/step", json=action).json()
24
+ print("Action:", action)
25
+ print("Reward:", step["reward"])
26
+ print("Done:", step["done"])
27
+ print("Progress:", step["state"]["rubric_progress"])
28
+ print("URL:", step["observation"]["current_url"])
29
+ print("-" * 40)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
scripts/measure_controls.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from playwright.async_api import async_playwright
6
+
7
+
8
+ async def main() -> None:
9
+ async with async_playwright() as playwright:
10
+ browser = await playwright.chromium.launch()
11
+ page = await browser.new_page()
12
+ await page.goto("http://127.0.0.1:3000/patient/pat-1001", wait_until="networkidle")
13
+
14
+ checkbox_box = await page.get_by_label("Submit order for signature").bounding_box()
15
+ order_button_box = await page.get_by_test_id("save-order-button").bounding_box()
16
+ note_button_box = await page.get_by_test_id("save-note-button").bounding_box()
17
+
18
+ print("checkbox", checkbox_box)
19
+ print("order_button", order_button_box)
20
+ print("note_button", note_button_box)
21
+
22
+ await browser.close()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ asyncio.run(main())
scripts/ui_smoke_test.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from time import time
5
+
6
+ from playwright.async_api import async_playwright
7
+
8
+
9
+ async def main() -> None:
10
+ async with async_playwright() as playwright:
11
+ browser = await playwright.chromium.launch()
12
+ page = await browser.new_page()
13
+ messages: list[str] = []
14
+
15
+ page.on("pageerror", lambda error: messages.append(f"PAGEERROR: {error}"))
16
+ page.on(
17
+ "console",
18
+ lambda message: messages.append(f"CONSOLE {message.type}: {message.text}") if message.type == "error" else None,
19
+ )
20
+
21
+ suffix = str(int(time()))
22
+ note_title = f"Progress Note Follow-up {suffix}"
23
+ order_name = f"Normal saline bolus {suffix}"
24
+
25
+ await page.goto("http://127.0.0.1:3000/patient/pat-1001", wait_until="networkidle")
26
+ print("loaded", page.url)
27
+
28
+ await page.get_by_test_id("chart-tab-labs").click()
29
+ print("labs_tab_active", await page.get_by_test_id("chart-tab-labs").get_attribute("aria-selected"))
30
+ await page.get_by_test_id("chart-tab-notes").click()
31
+ print("chart_notes_tab_active", await page.get_by_test_id("chart-tab-notes").get_attribute("aria-selected"))
32
+ await page.get_by_test_id("activity-orders").click()
33
+ await page.wait_for_timeout(150)
34
+ print("hash_after_orders_nav", await page.evaluate("window.location.hash"))
35
+
36
+ await page.get_by_label("Note author").fill("Dr. Test User")
37
+ await page.get_by_label("Note title").fill(note_title)
38
+ await page.get_by_label("Progress note content").fill(
39
+ "S: feels better\nO: creatinine trend reviewed\nA: volume depletion with AKI\nP: repeat BMP and volume assessment"
40
+ )
41
+ await page.get_by_test_id("save-note-button").click()
42
+ await page.locator("article.note-row", has_text=note_title).last.wait_for(timeout=5000)
43
+ print("has_new_note", await page.locator("article.note-row", has_text=note_title).count())
44
+
45
+ await page.get_by_label("Order name").fill(order_name)
46
+ await page.get_by_label("Order category").select_option("MED")
47
+ await page.get_by_label("Order parameters").fill("1 L IV once")
48
+ await page.get_by_label("Order rationale").fill("Volume repletion for AKI")
49
+ await page.get_by_label("Submit order for signature").check()
50
+ await page.get_by_test_id("save-order-button").click()
51
+ await page.locator("article.order-row", has_text=order_name).first.wait_for(timeout=5000)
52
+ print("has_new_order", await page.locator("article.order-row", has_text=order_name).count())
53
+
54
+ new_order_row = page.locator("article.order-row", has_text=order_name).first
55
+ sign_buttons = new_order_row.locator('[data-testid^="sign-order-"]')
56
+ print("sign_buttons", await sign_buttons.count())
57
+ if await sign_buttons.count() > 0:
58
+ await sign_buttons.first.click()
59
+ await new_order_row.get_by_text("SIGNED").wait_for(timeout=5000)
60
+ print("signed_clicked", True)
61
+
62
+ await page.get_by_test_id("sign-encounter-button").click()
63
+ await page.locator('[data-testid="patient-banner"] .status-pill').get_by_text("SIGNED").wait_for(timeout=5000)
64
+ print("encounter_status", await page.locator('[data-testid="patient-banner"] .status-pill').inner_text())
65
+
66
+ if messages:
67
+ for message in messages:
68
+ print(message)
69
+
70
+ await browser.close()
71
+
72
+
73
+ if __name__ == "__main__":
74
+ asyncio.run(main())
shared/reset-database.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PrismaClient } from "@prisma/client";
2
+
3
+ import { seedPatients } from "./seed-data";
4
+
5
+ export async function resetDatabase(prisma: PrismaClient) {
6
+ await prisma.order.deleteMany();
7
+ await prisma.clinicalNote.deleteMany();
8
+ await prisma.labResult.deleteMany();
9
+ await prisma.scenario.deleteMany();
10
+ await prisma.encounter.deleteMany();
11
+ await prisma.patient.deleteMany();
12
+
13
+ for (const patient of seedPatients) {
14
+ await prisma.patient.create({
15
+ data: {
16
+ id: patient.id,
17
+ mrn: patient.mrn,
18
+ fullName: patient.fullName,
19
+ age: patient.age,
20
+ sex: patient.sex,
21
+ allergiesJson: JSON.stringify(patient.allergies),
22
+ bannerFlagsJson: JSON.stringify(patient.bannerFlags),
23
+ summary: patient.summary,
24
+ encounters: {
25
+ create: patient.encounters.map((encounter) => ({
26
+ id: encounter.id,
27
+ type: encounter.type,
28
+ reasonForVisit: encounter.reasonForVisit,
29
+ provider: encounter.provider,
30
+ startedAt: new Date(encounter.startedAt),
31
+ status: encounter.status,
32
+ labs: {
33
+ create: encounter.labs.map((lab) => ({
34
+ id: lab.id,
35
+ name: lab.name,
36
+ loinc: lab.loinc,
37
+ value: lab.value,
38
+ unit: lab.unit,
39
+ referenceRange: lab.referenceRange,
40
+ abnormal: lab.abnormal,
41
+ collectedAt: new Date(lab.collectedAt)
42
+ }))
43
+ },
44
+ notes: {
45
+ create: encounter.notes.map((note) => ({
46
+ id: note.id,
47
+ type: note.type,
48
+ title: note.title,
49
+ author: note.author,
50
+ content: note.content,
51
+ signed: note.signed,
52
+ createdAt: new Date(note.createdAt)
53
+ }))
54
+ },
55
+ orders: {
56
+ create: encounter.orders.map((order) => ({
57
+ id: order.id,
58
+ name: order.name,
59
+ category: order.category,
60
+ parametersJson: JSON.stringify(order.parameters),
61
+ status: order.status,
62
+ rationale: order.rationale,
63
+ createdAt: new Date(order.createdAt)
64
+ }))
65
+ }
66
+ }))
67
+ },
68
+ scenarios: {
69
+ create: patient.scenarios.map((scenario, index) => ({
70
+ id: scenario.id,
71
+ title: scenario.title,
72
+ objective: scenario.objective,
73
+ rubricJson: JSON.stringify(scenario.rubric),
74
+ requiredOrdersJson: JSON.stringify(scenario.requiredOrders),
75
+ requiredNoteElementsJson: JSON.stringify(scenario.requiredNoteElements),
76
+ encounterId: patient.encounters[index]?.id ?? patient.encounters[0]?.id
77
+ }))
78
+ }
79
+ }
80
+ });
81
+ }
82
+ }
shared/seed-data.ts ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type SeedLab = {
2
+ id: string;
3
+ name: string;
4
+ loinc?: string;
5
+ value: string;
6
+ unit: string;
7
+ referenceRange: string;
8
+ abnormal: boolean;
9
+ collectedAt: string;
10
+ };
11
+
12
+ export type SeedNote = {
13
+ id: string;
14
+ type: "PROGRESS" | "CONSULT" | "DISCHARGE";
15
+ title: string;
16
+ author: string;
17
+ content: string;
18
+ signed: boolean;
19
+ createdAt: string;
20
+ };
21
+
22
+ export type SeedOrder = {
23
+ id: string;
24
+ name: string;
25
+ category: "LAB" | "MED" | "IMAGING";
26
+ parameters: Record<string, string>;
27
+ status: "DRAFT" | "PENDING_SIGNATURE" | "SIGNED";
28
+ rationale: string;
29
+ createdAt: string;
30
+ };
31
+
32
+ export type SeedEncounter = {
33
+ id: string;
34
+ type: string;
35
+ reasonForVisit: string;
36
+ provider: string;
37
+ startedAt: string;
38
+ status: "OPEN" | "SIGNED" | "CLOSED";
39
+ labs: SeedLab[];
40
+ notes: SeedNote[];
41
+ orders: SeedOrder[];
42
+ };
43
+
44
+ export type SeedScenario = {
45
+ id: string;
46
+ title: string;
47
+ objective: string;
48
+ rubric: string[];
49
+ requiredOrders: string[];
50
+ requiredNoteElements: string[];
51
+ };
52
+
53
+ export type SeedPatient = {
54
+ id: string;
55
+ mrn: string;
56
+ fullName: string;
57
+ age: number;
58
+ sex: string;
59
+ allergies: string[];
60
+ bannerFlags: string[];
61
+ summary: string;
62
+ encounters: SeedEncounter[];
63
+ scenarios: SeedScenario[];
64
+ };
65
+
66
+ export const seedPatients: SeedPatient[] = [
67
+ {
68
+ id: "pat-1001",
69
+ mrn: "SYN-1001",
70
+ fullName: "Mary Johnson",
71
+ age: 68,
72
+ sex: "F",
73
+ allergies: ["Penicillin"],
74
+ bannerFlags: ["Fall risk", "CKD stage 3"],
75
+ summary: "Admitted with weakness and oliguria after several days of poor oral intake.",
76
+ encounters: [
77
+ {
78
+ id: "enc-1001",
79
+ type: "Inpatient",
80
+ reasonForVisit: "Acute kidney injury evaluation",
81
+ provider: "Dr. Ada Carter",
82
+ startedAt: "2026-03-05T14:15:00.000Z",
83
+ status: "OPEN",
84
+ labs: [
85
+ {
86
+ id: "lab-1001",
87
+ name: "Creatinine",
88
+ loinc: "2160-0",
89
+ value: "2.3",
90
+ unit: "mg/dL",
91
+ referenceRange: "0.6-1.2",
92
+ abnormal: true,
93
+ collectedAt: "2026-03-06T08:12:00.000Z"
94
+ },
95
+ {
96
+ id: "lab-1002",
97
+ name: "Creatinine",
98
+ loinc: "2160-0",
99
+ value: "1.8",
100
+ unit: "mg/dL",
101
+ referenceRange: "0.6-1.2",
102
+ abnormal: true,
103
+ collectedAt: "2026-03-05T09:02:00.000Z"
104
+ },
105
+ {
106
+ id: "lab-1003",
107
+ name: "Hemoglobin",
108
+ loinc: "718-7",
109
+ value: "10.4",
110
+ unit: "g/dL",
111
+ referenceRange: "12.0-16.0",
112
+ abnormal: true,
113
+ collectedAt: "2026-03-06T08:12:00.000Z"
114
+ }
115
+ ],
116
+ notes: [
117
+ {
118
+ id: "note-1001",
119
+ type: "CONSULT",
120
+ title: "Nephrology consult",
121
+ author: "Dr. J. Morales",
122
+ content: "Assessment: pre-renal AKI suspected. Recommend isotonic fluids, medication review, and repeat BMP in 6 hours.",
123
+ signed: true,
124
+ createdAt: "2026-03-05T16:40:00.000Z"
125
+ }
126
+ ],
127
+ orders: [
128
+ {
129
+ id: "ord-1001",
130
+ name: "Basic metabolic panel",
131
+ category: "LAB",
132
+ parameters: {
133
+ frequency: "q6h"
134
+ },
135
+ status: "SIGNED",
136
+ rationale: "Trend renal function.",
137
+ createdAt: "2026-03-05T16:55:00.000Z"
138
+ }
139
+ ]
140
+ }
141
+ ],
142
+ scenarios: [
143
+ {
144
+ id: "scn-1001",
145
+ title: "AKI chart review",
146
+ objective: "Review labs, place fluid and repeat BMP orders, then write a grounded SOAP progress note.",
147
+ rubric: [
148
+ "Identify most recent creatinine",
149
+ "Order isotonic fluids or repeat BMP",
150
+ "Document likely pre-renal AKI in assessment",
151
+ "Sign the encounter"
152
+ ],
153
+ requiredOrders: ["Basic metabolic panel", "Normal saline bolus"],
154
+ requiredNoteElements: ["Creatinine trend", "Volume assessment", "Plan for repeat labs"]
155
+ }
156
+ ]
157
+ },
158
+ {
159
+ id: "pat-1002",
160
+ mrn: "SYN-1002",
161
+ fullName: "Robert Chen",
162
+ age: 52,
163
+ sex: "M",
164
+ allergies: ["None known"],
165
+ bannerFlags: ["Droplet precautions"],
166
+ summary: "Seen for fever, productive cough, and exertional dyspnea.",
167
+ encounters: [
168
+ {
169
+ id: "enc-1002",
170
+ type: "Observation",
171
+ reasonForVisit: "Community-acquired pneumonia",
172
+ provider: "Dr. Nina Brooks",
173
+ startedAt: "2026-03-04T11:05:00.000Z",
174
+ status: "OPEN",
175
+ labs: [
176
+ {
177
+ id: "lab-2001",
178
+ name: "WBC",
179
+ loinc: "6690-2",
180
+ value: "14.8",
181
+ unit: "K/uL",
182
+ referenceRange: "4.0-10.5",
183
+ abnormal: true,
184
+ collectedAt: "2026-03-04T11:40:00.000Z"
185
+ },
186
+ {
187
+ id: "lab-2002",
188
+ name: "Procalcitonin",
189
+ loinc: "33959-8",
190
+ value: "1.8",
191
+ unit: "ng/mL",
192
+ referenceRange: "<0.5",
193
+ abnormal: true,
194
+ collectedAt: "2026-03-04T11:40:00.000Z"
195
+ }
196
+ ],
197
+ notes: [
198
+ {
199
+ id: "note-2001",
200
+ type: "DISCHARGE",
201
+ title: "Prior urgent care note",
202
+ author: "Dr. A. Singh",
203
+ content: "Started doxycycline yesterday; advised chest imaging if symptoms worsen.",
204
+ signed: true,
205
+ createdAt: "2026-03-03T18:20:00.000Z"
206
+ }
207
+ ],
208
+ orders: [
209
+ {
210
+ id: "ord-2001",
211
+ name: "Chest X-ray",
212
+ category: "IMAGING",
213
+ parameters: {
214
+ priority: "Routine"
215
+ },
216
+ status: "PENDING_SIGNATURE",
217
+ rationale: "Evaluate infiltrate.",
218
+ createdAt: "2026-03-04T11:50:00.000Z"
219
+ }
220
+ ]
221
+ }
222
+ ],
223
+ scenarios: [
224
+ {
225
+ id: "scn-1002",
226
+ title: "Pneumonia follow-up",
227
+ objective: "Review infectious workup, document prior antibiotic exposure, and sign a chest X-ray order.",
228
+ rubric: [
229
+ "Find prior antibiotic exposure",
230
+ "Place or sign chest imaging order",
231
+ "Write a concise progress note"
232
+ ],
233
+ requiredOrders: ["Chest X-ray"],
234
+ requiredNoteElements: ["Antibiotic exposure", "Respiratory symptoms", "Follow-up plan"]
235
+ }
236
+ ]
237
+ }
238
+ ];
synthetic/README.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Synthetic data pipeline
2
+
3
+ This folder is reserved for Synthea generation, FHIR-shaped ingest, and repeatable seed tooling.
4
+
5
+ For the initial scaffold, synthetic chart data is defined in [shared/seed-data.ts](../shared/seed-data.ts) and loaded through Prisma.
tasks/examples/aki-chart-review.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "aki-chart-review",
3
+ "title": "AKI chart review to note and orders",
4
+ "patient_id": "pat-1001",
5
+ "encounter_id": "enc-1001",
6
+ "objective": "Review the patient chart, identify the rising creatinine, place fluid and repeat BMP orders, then sign the encounter.",
7
+ "required_orders": [
8
+ "Basic metabolic panel",
9
+ "Normal saline bolus"
10
+ ],
11
+ "required_note_elements": [
12
+ "Creatinine trend",
13
+ "Volume assessment",
14
+ "Plan for repeat labs"
15
+ ],
16
+ "scoring": {
17
+ "base_reward": 1.0,
18
+ "substeps": {
19
+ "chart_navigation": 0.1,
20
+ "target_lab_identified": 0.2,
21
+ "required_order_added": 0.3,
22
+ "note_grounded": 0.2,
23
+ "encounter_signed": 0.2
24
+ }
25
+ }
26
+ }
tsconfig.base.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "es2022"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "ESNext",
11
+ "moduleResolution": "Bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "baseUrl": "."
17
+ }
18
+ }
tsconfig.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "target": "ES2022",
7
+ "lib": ["ES2022"],
8
+ "types": ["node"]
9
+ },
10
+ "include": ["shared/**/*.ts", "prisma/**/*.ts"],
11
+ "exclude": ["node_modules", "apps", "env_server"]
12
+ }