projects/hot-tub/.gitignore
... ...
@@ -1,3 +1,4 @@
1 1
__pycache__/
2 2
*.py[cod]
3 3
*.pyo
4
+.venv-web/
projects/hot-tub/deploy/web/hot-tub-web.logrotate
... ...
@@ -0,0 +1,9 @@
1
+/var/log/hot-tub/web.log {
2
+ weekly
3
+ rotate 8
4
+ compress
5
+ delaycompress
6
+ missingok
7
+ notifempty
8
+ copytruncate
9
+}
projects/hot-tub/deploy/web/hot-tub-web.service
... ...
@@ -0,0 +1,17 @@
1
+[Unit]
2
+Description=Hot Tub Web Service
3
+After=network-online.target redis-server.service
4
+Wants=network-online.target
5
+Documentation=file://src/web/INSTALL.md
6
+
7
+[Service]
8
+Type=simple
9
+User=hot-tub
10
+Group=hot-tub
11
+Environment=PYTHONPATH=__INSTALL_ROOT__/src
12
+ExecStart=/usr/bin/python3 -m web --config /etc/hot-tub/web.json
13
+Restart=on-failure
14
+RestartSec=5
15
+
16
+[Install]
17
+WantedBy=multi-user.target
projects/hot-tub/deploy/web/install.sh
... ...
@@ -0,0 +1,71 @@
1
+#!/usr/bin/env bash
2
+# Install hot-tub web service on Raspberry Pi OS (run with sudo).
3
+set -euo pipefail
4
+
5
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
7
+INSTALL_ROOT="${INSTALL_ROOT:-${REPO_ROOT}}"
8
+
9
+if [[ "$(id -u)" -ne 0 ]]; then
10
+ echo "Run as root: sudo $0" >&2
11
+ exit 1
12
+fi
13
+
14
+DIST="${INSTALL_ROOT}/src/web/frontend/dist/index.html"
15
+if [[ ! -f "${DIST}" ]]; then
16
+ echo "Missing built frontend: ${DIST}" >&2
17
+ echo "Build on a dev machine: cd src/web/frontend && npm install && npm run build" >&2
18
+ exit 1
19
+fi
20
+
21
+echo "==> Installing packages (python3-redis, python3-flask)..."
22
+export DEBIAN_FRONTEND=noninteractive
23
+apt-get update -qq
24
+apt-get install -y -qq python3-redis python3-flask
25
+
26
+if ! systemctl is-active --quiet redis-server 2>/dev/null && ! systemctl is-active --quiet redis 2>/dev/null; then
27
+ echo "==> Redis not running; installing redis-server..."
28
+ apt-get install -y -qq redis-server
29
+fi
30
+
31
+echo "==> Ensuring hot-tub system user..."
32
+if ! id hot-tub &>/dev/null; then
33
+ useradd --system --home /var/lib/hot-tub --shell /usr/sbin/nologin hot-tub
34
+fi
35
+
36
+echo "==> Preparing log and config paths..."
37
+mkdir -p /var/log/hot-tub /etc/hot-tub /var/lib/hot-tub
38
+chown hot-tub:hot-tub /var/log/hot-tub /var/lib/hot-tub
39
+
40
+if [[ ! -f /etc/hot-tub/web.json ]]; then
41
+ install -m 0644 "${INSTALL_ROOT}/src/web/config.example.json" /etc/hot-tub/web.json
42
+ sed -i "s|\"static_dir\": \"frontend/dist\"|\"static_dir\": \"${INSTALL_ROOT}/src/web/frontend/dist\"|" /etc/hot-tub/web.json
43
+ echo " Created /etc/hot-tub/web.json from example"
44
+else
45
+ echo " Keeping existing /etc/hot-tub/web.json"
46
+fi
47
+
48
+echo "==> Installing systemd unit..."
49
+sed "s|__INSTALL_ROOT__|${INSTALL_ROOT}|g" "${SCRIPT_DIR}/hot-tub-web.service" \
50
+ > /etc/systemd/system/hot-tub-web.service
51
+
52
+echo "==> Installing logrotate..."
53
+install -m 0644 "${SCRIPT_DIR}/hot-tub-web.logrotate" /etc/logrotate.d/hot-tub-web
54
+
55
+echo "==> Enabling services..."
56
+systemctl enable redis-server 2>/dev/null || systemctl enable redis 2>/dev/null || true
57
+systemctl start redis-server 2>/dev/null || systemctl start redis 2>/dev/null || true
58
+systemctl daemon-reload
59
+systemctl enable hot-tub-web.service
60
+systemctl restart hot-tub-web.service || systemctl start hot-tub-web.service
61
+
62
+echo ""
63
+echo "Installation complete."
64
+echo " Repo / code root: ${INSTALL_ROOT}"
65
+echo " Config: /etc/hot-tub/web.json"
66
+echo " Log: /var/log/hot-tub/web.log"
67
+echo " UI: http://<pi-ip>:8080/"
68
+echo ""
69
+echo "Status: systemctl status hot-tub-web"
70
+echo "Logs: tail -f /var/log/hot-tub/web.log"
71
+echo "SSE: curl -N http://localhost:8080/api/events"
projects/hot-tub/diagrams/hot-tub-electrical-v2.drawio
... ...
@@ -0,0 +1,238 @@
1
+<mxfile host="app.diagrams.net" modified="2026-10-22T17:18:00.000Z" agent="Claude Sonnet via Cursor" version="22.1.0" type="device">
2
+ <diagram id="hot-tub-electrical-v2" name="Hot tub electrical (v2)">
3
+ <mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="970" math="0" shadow="0">
4
+ <root>
5
+ <mxCell id="0" />
6
+ <mxCell id="1" parent="0" />
7
+
8
+ <mxCell id="v2title" value="Hot tub — electrical wiring (v2)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=20;fontStyle=1" vertex="1" parent="1">
9
+ <mxGeometry x="200" y="18" width="1000" height="38" as="geometry" />
10
+ </mxCell>
11
+ <mxCell id="v2subtitle" value="Rendered by Claude Sonnet · same facts as v1: GPIO22 heat relay, GPIO23 filter relay, GPIO4 1-Wire bus with all 5 DS18B20 probes. Low-water float is a FUTURE addition — not installed, no GPIO wired today." style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;fontColor=#666666;" vertex="1" parent="1">
12
+ <mxGeometry x="120" y="56" width="1160" height="26" as="geometry" />
13
+ </mxCell>
14
+
15
+ <!-- Legend -->
16
+ <mxCell id="lg1sw" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
17
+ <mxGeometry x="40" y="92" width="16" height="16" as="geometry" />
18
+ </mxCell>
19
+ <mxCell id="lg1tx" value="Controller (Pi)" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=11;" vertex="1" parent="1">
20
+ <mxGeometry x="62" y="88" width="150" height="24" as="geometry" />
21
+ </mxCell>
22
+
23
+ <mxCell id="lg2sw" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
24
+ <mxGeometry x="260" y="92" width="16" height="16" as="geometry" />
25
+ </mxCell>
26
+ <mxCell id="lg2tx" value="Sensor / 1-Wire" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=11;" vertex="1" parent="1">
27
+ <mxGeometry x="282" y="88" width="150" height="24" as="geometry" />
28
+ </mxCell>
29
+
30
+ <mxCell id="lg3sw" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;dashed=1;" vertex="1" parent="1">
31
+ <mxGeometry x="480" y="92" width="16" height="16" as="geometry" />
32
+ </mxCell>
33
+ <mxCell id="lg3tx" value="Optional / planned" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=11;" vertex="1" parent="1">
34
+ <mxGeometry x="502" y="88" width="160" height="24" as="geometry" />
35
+ </mxCell>
36
+
37
+ <mxCell id="lg4sw" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
38
+ <mxGeometry x="700" y="92" width="16" height="16" as="geometry" />
39
+ </mxCell>
40
+ <mxCell id="lg4tx" value="GPIO control signal" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=11;" vertex="1" parent="1">
41
+ <mxGeometry x="722" y="88" width="170" height="24" as="geometry" />
42
+ </mxCell>
43
+
44
+ <mxCell id="lg5sw" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
45
+ <mxGeometry x="920" y="92" width="16" height="16" as="geometry" />
46
+ </mxCell>
47
+ <mxCell id="lg5tx" value="120 VAC power" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=11;" vertex="1" parent="1">
48
+ <mxGeometry x="942" y="88" width="150" height="24" as="geometry" />
49
+ </mxCell>
50
+
51
+ <mxCell id="lg6sw" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#666666;dashed=1;" vertex="1" parent="1">
52
+ <mxGeometry x="1140" y="92" width="16" height="16" as="geometry" />
53
+ </mxCell>
54
+ <mxCell id="lg6tx" value="Plumbing / mechanical" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=11;" vertex="1" parent="1">
55
+ <mxGeometry x="1162" y="88" width="180" height="24" as="geometry" />
56
+ </mxCell>
57
+
58
+ <!-- Lane: low-voltage control -->
59
+ <mxCell id="laneControl" value="Low-voltage control &amp; sensors" style="swimlane;html=1;startSize=34;fillColor=#f5f5f5;strokeColor=#666666;fontStyle=1;fontSize=13;rounded=0;" vertex="1" parent="1">
60
+ <mxGeometry x="40" y="130" width="620" height="580" as="geometry" />
61
+ </mxCell>
62
+
63
+ <mxCell id="pi" value="&lt;b&gt;Raspberry Pi 3&lt;/b&gt;&lt;br&gt;(controller)&lt;br&gt;&lt;br&gt;• hot-tub-control&lt;br&gt;• hot-tub-temps&lt;br&gt;• hot-tub-web&lt;br&gt;• Redis (localhost)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;spacingLeft=10;verticalAlign=top;spacingTop=8;" vertex="1" parent="laneControl">
64
+ <mxGeometry x="20" y="44" width="220" height="130" as="geometry" />
65
+ </mxCell>
66
+
67
+ <mxCell id="busbar" value="1-Wire bus — GPIO4 (BCM), 4.7 kΩ pull-up to 3.3V, shared GND" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=1;fontSize=11;verticalAlign=middle;" vertex="1" parent="laneControl">
68
+ <mxGeometry x="20" y="190" width="560" height="30" as="geometry" />
69
+ </mxCell>
70
+
71
+ <mxCell id="lbl_water" value="Water bundle — 4 probes (tub_avg_f)" style="text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontStyle=1;fontColor=#8a6d1a;" vertex="1" parent="laneControl">
72
+ <mxGeometry x="20" y="224" width="330" height="14" as="geometry" />
73
+ </mxCell>
74
+ <mxCell id="lbl_hotin" value="Hot-in bundle (hot_in_f)" style="text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontStyle=1;fontColor=#8a6d1a;" vertex="1" parent="laneControl">
75
+ <mxGeometry x="460" y="224" width="130" height="14" as="geometry" />
76
+ </mxCell>
77
+
78
+ <mxCell id="tub1" value="Tub 1&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="laneControl">
79
+ <mxGeometry x="20" y="242" width="84" height="56" as="geometry" />
80
+ </mxCell>
81
+ <mxCell id="tub2" value="Tub 2&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="laneControl">
82
+ <mxGeometry x="112" y="242" width="84" height="56" as="geometry" />
83
+ </mxCell>
84
+ <mxCell id="tub3" value="Tub 3&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="laneControl">
85
+ <mxGeometry x="204" y="242" width="84" height="56" as="geometry" />
86
+ </mxCell>
87
+ <mxCell id="tub4" value="Tub 4&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="laneControl">
88
+ <mxGeometry x="296" y="242" width="84" height="56" as="geometry" />
89
+ </mxCell>
90
+ <mxCell id="hotin" value="Hot-in&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6b3;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="laneControl">
91
+ <mxGeometry x="460" y="242" width="100" height="56" as="geometry" />
92
+ </mxCell>
93
+
94
+ <mxCell id="note_probes" value="5 physical DS18B20 probes share one 1-Wire bus (two pluggable M8/M12 bundles). Identity learned at install time — src/temp/README.md." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#999999;dashed=1;align=left;spacingLeft=10;verticalAlign=top;spacingTop=6;fontSize=10;fontColor=#555555;" vertex="1" parent="laneControl">
95
+ <mxGeometry x="20" y="308" width="560" height="46" as="geometry" />
96
+ </mxCell>
97
+
98
+ <mxCell id="future_label" value="FUTURE CONCERN — not yet installed" style="text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=11;fontStyle=1;fontColor=#9a5b00;" vertex="1" parent="laneControl">
99
+ <mxGeometry x="20" y="364" width="400" height="16" as="geometry" />
100
+ </mxCell>
101
+
102
+ <mxCell id="float" value="&lt;b&gt;Low-water float switch&lt;/b&gt;&lt;br&gt;Planned: GPIO17 → switch, other side → GND&lt;br&gt;&lt;i&gt;No hardware installed. Water level is presumed adequate in software (src/control/safety.py TODO).&lt;/i&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;dashed=1;dashPattern=4 4;align=left;spacingLeft=10;verticalAlign=top;spacingTop=8;fontSize=10;" vertex="1" parent="laneControl">
103
+ <mxGeometry x="20" y="384" width="270" height="110" as="geometry" />
104
+ </mxCell>
105
+
106
+ <mxCell id="flow" value="&lt;b&gt;Flow switch&lt;/b&gt; (optional)&lt;br&gt;Planned: GPIO27 → GND&lt;br&gt;&lt;i&gt;No hardware installed; not implemented.&lt;/i&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;dashed=1;dashPattern=4 4;align=left;spacingLeft=10;verticalAlign=top;spacingTop=8;fontSize=10;" vertex="1" parent="laneControl">
107
+ <mxGeometry x="310" y="384" width="270" height="80" as="geometry" />
108
+ </mxCell>
109
+
110
+ <mxCell id="gpio_out" value="&lt;b&gt;GPIO outputs&lt;/b&gt; — BCM22 → heat relay IN+ · BCM23 → filter relay IN+ · common GND to relay modules · active_high: true (config)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;spacingLeft=10;verticalAlign=middle;" vertex="1" parent="laneControl">
111
+ <mxGeometry x="20" y="504" width="560" height="50" as="geometry" />
112
+ </mxCell>
113
+
114
+ <!-- Lane: AC power -->
115
+ <mxCell id="laneAC" value="AC power &amp; pumps" style="swimlane;html=1;startSize=34;fillColor=#fff7f0;strokeColor=#d79b00;fontStyle=1;fontSize=13;rounded=0;" vertex="1" parent="1">
116
+ <mxGeometry x="700" y="130" width="620" height="580" as="geometry" />
117
+ </mxCell>
118
+
119
+ <mxCell id="gfci" value="&lt;b&gt;Outdoor outlet&lt;/b&gt;&lt;br&gt;+ GFCI protection" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;verticalAlign=middle;" vertex="1" parent="laneAC">
120
+ <mxGeometry x="20" y="44" width="160" height="60" as="geometry" />
121
+ </mxCell>
122
+
123
+ <mxCell id="cord" value="Outdoor-rated&lt;br&gt;extension cord" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;verticalAlign=middle;" vertex="1" parent="laneAC">
124
+ <mxGeometry x="210" y="44" width="150" height="60" as="geometry" />
125
+ </mxCell>
126
+
127
+ <mxCell id="relay1" value="&lt;b&gt;Enclosed AC relay #1&lt;/b&gt;&lt;br&gt;HEAT PUMP outlet&lt;br&gt;Low-voltage IN ← GPIO22" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;align=left;spacingLeft=8;verticalAlign=middle;" vertex="1" parent="laneAC">
128
+ <mxGeometry x="20" y="124" width="260" height="80" as="geometry" />
129
+ </mxCell>
130
+
131
+ <mxCell id="relay2" value="&lt;b&gt;Enclosed AC relay #2&lt;/b&gt;&lt;br&gt;FILTER PUMP outlet&lt;br&gt;Low-voltage IN ← GPIO23" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;align=left;spacingLeft=8;verticalAlign=middle;" vertex="1" parent="laneAC">
132
+ <mxGeometry x="310" y="124" width="260" height="80" as="geometry" />
133
+ </mxCell>
134
+
135
+ <mxCell id="pump_heat" value="&lt;b&gt;Heat circulation pump&lt;/b&gt;&lt;br&gt;120 VAC plug → relay #1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#b85450;align=left;spacingLeft=8;verticalAlign=middle;" vertex="1" parent="laneAC">
136
+ <mxGeometry x="20" y="224" width="260" height="90" as="geometry" />
137
+ </mxCell>
138
+
139
+ <mxCell id="pump_filt" value="&lt;b&gt;Filter pump&lt;/b&gt;&lt;br&gt;120 VAC plug → relay #2" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#b85450;align=left;spacingLeft=8;verticalAlign=middle;" vertex="1" parent="laneAC">
140
+ <mxGeometry x="310" y="224" width="260" height="70" as="geometry" />
141
+ </mxCell>
142
+
143
+ <mxCell id="heater" value="&lt;b&gt;Propane camp heater&lt;/b&gt;&lt;br&gt;Gas &amp; controls: manual&lt;br&gt;Ignites on water flow" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#666666;align=left;spacingLeft=8;verticalAlign=middle;" vertex="1" parent="laneAC">
144
+ <mxGeometry x="20" y="334" width="260" height="70" as="geometry" />
145
+ </mxCell>
146
+
147
+ <mxCell id="tub" value="&lt;b&gt;Stock-tank tub&lt;/b&gt;&lt;br&gt;(water reservoir)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#666666;align=left;spacingLeft=8;verticalAlign=middle;" vertex="1" parent="laneAC">
148
+ <mxGeometry x="310" y="334" width="260" height="70" as="geometry" />
149
+ </mxCell>
150
+
151
+ <mxCell id="note_plumb" value="&lt;i&gt;Plumbing is a closed loop:&lt;/i&gt; tub → pump intake → pump → (heater for the heat loop) → return to tub. AC relays switch pump power only — they never touch propane gas." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#999999;dashed=1;align=left;spacingLeft=10;verticalAlign=top;spacingTop=6;fontSize=11;fontColor=#555555;" vertex="1" parent="laneAC">
152
+ <mxGeometry x="20" y="424" width="550" height="90" as="geometry" />
153
+ </mxCell>
154
+
155
+ <!-- Edges: control/sensor side -->
156
+ <mxCell id="e_pi_busbar" value="3.3V / GND" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;endArrow=classic;endFill=1;fontSize=10;" edge="1" parent="1" source="pi" target="busbar">
157
+ <mxGeometry relative="1" as="geometry" />
158
+ </mxCell>
159
+ <mxCell id="e_tub1_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub1" target="busbar">
160
+ <mxGeometry relative="1" as="geometry" />
161
+ </mxCell>
162
+ <mxCell id="e_tub2_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub2" target="busbar">
163
+ <mxGeometry relative="1" as="geometry" />
164
+ </mxCell>
165
+ <mxCell id="e_tub3_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub3" target="busbar">
166
+ <mxGeometry relative="1" as="geometry" />
167
+ </mxCell>
168
+ <mxCell id="e_tub4_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub4" target="busbar">
169
+ <mxGeometry relative="1" as="geometry" />
170
+ </mxCell>
171
+ <mxCell id="e_hotin_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="hotin" target="busbar">
172
+ <mxGeometry relative="1" as="geometry" />
173
+ </mxCell>
174
+ <mxCell id="e_float_pi" value="GPIO17 (future)" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#9673a6;dashed=1;dashPattern=4 4;endArrow=classic;fontSize=10;" edge="1" parent="1" source="float" target="pi">
175
+ <mxGeometry relative="1" as="geometry" />
176
+ </mxCell>
177
+ <mxCell id="e_flow_pi" value="GPIO27 (optional)" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#9673a6;dashed=1;dashPattern=4 4;endArrow=classic;fontSize=10;" edge="1" parent="1" source="flow" target="pi">
178
+ <mxGeometry relative="1" as="geometry" />
179
+ </mxCell>
180
+ <mxCell id="e_pi_gpioout" value="GPIO22 / GPIO23" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#82b366;endArrow=classic;strokeWidth=2;fontSize=10;" edge="1" parent="1" source="pi" target="gpio_out">
181
+ <mxGeometry relative="1" as="geometry" />
182
+ </mxCell>
183
+
184
+ <!-- Edges: GPIO crossing to AC lane -->
185
+ <mxCell id="e_gpio_relay1" value="heat control" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#82b366;endArrow=classic;strokeWidth=2;fontSize=10;exitX=1;exitY=0.3;exitDx=0;exitDy=0;entryX=0;entryY=0.3;entryDx=0;entryDy=0;" edge="1" parent="1" source="gpio_out" target="relay1">
186
+ <mxGeometry relative="1" as="geometry" />
187
+ </mxCell>
188
+ <mxCell id="e_gpio_relay2" value="filter control" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#82b366;endArrow=classic;strokeWidth=2;fontSize=10;exitX=1;exitY=0.7;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="gpio_out" target="relay2">
189
+ <mxGeometry relative="1" as="geometry" />
190
+ </mxCell>
191
+
192
+ <!-- Edges: AC power chain -->
193
+ <mxCell id="e_gfci_cord" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="gfci" target="cord">
194
+ <mxGeometry relative="1" as="geometry" />
195
+ </mxCell>
196
+ <mxCell id="e_cord_relay1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="cord" target="relay1">
197
+ <mxGeometry relative="1" as="geometry" />
198
+ </mxCell>
199
+ <mxCell id="e_cord_relay2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="cord" target="relay2">
200
+ <mxGeometry relative="1" as="geometry" />
201
+ </mxCell>
202
+ <mxCell id="e_relay1_pump" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="relay1" target="pump_heat">
203
+ <mxGeometry relative="1" as="geometry" />
204
+ </mxCell>
205
+ <mxCell id="e_relay2_pump" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="relay2" target="pump_filt">
206
+ <mxGeometry relative="1" as="geometry" />
207
+ </mxCell>
208
+
209
+ <!-- Edges: plumbing loop -->
210
+ <mxCell id="e_pumpheat_heater" value="hot water" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;dashed=1;endArrow=classic;fontSize=10;" edge="1" parent="1" source="pump_heat" target="heater">
211
+ <mxGeometry relative="1" as="geometry" />
212
+ </mxCell>
213
+ <mxCell id="e_heater_tub" value="return" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;dashed=1;endArrow=classic;fontSize=10;" edge="1" parent="1" source="heater" target="tub">
214
+ <mxGeometry relative="1" as="geometry" />
215
+ </mxCell>
216
+ <mxCell id="e_tub_pumpheat" value="intake" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;dashed=1;endArrow=classic;fontSize=10;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=1;entryY=1;entryDx=0;entryDy=0;" edge="1" parent="1" source="tub" target="pump_heat">
217
+ <mxGeometry relative="1" as="geometry" />
218
+ </mxCell>
219
+ <mxCell id="e_tub_pumpfilt" value="intake" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;dashed=1;endArrow=classic;fontSize=10;" edge="1" parent="1" source="tub" target="pump_filt">
220
+ <mxGeometry relative="1" as="geometry" />
221
+ </mxCell>
222
+ <mxCell id="e_pumpfilt_tub" value="filtered return" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;dashed=1;endArrow=classic;fontSize=10;exitX=1;exitY=0.3;exitDx=0;exitDy=0;entryX=1;entryY=0.3;entryDx=0;entryDy=0;" edge="1" parent="1" source="pump_filt" target="tub">
223
+ <mxGeometry relative="1" as="geometry" />
224
+ </mxCell>
225
+
226
+ <!-- GPIO summary table -->
227
+ <mxCell id="table" value="&lt;b&gt;GPIO summary (BCM numbering)&lt;/b&gt;&lt;br&gt;&lt;table cellpadding=&quot;4&quot; style=&quot;font-size:11px&quot;&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;Function&lt;/b&gt;&lt;/td&gt;&lt;td&gt;&lt;b&gt;GPIO&lt;/b&gt;&lt;/td&gt;&lt;td&gt;&lt;b&gt;Notes&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Heat relay&lt;/td&gt;&lt;td&gt;22&lt;/td&gt;&lt;td&gt;control daemon (config.json: gpio.heat_pump)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Filter relay&lt;/td&gt;&lt;td&gt;23&lt;/td&gt;&lt;td&gt;control daemon (config.json: gpio.filter_pump)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;1-Wire data (×5 probes)&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;w1-gpio overlay; 4.7kΩ pull-up to 3.3V; 4 tub + 1 hot-in&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Low-water float&lt;/td&gt;&lt;td&gt;17&lt;/td&gt;&lt;td&gt;&lt;b&gt;FUTURE — no hardware installed&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Flow switch&lt;/td&gt;&lt;td&gt;27&lt;/td&gt;&lt;td&gt;optional, not implemented&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&lt;br&gt;&lt;b&gt;Note:&lt;/b&gt; the shipped &lt;code&gt;hot-tub-temps&lt;/code&gt; daemon reads the Pi's own 1-Wire sysfs directly — no separate USB/UART sensor hub is used." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#999999;align=left;spacingLeft=10;verticalAlign=top;spacingTop=8;" vertex="1" parent="1">
228
+ <mxGeometry x="40" y="730" width="1280" height="150" as="geometry" />
229
+ </mxCell>
230
+
231
+ <mxCell id="warn" value="⚠ Pi does not power pumps. Use GFCI protection. Drip loops on every plug. Never exceed the amp rating of the relay module, cord, or outlet. Do not automate the propane gas valve. Water level is currently software-presumed adequate — add the float switch before leaving the tub unattended." style="text;html=1;strokeColor=none;fillColor=#fff2cc;align=center;verticalAlign=middle;fontSize=11;fontStyle=1" vertex="1" parent="1">
232
+ <mxGeometry x="40" y="900" width="1280" height="30" as="geometry" />
233
+ </mxCell>
234
+
235
+ </root>
236
+ </mxGraphModel>
237
+ </diagram>
238
+</mxfile>
projects/hot-tub/diagrams/hot-tub-electrical-v3.drawio
... ...
@@ -0,0 +1,260 @@
1
+<mxfile host="app.diagrams.net" modified="2026-10-22T17:48:00.000Z" agent="cursor-agent" version="24.0.0" type="device">
2
+ <diagram id="hotTubElectricalV3" name="Hot Tub Electrical v3">
3
+ <mxGraphModel dx="2200" dy="1400" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1800" pageHeight="1350" math="0" shadow="0">
4
+ <root>
5
+ <mxCell id="0" />
6
+ <mxCell id="1" parent="0" />
7
+
8
+ <!-- ===== Title ===== -->
9
+ <mxCell id="title1" value="Hot Tub Electrical Circuit Diagram — v3 (fresh design)" style="text;html=1;align=center;verticalAlign=middle;fontSize=22;fontStyle=1;fillColor=none;strokeColor=none;" vertex="1" parent="1">
10
+ <mxGeometry x="40" y="10" width="1710" height="40" as="geometry" />
11
+ </mxCell>
12
+
13
+ <!-- ===== Section A: AC Power Path ===== -->
14
+ <mxCell id="secA_bg" value="AC Power Path — 120V AC, GFCI Protected" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;align=left;fontSize=14;fontStyle=1;fillColor=#FFE6CC;strokeColor=#D79B00;spacingLeft=14;spacingTop=10;" vertex="1" parent="1">
15
+ <mxGeometry x="40" y="60" width="1710" height="260" as="geometry" />
16
+ </mxCell>
17
+
18
+ <mxCell id="houseOutlet" value="House / Outdoor&#10;Outlet&#10;(120V AC)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#D79B00;" vertex="1" parent="1">
19
+ <mxGeometry x="80" y="160" width="160" height="70" as="geometry" />
20
+ </mxCell>
21
+
22
+ <mxCell id="gfci" value="GFCI Protection&#10;(outlet, breaker, or&#10;inline GFCI adapter)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#D79B00;" vertex="1" parent="1">
23
+ <mxGeometry x="300" y="160" width="160" height="70" as="geometry" />
24
+ </mxCell>
25
+
26
+ <mxCell id="extCord" value="Outdoor-Rated&#10;Extension Cord" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#D79B00;" vertex="1" parent="1">
27
+ <mxGeometry x="520" y="160" width="180" height="70" as="geometry" />
28
+ </mxCell>
29
+
30
+ <mxCell id="relay1" value="Enclosed Relay Module 1&#10;(low-voltage control input)&#10;&quot;HEAT PUMP OUTLET&quot;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#D79B00;" vertex="1" parent="1">
31
+ <mxGeometry x="760" y="110" width="200" height="70" as="geometry" />
32
+ </mxCell>
33
+
34
+ <mxCell id="relay2" value="Enclosed Relay Module 2&#10;(low-voltage control input)&#10;&quot;FILTER PUMP OUTLET&quot;" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#D79B00;" vertex="1" parent="1">
35
+ <mxGeometry x="760" y="220" width="200" height="70" as="geometry" />
36
+ </mxCell>
37
+
38
+ <mxCell id="heatPump" value="Heat Circulation Pump&#10;(2.64 gpm)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#D79B00;" vertex="1" parent="1">
39
+ <mxGeometry x="1020" y="110" width="180" height="70" as="geometry" />
40
+ </mxCell>
41
+
42
+ <mxCell id="filterPump" value="Filter Pump&#10;(1000 gph)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#D79B00;" vertex="1" parent="1">
43
+ <mxGeometry x="1020" y="220" width="180" height="70" as="geometry" />
44
+ </mxCell>
45
+
46
+ <mxCell id="propaneHeater" value="Propane Heater&#10;(manual gas &amp; water&#10;controls)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#F5F5F5;strokeColor=#666666;" vertex="1" parent="1">
47
+ <mxGeometry x="1260" y="110" width="180" height="70" as="geometry" />
48
+ </mxCell>
49
+
50
+ <mxCell id="tub" value="Stock Tank&#10;(6' x 2' x 2')" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#F5F5F5;strokeColor=#666666;" vertex="1" parent="1">
51
+ <mxGeometry x="1500" y="165" width="180" height="70" as="geometry" />
52
+ </mxCell>
53
+
54
+ <!-- AC path edges -->
55
+ <mxCell id="e_house_gfci" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#D79B00;strokeWidth=2;rounded=0;" edge="1" parent="1" source="houseOutlet" target="gfci">
56
+ <mxGeometry relative="1" as="geometry" />
57
+ </mxCell>
58
+ <mxCell id="e_gfci_cord" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#D79B00;strokeWidth=2;rounded=0;" edge="1" parent="1" source="gfci" target="extCord">
59
+ <mxGeometry relative="1" as="geometry" />
60
+ </mxCell>
61
+ <mxCell id="e_cord_relay1" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#D79B00;strokeWidth=2;rounded=0;exitX=1;exitY=0.25;entryX=0;entryY=0.5;" edge="1" parent="1" source="extCord" target="relay1">
62
+ <mxGeometry relative="1" as="geometry" />
63
+ </mxCell>
64
+ <mxCell id="e_cord_relay2" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#D79B00;strokeWidth=2;rounded=0;exitX=1;exitY=0.75;entryX=0;entryY=0.5;" edge="1" parent="1" source="extCord" target="relay2">
65
+ <mxGeometry relative="1" as="geometry" />
66
+ </mxCell>
67
+ <mxCell id="e_relay1_heatpump" value="switched 120V" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#D79B00;strokeWidth=2;rounded=0;fontSize=10;" edge="1" parent="1" source="relay1" target="heatPump">
68
+ <mxGeometry relative="1" as="geometry" />
69
+ </mxCell>
70
+ <mxCell id="e_relay2_filterpump" value="switched 120V" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#D79B00;strokeWidth=2;rounded=0;fontSize=10;" edge="1" parent="1" source="relay2" target="filterPump">
71
+ <mxGeometry relative="1" as="geometry" />
72
+ </mxCell>
73
+
74
+ <!-- Plumbing context edges (non-electrical, for reference only) -->
75
+ <mxCell id="e_heatpump_heater" value="water flow" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#999999;dashed=1;dashPattern=1 3;endArrow=open;fontSize=9;fontColor=#666666;" edge="1" parent="1" source="heatPump" target="propaneHeater">
76
+ <mxGeometry relative="1" as="geometry" />
77
+ </mxCell>
78
+ <mxCell id="e_heater_tub" value="hot return" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#999999;dashed=1;dashPattern=1 3;endArrow=open;fontSize=9;fontColor=#666666;exitX=1;exitY=0.5;entryX=0.5;entryY=0;" edge="1" parent="1" source="propaneHeater" target="tub">
79
+ <mxGeometry relative="1" as="geometry" />
80
+ </mxCell>
81
+ <mxCell id="e_filterpump_tub" value="filter loop (intake &amp; return)" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#999999;dashed=1;dashPattern=1 3;startArrow=open;endArrow=open;fontSize=9;fontColor=#666666;exitX=1;exitY=0.5;entryX=0.5;entryY=1;" edge="1" parent="1" source="filterPump" target="tub">
82
+ <mxGeometry relative="1" as="geometry" />
83
+ </mxCell>
84
+
85
+ <!-- ===== Section B: Low-Voltage Control (Raspberry Pi) ===== -->
86
+ <mxCell id="secB_bg" value="Low-Voltage Control — Raspberry Pi GPIO" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;align=left;fontSize=14;fontStyle=1;fillColor=#DAE8FC;strokeColor=#6C8EBF;spacingLeft=14;spacingTop=10;" vertex="1" parent="1">
87
+ <mxGeometry x="40" y="340" width="1710" height="220" as="geometry" />
88
+ </mxCell>
89
+
90
+ <mxCell id="piPower" value="5V USB Power Supply&#10;(separate wall outlet,&#10;not through relay)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;fillColor=#FFFFFF;strokeColor=#1F77B4;" vertex="1" parent="1">
91
+ <mxGeometry x="80" y="420" width="180" height="70" as="geometry" />
92
+ </mxCell>
93
+
94
+ <mxCell id="pi" value="Raspberry Pi&#10;&#10;GPIO4 = 1-Wire data bus&#10;GPIO22 = Heat relay control&#10;GPIO23 = Filter relay control&#10;GPIO17 = Float switch (future)&#10;GPIO27 = Flow switch (optional)&#10;3.3V / GND = sensor power" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;align=left;spacingLeft=8;fillColor=#FFFFFF;strokeColor=#1F77B4;strokeWidth=2;" vertex="1" parent="1">
95
+ <mxGeometry x="360" y="390" width="260" height="140" as="geometry" />
96
+ </mxCell>
97
+
98
+ <mxCell id="noteB" value="Blue lines above connect GPIO22 / GPIO23 to the relay modules' low-voltage control inputs in the AC Power Path section. The Pi never switches 120V AC directly — it only triggers the enclosed relay modules." style="text;html=1;align=left;verticalAlign=middle;fontSize=11;fontStyle=2;fillColor=none;strokeColor=none;whiteSpace=wrap;" vertex="1" parent="1">
99
+ <mxGeometry x="680" y="420" width="1000" height="60" as="geometry" />
100
+ </mxCell>
101
+
102
+ <mxCell id="e_pipower_pi" value="5V" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#1F77B4;strokeWidth=2;rounded=0;fontSize=10;" edge="1" parent="1" source="piPower" target="pi">
103
+ <mxGeometry relative="1" as="geometry" />
104
+ </mxCell>
105
+
106
+ <mxCell id="e_pi_relay1_ctrl" value="GPIO22 control&#10;(low-voltage)" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#1F77B4;strokeWidth=2;rounded=0;fontSize=10;exitX=0.65;exitY=0;entryX=0.15;entryY=1;" edge="1" parent="1" source="pi" target="relay1">
107
+ <mxGeometry relative="1" as="geometry" />
108
+ </mxCell>
109
+
110
+ <mxCell id="e_pi_relay2_ctrl" value="GPIO23 control&#10;(low-voltage)" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#1F77B4;strokeWidth=2;rounded=0;fontSize=10;exitX=0.85;exitY=0;entryX=0.15;entryY=1;" edge="1" parent="1" source="pi" target="relay2">
111
+ <mxGeometry relative="1" as="geometry" />
112
+ </mxCell>
113
+
114
+ <!-- ===== Section C: 1-Wire Temperature Sensor Bus ===== -->
115
+ <mxCell id="secC_bg" value="1-Wire Temperature Sensor Bus — GPIO4" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;align=left;fontSize=14;fontStyle=1;fillColor=#D5E8D4;strokeColor=#82B366;spacingLeft=14;spacingTop=10;" vertex="1" parent="1">
116
+ <mxGeometry x="40" y="580" width="1710" height="380" as="geometry" />
117
+ </mxCell>
118
+
119
+ <mxCell id="resistor" value="4.7kΩ Pull-up&#10;Resistor" style="rounded=0;whiteSpace=wrap;html=1;fontSize=11;fillColor=#FFFFFF;strokeColor=#2CA02C;" vertex="1" parent="1">
120
+ <mxGeometry x="660" y="630" width="160" height="50" as="geometry" />
121
+ </mxCell>
122
+
123
+ <mxCell id="junction" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=#000000;strokeColor=#000000;resizable=0;" vertex="1" parent="1">
124
+ <mxGeometry x="732" y="700" width="16" height="16" as="geometry" />
125
+ </mxCell>
126
+
127
+ <mxCell id="bus" value="Shared 1-Wire Bus (GPIO4 Data + 3.3V + GND)" style="rounded=0;whiteSpace=wrap;html=1;fontSize=11;fillColor=#FFFFFF;strokeColor=#2CA02C;strokeWidth=2;" vertex="1" parent="1">
128
+ <mxGeometry x="700" y="750" width="960" height="40" as="geometry" />
129
+ </mxCell>
130
+
131
+ <mxCell id="labelHotInNote" value="Not included in tub average" style="text;html=1;align=center;verticalAlign=middle;fontSize=10;fontStyle=2;fillColor=none;strokeColor=none;whiteSpace=wrap;" vertex="1" parent="1">
132
+ <mxGeometry x="700" y="805" width="170" height="20" as="geometry" />
133
+ </mxCell>
134
+
135
+ <mxCell id="labelTubAvg" value="4 Tub Probes — averaged for tub temperature (software flags disagreement between probes)" style="text;html=1;align=center;verticalAlign=middle;fontSize=10;fontStyle=2;fillColor=none;strokeColor=none;whiteSpace=wrap;" vertex="1" parent="1">
136
+ <mxGeometry x="900" y="805" width="760" height="20" as="geometry" />
137
+ </mxCell>
138
+
139
+ <mxCell id="probeHotIn" value="DS18B20&#10;Hot-Water-Input Probe&#10;(hot_in_f)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#FFFFFF;strokeColor=#2CA02C;" vertex="1" parent="1">
140
+ <mxGeometry x="700" y="830" width="170" height="80" as="geometry" />
141
+ </mxCell>
142
+
143
+ <mxCell id="probeTub1" value="DS18B20&#10;Tub Probe 1&#10;(hot-return side)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#FFFFFF;strokeColor=#2CA02C;" vertex="1" parent="1">
144
+ <mxGeometry x="900" y="830" width="170" height="80" as="geometry" />
145
+ </mxCell>
146
+
147
+ <mxCell id="probeTub2" value="DS18B20&#10;Tub Probe 2&#10;(opposite side)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#FFFFFF;strokeColor=#2CA02C;" vertex="1" parent="1">
148
+ <mxGeometry x="1100" y="830" width="170" height="80" as="geometry" />
149
+ </mxCell>
150
+
151
+ <mxCell id="probeTub3" value="DS18B20&#10;Tub Probe 3&#10;(near end)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#FFFFFF;strokeColor=#2CA02C;" vertex="1" parent="1">
152
+ <mxGeometry x="1300" y="830" width="170" height="80" as="geometry" />
153
+ </mxCell>
154
+
155
+ <mxCell id="probeTub4" value="DS18B20&#10;Tub Probe 4&#10;(far end)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#FFFFFF;strokeColor=#2CA02C;" vertex="1" parent="1">
156
+ <mxGeometry x="1500" y="830" width="170" height="80" as="geometry" />
157
+ </mxCell>
158
+
159
+ <!-- Sensor bus edges -->
160
+ <mxCell id="e_pi_junction" value="GPIO4 (1-Wire Data)" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=2;rounded=0;fontSize=10;exitX=0.3;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="pi" target="junction">
161
+ <mxGeometry relative="1" as="geometry" />
162
+ </mxCell>
163
+
164
+ <mxCell id="e_junction_resistor" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=2;rounded=0;exitX=0.5;exitY=0;entryX=0.3;entryY=1;" edge="1" parent="1" source="junction" target="resistor">
165
+ <mxGeometry relative="1" as="geometry" />
166
+ </mxCell>
167
+
168
+ <mxCell id="e_resistor_pi3v3" value="3.3V" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=2;rounded=0;fontSize=10;exitX=0.3;exitY=0;entryX=0.45;entryY=1;" edge="1" parent="1" source="resistor" target="pi">
169
+ <mxGeometry relative="1" as="geometry" />
170
+ </mxCell>
171
+
172
+ <mxCell id="e_junction_bus" value="data" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=2;rounded=0;fontSize=10;exitX=1;exitY=0.5;entryX=0.05;entryY=0;" edge="1" parent="1" source="junction" target="bus">
173
+ <mxGeometry relative="1" as="geometry" />
174
+ </mxCell>
175
+
176
+ <mxCell id="e_pi_gnd_bus" value="GND" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=2;rounded=0;fontSize=10;exitX=0.55;exitY=1;entryX=0.95;entryY=0;" edge="1" parent="1" source="pi" target="bus">
177
+ <mxGeometry relative="1" as="geometry" />
178
+ </mxCell>
179
+
180
+ <mxCell id="e_bus_probeHotIn" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=1;rounded=0;endArrow=none;startArrow=none;" edge="1" parent="1" source="bus" target="probeHotIn">
181
+ <mxGeometry relative="1" as="geometry" />
182
+ </mxCell>
183
+ <mxCell id="e_bus_probeTub1" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=1;rounded=0;endArrow=none;startArrow=none;" edge="1" parent="1" source="bus" target="probeTub1">
184
+ <mxGeometry relative="1" as="geometry" />
185
+ </mxCell>
186
+ <mxCell id="e_bus_probeTub2" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=1;rounded=0;endArrow=none;startArrow=none;" edge="1" parent="1" source="bus" target="probeTub2">
187
+ <mxGeometry relative="1" as="geometry" />
188
+ </mxCell>
189
+ <mxCell id="e_bus_probeTub3" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=1;rounded=0;endArrow=none;startArrow=none;" edge="1" parent="1" source="bus" target="probeTub3">
190
+ <mxGeometry relative="1" as="geometry" />
191
+ </mxCell>
192
+ <mxCell id="e_bus_probeTub4" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#2CA02C;strokeWidth=1;rounded=0;endArrow=none;startArrow=none;" edge="1" parent="1" source="bus" target="probeTub4">
193
+ <mxGeometry relative="1" as="geometry" />
194
+ </mxCell>
195
+
196
+ <!-- ===== Section D: Future / Optional additions ===== -->
197
+ <mxCell id="secD_bg" value="Future / Optional — Not Currently Installed" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;align=left;fontSize=14;fontStyle=1;fillColor=#F5F5F5;strokeColor=#666666;dashed=1;spacingLeft=14;spacingTop=10;" vertex="1" parent="1">
198
+ <mxGeometry x="40" y="980" width="1710" height="160" as="geometry" />
199
+ </mxCell>
200
+
201
+ <mxCell id="floatSwitch" value="Low-Water Float Switch&#10;GPIO17 — FUTURE&#10;(not installed; water level&#10;presumed adequate today)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#F5F5F5;strokeColor=#666666;dashed=1;" vertex="1" parent="1">
202
+ <mxGeometry x="300" y="1030" width="240" height="80" as="geometry" />
203
+ </mxCell>
204
+
205
+ <mxCell id="flowSwitch" value="Inline Flow Switch&#10;GPIO27 — OPTIONAL&#10;(not installed)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#F5F5F5;strokeColor=#666666;dashed=1;" vertex="1" parent="1">
206
+ <mxGeometry x="620" y="1030" width="240" height="80" as="geometry" />
207
+ </mxCell>
208
+
209
+ <mxCell id="outsideTemp" value="Outside Air DS18B20 Probe&#10;OPTIONAL — would share&#10;the 1-Wire bus above&#10;(not installed; for future&#10;anti-freeze logic)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=11;fillColor=#F5F5F5;strokeColor=#666666;dashed=1;" vertex="1" parent="1">
210
+ <mxGeometry x="940" y="1030" width="260" height="80" as="geometry" />
211
+ </mxCell>
212
+
213
+ <mxCell id="e_bus_outsideTemp" value="optional bus tap" style="edgeStyle=orthogonalEdgeStyle;html=1;strokeColor=#666666;dashed=1;dashPattern=2 2;rounded=0;fontSize=9;exitX=0.9;exitY=1;entryX=0.5;entryY=0;" edge="1" parent="1" source="bus" target="outsideTemp">
214
+ <mxGeometry relative="1" as="geometry" />
215
+ </mxCell>
216
+
217
+ <!-- ===== Legend ===== -->
218
+ <mxCell id="legend_bg" value="Legend" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;align=left;fontSize=14;fontStyle=1;fillColor=#FFFFFF;strokeColor=#000000;spacingLeft=14;spacingTop=10;" vertex="1" parent="1">
219
+ <mxGeometry x="40" y="1160" width="620" height="170" as="geometry" />
220
+ </mxCell>
221
+
222
+ <mxCell id="legendSwatch1" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#D79B00;strokeColor=none;" vertex="1" parent="1">
223
+ <mxGeometry x="60" y="1198" width="40" height="10" as="geometry" />
224
+ </mxCell>
225
+ <mxCell id="legendText1" value="120V AC mains (GFCI protected)" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fillColor=none;strokeColor=none;" vertex="1" parent="1">
226
+ <mxGeometry x="115" y="1190" width="500" height="26" as="geometry" />
227
+ </mxCell>
228
+
229
+ <mxCell id="legendSwatch2" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#1F77B4;strokeColor=none;" vertex="1" parent="1">
230
+ <mxGeometry x="60" y="1228" width="40" height="10" as="geometry" />
231
+ </mxCell>
232
+ <mxCell id="legendText2" value="Low-voltage DC — Pi power &amp; relay control signals" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fillColor=none;strokeColor=none;" vertex="1" parent="1">
233
+ <mxGeometry x="115" y="1220" width="500" height="26" as="geometry" />
234
+ </mxCell>
235
+
236
+ <mxCell id="legendSwatch3" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#2CA02C;strokeColor=none;" vertex="1" parent="1">
237
+ <mxGeometry x="60" y="1258" width="40" height="10" as="geometry" />
238
+ </mxCell>
239
+ <mxCell id="legendText3" value="1-Wire sensor bus (3.3V / GND / GPIO4 data)" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fillColor=none;strokeColor=none;" vertex="1" parent="1">
240
+ <mxGeometry x="115" y="1250" width="500" height="26" as="geometry" />
241
+ </mxCell>
242
+
243
+ <mxCell id="legendSwatch4" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#999999;dashed=1;" vertex="1" parent="1">
244
+ <mxGeometry x="60" y="1288" width="40" height="10" as="geometry" />
245
+ </mxCell>
246
+ <mxCell id="legendText4" value="Plumbing / water flow (non-electrical, shown for context)" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fillColor=none;strokeColor=none;" vertex="1" parent="1">
247
+ <mxGeometry x="115" y="1280" width="500" height="26" as="geometry" />
248
+ </mxCell>
249
+
250
+ <mxCell id="legendSwatch5" value="" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#F5F5F5;strokeColor=#666666;dashed=1;" vertex="1" parent="1">
251
+ <mxGeometry x="60" y="1313" width="40" height="10" as="geometry" />
252
+ </mxCell>
253
+ <mxCell id="legendText5" value="Future / optional — not currently installed" style="text;html=1;align=left;verticalAlign=middle;fontSize=12;fillColor=none;strokeColor=none;" vertex="1" parent="1">
254
+ <mxGeometry x="115" y="1305" width="500" height="26" as="geometry" />
255
+ </mxCell>
256
+
257
+ </root>
258
+ </mxGraphModel>
259
+ </diagram>
260
+</mxfile>
projects/hot-tub/diagrams/hot-tub-electrical.drawio
... ...
@@ -0,0 +1,180 @@
1
+<mxfile host="app.diagrams.net" modified="2026-10-22T18:00:00.000Z" agent="Cursor" version="22.1.0" type="device">
2
+ <diagram id="hot-tub-electrical" name="Hot tub electrical">
3
+ <mxGraphModel dx="1200" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="940" math="0" shadow="0">
4
+ <root>
5
+ <mxCell id="0" />
6
+ <mxCell id="1" parent="0" />
7
+
8
+ <mxCell id="title" value="Hot tub — electrical wiring (control side)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=18;fontStyle=1" vertex="1" parent="1">
9
+ <mxGeometry x="250" y="20" width="700" height="40" as="geometry" />
10
+ </mxCell>
11
+ <mxCell id="subtitle" value="Matches hardware-setup.md and control.json (GPIO22 heat, GPIO23 filter, GPIO4 1-Wire). All 5 DS18B20 probes read directly on the Pi's own 1-Wire bus. Low-water float is a FUTURE addition — not installed, no GPIO wired today." style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;fontSize=11;fontColor=#666666;" vertex="1" parent="1">
12
+ <mxGeometry x="100" y="56" width="1000" height="34" as="geometry" />
13
+ </mxCell>
14
+
15
+ <mxCell id="enc" value="Waterproof enclosure — keep low-voltage wiring separate from AC pump cords" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;dashed=1;dashPattern=8 8;verticalAlign=top;fontStyle=1;spacingTop=8;" vertex="1" parent="1">
16
+ <mxGeometry x="40" y="100" width="580" height="600" as="geometry" />
17
+ </mxCell>
18
+
19
+ <mxCell id="pi" value="&lt;b&gt;Raspberry Pi 3&lt;/b&gt;&lt;br&gt;(controller)&lt;br&gt;&lt;br&gt;• hot-tub-control&lt;br&gt;• hot-tub-temps&lt;br&gt;• hot-tub-web&lt;br&gt;• Redis (localhost)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;spacingLeft=10;verticalAlign=top;spacingTop=8;" vertex="1" parent="1">
20
+ <mxGeometry x="70" y="150" width="200" height="130" as="geometry" />
21
+ </mxCell>
22
+
23
+ <mxCell id="busbar" value="1-Wire bus — GPIO4 (BCM), 4.7 kΩ pull-up to 3.3V, shared GND" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=1;fontSize=11;" vertex="1" parent="1">
24
+ <mxGeometry x="70" y="300" width="510" height="34" as="geometry" />
25
+ </mxCell>
26
+
27
+ <mxCell id="lbl_water" value="Water bundle — 4 probes (tub_avg_f)" style="text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontStyle=1;fontColor=#8a6d1a;" vertex="1" parent="1">
28
+ <mxGeometry x="70" y="348" width="320" height="16" as="geometry" />
29
+ </mxCell>
30
+ <mxCell id="lbl_hotin" value="Hot-in bundle — 1 probe (hot_in_f)" style="text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontStyle=1;fontColor=#8a6d1a;" vertex="1" parent="1">
31
+ <mxGeometry x="400" y="348" width="180" height="16" as="geometry" />
32
+ </mxCell>
33
+
34
+ <mxCell id="tub1" value="Tub probe 1&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
35
+ <mxGeometry x="70" y="368" width="90" height="56" as="geometry" />
36
+ </mxCell>
37
+ <mxCell id="tub2" value="Tub probe 2&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
38
+ <mxGeometry x="170" y="368" width="90" height="56" as="geometry" />
39
+ </mxCell>
40
+ <mxCell id="tub3" value="Tub probe 3&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
41
+ <mxGeometry x="270" y="368" width="90" height="56" as="geometry" />
42
+ </mxCell>
43
+ <mxCell id="tub4" value="Tub probe 4&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
44
+ <mxGeometry x="370" y="368" width="90" height="56" as="geometry" />
45
+ </mxCell>
46
+ <mxCell id="hotin" value="Hot-in probe&lt;br&gt;DS18B20" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6b3;strokeColor=#d6b656;fontSize=10;" vertex="1" parent="1">
47
+ <mxGeometry x="490" y="368" width="90" height="56" as="geometry" />
48
+ </mxCell>
49
+
50
+ <mxCell id="note_probes" value="5 physical DS18B20 probes share one 1-Wire bus, wired as two pluggable bundles (M8/M12 drops). Identity (which ROM id is which role) is learned at install time — see src/temp/README.md." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#999999;dashed=1;align=left;spacingLeft=10;verticalAlign=top;spacingTop=6;fontSize=10;fontColor=#555555;" vertex="1" parent="1">
51
+ <mxGeometry x="70" y="434" width="510" height="56" as="geometry" />
52
+ </mxCell>
53
+
54
+ <mxCell id="future_box" value="FUTURE CONCERN — not yet installed" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fdf0e6;strokeColor=#d79b00;dashed=1;dashPattern=6 6;verticalAlign=top;fontStyle=1;fontSize=12;fontColor=#9a5b00;spacingTop=6;align=center;" vertex="1" parent="1">
55
+ <mxGeometry x="70" y="500" width="510" height="130" as="geometry" />
56
+ </mxCell>
57
+
58
+ <mxCell id="float" value="&lt;b&gt;Low-water float switch&lt;/b&gt;&lt;br&gt;Planned: GPIO17 → switch, other side → GND&lt;br&gt;&lt;i&gt;No hardware installed. Water level is presumed adequate in software (src/control/safety.py TODO). Not wired into the control daemon.&lt;/i&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;dashed=1;dashPattern=4 4;align=left;spacingLeft=10;verticalAlign=top;spacingTop=6;fontSize=10;" vertex="1" parent="1">
59
+ <mxGeometry x="90" y="530" width="240" height="90" as="geometry" />
60
+ </mxCell>
61
+
62
+ <mxCell id="flow" value="&lt;b&gt;Flow switch&lt;/b&gt; (optional)&lt;br&gt;Planned: GPIO27 → GND&lt;br&gt;&lt;i&gt;No hardware installed; not implemented.&lt;/i&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;dashed=1;dashPattern=4 4;align=left;spacingLeft=10;verticalAlign=top;spacingTop=6;fontSize=10;" vertex="1" parent="1">
63
+ <mxGeometry x="350" y="530" width="210" height="90" as="geometry" />
64
+ </mxCell>
65
+
66
+ <mxCell id="gpio_out" value="&lt;b&gt;GPIO outputs&lt;/b&gt; — BCM22 → heat relay IN+ · BCM23 → filter relay IN+ · common GND to relay modules · active_high: true (config)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;spacingLeft=10;verticalAlign=middle;" vertex="1" parent="1">
67
+ <mxGeometry x="70" y="650" width="510" height="34" as="geometry" />
68
+ </mxCell>
69
+
70
+ <mxCell id="e_pi_bus" value="3.3V + GND" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;endArrow=classic;endFill=1;fontSize=10;" edge="1" parent="1" source="pi" target="busbar">
71
+ <mxGeometry relative="1" as="geometry" />
72
+ </mxCell>
73
+ <mxCell id="e_tub1_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub1" target="busbar">
74
+ <mxGeometry relative="1" as="geometry" />
75
+ </mxCell>
76
+ <mxCell id="e_tub2_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub2" target="busbar">
77
+ <mxGeometry relative="1" as="geometry" />
78
+ </mxCell>
79
+ <mxCell id="e_tub3_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub3" target="busbar">
80
+ <mxGeometry relative="1" as="geometry" />
81
+ </mxCell>
82
+ <mxCell id="e_tub4_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="tub4" target="busbar">
83
+ <mxGeometry relative="1" as="geometry" />
84
+ </mxCell>
85
+ <mxCell id="e_hotin_bus" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#d6b656;startArrow=none;endArrow=none;" edge="1" parent="1" source="hotin" target="busbar">
86
+ <mxGeometry relative="1" as="geometry" />
87
+ </mxCell>
88
+
89
+ <mxCell id="e_float_pi" value="GPIO17 (future)" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#9673a6;dashed=1;dashPattern=4 4;endArrow=classic;fontSize=9;" edge="1" parent="1" source="float" target="pi">
90
+ <mxGeometry relative="1" as="geometry">
91
+ <Array as="points">
92
+ <mxPoint x="30" y="575" />
93
+ <mxPoint x="30" y="215" />
94
+ </Array>
95
+ </mxGeometry>
96
+ </mxCell>
97
+
98
+ <mxCell id="ac_box" value="AC side (enclosed relay modules or outdoor-rated location)" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;dashed=1;dashPattern=8 8;verticalAlign=top;fontStyle=1;spacingTop=8;" vertex="1" parent="1">
99
+ <mxGeometry x="650" y="100" width="460" height="420" as="geometry" />
100
+ </mxCell>
101
+
102
+ <mxCell id="gfci" value="&lt;b&gt;Outdoor outlet&lt;/b&gt;&lt;br&gt;+ GFCI protection" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
103
+ <mxGeometry x="690" y="140" width="160" height="60" as="geometry" />
104
+ </mxCell>
105
+
106
+ <mxCell id="cord" value="Outdoor-rated&lt;br&gt;extension cord" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
107
+ <mxGeometry x="890" y="140" width="120" height="60" as="geometry" />
108
+ </mxCell>
109
+
110
+ <mxCell id="relay1" value="&lt;b&gt;Enclosed AC relay #1&lt;/b&gt;&lt;br&gt;HEAT PUMP outlet&lt;br&gt;Low-voltage IN ← GPIO22" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;align=left;spacingLeft=8;" vertex="1" parent="1">
111
+ <mxGeometry x="690" y="240" width="200" height="80" as="geometry" />
112
+ </mxCell>
113
+
114
+ <mxCell id="relay2" value="&lt;b&gt;Enclosed AC relay #2&lt;/b&gt;&lt;br&gt;FILTER PUMP outlet&lt;br&gt;Low-voltage IN ← GPIO23" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;align=left;spacingLeft=8;" vertex="1" parent="1">
115
+ <mxGeometry x="690" y="350" width="200" height="80" as="geometry" />
116
+ </mxCell>
117
+
118
+ <mxCell id="pump_heat" value="&lt;b&gt;Heat circulation pump&lt;/b&gt;&lt;br&gt;120 VAC plug → relay #1&lt;br&gt;Tub → pump → propane heater → tub" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#b85450;align=left;spacingLeft=8;" vertex="1" parent="1">
119
+ <mxGeometry x="910" y="230" width="180" height="90" as="geometry" />
120
+ </mxCell>
121
+
122
+ <mxCell id="pump_filt" value="&lt;b&gt;Filter pump&lt;/b&gt;&lt;br&gt;120 VAC plug → relay #2" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#b85450;align=left;spacingLeft=8;" vertex="1" parent="1">
123
+ <mxGeometry x="910" y="345" width="180" height="70" as="geometry" />
124
+ </mxCell>
125
+
126
+ <mxCell id="heater" value="&lt;b&gt;Propane camp heater&lt;/b&gt;&lt;br&gt;Gas &amp; controls: &lt;b&gt;manual&lt;/b&gt;&lt;br&gt;Ignites on water flow" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#666666;align=left;spacingLeft=8;" vertex="1" parent="1">
127
+ <mxGeometry x="910" y="430" width="180" height="70" as="geometry" />
128
+ </mxCell>
129
+
130
+ <mxCell id="e_gfci_cord" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="gfci" target="cord">
131
+ <mxGeometry relative="1" as="geometry" />
132
+ </mxCell>
133
+ <mxCell id="e_cord_r1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="cord" target="relay1">
134
+ <mxGeometry relative="1" as="geometry" />
135
+ </mxCell>
136
+ <mxCell id="e_cord_r2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="cord" target="relay2">
137
+ <mxGeometry relative="1" as="geometry" />
138
+ </mxCell>
139
+ <mxCell id="e_r1_p" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="relay1" target="pump_heat">
140
+ <mxGeometry relative="1" as="geometry" />
141
+ </mxCell>
142
+ <mxCell id="e_r2_p" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#b85450;endArrow=classic;" edge="1" parent="1" source="relay2" target="pump_filt">
143
+ <mxGeometry relative="1" as="geometry" />
144
+ </mxCell>
145
+ <mxCell id="e_pump_h" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#666666;dashed=1;endArrow=classic;" edge="1" parent="1" source="pump_heat" target="heater">
146
+ <mxGeometry relative="1" as="geometry" />
147
+ </mxCell>
148
+
149
+ <mxCell id="e_gpio_r1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#82b366;endArrow=classic;strokeWidth=2;" edge="1" parent="1" source="gpio_out" target="relay1">
150
+ <mxGeometry relative="1" as="geometry">
151
+ <Array as="points">
152
+ <mxPoint x="630" y="667" />
153
+ <mxPoint x="630" y="280" />
154
+ </Array>
155
+ </mxGeometry>
156
+ </mxCell>
157
+ <mxCell id="e_gpio_r2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#82b366;endArrow=classic;strokeWidth=2;" edge="1" parent="1" source="gpio_out" target="relay2">
158
+ <mxGeometry relative="1" as="geometry">
159
+ <Array as="points">
160
+ <mxPoint x="615" y="667" />
161
+ <mxPoint x="615" y="390" />
162
+ </Array>
163
+ </mxGeometry>
164
+ </mxCell>
165
+ <mxCell id="e_gpio_l" value="Low-voltage control wires only (follow relay module manual)" style="text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=#006600;" vertex="1" parent="1">
166
+ <mxGeometry x="595" y="560" width="105" height="90" as="geometry" />
167
+ </mxCell>
168
+
169
+ <mxCell id="table" value="&lt;b&gt;GPIO summary (BCM)&lt;/b&gt;&lt;br&gt;&lt;table cellpadding=&quot;4&quot; style=&quot;font-size:11px&quot;&gt;&lt;tr&gt;&lt;td&gt;&lt;b&gt;Function&lt;/b&gt;&lt;/td&gt;&lt;td&gt;&lt;b&gt;GPIO&lt;/b&gt;&lt;/td&gt;&lt;td&gt;&lt;b&gt;Notes&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Heat relay&lt;/td&gt;&lt;td&gt;22&lt;/td&gt;&lt;td&gt;control daemon&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Filter relay&lt;/td&gt;&lt;td&gt;23&lt;/td&gt;&lt;td&gt;control daemon&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;1-Wire data (×5 probes)&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;w1-gpio overlay; 4.7k pull-up; 4 tub + 1 hot-in&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Low-water float&lt;/td&gt;&lt;td&gt;17&lt;/td&gt;&lt;td&gt;&lt;b&gt;FUTURE — no hardware installed&lt;/b&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Flow switch&lt;/td&gt;&lt;td&gt;27&lt;/td&gt;&lt;td&gt;optional, not implemented&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&lt;br&gt;&lt;b&gt;Note:&lt;/b&gt; the shipped &lt;code&gt;hot-tub-temps&lt;/code&gt; daemon reads the Pi's own 1-Wire sysfs directly — no separate USB/UART sensor hub is used." style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#999999;align=left;spacingLeft=10;verticalAlign=top;spacingTop=8;" vertex="1" parent="1">
170
+ <mxGeometry x="40" y="730" width="1120" height="160" as="geometry" />
171
+ </mxCell>
172
+
173
+ <mxCell id="warn" value="⚠ Pi does not power pumps. Use GFCI. Drip loops on all plugs. Do not exceed relay/cord/outlet amp ratings. Water level is currently software-presumed adequate — add the float switch before leaving the tub unattended." style="text;html=1;strokeColor=none;fillColor=#fff2cc;align=center;verticalAlign=middle;fontSize=11;fontStyle=1" vertex="1" parent="1">
174
+ <mxGeometry x="40" y="900" width="1120" height="30" as="geometry" />
175
+ </mxCell>
176
+
177
+ </root>
178
+ </mxGraphModel>
179
+ </diagram>
180
+</mxfile>
projects/hot-tub/hardware-setup.md
... ...
@@ -8,7 +8,11 @@ The design goal is to keep the system beginner-friendly: use plug-in AC switchin
8 8
9 9
## System Overview
10 10
11
-The Raspberry Pi receives temperature data from a small sensor hub, reads safety sensors, then turns two AC loads on and off:
11
+**Editable wiring diagrams:** [diagrams/hot-tub-electrical.drawio](diagrams/hot-tub-electrical.drawio) (v1) and [diagrams/hot-tub-electrical-v2.drawio](diagrams/hot-tub-electrical-v2.drawio) (v2, same facts, swimlane layout with legend) — open either in [diagrams.net](https://app.diagrams.net/) or the Draw.io desktop app.
12
+
13
+**Current understanding (2026-10-22):** the Raspberry Pi reads **five DS18B20 probes directly on its own 1-Wire bus** (4 tub-water probes + 1 hot-water-input probe) — there is no separate microcontroller sensor hub in the shipped design (that approach is kept below only as a legacy alternative). A **low-water float switch is a FUTURE addition**: no hardware is installed yet, and the control daemon does not read one today. Water level is presumed adequate in software until that hardware exists.
14
+
15
+The Raspberry Pi reads the five temperature probes, then turns two AC loads on and off:
12 16
13 17
- Heat circulation pump: moves tub water through the propane heater.
14 18
- Filter pump: runs the 1000 gph filtration pump on demand or on a schedule.
... ...
@@ -27,20 +31,20 @@ When the heat pump runs, water flow causes the propane heater to ignite using it
27 31
+-------------+
28 32
| Raspberry Pi |
29 33
+-------------+
30
- | | |
31
- USB / UART| | |GPIO / USB control
32
- | | |
33
- +-----+ | +------------------+
34
- | | |
35
- +-----------+ +-----------+ +-----------+
36
- | Temp | | Low Water | | AC Switch |
37
- | Sensor Hub| | Sensor | | Modules |
38
- +-----+-----+ +-----------+ +-----+-----+
39
- | |
40
- +-----------+ +--------------+--------------+
41
- | 4 Tub | | |
42
- | Probes | +---------------+ +---------------+
43
- +-----------+ | Heat Circ Pump| | Filter Pump |
34
+ | |
35
+ 1-Wire GPIO4| |GPIO22 / GPIO23
36
+ | |
37
+ +-----------+ +------------------+
38
+ | 5 DS18B20 | |
39
+ | probes: | +-----------+
40
+ | 4 tub + | | AC Switch |
41
+ | 1 hot-in | | Modules |
42
+ +-----------+ +-----+-----+
43
+ |
44
+ +--------------+--------------+
45
+ | |
46
+ +---------------+ +---------------+
47
+ | Heat Circ Pump| | Filter Pump |
44 48
+-------+-------+ +---------------+
45 49
|
46 50
v
... ...
@@ -50,6 +54,8 @@ When the heat pump runs, water flow causes the propane heater to ignite using it
50 54
|
51 55
v
52 56
Back to Tub
57
+
58
+ [FUTURE, not installed] Low-water float switch -----> GPIO17
53 59
```
54 60
55 61
## Recommended Shopping List
... ...
@@ -98,18 +104,50 @@ Avoid:
98 104
- Non-GFCI AC power.
99 105
- Running pump current through breadboards, jumper wires, or small PCB traces.
100 106
101
-### Temperature Sensor Hub
107
+### Temperature Probes — Direct Pi 1-Wire (shipped design)
102 108
103 109
Buy:
104 110
111
+- **5** waterproof DS18B20 temperature probes with stainless tips and 6 ft to 10 ft cables — 4 for the tub, 1 for the hot-water input.
112
+- 1 4.7 kOhm resistor for the DS18B20 pull-up (one resistor serves the whole bus).
113
+- 5 waterproof 3-pin connectors, such as M8/M12 aviation connectors, or 3-pin waterproof pigtail connectors, wired as **two pluggable bundles**: a 4-probe "water" bundle and a 1-probe "hot-in" bundle.
114
+
115
+All five probes connect directly to the Raspberry Pi's own 1-Wire bus — **no separate microcontroller sensor hub is used.** The shipped `hot-tub-temps` daemon (`src/temp/`) reads `/sys/bus/w1/devices/28-*` via the Pi's `w1-gpio` overlay, learns which ROM ids belong to which bundle during a guided install (see [src/temp/README.md](src/temp/README.md)), and publishes readings to Redis. This replaces the Pico/hub concept described in the legacy alternative below.
116
+
117
+**Four tub probes** — calculate the tub temperature from their average:
118
+
119
+- Hot-return side probe: measures the side where heated water enters the tub.
120
+- Opposite-side probe: measures the side farthest from the hot return.
121
+- Near-end probe: measures one end of the tank.
122
+- Far-end probe: measures the other end of the tank.
123
+
124
+A 4-point average is a better fit for this tub than a single probe because the heater return can create temporary hot spots. The controller also watches the spread between probes — if one probe reads far hotter than the others, the software can flag disagreement instead of trusting a bad reading.
125
+
126
+**One hot-water-input probe** — measures the water entering from the propane heater (the `hot_in_f` reading), not tub water. It is mounted on the heater-side return line, close to where hot water re-enters the tub plumbing, so the control daemon can confirm the heater is actually producing hot water during startup (see "startup check" in [src/control/README.md](src/control/README.md)). It is **not** included in the tub average.
127
+
128
+The DS18B20 probes use three wires, and all five probes share one bus:
129
+
130
+```text
131
+DS18B20 wire Pi connection
132
+------------------------------------------------
133
+Red / VCC 3.3V
134
+Black / GND Ground
135
+Yellow / Data GPIO4 (BCM), with 4.7k pull-up to 3.3V
136
+```
137
+
138
+Many DS18B20 probes use red, black, and yellow wires, but verify the datasheet for the exact probes you buy. Each probe has a unique digital address (ROM id) that the temperature daemon labels during a one-time teach-in: connect one bundle at a time so the daemon can learn which ids are "hot-in" vs "tub".
139
+
140
+For reliability, run all five probe cables back to a small terminal block near the Pi instead of splicing them permanently. This makes it easier to replace a bad probe later.
141
+
142
+#### Legacy alternative: separate microcontroller sensor hub (not used in the shipped build)
143
+
144
+An earlier design considered offloading temperature reading to a small microcontroller (e.g. Raspberry Pi Pico) that would read the DS18B20 bus and send one compact package to the Pi over USB/UART. This is **not** what the shipped `hot-tub-temps` daemon does, and is kept here only for historical reference in case a future revision wants to move sensing off the main Pi.
145
+
146
+Would have needed:
147
+
105 148
- 1 Raspberry Pi Pico, Pico W, Arduino Nano / Uno, Adafruit QT Py RP2040, or similar small microcontroller.
106 149
- 1 USB cable from the sensor hub to the Raspberry Pi, or a 3-wire UART connection if the hub will be mounted separately.
107 150
- 1 small screw-terminal breakout board or terminal strip for the sensor hub.
108
-- Optional: 1 small IP65 junction box if the sensor hub will sit near the tub instead of inside the main control enclosure.
109
-
110
-The recommended approach is to connect all tub temperature probes to this small sensor hub instead of connecting the probes directly to the Raspberry Pi. A Raspberry Pi Pico is a good fit because it is inexpensive, easy to power from the Pi over USB, and can run simple firmware that reads the DS18B20 bus, averages the readings, detects missing probes, and sends one compact data package to the Pi.
111
-
112
-The main Raspberry Pi remains the system controller. It runs the web interface, target-temperature logic, pump control, logging, and alerts. The sensor hub only owns temperature measurement.
113 151
114 152
```text
115 153
4 DS18B20 tub probes
... ...
@@ -128,7 +166,7 @@ The main Raspberry Pi remains the system controller. It runs the web interface,
128 166
+----------------------+
129 167
```
130 168
131
-The sensor hub should report data in a simple JSON or JSON-like line format, one message per reading interval. For example:
169
+Illustrative hub-to-Pi message format (not the actual `temps:latest` schema the shipped daemon publishes — see [src/temp/README.md](src/temp/README.md) for that):
132 170
133 171
```json
134 172
{
... ...
@@ -143,42 +181,11 @@ The sensor hub should report data in a simple JSON or JSON-like line format, one
143 181
}
144 182
```
145 183
146
-The average should only include sensors with `status: "ok"`. If too many sensors are missing, or if the working sensors disagree by an extreme amount, the Raspberry Pi should treat the temperature package as unsafe and refuse to heat.
147
-
148
-### Temperature Probes
149
-
150
-Buy:
151
-
152
-- 4 waterproof DS18B20 temperature probes with stainless tips and 6 ft to 10 ft cables.
153
-- 1 4.7 kOhm resistor, if your sensor hub breakout does not include a DS18B20 pull-up resistor.
154
-- 4 waterproof 3-pin connectors, such as M8/M12 aviation connectors, or 3-pin waterproof pigtail connectors.
155
-
156
-Use four tub temperature probes and calculate the tub temperature from their average:
157
-
158
-- Hot-return side probe: measures the side where heated water enters.
159
-- Opposite-side probe: measures the side farthest from the hot return.
160
-- Near-end probe: measures one end of the tank.
161
-- Far-end probe: measures the other end of the tank.
162
-
163
-A 4-point average is a better fit for this tub than a single probe because the heater return can create temporary hot spots. The controller can also watch the spread between probes. For example, if one probe reads 10 degrees hotter than the others, the software can ignore that probe temporarily or keep circulating water until the readings converge.
164
-
165
-The DS18B20 probes use three wires:
166
-
167
-```text
168
-DS18B20 wire Sensor hub connection
169
-------------------------------------------------
170
-Red / VCC 3.3V
171
-Black / GND Ground
172
-Yellow / Data One digital input, with 4.7k pull-up to 3.3V
173
-```
174
-
175
-Many DS18B20 probes use red, black, and yellow wires, but verify the datasheet for the exact probes you buy. DS18B20 probes can share the same 3-wire bus, so all four probes can connect to the same 3.3V, ground, and sensor-hub data terminals. Each probe has a unique digital address that the sensor-hub firmware can label during setup.
184
+### Water Level / Dry-Run Protection — FUTURE CONCERN, not installed
176 185
177
-For reliability, run the four probe cables back to a small terminal block near the sensor hub instead of splicing them permanently. This makes it easier to replace a bad probe later.
186
+**Current state: no float switch is installed, and the control daemon does not read one.** Water level is presumed adequate in software (`src/control/safety.py` has a `# TODO: GPIO17 low-water float when hardware is added` marker, but nothing wires to it yet). This section describes the planned future addition, not a required part of the current build.
178 187
179
-### Water Level / Dry-Run Protection
180
-
181
-Buy:
188
+Would need, when added:
182 189
183 190
- 1 vertical float switch or horizontal side-mount float switch rated for water exposure.
184 191
- 1 waterproof 2-pin connector.
... ...
@@ -186,7 +193,7 @@ Buy:
186 193
187 194
The water-level sensor should tell the Pi whether the tub has enough water to safely run the heating pump. A float switch is more beginner-friendly than ultrasonic sensing because steam, splashing, and outdoor light can confuse distance sensors.
188 195
189
-Recommended mounting:
196
+Recommended mounting (once installed):
190 197
191 198
- Place the float switch near the side wall of the tank.
192 199
- Set it slightly above the minimum safe intake level for the heat circulation pump.
... ...
@@ -250,8 +257,9 @@ Recommended enclosure layout:
250 257
| Waterproof Control Box |
251 258
| |
252 259
| +------------------+ +-----------------+ |
253
-| | Raspberry Pi | USB | Temp Sensor Hub | |
254
-| | on standoffs +------>| plus terminals | |
260
+| | Raspberry Pi | 1-Wire| Probe terminal | |
261
+| | on standoffs +------>| block (5x DS18B20| |
262
+| | (GPIO4 + pull-up)| | 4 tub + 1 hot-in)| |
255 263
| +------------------+ +-----------------+ |
256 264
| |
257 265
| Low-voltage side AC relay side |
... ...
@@ -337,9 +345,9 @@ Probe 4: other long end of the tank
337 345
338 346
During heating, display both the average temperature and the temperature spread. The average is useful for control. The spread tells the user whether the tank is evenly mixed yet.
339 347
340
-### Low-Water Float Switch
348
+### Low-Water Float Switch — FUTURE CONCERN, not installed
341 349
342
-Mount the float switch near the pump intake height. The controller should refuse to run the heat pump if this switch indicates low water.
350
+Not built yet; the mounting below is the plan for when this hardware is added. Today the controller does not check water level at all. Mount the float switch near the pump intake height, once installed. The controller should refuse to run the heat pump if this switch indicates low water.
343 351
344 352
```text
345 353
Side view of tub wall
... ...
@@ -348,7 +356,7 @@ Side view of tub wall
348 356
349 357
[ temp probe ]
350 358
351
- Minimum safe line -----[ float switch ]----
359
+ Minimum safe line -----[ float switch ]---- <-- FUTURE, not installed
352 360
353 361
[ pump intake / strainer ]
354 362
```
... ...
@@ -362,32 +370,29 @@ Bring each sensor into the enclosure through a waterproof connector or cable gla
362 370
Suggested Pi GPIO assignments:
363 371
364 372
```text
365
-Function Pi Pin / GPIO
366
-------------------------------------------------
367
-Temperature sensor hub USB serial, or UART GPIO pins
368
-Low-water float switch GPIO17
369
-Optional flow switch GPIO27
370
-Heat relay control GPIO22
371
-Filter relay control GPIO23
373
+Function Pi Pin / GPIO Status
374
+----------------------------------------------------------------
375
+1-Wire probe bus (x5) GPIO4 Shipped (hot-tub-temps)
376
+Heat relay control GPIO22 Shipped (hot-tub-control)
377
+Filter relay control GPIO23 Shipped (hot-tub-control)
378
+Low-water float switch GPIO17 FUTURE — not installed
379
+Optional flow switch GPIO27 Optional — not implemented
372 380
Ground Any Pi GND pin
373 381
3.3V sensor power Pi 3.3V pin
374 382
```
375 383
376
-Suggested sensor hub wiring:
384
+Suggested DS18B20 bus wiring (direct to the Pi, no sensor hub):
377 385
378 386
```text
379
-Function Sensor hub connection
387
+Function Pi connection
380 388
------------------------------------------------
381
-DS18B20 data bus Any firmware-supported digital input
389
+DS18B20 data bus (x5) GPIO4 (BCM)
382 390
DS18B20 pull-up 4.7 kOhm from data to 3.3V
383 391
DS18B20 power 3.3V
384 392
DS18B20 ground Ground
385
-Pi communication USB serial, or UART TX/RX plus shared ground
386 393
```
387 394
388
-Use USB serial if the sensor hub sits inside the same enclosure as the Pi. It is simple, powers the hub, and appears to the Pi as a serial device. Use UART only if you have a reason to avoid USB, and keep the Pi and hub grounds tied together.
389
-
390
-For switches such as float and flow sensors, wire one side to GPIO and the other to ground, then configure the Pi input with an internal pull-up resistor in software.
395
+For switches such as the future float and optional flow sensors, wire one side to GPIO and the other to ground, then configure the Pi input with an internal pull-up resistor in software.
391 396
392 397
### AC Power Path
393 398
... ...
@@ -429,31 +434,28 @@ Important rules:
429 434
3. Install cable glands or bulkhead connectors.
430 435
Put sensor connectors on one side or the bottom of the enclosure. Put AC/control cable entries separately.
431 436
432
-4. Mount the Raspberry Pi, temperature sensor hub, and terminal blocks.
437
+4. Mount the Raspberry Pi and terminal blocks.
433 438
Use standoffs, DIN rail adapters, or a mounting plate. Do not let electronics float loose in the box.
434 439
435
-5. Connect the DS18B20 temperature probes to the sensor hub.
436
- Wire VCC to 3.3V, ground to ground, and data to the hub's DS18B20 data input with a 4.7 kOhm pull-up resistor.
437
-
438
-6. Connect the sensor hub to the Raspberry Pi.
439
- Use USB serial if the hub is in the same enclosure. Confirm the Pi receives JSON-style readings with average temperature, individual readings, and failed-sensor status.
440
+5. Connect the DS18B20 temperature probes directly to the Pi's 1-Wire bus.
441
+ Wire VCC to 3.3V, ground to ground, and data to GPIO4 with a 4.7 kOhm pull-up resistor. Connect one bundle at a time (hot-in, then water, or vice versa) so `hot-tub-temps` can learn each probe's role — see [src/temp/INSTALL.md](src/temp/INSTALL.md).
440 442
441
-7. Connect the low-water float switch.
442
- Wire it to a GPIO input and ground through a waterproof connector.
443
+6. *(Future — skip for now.)* Connect the low-water float switch.
444
+ Not part of the current build. When this hardware is added, wire it to a GPIO input and ground through a waterproof connector.
443 445
444
-8. Connect relay control inputs.
446
+7. Connect relay control inputs.
445 447
Wire the Pi GPIO relay outputs to the low-voltage control inputs of the enclosed relay modules. Follow the relay module's manual for polarity and trigger voltage.
446 448
447
-9. Plug pumps into the relay-controlled outlets.
449
+8. Plug pumps into the relay-controlled outlets.
448 450
Label one outlet "HEAT PUMP" and the other "FILTER PUMP".
449 451
450
-10. Test each part manually.
451
- Confirm the sensor hub can read all four temperature probes, the Pi can receive the temperature package, the Pi can see the float switch change state, and the Pi can turn the heat pump and filter outlets on/off.
452
+9. Test each part manually.
453
+ Confirm the Pi can read all five temperature probes (`redis-cli GET temps:latest`), and can turn the heat pump and filter outlets on/off.
452 454
453
-11. Test with water before using heat.
455
+10. Test with water before using heat.
454 456
Run the heat circulation pump with propane closed first. Confirm water flow and no leaks.
455 457
456
-12. Test heating under supervision.
458
+11. Test heating under supervision.
457 459
Open propane, start heating, and watch the first full heat-up cycle in person. Confirm the controller stops heating near the target temperature.
458 460
459 461
## Recommended Labels
... ...
@@ -466,9 +468,8 @@ Label everything before closing the box:
466 468
- TUB TEMP 2
467 469
- TUB TEMP 3
468 470
- TUB TEMP 4
469
-- TEMP SENSOR HUB
470
-- PI USB TEMP LINK
471
-- LOW WATER SWITCH
471
+- HOT WATER INPUT TEMP
472
+- LOW WATER SWITCH, once installed (FUTURE — not currently part of the build)
472 473
- OUTSIDE TEMP, if installed
473 474
- PI POWER
474 475
- GFCI REQUIRED
... ...
@@ -479,12 +480,12 @@ Labels matter because this system will be used outdoors, serviced later, and pos
479 480
480 481
The hardware should make it possible for the software to enforce these rules:
481 482
482
-- Do not run heat if the low-water switch says water is too low.
483
-- Do not run heat if the temperature sensor hub reports missing probes beyond the allowed limit.
483
+- *(Future — not enforced today; no float switch installed.)* Do not run heat if the low-water switch says water is too low.
484
+- Do not run heat if too many temperature probes are missing or failing beyond the allowed limit.
484 485
- Do not run heat if the working temperature probes disagree by an extreme amount.
485 486
- Do not run heat above a hard maximum temperature.
486 487
- Do not run heat indefinitely if temperature is not rising.
487
-- Optionally stop heat if the flow switch says water is not moving.
488
+- Optionally stop heat if the flow switch says water is not moving (also not installed today).
488 489
- Keep filter control independent from heat control.
489 490
490 491
Hardware alone will not enforce all of this, but the sensors and relay layout make those software checks possible.
... ...
@@ -506,47 +507,45 @@ Result: remote on/off for heating circulation and filtration.
506 507
507 508
Add:
508 509
509
-- Four waterproof DS18B20 tub temperature probes.
510
-- Temperature sensor hub.
511
-- Sensor-hub firmware that reports average temperature, individual probe readings, and failed probes to the Pi.
510
+- Five waterproof DS18B20 probes (4 tub + 1 hot-water-input), wired directly to the Pi's own 1-Wire bus on GPIO4 — no separate sensor hub.
511
+- The `hot-tub-temps` daemon (shipped in `src/temp/`), which reads the probes, learns probe roles during install, and publishes average temperature, individual readings, and failed probes to Redis.
512 512
- Web interface target temperature setting.
513
-- Software hysteresis, such as heating until the 4-point average reaches target temperature and restarting when the average drops 3 degrees.
513
+- Software hysteresis, such as heating until the 4-point tub average reaches target temperature and restarting when the average drops 3 degrees.
514 514
515 515
Result: the system can maintain a target temperature range.
516 516
517
-### Phase 3: Safety Sensors
517
+### Phase 3: Safety Sensors — FUTURE, not yet built
518 518
519
-Add:
519
+Add, when this hardware is built:
520 520
521
-- Low-water float switch.
522
-- Optional flow switch.
521
+- Low-water float switch (not installed today; `src/control/safety.py` has a TODO hook for it).
522
+- Optional flow switch (not installed today).
523 523
- Optional outside temperature sensor.
524 524
525
-Result: the system can refuse unsafe heating and support future freeze-protection behavior.
525
+Result (once built): the system can refuse unsafe heating and support future freeze-protection behavior. **Today, none of this phase exists** — water level is presumed adequate in software.
526 526
527 527
## Final Recommended Parts Summary
528 528
529 529
For the first complete version, buy:
530 530
531 531
- Raspberry Pi Zero 2 W, Pi 3, or Pi 4.
532
-- Raspberry Pi Pico, Arduino Nano / Uno, or similar microcontroller for the temperature sensor hub.
533
-- USB cable for the sensor hub, or UART wiring if mounted separately.
534
-- Small screw-terminal breakout board or terminal strip for the sensor hub.
535
-- Optional small IP65 junction box for the sensor hub if it will sit near the tub.
536 532
- Official Pi power supply.
537 533
- 32 GB or larger high-endurance microSD card.
538 534
- IP65 / NEMA 4X waterproof enclosure.
539 535
- Pi screw-terminal breakout or HAT.
540 536
- Waterproof cable glands or M8/M12 connectors.
541 537
- 2 enclosed AC relay outlet modules, or 2 outdoor smart plugs with local control.
542
-- 4 waterproof DS18B20 tub temperature probes.
543
-- 1 4.7 kOhm resistor for the DS18B20 pull-up, unless included on the sensor hub breakout.
544
-- 4 waterproof 3-pin connectors for the temperature probes.
545
-- 1 waterproof float switch.
546
-- Optional inline flow switch.
547
-- Optional outside temperature probe.
538
+- **5** waterproof DS18B20 temperature probes — 4 for the tub, 1 for the hot-water input — wired directly to the Pi's 1-Wire bus (GPIO4). No sensor-hub microcontroller is needed.
539
+- 1 4.7 kOhm resistor for the DS18B20 pull-up.
540
+- 5 waterproof 3-pin connectors for the temperature probes, bundled as a 4-probe "water" connector and a 1-probe "hot-in" connector.
548 541
- Wago connectors or screw terminal blocks.
549 542
- Outdoor-rated extension cord and GFCI protection.
550 543
- Hot-water-rated hose, hose clamps, valves, unions, and strainers as needed.
551 544
552
-This gives the system enough hardware to measure a 4-point average water temperature, control heating circulation, control filtration, and detect the most important unsafe condition: low water.
545
+**Not required for the current build, future concern only:**
546
+
547
+- 1 waterproof float switch (low-water protection — planned for GPIO17, not installed, not read by the control daemon today).
548
+- Optional inline flow switch.
549
+- Optional outside temperature probe.
550
+
551
+This gives the system enough hardware to measure a 4-point average tub temperature plus a hot-water-input temperature, control heating circulation, and control filtration. **Low-water detection is not yet implemented** — water level is presumed adequate in software until the float switch above is added.
projects/hot-tub/software-architecture.md
... ...
@@ -5,7 +5,7 @@ This document describes the software running on the Raspberry Pi 3 controller: f
5 5
## Design Principles
6 6
7 7
1. **Single decision-maker.** Only the Control Daemon may drive the heat and filter relays (GPIO). Every other process or remote device sends *intents* (mode, target temperature, enable/disable). The Control Daemon validates those intents against safety rules before changing outputs.
8
-2. **Sensors are read-only.** The Temperature Daemon owns the 1-Wire (or sensor-hub) interface. Nothing else bit-bangs the DS18B20 bus.
8
+2. **Sensors are read-only.** The Temperature Daemon owns the Pi's own 1-Wire interface (direct GPIO4, no separate sensor-hub microcontroller — see [hardware-setup.md](hardware-setup.md)). Nothing else bit-bangs the DS18B20 bus.
9 9
3. **Redis is the local bus.** Services do not call each other over HTTP for core control. They publish events and subscribe to what they need. Redis also holds the latest snapshot so a newly started subscriber can catch up without waiting for the next publish.
10 10
4. **Clients are interchangeable.** Web UI, HTTP API, tub-side console, and indoor monitor all express the same kinds of intents. They never talk to GPIO or sensors directly.
11 11
5. **Fail safe.** On Control Daemon crash, restart, or loss of valid temperature data, heat turns off (or stays off). Filter behavior is mode-dependent but must never leave heat running unsupervised after a fault.
... ...
@@ -39,7 +39,8 @@ This document describes the software running on the Raspberry Pi 3 controller: f
39 39
| | GPIO | | |
40 40
| v | | |
41 41
| Heat / filter relays | | |
42
-| Float / flow inputs | | |
42
+| Float / flow inputs (FUTURE, | | |
43
+| not installed) | | |
43 44
| | | |
44 45
| +----------------+ health | | |
45 46
| | Web / API | -----------> | | |
... ...
@@ -70,7 +71,7 @@ GPIO numbers for relays and safety inputs match [hardware-setup.md](hardware-set
70 71
71 72
**Inputs (hardware):**
72 73
73
-- DS18B20 probes on the Pi 1-Wire bus (`w1-gpio`), or a USB/UART sensor hub if that path is used instead.
74
+- Five DS18B20 probes directly on the Pi's own 1-Wire bus (`w1-gpio` overlay, GPIO4) — 4 tub probes + 1 hot-water-input probe. No separate microcontroller sensor hub is used in the shipped design.
74 75
- Optional: outside-air probe on the same bus (labeled distinctly in config).
75 76
76 77
**Outputs (Redis):**
... ...
@@ -115,7 +116,7 @@ GPIO numbers for relays and safety inputs match [hardware-setup.md](hardware-set
115 116
|---|---|
116 117
| Redis `temps.reading` | Latest probe package from Temperature Daemon |
117 118
| Redis command channel (e.g. `control.command`) | User intents: set mode, set target °F, acknowledge fault, etc. |
118
-| GPIO inputs | Low-water float (GPIO17), optional flow switch (GPIO27) |
119
+| GPIO inputs | **FUTURE, not installed today:** low-water float (GPIO17), optional flow switch (GPIO27) — see [hardware-setup.md](hardware-setup.md#water-level--dry-run-protection--future-concern-not-installed). `src/control/safety.py` has a TODO hook only; water level is presumed adequate for now. |
119 120
| Local config / Redis keys | Hard max temp, hysteresis band, probe disagreement limit, no-rise timeout |
120 121
121 122
**Outputs:**
... ...
@@ -156,12 +157,12 @@ Heat hysteresis example (compatible with the current PoC thinking): turn heat **
156 157
157 158
**Safety rules (enforced here, not in the Web UI):**
158 159
159
-- No heat if low-water float indicates dry/low.
160
+- *(Future — not implemented; no float hardware installed.)* No heat if low-water float indicates dry/low. Water level is presumed adequate today.
160 161
- No heat if too few tub probes are valid (configurable minimum).
161 162
- No heat if probe spread exceeds a hard limit.
162 163
- No heat above a hard maximum °F (absolute ceiling, independent of user target).
163 164
- No heat if temperature is not rising for N minutes while heat has been commanded on (stuck/no-fuel/no-flow).
164
-- Optional: no heat if flow switch says no flow while heat pump should be running.
165
+- *(Future — not implemented.)* Optional: no heat if flow switch says no flow while heat pump should be running.
165 166
- User target is clamped to a safe range before use.
166 167
- On daemon start: relays initialized **off** until the first valid sample and mode evaluation.
167 168
... ...
@@ -170,7 +171,7 @@ Heat hysteresis example (compatible with the current PoC thinking): turn heat **
170 171
- Relays are low-voltage control inputs to enclosed AC modules — the Pi never switches mains directly.
171 172
- Confirm active-high vs active-low for the chosen IoT Relay modules; invert in one place in this daemon.
172 173
- GPIO access should be exclusive to this process (do not also run `control_poc.py` or other experiments against the same pins).
173
-- Float/flow inputs use internal pull-ups; treat open/closed polarity in config.
174
+- *(Future — not installed today.)* Float/flow inputs, once added, would use internal pull-ups; treat open/closed polarity in config.
174 175
175 176
**Persistence:**
176 177
... ...
@@ -383,10 +384,10 @@ Architectural rule reused everywhere: **intent in, Control decides, state out.**
383 384
384 385
| Hardware | Owning software | Notes |
385 386
|---|---|---|
386
-| DS18B20 bus / sensor hub | Temperature Daemon | Exclusive |
387
+| DS18B20 bus (5 probes, direct Pi 1-Wire, GPIO4) | Temperature Daemon | Exclusive; no sensor hub in shipped design |
387 388
| Heat relay GPIO | Control Daemon | Exclusive |
388 389
| Filter relay GPIO | Control Daemon | Exclusive |
389
-| Float / flow GPIO | Control Daemon | Read in safety path |
390
+| Float / flow GPIO | Control Daemon | **FUTURE** — not installed; would be read in safety path once added |
390 391
| Status LED — Temperature | Health Daemon | Exclusive; reflects `health:temps` |
391 392
| Status LED — Control | Health Daemon | Exclusive; reflects `health:control` |
392 393
| Status LED — Web | Health Daemon | Exclusive; reflects `health:web` |
... ...
@@ -418,7 +419,9 @@ Temperature Daemon publishes tub_avg_f=104.5
418 419
→ Control: avg above band → heat GPIO OFF, publish state
419 420
```
420 421
421
-### Low water while heating
422
+### Low water while heating — FUTURE, not implemented today
423
+
424
+No float switch hardware is installed, and `src/control/safety.py` only has a TODO hook for it — this scenario describes the planned behavior once that hardware exists, not current behavior. Today, water level is presumed adequate in software.
422 425
423 426
```text
424 427
Float opens (low water)
projects/hot-tub/src/control/INSTALL.md
... ...
@@ -67,6 +67,7 @@ Expect heat to stay off until `temps:latest` is valid and fresh (default stale t
67 67
```bash
68 68
cd src
69 69
PYTHONPATH=. python3 -m control --dry-run --once
70
+PYTHONPATH=. python3 -m control --no-gpio # Redis live, GPIO faked (stack test with temp_sim + web)
70 71
PYTHONPATH=. python3 -m unittest control.tests.test_commands control.tests.test_safety \
71 72
control.tests.test_filter_policy control.tests.test_heat_fsm -v
72 73
```
projects/hot-tub/src/control/__main__.py
... ...
@@ -42,6 +42,11 @@ def main() -> None:
42 42
help="No Redis; log GPIO decisions only",
43 43
)
44 44
parser.add_argument(
45
+ "--no-gpio",
46
+ action="store_true",
47
+ help="Do not drive relays; still connect to Redis (for stack testing)",
48
+ )
49
+ parser.add_argument(
45 50
"-v",
46 51
"--verbose",
47 52
action="store_true",
... ...
@@ -63,12 +68,13 @@ def main() -> None:
63 68
64 69
setup_logging(cfg.log_path, verbose=args.verbose)
65 70
71
+ gpio_dry_run = args.no_gpio
66 72
if args.once:
67
- snapshot = run_once(cfg, dry_run=args.dry_run)
73
+ snapshot = run_once(cfg, dry_run=args.dry_run, gpio_dry_run=gpio_dry_run)
68 74
print(json.dumps(snapshot, indent=2))
69 75
return
70 76
71
- run_loop(cfg, dry_run=args.dry_run)
77
+ run_loop(cfg, dry_run=args.dry_run, gpio_dry_run=gpio_dry_run)
72 78
73 79
74 80
if __name__ == "__main__":
projects/hot-tub/src/control/daemon.py
... ...
@@ -51,13 +51,19 @@ def _apply_gpio(relays: RelayOutputs, state: ControlState) -> None:
51 51
52 52
53 53
class ControlEngine:
54
- def __init__(self, cfg: ControlConfig, *, dry_run: bool = False) -> None:
54
+ def __init__(
55
+ self,
56
+ cfg: ControlConfig,
57
+ *,
58
+ dry_run: bool = False,
59
+ gpio_dry_run: bool = False,
60
+ ) -> None:
55 61
self.cfg = cfg
56 62
self.dry_run = dry_run
57 63
self.state = load_persisted_state(cfg.state_path, default_target=cfg.target_f_default)
58 64
self.ctx = FsmContext()
59 65
self.reading: dict[str, Any] | None = None
60
- self.relays = RelayOutputs(cfg.gpio, dry_run=dry_run)
66
+ self.relays = RelayOutputs(cfg.gpio, dry_run=dry_run or gpio_dry_run)
61 67
self.relays.all_off()
62 68
self._last_persist_hash: str | None = None
63 69
self._last_publish_mono = 0.0
... ...
@@ -111,10 +117,11 @@ def run_once(
111 117
cfg: ControlConfig,
112 118
*,
113 119
dry_run: bool = False,
120
+ gpio_dry_run: bool = False,
114 121
engine: ControlEngine | None = None,
115 122
redis_io: ControlRedis | None = None,
116 123
) -> dict[str, Any]:
117
- eng = engine or ControlEngine(cfg, dry_run=dry_run)
124
+ eng = engine or ControlEngine(cfg, dry_run=dry_run, gpio_dry_run=gpio_dry_run)
118 125
if eng.reading is None and not dry_run:
119 126
r = redis_io or ControlRedis(cfg.redis_url)
120 127
try:
... ...
@@ -128,14 +135,19 @@ def run_once(
128 135
return snapshot
129 136
130 137
131
-def run_loop(cfg: ControlConfig, *, dry_run: bool = False) -> None:
138
+def run_loop(
139
+ cfg: ControlConfig,
140
+ *,
141
+ dry_run: bool = False,
142
+ gpio_dry_run: bool = False,
143
+) -> None:
132 144
global _stop
133 145
_stop = False
134 146
signal.signal(signal.SIGTERM, _handle_signal)
135 147
signal.signal(signal.SIGINT, _handle_signal)
136 148
137
- logger.info("Control daemon starting (dry_run=%s)", dry_run)
138
- engine = ControlEngine(cfg, dry_run=dry_run)
149
+ logger.info("Control daemon starting (dry_run=%s gpio_dry_run=%s)", dry_run, gpio_dry_run)
150
+ engine = ControlEngine(cfg, dry_run=dry_run, gpio_dry_run=gpio_dry_run)
139 151
redis_io: ControlRedis | None = None
140 152
if not dry_run:
141 153
redis_io = ControlRedis(cfg.redis_url)
projects/hot-tub/src/control/heat_fsm.py
... ...
@@ -110,11 +110,14 @@ def tick_heat(
110 110
cfg,
111 111
reading,
112 112
require_hot_in=False,
113
+ ignore_target=True,
113 114
)
114 115
if not safety.allow_heat and state.heat_phase == "backflush":
116
+ state.heat_pump = "off"
115 117
if safety.trip:
116 118
apply_safety_trip(state, safety.reason or "safety")
117
- state.heat_pump = "off"
119
+ logger.info("backflush aborted by safety; heat_mode -> sleep")
120
+ state.heat_mode = "sleep"
118 121
return
119 122
120 123
if state.heat_phase != "backflush":
projects/hot-tub/src/control/safety.py
... ...
@@ -47,6 +47,7 @@ def check_heat_allowed(
47 47
reading: dict[str, Any] | None,
48 48
*,
49 49
require_hot_in: bool = False,
50
+ ignore_target: bool = False,
50 51
now: datetime | None = None,
51 52
) -> SafetyResult:
52 53
"""Evaluate whether heat pump may be turned on."""
... ...
@@ -70,7 +71,7 @@ def check_heat_allowed(
70 71
hot = reading.get("hot_in_f")
71 72
state.hot_in_f = float(hot) if hot is not None else None
72 73
73
- if state.tub_avg_f >= state.target_f:
74
+ if not ignore_target and state.tub_avg_f >= state.target_f:
74 75
return SafetyResult(False, "at or above target")
75 76
76 77
if require_hot_in and state.hot_in_f is None:
projects/hot-tub/src/control/tests/test_heat_fsm.py
... ...
@@ -63,6 +63,31 @@ class TestHeatFsm(unittest.TestCase):
63 63
self.assertEqual(state.heat_mode, "sleep")
64 64
self.assertEqual(state.heat_pump, "off")
65 65
66
+ def test_backflush_completes_when_at_or_above_target(self) -> None:
67
+ state = ControlState(heat_mode="backflush", target_f=102.0)
68
+ ctx = FsmContext()
69
+ reading = _reading(105.0)
70
+ tick_heat(state, ctx, self.cfg, reading)
71
+ self.assertEqual(state.heat_phase, "backflush")
72
+ self.assertEqual(state.heat_pump, "on")
73
+ tick_heat(state, ctx, self.cfg, reading)
74
+ self.assertEqual(state.heat_mode, "sleep")
75
+ self.assertEqual(state.heat_pump, "off")
76
+
77
+ def test_backflush_safety_trip_reverts_to_sleep(self) -> None:
78
+ state = ControlState(heat_mode="backflush", target_f=102.0)
79
+ ctx = FsmContext()
80
+ tick_heat(state, ctx, self.cfg, _reading(90.0))
81
+ self.assertEqual(state.heat_phase, "backflush")
82
+ self.assertEqual(state.heat_pump, "on")
83
+ tick_heat(state, ctx, self.cfg, None)
84
+ self.assertEqual(state.heat_mode, "sleep")
85
+ self.assertEqual(state.heat_pump, "off")
86
+ self.assertEqual(state.heat_phase, "error_safety")
87
+ tick_heat(state, ctx, self.cfg, None)
88
+ self.assertEqual(state.heat_pump, "off")
89
+ self.assertNotEqual(state.heat_mode, "backflush")
90
+
66 91
67 92
if __name__ == "__main__":
68 93
unittest.main()
projects/hot-tub/src/health/overview.md
... ...
@@ -0,0 +1,47 @@
1
+# Health Daemon Overview
2
+
3
+## Overview
4
+
5
+The purpose of this daemon is to monitor the health of all of the other services and translate them into signals via 3 physical LED lights. There are 3 lights this will control:
6
+
7
+- Temp:
8
+ - Off - No power, completely disconnected
9
+ - Red - Daemon reporting an error
10
+ - Orange, Solid - Daemon reporting a warning
11
+ - Orange, slow blinking - No temp sensors connected
12
+ - Orange, rapid blinking - Some temp sensors connected
13
+ - Green - Temp daemon reporting temps across all sensors
14
+- Control:
15
+ - Off - No power, completely disconnected
16
+ - Red - Daemon reporting an error
17
+ - Orange - Daemon reporting a warning
18
+ - Green - Control daemon working properly
19
+- Web:
20
+ - Off - No power, completely disconnected
21
+ - Red - Daemon reporting an error
22
+ - Warning - Daemon reporting a warning
23
+ - Green - Web interface up and working properly
24
+
25
+Health for each service should be monitored in the following ways:
26
+
27
+- Temp - Read Redis health state and subscribe to temp events
28
+ - if there are no temp events within the last 10 seconds, set LED to red
29
+ - if there is an error reported, set LED to red
30
+ - if there is a warning reported, set LED to orange
31
+ - use the sensor-connected logic above in the case that any sensors are not listed
32
+ - otherwise, set LED to green
33
+- Control - Read control health status from redis. Health should indicate:
34
+ - Healthy - Green LED
35
+ - Warning reported - Orange LED
36
+ - Error reported - Red LED
37
+ - Any health status timestamp older than 10 seconds - Orange LED
38
+ - Any health status timestamp older than 1 minute - Red LED
39
+- Web
40
+ - Http request to API health endpoint every 5 seconds
41
+ - Healthy - Green LED
42
+ - Warning reported - Orange LED
43
+ - Error reported - Red LED
44
+ - Http request to index every 5 seconds
45
+ - On 2xx status - Green LED
46
+ - On error status - Red LED
47
+ - on response lasting longer than 1 second - Orange LED
... ...
\ No newline at end of file
projects/hot-tub/src/temp/INSTALL.md
... ...
@@ -43,6 +43,10 @@ The script installs `redis-server`, `python3-redis`, user `hot-tub`, config unde
43 43
44 44
## 3. Guided probe teach-in
45 45
46
+**Preferred (with web UI):** Open **Diagnostic → Sensor Setup** on the hot tub web interface (`hot-tub-web` service). The wizard calls `temps.command` at runtime; you do not need to edit `setup_mode` or restart the daemon.
47
+
48
+**Manual / headless:**
49
+
46 50
ROM ids are learned once and stored in `/etc/hot-tub/temp-probes.json`.
47 51
48 52
**Recommended order**
projects/hot-tub/src/temp/README.md
... ...
@@ -127,6 +127,7 @@ Suggested reading shape (aligned with the architecture doc):
127 127
{
128 128
"ts": "2026-10-19T20:00:00Z",
129 129
"probes": { "28-...": 101.2, "28-...": null },
130
+ "roles": { "hot_in": 112.0, "tub1": 101.2, "tub2": 101.0, "tub3": null, "tub4": 100.9 },
130 131
"tub_avg_f": 101.0,
131 132
"hot_in_f": null,
132 133
"outdoor_f": null,
projects/hot-tub/src/temp/commands.py
... ...
@@ -0,0 +1,67 @@
1
+"""Handle temps.command payloads (runtime sensor setup)."""
2
+
3
+from __future__ import annotations
4
+
5
+import logging
6
+from typing import Any
7
+
8
+from .config import TempConfig
9
+from .runtime import TempRuntime
10
+from .identity import backup_probe_map, clear_probe_map, restore_probe_map
11
+
12
+logger = logging.getLogger("hot_tub.temp")
13
+
14
+
15
+class TempCommandError(ValueError):
16
+ pass
17
+
18
+
19
+def apply_temp_command(
20
+ cfg: TempConfig,
21
+ runtime: TempRuntime,
22
+ envelope: dict[str, Any],
23
+) -> None:
24
+ op = envelope.get("op")
25
+ if not op or not isinstance(op, str):
26
+ raise TempCommandError("missing or invalid op")
27
+
28
+ path = cfg.probe_map_path
29
+
30
+ if op == "setup_start":
31
+ backup_probe_map(path)
32
+ clear_probe_map(path)
33
+ runtime.setup_mode_override = True
34
+ runtime.setup_active = True
35
+ logger.info("sensor setup started (probe map cleared, backup saved)")
36
+ return
37
+
38
+ if op == "setup_finish":
39
+ if runtime.last_setup_state != "ready":
40
+ raise TempCommandError(
41
+ f"setup_finish requires setup_state ready (got {runtime.last_setup_state!r})"
42
+ )
43
+ runtime.setup_mode_override = False
44
+ runtime.setup_active = False
45
+ logger.info("sensor setup finished")
46
+ return
47
+
48
+ if op == "setup_cancel":
49
+ restore_probe_map(path)
50
+ runtime.setup_mode_override = False
51
+ runtime.setup_active = False
52
+ logger.info("sensor setup cancelled (probe map restored)")
53
+ return
54
+
55
+ raise TempCommandError(f"unknown op: {op!r}")
56
+
57
+
58
+def parse_command_message(raw: str) -> dict[str, Any]:
59
+ import json
60
+
61
+ try:
62
+ data = json.loads(raw)
63
+ except json.JSONDecodeError as exc:
64
+ raise TempCommandError("invalid JSON") from exc
65
+ if not isinstance(data, dict):
66
+ raise TempCommandError("command must be a JSON object")
67
+ return data
projects/hot-tub/src/temp/daemon.py
... ...
@@ -9,16 +9,19 @@ import time
9 9
from datetime import datetime, timezone
10 10
from typing import Any
11 11
12
+from .commands import TempCommandError, apply_temp_command, parse_command_message
12 13
from .config import TempConfig
13 14
from .identity import (
14 15
TUB_COUNT,
15 16
classify_readings,
16 17
evaluate_identity,
17 18
load_probe_map,
19
+ role_readings,
18 20
save_probe_map,
19 21
)
20 22
from .reader import read_all_f
21
-from .redis_out import RedisPublisher
23
+from .redis_out import CHAN_COMMAND, RedisPublisher
24
+from .runtime import TempRuntime
22 25
23 26
logger = logging.getLogger("hot_tub.temp")
24 27
... ...
@@ -49,11 +52,13 @@ def _health_status(setup_state: str, ok_count: int, visible_count: int) -> str:
49 52
50 53
def build_sample(
51 54
cfg: TempConfig,
55
+ runtime: TempRuntime | None = None,
52 56
) -> tuple[dict[str, Any], Any, dict[str, Any], dict[str, Any]]:
53 57
from pathlib import Path
54 58
55 59
path = cfg.probe_map_path
56
- setup_mode = cfg.setup_mode
60
+ rt = runtime or TempRuntime()
61
+ setup_mode = rt.effective_setup_mode(cfg)
57 62
probes_f = read_all_f(cfg.devices_path)
58 63
visible = list(probes_f.keys())
59 64
pmap = load_probe_map(path)
... ...
@@ -66,10 +71,14 @@ def build_sample(
66 71
probes_f, identity.probe_map
67 72
)
68 73
74
+ roles = role_readings(probes_f, identity.probe_map)
75
+ rt.last_setup_state = identity.setup_state
76
+
69 77
reading: dict[str, Any] = {
70 78
"ts": _utc_now(),
71 79
"setup_state": identity.setup_state,
72 80
"probes": labeled,
81
+ "roles": roles,
73 82
"tub_avg_f": tub_avg,
74 83
"hot_in_f": hot_in_f,
75 84
"outdoor_f": None,
... ...
@@ -83,6 +92,7 @@ def build_sample(
83 92
"state": identity.setup_state,
84 93
"detail": identity.detail,
85 94
"visible_ids": identity.visible_ids,
95
+ "setup_active": rt.setup_active,
86 96
}
87 97
88 98
health_payload = {
... ...
@@ -101,8 +111,10 @@ def run_once(
101 111
*,
102 112
dry_run: bool = False,
103 113
redis_pub: RedisPublisher | None = None,
114
+ runtime: TempRuntime | None = None,
104 115
) -> dict[str, Any]:
105
- reading, identity, setup_payload, health_payload = build_sample(cfg)
116
+ rt = runtime or TempRuntime()
117
+ reading, identity, setup_payload, health_payload = build_sample(cfg, runtime=rt)
106 118
logger.info(
107 119
"sample setup=%s tub_avg=%s hot_in=%s ok=%s/%s visible=%s",
108 120
identity.setup_state,
... ...
@@ -117,7 +129,7 @@ def run_once(
117 129
pub = redis_pub or RedisPublisher(cfg.redis_url)
118 130
try:
119 131
pub.publish_reading(reading)
120
- pub.publish_setup(setup_payload)
132
+ pub.publish_setup(setup_payload, force=rt.setup_active)
121 133
pub.publish_health(health_payload)
122 134
finally:
123 135
if redis_pub is None:
... ...
@@ -142,11 +154,21 @@ def run_loop(cfg: TempConfig, *, dry_run: bool = False) -> None:
142 154
if not dry_run:
143 155
redis_pub = RedisPublisher(cfg.redis_url)
144 156
157
+ runtime = TempRuntime()
145 158
try:
146 159
while not _stop:
147 160
loop_start = time.monotonic()
161
+ if redis_pub is not None:
162
+ for raw in redis_pub.poll_commands(timeout_s=min(0.5, cfg.interval_s)):
163
+ try:
164
+ envelope = parse_command_message(raw)
165
+ apply_temp_command(cfg, runtime, envelope)
166
+ except TempCommandError as exc:
167
+ logger.warning("temps.command rejected: %s", exc)
168
+ except Exception:
169
+ logger.exception("temps.command handling failed")
148 170
try:
149
- run_once(cfg, dry_run=dry_run, redis_pub=redis_pub)
171
+ run_once(cfg, dry_run=dry_run, redis_pub=redis_pub, runtime=runtime)
150 172
except Exception:
151 173
logger.exception("Error in poll cycle")
152 174
elapsed = time.monotonic() - loop_start
projects/hot-tub/src/temp/identity.py
... ...
@@ -45,6 +45,34 @@ def load_probe_map(path: Path) -> ProbeMap:
45 45
return ProbeMap(hot_in=hot_in, tub=tub)
46 46
47 47
48
+def setup_backup_path(path: Path) -> Path:
49
+ return path.with_name(path.name + ".setup-backup")
50
+
51
+
52
+def backup_probe_map(path: Path) -> None:
53
+ """Save current probe map for setup_cancel restore."""
54
+ bak = setup_backup_path(path)
55
+ bak.parent.mkdir(parents=True, exist_ok=True)
56
+ if path.exists():
57
+ bak.write_text(path.read_text())
58
+ else:
59
+ save_probe_map(bak, ProbeMap())
60
+
61
+
62
+def restore_probe_map(path: Path) -> bool:
63
+ bak = setup_backup_path(path)
64
+ if not bak.exists():
65
+ return False
66
+ restored = load_probe_map(bak)
67
+ save_probe_map(path, restored)
68
+ bak.unlink(missing_ok=True)
69
+ return True
70
+
71
+
72
+def clear_probe_map(path: Path) -> None:
73
+ save_probe_map(path, ProbeMap())
74
+
75
+
48 76
def save_probe_map(path: Path, probe_map: ProbeMap) -> None:
49 77
path.parent.mkdir(parents=True, exist_ok=True)
50 78
payload = {"hot_in": probe_map.hot_in, "tub": sorted(probe_map.tub)}
... ...
@@ -179,3 +207,23 @@ def classify_readings(
179 207
spread = max(tub_temps) - min(tub_temps)
180 208
181 209
return labeled, tub_avg, hot_in_f, ok_count, fail_count, spread
210
+
211
+
212
+def role_readings(
213
+ probes_f: dict[str, float | None],
214
+ probe_map: ProbeMap,
215
+) -> dict[str, float | None]:
216
+ """Map hot_in and tub1..tub4 (sorted tub ROM order) to °F or null."""
217
+ roles: dict[str, float | None] = {}
218
+ if probe_map.hot_in:
219
+ roles["hot_in"] = probes_f.get(probe_map.hot_in)
220
+ else:
221
+ roles["hot_in"] = None
222
+ sorted_tub = sorted(probe_map.tub)
223
+ for i in range(TUB_COUNT):
224
+ key = f"tub{i + 1}"
225
+ if i < len(sorted_tub):
226
+ roles[key] = probes_f.get(sorted_tub[i])
227
+ else:
228
+ roles[key] = None
229
+ return roles
projects/hot-tub/src/temp/redis_out.py
... ...
@@ -20,6 +20,7 @@ KEY_SETUP = "temps:setup"
20 20
CHAN_SETUP = "temps.setup"
21 21
KEY_HEALTH = "health:temps"
22 22
CHAN_HEALTH = "health.temps"
23
+CHAN_COMMAND = "temps.command"
23 24
24 25
25 26
class RedisPublisher:
... ...
@@ -28,6 +29,7 @@ class RedisPublisher:
28 29
raise RuntimeError("redis package not installed (apt: python3-redis)")
29 30
self._url = redis_url
30 31
self._client: redis.Redis | None = None
32
+ self._pubsub: redis.client.PubSub | None = None
31 33
self._last_setup_json: str | None = None
32 34
self._backoff_s = 1.0
33 35
... ...
@@ -44,11 +46,46 @@ class RedisPublisher:
44 46
return client
45 47
except Exception as exc:
46 48
logger.warning("Redis unavailable: %s (retry in %.0fs)", exc, self._backoff_s)
49
+ self._pubsub = None
47 50
self._client = None
48 51
time.sleep(min(self._backoff_s, 30.0))
49 52
self._backoff_s = min(self._backoff_s * 2, 30.0)
50 53
return None
51 54
55
+ def subscribe_commands(self) -> redis.client.PubSub | None:
56
+ client = self._ensure()
57
+ if client is None:
58
+ return None
59
+ try:
60
+ if self._pubsub is None:
61
+ self._pubsub = client.pubsub(ignore_subscribe_messages=True)
62
+ self._pubsub.subscribe(CHAN_COMMAND)
63
+ return self._pubsub
64
+ except Exception as exc:
65
+ logger.warning("Redis subscribe failed: %s", exc)
66
+ self._pubsub = None
67
+ self._client = None
68
+ return None
69
+
70
+ def poll_commands(self, timeout_s: float = 0.5) -> list[str]:
71
+ pubsub = self.subscribe_commands()
72
+ if pubsub is None:
73
+ return []
74
+ out: list[str] = []
75
+ deadline = time.monotonic() + timeout_s
76
+ while time.monotonic() < deadline:
77
+ msg = pubsub.get_message(timeout=0.1)
78
+ if msg is None:
79
+ continue
80
+ if msg.get("type") != "message":
81
+ continue
82
+ data = msg.get("data")
83
+ if isinstance(data, str):
84
+ out.append(data)
85
+ if len(out) >= 32:
86
+ break
87
+ return out
88
+
52 89
def publish_reading(self, payload: dict[str, Any]) -> bool:
53 90
client = self._ensure()
54 91
if client is None:
... ...
@@ -58,12 +95,12 @@ class RedisPublisher:
58 95
client.publish(CHAN_READING, body)
59 96
return True
60 97
61
- def publish_setup(self, payload: dict[str, Any]) -> bool:
98
+ def publish_setup(self, payload: dict[str, Any], *, force: bool = False) -> bool:
62 99
client = self._ensure()
63 100
if client is None:
64 101
return False
65 102
body = json.dumps(payload)
66
- if body == self._last_setup_json:
103
+ if not force and body == self._last_setup_json:
67 104
return True
68 105
self._last_setup_json = body
69 106
client.set(KEY_SETUP, body)
... ...
@@ -80,6 +117,13 @@ class RedisPublisher:
80 117
return True
81 118
82 119
def close(self) -> None:
120
+ if self._pubsub is not None:
121
+ try:
122
+ self._pubsub.unsubscribe()
123
+ self._pubsub.close()
124
+ except Exception:
125
+ pass
126
+ self._pubsub = None
83 127
if self._client is not None:
84 128
try:
85 129
self._client.close()
projects/hot-tub/src/temp/runtime.py
... ...
@@ -0,0 +1,19 @@
1
+"""Runtime flags for the temperature daemon (not persisted in config file)."""
2
+
3
+from __future__ import annotations
4
+
5
+from dataclasses import dataclass
6
+
7
+from .config import TempConfig
8
+
9
+
10
+@dataclass
11
+class TempRuntime:
12
+ setup_mode_override: bool | None = None
13
+ setup_active: bool = False
14
+ last_setup_state: str = "need_hot_in"
15
+
16
+ def effective_setup_mode(self, cfg: TempConfig) -> bool:
17
+ if self.setup_mode_override is not None:
18
+ return self.setup_mode_override
19
+ return cfg.setup_mode
projects/hot-tub/src/temp/tests/test_commands.py
... ...
@@ -0,0 +1,44 @@
1
+"""Tests for temps.command handling."""
2
+
3
+from __future__ import annotations
4
+
5
+import tempfile
6
+import unittest
7
+from pathlib import Path
8
+
9
+from temp.commands import TempCommandError, apply_temp_command
10
+from temp.config import TempConfig
11
+from temp.identity import ProbeMap, load_probe_map, save_probe_map
12
+from temp.runtime import TempRuntime
13
+
14
+
15
+class TestTempCommands(unittest.TestCase):
16
+ def setUp(self) -> None:
17
+ self.tmp = tempfile.TemporaryDirectory()
18
+ self.path = Path(self.tmp.name) / "probes.json"
19
+ save_probe_map(self.path, ProbeMap(hot_in="28-h", tub=["28-a", "28-b", "28-c", "28-d"]))
20
+ self.cfg = TempConfig(probe_map_path=self.path)
21
+ self.runtime = TempRuntime(last_setup_state="ready")
22
+
23
+ def tearDown(self) -> None:
24
+ self.tmp.cleanup()
25
+
26
+ def test_setup_start_clears_map(self) -> None:
27
+ apply_temp_command(self.cfg, self.runtime, {"op": "setup_start"})
28
+ self.assertTrue(self.runtime.setup_active)
29
+ self.assertEqual(load_probe_map(self.path).hot_in, None)
30
+
31
+ def test_setup_cancel_restores(self) -> None:
32
+ apply_temp_command(self.cfg, self.runtime, {"op": "setup_start"})
33
+ apply_temp_command(self.cfg, self.runtime, {"op": "setup_cancel"})
34
+ self.assertFalse(self.runtime.setup_active)
35
+ self.assertEqual(load_probe_map(self.path).hot_in, "28-h")
36
+
37
+ def test_setup_finish_requires_ready(self) -> None:
38
+ self.runtime.last_setup_state = "need_water"
39
+ with self.assertRaises(TempCommandError):
40
+ apply_temp_command(self.cfg, self.runtime, {"op": "setup_finish"})
41
+
42
+
43
+if __name__ == "__main__":
44
+ unittest.main()
projects/hot-tub/src/temp/tests/test_identity.py
... ...
@@ -8,8 +8,12 @@ from pathlib import Path
8 8
9 9
from temp.identity import (
10 10
ProbeMap,
11
+ backup_probe_map,
12
+ clear_probe_map,
11 13
evaluate_identity,
12 14
load_probe_map,
15
+ restore_probe_map,
16
+ role_readings,
13 17
save_probe_map,
14 18
)
15 19
... ...
@@ -78,5 +82,35 @@ class TestEvaluateIdentity(unittest.TestCase):
78 82
self.assertEqual(r.setup_state, "ready")
79 83
80 84
85
+class TestRoleReadings(unittest.TestCase):
86
+ def test_roles_sorted_tub_order(self) -> None:
87
+ pm = ProbeMap(hot_in="28-h", tub=["28-d", "28-b", "28-a", "28-c"])
88
+ probes = {"28-h": 112.0, "28-a": 101.0, "28-b": 101.2, "28-c": None, "28-d": 100.9}
89
+ roles = role_readings(probes, pm)
90
+ self.assertEqual(roles["hot_in"], 112.0)
91
+ self.assertEqual(roles["tub1"], 101.0)
92
+ self.assertEqual(roles["tub2"], 101.2)
93
+ self.assertIsNone(roles["tub3"])
94
+ self.assertEqual(roles["tub4"], 100.9)
95
+
96
+ def test_missing_probe_on_bus_is_null(self) -> None:
97
+ pm = ProbeMap(hot_in="28-h", tub=["28-a"])
98
+ roles = role_readings({}, pm)
99
+ self.assertIsNone(roles["hot_in"])
100
+ self.assertIsNone(roles["tub1"])
101
+
102
+
103
+class TestProbeMapBackup(unittest.TestCase):
104
+ def test_backup_clear_restore(self) -> None:
105
+ with tempfile.TemporaryDirectory() as tmp:
106
+ path = Path(tmp) / "probes.json"
107
+ save_probe_map(path, ProbeMap(hot_in="28-h", tub=["28-a"]))
108
+ backup_probe_map(path)
109
+ clear_probe_map(path)
110
+ self.assertEqual(load_probe_map(path).hot_in, None)
111
+ self.assertTrue(restore_probe_map(path))
112
+ self.assertEqual(load_probe_map(path).hot_in, "28-h")
113
+
114
+
81 115
if __name__ == "__main__":
82 116
unittest.main()
projects/hot-tub/src/temp_sim/simulator.py
... ...
@@ -100,6 +100,13 @@ def build_payloads(state: SimState) -> tuple[dict[str, Any], dict[str, Any], dic
100 100
"ts": ts,
101 101
"setup_state": "ready",
102 102
"probes": probes,
103
+ "roles": {
104
+ "hot_in": state.hot_in_f,
105
+ "tub1": state.tub_avg_f,
106
+ "tub2": state.tub_avg_f,
107
+ "tub3": state.tub_avg_f,
108
+ "tub4": state.tub_avg_f,
109
+ },
103 110
"tub_avg_f": state.tub_avg_f,
104 111
"hot_in_f": state.hot_in_f,
105 112
"outdoor_f": None,
... ...
@@ -112,6 +119,7 @@ def build_payloads(state: SimState) -> tuple[dict[str, Any], dict[str, Any], dic
112 119
"state": "ready",
113 120
"detail": "temp_sim diagnostic simulator",
114 121
"visible_ids": list(TUB_PROBE_IDS) + [HOT_PROBE_ID],
122
+ "setup_active": False,
115 123
}
116 124
health_payload = {
117 125
"service": "temps",
projects/hot-tub/src/web/INSTALL.md
... ...
@@ -0,0 +1,89 @@
1
+# Web Service — Installation (Raspberry Pi)
2
+
3
+Serves the React UI and HTTP API on the LAN. Design details: [README.md](README.md).
4
+
5
+## Prerequisites
6
+
7
+- Raspberry Pi OS with Python 3
8
+- **Redis** running locally
9
+- **Temperature daemon** (`hot-tub-temps`) and **control daemon** (`hot-tub-control`) recommended for live data
10
+- Built frontend at `src/web/frontend/dist/` (committed in the repo, or build on a dev machine with Node.js)
11
+
12
+## 1. Install service
13
+
14
+From the hot-tub repository on the Pi:
15
+
16
+```bash
17
+cd /path/to/hot-tub
18
+sudo deploy/web/install.sh
19
+```
20
+
21
+Optional fixed install root:
22
+
23
+```bash
24
+sudo INSTALL_ROOT=/opt/hot-tub deploy/web/install.sh
25
+```
26
+
27
+The script installs `python3-redis`, `python3-flask`, creates `/etc/hot-tub/web.json`, installs systemd unit `hot-tub-web`, and logrotate for `/var/log/hot-tub/web.log`. The service is **enabled on boot**.
28
+
29
+Install order: Redis → `hot-tub-temps` → `hot-tub-control` → `hot-tub-web`.
30
+
31
+## 2. Configure
32
+
33
+Edit `/etc/hot-tub/web.json` if needed:
34
+
35
+| Key | Default | Purpose |
36
+|---|---|---|
37
+| `bind_host` | `0.0.0.0` | Listen address (LAN) |
38
+| `bind_port` | `8080` | HTTP port |
39
+| `redis_url` | `redis://127.0.0.1:6379/0` | Local Redis only |
40
+| `static_dir` | path to `frontend/dist` | Built React app |
41
+| `stale_s` | `15` | Upstream snapshot age for degraded health |
42
+
43
+## 3. Verify
44
+
45
+```bash
46
+systemctl status hot-tub-web
47
+tail -f /var/log/hot-tub/web.log
48
+curl -s http://localhost:8080/api/status | python3 -m json.tool
49
+curl -N http://localhost:8080/api/events
50
+redis-cli GET health:web
51
+```
52
+
53
+From a phone on Wi‑Fi: `http://<pi-ip>:8080/`
54
+
55
+## 4. Development (off the Pi)
56
+
57
+Build the UI when changing frontend code:
58
+
59
+```bash
60
+cd src/web/frontend
61
+npm install
62
+npm run dev # Vite dev server; proxies /api to :8080
63
+npm run build # output: dist/
64
+```
65
+
66
+Run the Python service locally:
67
+
68
+```bash
69
+cd src
70
+PYTHONPATH=. python3 -m web --config web/config.example.json -v
71
+```
72
+
73
+Stack test without hardware: local Redis, `temp_sim`, `python3 -m control --no-gpio`, and `python3 -m web`.
74
+
75
+## 5. Disable / remove
76
+
77
+```bash
78
+sudo systemctl disable --now hot-tub-web
79
+sudo rm /etc/systemd/system/hot-tub-web.service
80
+sudo systemctl daemon-reload
81
+```
82
+
83
+## File locations
84
+
85
+| Path | Purpose |
86
+|---|---|
87
+| `/etc/hot-tub/web.json` | Service config |
88
+| `/var/log/hot-tub/web.log` | Application log |
89
+| `src/web/frontend/dist/` | Static UI served at `/` |
projects/hot-tub/src/web/README.md
... ...
@@ -0,0 +1,412 @@
1
+# Web Interface + API — Developer Guide
2
+
3
+Handoff document for whoever builds the real web service and React UI (human or AI agent). It collects the architecture rules, the data contracts the UI depends on, what the HTML mockup already settles, what is still missing elsewhere in the system, and a suggested order of work.
4
+
5
+**Status (2026-10-19):** Flask web service, React UI (`frontend/`), systemd install, and temp-daemon setup commands are implemented. See [INSTALL.md](INSTALL.md).
6
+
7
+## Read these first
8
+
9
+| Document | Why it matters |
10
+|---|---|
11
+| [interface-requirements.md](interface-requirements.md) | The user's requirements for the UI. Source of truth for screens and behavior. |
12
+| [mockup/](mockup/) | Interactive HTML mockup of every screen, with a fake backend. Visual and behavioral reference. |
13
+| [../../software-architecture.md](../../software-architecture.md) | System design: four services + Redis, ownership rules, the original Web/API section. |
14
+| [../control/README.md](../control/README.md) | Control daemon behavior: modes, `heat_phase` values, commands, published state. **Supersedes the mode list in the architecture doc.** |
15
+| [../temp/README.md](../temp/README.md) | Temperature daemon: probe bundles, teach-in rules, `temps:latest` / `temps:setup`. |
16
+| [../temp_sim/README.md](../temp_sim/README.md) | Simulator that publishes fake temperatures to Redis; useful for testing the web service without probes. |
17
+| [../../pi-zero-control-panel.md](../../pi-zero-control-panel.md), [../../indoor-monitor.md](../../indoor-monitor.md) | Other clients that will talk to this API (see [API contract](#http-api)). |
18
+
19
+## Where the web service fits
20
+
21
+```text
22
+ Browser / phone / tub console
23
+ │ HTTP (LAN)
24
+ ▼
25
+ ┌──────────────────────┐ PUBLISH control.command ┌─────────────────┐
26
+ │ Web service │ ──────────────────────────▶ │ Control daemon │──▶ GPIO relays
27
+ │ (this package) │ │ (src/control) │
28
+ │ │ ◀── SUBSCRIBE control.state ─┤ │
29
+ │ - serves React app │ GET control:latest └─────────────────┘
30
+ │ - /api/events (SSE) │
31
+ │ - /api/command │ ◀── SUBSCRIBE temps.reading ┌─────────────────┐
32
+ │ - health heartbeat │ GET temps:latest/setup │ Temp daemon │──▶ 1-Wire probes
33
+ └──────────┬───────────┘ ── (new) temps.command ────▶ │ (src/temp) │
34
+ │ └─────────────────┘
35
+ └── SET/PUBLISH health:web / health.web ──▶ Health daemon (LEDs, not built yet)
36
+```
37
+
38
+### Rules that must not be broken
39
+
40
+1. **The web service never touches GPIO or the 1-Wire bus.** It only publishes *intents* to Redis. The control daemon is the only process that decides whether heat may run, and it re-validates everything (target clamping, safety interlocks). See "Design Principles" in the architecture doc.
41
+2. **"Intent in, Control decides, state out."** The UI displays what `control.state` says, not what it asked for. Don't flip the UI optimistically and leave it there; wait for the next state snapshot (it arrives within about one second).
42
+3. **Never fake a fresh-looking number.** If the live connection drops or data is stale, the UI must say so (the mockup greys the page and shows "Reconnecting…").
43
+4. **Redis stays on localhost.** The web service is the only thing exposed on the LAN.
44
+5. **No auth in v1.** The Pi is on a private LAN. If remote access is ever wanted, use a VPN (e.g. Tailscale) rather than building a user system (architecture doc, "Open Decisions").
45
+
46
+## What exists today
47
+
48
+| Piece | State | Location |
49
+|---|---|---|
50
+| Temperature daemon | Built, tested, has systemd/install | `src/temp/`, `deploy/temp/` |
51
+| Control daemon | Built, unit tests, has systemd/install | `src/control/`, `deploy/control/` |
52
+| Temperature simulator | Built (manual tool) | `src/temp_sim/` |
53
+| Health daemon (LEDs) | Not built | — |
54
+| Web service + React UI | Built | `src/web/`, `deploy/web/` |
55
+| UI mockup | Built | `src/web/mockup/` |
56
+
57
+## The mockup
58
+
59
+### Running it
60
+
61
+No build step and no dependencies; `mock-backend.js` runs inside the browser.
62
+
63
+```bash
64
+# Same machine: just open the file
65
+xdg-open src/web/mockup/index.html
66
+
67
+# From a phone on the LAN
68
+cd src/web/mockup && python3 -m http.server 8080 --bind 0.0.0.0
69
+# then browse to http://<this-machine-ip>:8080/
70
+```
71
+
72
+Each browser tab runs its own independent simulated tub and resets on reload.
73
+
74
+### Files
75
+
76
+| File | Role | Fate in the real app |
77
+|---|---|---|
78
+| `index.html` | Shell that loads the two scripts | Becomes Vite's `index.html` |
79
+| `styles.css` | All styling, CSS variables, responsive breakpoints, animations | Reuse almost verbatim (global CSS or split per component) |
80
+| `app.js` | UI: state, routing, components, event handling | Port to React components (see map below) |
81
+| `mock-backend.js` | Fake API + fake tub physics + the dashed "Mock backend" panel | **Delete**; replace with a real API client. Optionally keep as a dev-only mock. |
82
+
83
+### Component map (mockup function → React component)
84
+
85
+Every PascalCase function in `app.js` takes a props object and returns markup, so each maps 1:1 to a React component:
86
+
87
+| Mockup function | Purpose |
88
+|---|---|
89
+| `App`, `Page` | Layout + route switch |
90
+| `Header`, `ConnectionBadge` | Brand, the two nav tabs, live/reconnecting badge |
91
+| `StatusLight` | Reusable green / yellow (pulsing) / red / grey indicator |
92
+| `ControlPage` | Main Control screen |
93
+| `ErrorBanner` | "Heater problem" banner with "Reset (set Sleep)" |
94
+| `TubGraphic`, `Duck` | Horse-trough SVG with animated water, steam, duck; temp shown in the water |
95
+| `Pump` | Fan SVG; blades spin + green light when running |
96
+| `TargetTemp` | Target °F with −/+ buttons (disabled at 50 / 110 °F) |
97
+| `ModeSelects` | Heat mode (Sleep / Maintain) and filter mode (Auto / On / Off) dropdowns |
98
+| `StatusSummary` | Heater phase label, hot-input temp, filter status |
99
+| `DiagnosticPage` | Backflush + Sensor Setup cards |
100
+| `BackflushAnimation` | Droplet with spinning arrows + bubbles |
101
+| `SensorReadings`, `ProbeRow` | Per-probe readings with status lights |
102
+| `SensorSetupPage`, `Stepper`, `SetupStep1..3` | Three-step sensor teach-in wizard |
103
+
104
+Non-component pieces and their React equivalents:
105
+
106
+| Mockup | React |
107
+|---|---|
108
+| Single `state` object + `setState` | One `useReducer` at the top (or a small context). State is small; no Redux needed. |
109
+| `data-action` attributes + delegated click/change handlers | `onClick` / `onChange` props |
110
+| Hash routes `#/control`, `#/diagnostic`, `#/diagnostic/sensor-setup/:step` | react-router. Hash routing (`HashRouter`) avoids needing server-side fallback routes; browser routing also works if the server returns `index.html` for unknown paths. |
111
+| `patch()` DOM differ | Delete. It exists only so CSS animations don't restart on each update; React does this already. **Keep SVG structure stable across renders** (toggle classes, don't conditionally mount the fan blades or water) or animations will restart. |
112
+| `window.HotTubApi` | An `api.js` module + a `useHotTubEvents()` hook wrapping `EventSource` |
113
+
114
+### UI decisions made in the mockup
115
+
116
+- **Two mode dropdowns, not one.** The requirements say "a dropdown to set the mode", but the control daemon has two independent modes (`heat_mode`, `filter_mode`), so the mockup shows both.
117
+- **Backflush is not in the heat-mode dropdown.** It is started only from the Diagnostic page. While it runs, the dropdown shows a disabled "Backflush (running)" option so the select still displays a valid value.
118
+- **Backflush button state:** disabled from the moment it's pressed (local `backflushRequested` flag) until `control.state` shows backflush has finished. The local flag covers the gap before the first state update arrives. It re-enables when `heat_mode` and `heat_phase` are no longer `backflush`. There's no countdown because the control daemon doesn't publish remaining time.
119
+- **Startup failure** (`heat_phase = error_startup`): red banner with the error detail and a "Reset (set Sleep)" button. Setting `heat_mode = sleep` is the control daemon's documented reset path.
120
+- **Steam** shows when the heat pump is on or water is ≥ 95 °F (purely decorative).
121
+- **Animations** respect `prefers-reduced-motion`.
122
+- **Responsive breakpoints:** ≥ 900 px two columns on Main Control; ≥ 760 px two columns on Diagnostic; < 600 px nav tabs move to a full-width second header row.
123
+
124
+### Where the mockup's fake data differs from the real system
125
+
126
+These are the traps when swapping in the real backend:
127
+
128
+| Topic | Mockup | Real system | What to do |
129
+|---|---|---|---|
130
+| Probe keys in `temps.reading` | `probes: { hot_in, tub1..tub4 }` keyed by **role** | `probes` keyed by **ROM id** (`"28-0123…"`) | Temp daemon should publish by role (see [Gap 1](#gap-1-role-labeled-probe-readings)). |
131
+| Unplugged probe | Present with value `null` | **Absent** from `probes`. `null` means "on the bus but the read failed" (CRC error / 85 °C sentinel) | UI must treat "missing key" and "null" as different states. |
132
+| Detection delay | Instant | The Linux 1-Wire driver can take several seconds to add or remove devices after a plug change, and readings arrive every 5 s | Wizard lights may lag; don't add timeouts that assume instant detection. |
133
+| Setup mode | `startSensorSetup()` etc. do nothing | No runtime way to enter teach-in today | See [Gap 2](#gap-2-runtime-sensor-setup-mode). |
134
+| Startup check timing | 3 s per attempt | 20 s per attempt + 5 s pause between attempts (`maintain.startup_delay_s`, `retry_off_s`) | A failed startup takes about a minute and a half to reach `error_startup`. |
135
+| Heating rate | 0.15 °F/s (demo speed) | Real propane heater: very slow | — |
136
+| Command acknowledgement | `sendCommand` resolves with `{id}` | Control daemon doesn't reply; it sets `last_command_id` in its next `control.state` | Web service generates the `id`, returns it from POST; UI can match it against `last_command_id` if it wants confirmation. |
137
+| `error_safety` phase | Never produced | Produced when temps go stale / `need_reinstall` while heating | UI already has a label for it ("Blocked by safety check"). Consider a banner too. |
138
+
139
+## Data contracts
140
+
141
+The UI should only depend on these shapes. They're produced by the other daemons today (except where marked).
142
+
143
+### `control.state` / `control:latest` (from `src/control/state.py`, `state_snapshot`)
144
+
145
+```json
146
+{
147
+ "ts": "2026-10-19T20:00:05Z",
148
+ "heat_mode": "maintain", // sleep | maintain | backflush
149
+ "filter_mode": "auto", // on | auto | off
150
+ "target_f": 102.0,
151
+ "tub_avg_f": 98.5, // null if no valid temps
152
+ "hot_in_f": 112.0, // null if missing
153
+ "heat_pump": "on", // on | off (actual relay state)
154
+ "filter_pump": "off",
155
+ "heat_phase": "heating", // idle | startup_1..3 | heating | backflush | error_startup | error_safety
156
+ "startup_attempt": 1,
157
+ "error": null, // or { "code": "startup_failed" | "safety", "detail": "..." }
158
+ "last_command_id": "…",
159
+ "filter_window_active": false,
160
+ "filter_next_start": "2026-10-20T06:00:00Z" // UTC; null unless filter_mode=auto and outside window
161
+}
162
+```
163
+
164
+Published on every change and at least every 5 s (`heartbeat_s`). Times are UTC with a `Z` suffix; display in local time.
165
+
166
+### `temps.reading` / `temps:latest` (from `src/temp/daemon.py`, `build_sample`)
167
+
168
+```json
169
+{
170
+ "ts": "2026-10-19T20:00:00Z",
171
+ "setup_state": "ready", // ready | need_hot_in | need_water | need_reinstall
172
+ "probes": { "28-aaa": 101.2, "28-bbb": null }, // by ROM id today, see Gap 1
173
+ "tub_avg_f": 101.0,
174
+ "hot_in_f": 112.0,
175
+ "outdoor_f": null,
176
+ "ok_count": 4,
177
+ "fail_count": 0,
178
+ "spread_f": 0.6
179
+}
180
+```
181
+
182
+Published every 5 s (`interval_s`). The control daemon treats readings older than 15 s as stale.
183
+
184
+### `temps.setup` / `temps:setup`
185
+
186
+```json
187
+{ "ts": "…", "state": "need_water", "detail": "Learned hot_in=28-zzz", "visible_ids": ["28-zzz"] }
188
+```
189
+
190
+Published only when it changes. Useful for the wizard (see [Gap 2](#gap-2-runtime-sensor-setup-mode)).
191
+
192
+### Commands → `control.command` (validated in `src/control/commands.py`)
193
+
194
+Envelope:
195
+
196
+```json
197
+{ "ts": "2026-10-19T20:00:00Z", "source": "web", "id": "uuid", "op": "set_heat_mode", "args": { "mode": "maintain" } }
198
+```
199
+
200
+| `op` | `args` | Used by UI for |
201
+|---|---|---|
202
+| `set_heat_mode` | `mode`: `sleep` \| `maintain` \| `backflush` | Heat dropdown, Backflush button, Reset button |
203
+| `set_target_temp` | `target_f` | (absolute set; tub console / API) |
204
+| `target_temp_increment` | optional `delta_f` (default 1) | + button |
205
+| `target_temp_decrement` | optional `delta_f` (default 1) | − button |
206
+| `set_filter_mode` | `mode`: `on` \| `auto` \| `off` | Filter dropdown |
207
+
208
+Unknown ops or bad args are logged and ignored by the control daemon; there is no error reply. The web service should validate the same rules itself and return HTTP 400, so the UI gets immediate feedback.
209
+
210
+### Health → `health.web` / `health:web` (the web service must publish this)
211
+
212
+Same shape as the other services:
213
+
214
+```json
215
+{ "service": "web", "ts": "…", "status": "ok", "detail": "serving; redis ok", "pid": 1234 }
216
+```
217
+
218
+| `status` | When |
219
+|---|---|
220
+| `ok` | Accepting HTTP, Redis reachable |
221
+| `degraded` | Redis flaky, or `control:latest` / `temps:latest` stale |
222
+| `error` | Redis down |
223
+
224
+Publish every ~5 s and on change. Use SET + PUBLISH with identical JSON, like `src/temp/redis_out.py`.
225
+
226
+## HTTP API
227
+
228
+Recommended contract. Settle it before writing the React client:
229
+
230
+| Method | Path | Body | Response / behavior |
231
+|---|---|---|---|
232
+| `GET` | `/api/events` | — | Server-Sent Events. On connect, immediately send current `control:latest` and `temps:latest` (and `temps:setup`), then forward each Redis message. Event names = channel names: `control.state`, `temps.reading`, `temps.setup`. Send a comment line every ~15 s as keepalive. |
233
+| `POST` | `/api/command` | `{ "op": "...", "args": {...} }` | Validate, add `ts` / `source: "web"` / `id`, publish to `control.command`. Return `202 { "id": "..." }` or `400 { "error": "..." }`. |
234
+| `GET` | `/api/status` | — | `{ "control": <control:latest>, "temps": <temps:latest>, "health": { temps, control, web } }` for polling clients |
235
+| `GET` | `/api/health` | — | Web service health + ages of upstream keys |
236
+| `POST` | `/api/sensor-setup/start` | — | Depends on Gap 2 |
237
+| `POST` | `/api/sensor-setup/finish` | — | Depends on Gap 2 |
238
+| `POST` | `/api/sensor-setup/cancel` | — | Depends on Gap 2 |
239
+| `GET` | `/` and static assets | — | Built React app |
240
+
241
+SSE was chosen over WebSockets because traffic is one-way (server to browser), browsers reconnect `EventSource` automatically, and commands are plain POSTs. The architecture doc lists this as an open decision, and the requirements accept "SSE or websockets".
242
+
243
+**Conflicting older contract:** [pi-zero-control-panel.md](../../pi-zero-control-panel.md) sketches `GET /status`, `POST /heating {enabled}`, `POST /target {target_f | delta_f}` with differently named fields (`average_f`, `heating_enabled`, …). It predates the control daemon. Either update that doc to use `/api/status` + `/api/command`, or add thin compatibility routes that translate (`enabled: true` → `set_heat_mode maintain`, `false` → `sleep`). Decide before the tub console is built. [indoor-monitor.md](../../indoor-monitor.md) only needs target ±1 and status, routed through the same command path.
244
+
245
+## Gaps in other services that the UI depends on
246
+
247
+### Gap 1: role-labeled probe readings
248
+
249
+The Diagnostic readings list and wizard step 3 show **Hot input, Tub 1–4**, but `temps:latest.probes` is keyed by ROM id. The temperature daemon already knows the roles (`ProbeMap` in `src/temp/identity.py`).
250
+
251
+**Recommended fix (temp daemon):** add a role-keyed field to the reading, e.g.
252
+
253
+```json
254
+"roles": { "hot_in": 112.0, "tub1": 101.2, "tub2": 101.0, "tub3": null, "tub4": 100.9 }
255
+```
256
+
257
+Order `tub1..4` by the sorted ROM ids in the probe map (already sorted when saved). Distinguish "not on bus" from "read failed": either omit missing roles or add a parallel `"present": {...}` map. Keep `probes` as-is so nothing else breaks. Add unit tests next to `src/temp/tests/test_identity.py`.
258
+
259
+Translating in the web service using `/etc/hot-tub/temp-probes.json` is possible but duplicates identity logic in a second process. Avoid that.
260
+
261
+### Gap 2: runtime sensor setup mode
262
+
263
+Teach-in currently requires editing `setup_mode` in `/etc/hot-tub/temp.json` (or the `--setup` flag) and restarting `hot-tub-temps`. The wizard needs to start and stop it at runtime.
264
+
265
+How teach-in works (from `src/temp/identity.py`, `_try_learn`), which constrains the design:
266
+
267
+- Nothing is learned once the probe map is complete. **Starting setup must clear the probe map**, not just set `setup_mode`.
268
+- With an empty map: **1 probe visible → learned as hot-in**; **4 probes visible (setup mode only) → learned as the water set**.
269
+- With one side known: **5 visible → the other side is derived** by set difference.
270
+- **5 visible with an empty map → `need_reinstall`.** This is exactly why wizard step 1 makes the user unplug everything first.
271
+
272
+**Recommended design:**
273
+
274
+1. Temp daemon subscribes to a new channel `temps.command` with ops `setup_start` (back up then clear the probe map, enable setup mode), `setup_finish` (disable setup mode; only valid when `setup_state == ready`), `setup_cancel` (restore the backed-up map, disable setup mode). Publish the active flag in `temps:setup` (e.g. `"setup_active": true`).
275
+2. Web service maps `/api/sensor-setup/{start,finish,cancel}` to those ops.
276
+3. Wizard gating should use **`setup_state` / learned roles**, not just "a reading appeared":
277
+ - Step 1 → 2: user confirms disconnection; call `start`. Show a warning if probes are still visible (the map would otherwise be learned wrong).
278
+ - Step 2 Continue: enable when `setup_state` becomes `need_water` (hot-in learned) or `need_hot_in` with 4 tub roles present (water learned).
279
+ - Step 3 Finish: enable when `setup_state == ready` and all five roles report values. Then call `finish`.
280
+4. While setup is active the control daemon sees no valid tub average and holds heat off. That's the desired behavior; the UI should say "Heating paused during sensor setup".
281
+
282
+The interface requirements say Continue/Finish enable "when temperature readings start coming in". Gating on learned roles meets that intent and avoids letting the user continue before the daemon has actually stored the IDs.
283
+
284
+## Known issues to fix first
285
+
286
+Found while writing this guide (reproduced with a direct `tick_heat` script). Both are in `src/control/heat_fsm.py`, in the `heat_mode == "backflush"` branch, and both directly affect the Backflush button:
287
+
288
+1. **Backflush never finishes when the tub is at or above target.** `check_heat_allowed` refuses heat when `tub_avg_f >= target_f`, and the backflush branch returns before checking the timer. Result: `heat_mode` stays `backflush` with the pump off forever, and the UI's Backflush button stays disabled. Observed: `tick 1..3: mode=backflush phase=backflush pump=off`.
289
+2. **With stale or missing temps, the heat pump turns on and off every second.** The safety trip sets `heat_phase = error_safety`; on the next tick the branch sees `heat_phase != "backflush"` and starts a new backflush with the pump on. Observed: `pump=on, off, on, off`.
290
+
291
+Likely fix direction: backflush should not use the maintain "below target" gate (the control README says only *other* safety interlocks apply). On a real safety trip it should end the backflush and revert `heat_mode` to `sleep` instead of re-entering it. Add regression tests to `src/control/tests/test_heat_fsm.py` for both cases.
292
+
293
+## Implementation plan
294
+
295
+Suggested order. Each step is independently testable.
296
+
297
+### Step 0: Fix control backflush bugs
298
+
299
+See above. Without this, the Backflush button can get stuck disabled.
300
+
301
+### Step 1: Temperature daemon changes (Gaps 1 and 2)
302
+
303
+- Role-keyed readings + tests.
304
+- `temps.command` subscription, setup start/finish/cancel with probe-map backup + tests.
305
+- Update `src/temp/README.md` and `INSTALL.md` (teach-in can now be done from the UI).
306
+
307
+### Step 2: Web service (Python)
308
+
309
+Suggested layout, mirroring `src/temp/` and `src/control/`:
310
+
311
+```text
312
+src/web/
313
+ README.md ← this file
314
+ interface-requirements.md
315
+ mockup/ ← keep as reference
316
+ __init__.py
317
+ __main__.py # python -m web --config /etc/hot-tub/web.json [--dev]
318
+ config.py # bind host/port, redis_url, log_path, static_dir, stale thresholds
319
+ logging_setup.py # /var/log/hot-tub/web.log (same pattern as other daemons)
320
+ redis_io.py # GET snapshots, pubsub fan-out, publish command, health
321
+ commands.py # validate op/args (mirror src/control/commands.py rules)
322
+ server.py # HTTP routes + SSE
323
+ health.py # web health computation + heartbeat thread
324
+ config.example.json
325
+ tests/ # unittest: command validation, SSE framing, health rules
326
+ frontend/ # React app (Step 3)
327
+deploy/web/
328
+ hot-tub-web.service # User=hot-tub, After=redis-server.service, Restart=on-failure
329
+ hot-tub-web.logrotate
330
+ install.sh
331
+src/web/INSTALL.md
332
+```
333
+
334
+Framework choice (open, recommendation first):
335
+
336
+- **Flask** (`python3-flask` via apt, matches the existing `temp-sensor/app.py`). SSE works with a streaming generator response; run threaded (`threaded=True` or `waitress`) because each SSE client holds a connection. Simplest to deploy.
337
+- **FastAPI + uvicorn** (`python3-fastapi`, `python3-uvicorn` in Debian bookworm). Cleaner async SSE, more moving parts.
338
+
339
+Either way, use **one Redis pubsub connection** that fans out to per-client queues, not one subscription per browser tab. Config defaults: bind `0.0.0.0:8080`, Redis `redis://127.0.0.1:6379/0`.
340
+
341
+Conventions to keep consistent with the other services:
342
+
343
+- Package importable as `web` with `PYTHONPATH=src`; runs as `python3 -m web`.
344
+- Runs as system user `hot-tub`; config in `/etc/hot-tub/web.json`; log to `/var/log/hot-tub/web.log` via stdlib `logging` + system logrotate (`copytruncate`).
345
+- Redis reconnect with exponential backoff (copy the pattern in `src/temp/redis_out.py` / `src/control/redis_io.py`).
346
+- Install script is idempotent and doesn't reinstall Redis if present (see `deploy/control/install.sh`).
347
+- Tests use stdlib `unittest`, runnable off the Pi: `PYTHONPATH=. python3 -m unittest discover -s web/tests -v`.
348
+
349
+### Step 3: React app
350
+
351
+- `src/web/frontend/`, Vite + React (TypeScript optional but recommended for the payload types above).
352
+- Port components from `mockup/app.js`; reuse `mockup/styles.css`.
353
+- `api.js`: `connect()` → `new EventSource("/api/events")` with `addEventListener("control.state", …)` etc.; `sendCommand(op, args)` → `fetch("/api/command", { method: "POST", … })`.
354
+- Connection badge: `open` → Live; `EventSource.onerror` → Reconnecting (it auto-retries). Also mark data stale if `ts` is older than ~15 s even while connected (upstream daemon died).
355
+- Dev: Vite dev server with `server.proxy` for `/api` → the Python service (on the Pi, or locally with Redis + `temp_sim` + control daemon in `--dry-run`).
356
+- **Build on a dev machine, not on the Pi 3.** Output to `src/web/frontend/dist/`; the Python service serves that directory. Recommended: commit `dist/` (or have `deploy/web/install.sh` require it) so the Pi never needs Node.
357
+
358
+### Step 4: End-to-end on the Pi
359
+
360
+- Install order: Redis → `hot-tub-temps` → `hot-tub-control` → `hot-tub-web`.
361
+- Verify from a phone: live temps, ± target, mode changes reflected in `redis-cli GET control:latest`, backflush completes and re-enables, wizard with real bundles, `health:web` present.
362
+
363
+## Testing without hardware
364
+
365
+Target setup for running the whole stack off the Pi (everything except real probes and relays):
366
+
367
+```bash
368
+redis-server & # local Redis
369
+cd src
370
+PYTHONPATH=. python3 -m temp_sim # fake temps; reacts to control state
371
+PYTHONPATH=. python3 -m control ... # needs a "Redis on, GPIO faked" mode (see below)
372
+PYTHONPATH=. python3 -m web # to be built
373
+```
374
+
375
+**Missing piece:** `python3 -m control --dry-run` currently skips Redis *and* GPIO, so it can't take part in this loop, and without `--dry-run` it requires `gpiozero` and real pins. Add a small flag to the control daemon (e.g. `--no-gpio`) that builds `RelayOutputs(dry_run=True)` but keeps `ControlRedis`. Until then, inject state by hand with `redis-cli SET control:latest '…'` (examples in [temp_sim/README.md](../temp_sim/README.md)).
376
+
377
+Useful manual checks:
378
+
379
+```bash
380
+redis-cli SUBSCRIBE control.command # see what the web service publishes
381
+redis-cli PUBLISH control.command '{"op":"set_heat_mode","args":{"mode":"sleep"},"id":"t1","source":"cli"}'
382
+redis-cli GET control:latest
383
+curl -N http://localhost:8080/api/events # watch the SSE stream
384
+```
385
+
386
+`temp_sim` always reports `setup_state: ready` with fake IDs, so it cannot exercise the wizard. Wizard testing needs real probes or an extension to `temp_sim`.
387
+
388
+## Open decisions
389
+
390
+| Decision | Recommendation |
391
+|---|---|
392
+| Flask vs FastAPI | Flask + threaded server (apt-installable, simplest) |
393
+| Hash vs browser routing | Hash routing (no server fallback needed) |
394
+| Commit built `dist/` or build during install | Commit `dist/` (Pi 3 shouldn't run Node) |
395
+| Old Pi Zero API (`/status`, `/heating`, `/target`) | Update that doc to the `/api/*` contract; add compatibility routes only if needed |
396
+| Expose filter auto schedule / `band_f` in UI | Not in v1; config-file only (control README "Open decisions") |
397
+| History charts (architecture "UI capabilities") | Out of scope until the control daemon writes history (SQLite not built) |
398
+| Health summary on the Diagnostic page | Nice to have once the Health daemon exists; `health:*` keys are readable now |
399
+| Auth | None in v1, LAN only |
400
+
401
+## Acceptance checklist (from the requirements)
402
+
403
+- [x] Responsive: large, medium, phone.
404
+- [x] Live updates via SSE; visible reconnecting/stale state.
405
+- [x] Main Control: trough graphic with current °F in the water.
406
+- [x] Heat and filter fans below the tub; spin + green light when running.
407
+- [x] Target °F with + / − buttons.
408
+- [x] Mode dropdown(s).
409
+- [x] Diagnostic: big Backflush button: starts backflush, disables, shows animation, re-enables when finished.
410
+- [x] Diagnostic: big Sensor Setup button leading to the 3-step wizard with indicator lights and gated Continue / Finish, returning to Diagnostic.
411
+- [x] Web publishes `health:web`.
412
+- [x] systemd unit, logrotate, install script, `INSTALL.md`.
projects/hot-tub/src/web/__init__.py
... ...
@@ -0,0 +1 @@
1
+"""Hot tub web service and API."""
projects/hot-tub/src/web/__main__.py
... ...
@@ -0,0 +1,71 @@
1
+"""CLI entry: python -m web"""
2
+
3
+from __future__ import annotations
4
+
5
+import argparse
6
+import signal
7
+import sys
8
+import threading
9
+from pathlib import Path
10
+
11
+from .config import load_config
12
+from .health import start_health_thread
13
+from .logging_setup import setup_logging
14
+from .redis_io import WebRedis
15
+from .server import run_server
16
+
17
+
18
+def _path_writable(dir_path: Path) -> bool:
19
+ try:
20
+ dir_path.mkdir(parents=True, exist_ok=True)
21
+ test = dir_path / ".write_test"
22
+ test.write_text("")
23
+ test.unlink()
24
+ return True
25
+ except OSError:
26
+ return False
27
+
28
+
29
+def main() -> None:
30
+ parser = argparse.ArgumentParser(description="Hot tub web service")
31
+ parser.add_argument(
32
+ "--config",
33
+ type=Path,
34
+ default=Path("/etc/hot-tub/web.json"),
35
+ help="Path to JSON config (default: /etc/hot-tub/web.json)",
36
+ )
37
+ parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
38
+ args = parser.parse_args()
39
+
40
+ cfg_path = args.config if args.config.exists() else None
41
+ if cfg_path is None:
42
+ dev_example = Path(__file__).resolve().parent / "config.example.json"
43
+ cfg_path = dev_example if dev_example.exists() else None
44
+
45
+ cfg = load_config(cfg_path)
46
+
47
+ if not _path_writable(cfg.log_path.parent):
48
+ cfg.log_path = Path("/tmp/hot-tub-web.log")
49
+
50
+ setup_logging(cfg.log_path, verbose=args.verbose)
51
+
52
+ redis_io = WebRedis(cfg.redis_url)
53
+ stop = threading.Event()
54
+
55
+ def _shutdown(_signum: int, _frame: object) -> None:
56
+ stop.set()
57
+ sys.exit(0)
58
+
59
+ signal.signal(signal.SIGTERM, _shutdown)
60
+ signal.signal(signal.SIGINT, _shutdown)
61
+
62
+ start_health_thread(redis_io, cfg, stop)
63
+ try:
64
+ run_server(cfg, redis_io)
65
+ finally:
66
+ stop.set()
67
+ redis_io.close()
68
+
69
+
70
+if __name__ == "__main__":
71
+ main()
projects/hot-tub/src/web/commands.py
... ...
@@ -0,0 +1,76 @@
1
+"""Validate control.command payloads before publishing."""
2
+
3
+from __future__ import annotations
4
+
5
+from typing import Any
6
+
7
+HEAT_MODES = frozenset({"sleep", "maintain", "backflush"})
8
+FILTER_MODES = frozenset({"on", "auto", "off"})
9
+CONTROL_OPS = frozenset(
10
+ {
11
+ "set_heat_mode",
12
+ "set_target_temp",
13
+ "target_temp_increment",
14
+ "target_temp_decrement",
15
+ "set_filter_mode",
16
+ }
17
+)
18
+TEMP_SETUP_OPS = frozenset({"setup_start", "setup_finish", "setup_cancel"})
19
+
20
+TARGET_F_MIN = 50.0
21
+TARGET_F_MAX = 110.0
22
+
23
+
24
+class CommandValidationError(ValueError):
25
+ pass
26
+
27
+
28
+def _clamp_target(target: float) -> float:
29
+ return max(TARGET_F_MIN, min(TARGET_F_MAX, target))
30
+
31
+
32
+def validate_control_command(body: dict[str, Any]) -> tuple[str, dict[str, Any]]:
33
+ op = body.get("op")
34
+ if not op or not isinstance(op, str):
35
+ raise CommandValidationError("missing or invalid op")
36
+ if op not in CONTROL_OPS:
37
+ raise CommandValidationError(f"unknown op: {op!r}")
38
+
39
+ args = body.get("args")
40
+ if args is not None and not isinstance(args, dict):
41
+ raise CommandValidationError("args must be an object")
42
+ args = dict(args or {})
43
+
44
+ if op == "set_heat_mode":
45
+ mode = args.get("mode")
46
+ if mode not in HEAT_MODES:
47
+ raise CommandValidationError(f"invalid heat mode: {mode!r}")
48
+ return op, {"mode": mode}
49
+
50
+ if op == "set_filter_mode":
51
+ mode = args.get("mode")
52
+ if mode not in FILTER_MODES:
53
+ raise CommandValidationError(f"invalid filter mode: {mode!r}")
54
+ return op, {"mode": mode}
55
+
56
+ if op == "set_target_temp":
57
+ raw = args.get("target_f")
58
+ if raw is None:
59
+ raise CommandValidationError("target_f required")
60
+ return op, {"target_f": _clamp_target(float(raw))}
61
+
62
+ if op == "target_temp_increment":
63
+ delta = float(args.get("delta_f", 1))
64
+ return op, {"delta_f": delta}
65
+
66
+ if op == "target_temp_decrement":
67
+ delta = float(args.get("delta_f", 1))
68
+ return op, {"delta_f": delta}
69
+
70
+ raise CommandValidationError(f"unknown op: {op!r}")
71
+
72
+
73
+def validate_temp_setup_op(op: str) -> str:
74
+ if op not in TEMP_SETUP_OPS:
75
+ raise CommandValidationError(f"unknown setup op: {op!r}")
76
+ return op
projects/hot-tub/src/web/config.example.json
... ...
@@ -0,0 +1,10 @@
1
+{
2
+ "bind_host": "0.0.0.0",
3
+ "bind_port": 8080,
4
+ "redis_url": "redis://127.0.0.1:6379/0",
5
+ "log_path": "/var/log/hot-tub/web.log",
6
+ "static_dir": "frontend/dist",
7
+ "stale_s": 15,
8
+ "heartbeat_s": 5,
9
+ "sse_keepalive_s": 15
10
+}
projects/hot-tub/src/web/config.py
... ...
@@ -0,0 +1,45 @@
1
+"""Load web service configuration."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from dataclasses import dataclass
7
+from pathlib import Path
8
+
9
+
10
+@dataclass
11
+class WebConfig:
12
+ bind_host: str = "0.0.0.0"
13
+ bind_port: int = 8080
14
+ redis_url: str = "redis://127.0.0.1:6379/0"
15
+ log_path: Path = Path("/var/log/hot-tub/web.log")
16
+ static_dir: Path = Path(__file__).resolve().parent / "frontend" / "dist"
17
+ stale_s: float = 15.0
18
+ heartbeat_s: float = 5.0
19
+ sse_keepalive_s: float = 15.0
20
+
21
+
22
+def load_config(path: Path | None) -> WebConfig:
23
+ cfg = WebConfig()
24
+ if path is None or not path.exists():
25
+ return cfg
26
+ data = json.loads(path.read_text())
27
+ if "bind_host" in data:
28
+ cfg.bind_host = str(data["bind_host"])
29
+ if "bind_port" in data:
30
+ cfg.bind_port = int(data["bind_port"])
31
+ if "redis_url" in data:
32
+ cfg.redis_url = str(data["redis_url"])
33
+ if "log_path" in data:
34
+ cfg.log_path = Path(data["log_path"])
35
+ if "static_dir" in data:
36
+ cfg.static_dir = Path(data["static_dir"])
37
+ if not cfg.static_dir.is_absolute():
38
+ cfg.static_dir = Path(__file__).resolve().parent / cfg.static_dir
39
+ if "stale_s" in data:
40
+ cfg.stale_s = float(data["stale_s"])
41
+ if "heartbeat_s" in data:
42
+ cfg.heartbeat_s = float(data["heartbeat_s"])
43
+ if "sse_keepalive_s" in data:
44
+ cfg.sse_keepalive_s = float(data["sse_keepalive_s"])
45
+ return cfg
projects/hot-tub/src/web/frontend/.gitignore
... ...
@@ -0,0 +1,23 @@
1
+# Logs
2
+logs
3
+*.log
4
+npm-debug.log*
5
+yarn-debug.log*
6
+yarn-error.log*
7
+pnpm-debug.log*
8
+lerna-debug.log*
9
+
10
+node_modules
11
+dist-ssr
12
+*.local
13
+
14
+# Editor directories and files
15
+.vscode/*
16
+!.vscode/extensions.json
17
+.idea
18
+.DS_Store
19
+*.suo
20
+*.ntvs*
21
+*.njsproj
22
+*.sln
23
+*.sw?
projects/hot-tub/src/web/frontend/.oxlintrc.json
... ...
@@ -0,0 +1,8 @@
1
+{
2
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3
+ "plugins": ["react", "typescript", "oxc"],
4
+ "rules": {
5
+ "react/rules-of-hooks": "error",
6
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
7
+ }
8
+}
projects/hot-tub/src/web/frontend/README.md
... ...
@@ -0,0 +1,32 @@
1
+# React + TypeScript + Vite
2
+
3
+This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
4
+
5
+Currently, two official plugins are available:
6
+
7
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+## React Compiler
11
+
12
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+## Expanding the Oxlint configuration
15
+
16
+If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
17
+
18
+```json
19
+{
20
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
21
+ "plugins": ["react", "typescript", "oxc"],
22
+ "options": {
23
+ "typeAware": true
24
+ },
25
+ "rules": {
26
+ "react/rules-of-hooks": "error",
27
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
28
+ }
29
+}
30
+```
31
+
32
+See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
projects/hot-tub/src/web/frontend/dist/assets/index-BUdY2Uw4.js
... ...
@@ -0,0 +1,11 @@
1
+var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t,t.exports),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,o)=>(o=n==null?{}:e(i(n)),s(r||!n||!n.__esModule||!a.call(n,`default`)?t(o,`default`,{value:n,enumerable:!0}):o,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.for(`react.view_transition`),m=Symbol.iterator;function h(e){return typeof e!=`object`||!e?null:(e=m&&e[m]||e[`@@iterator`],typeof e==`function`?e:null)}var g={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},_=Object.assign,v={};function y(e,t,n){this.props=e,this.context=t,this.refs=v,this.updater=n||g}y.prototype.isReactComponent={},y.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},y.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function b(){}b.prototype=y.prototype;function ee(e,t,n){this.props=e,this.context=t,this.refs=v,this.updater=n||g}var te=ee.prototype=new b;te.constructor=ee,_(te,y.prototype),te.isPureReactComponent=!0;var x=Array.isArray;function ne(){}var S={H:null,A:null,T:null,S:null},re=Object.prototype.hasOwnProperty;function C(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ie(e,t){return C(e.type,t,e.props)}function ae(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function w(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var T=/\/+/g;function oe(e,t){return typeof e==`object`&&e&&e.key!=null?w(``+e.key):t.toString(36)}function se(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(ne,ne):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ce(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ce(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+oe(e,0):a,x(o)?(i=``,c!=null&&(i=c.replace(T,`$&/`)+`/`),ce(o,r,i,``,function(e){return e})):o!=null&&(ae(o)&&(o=ie(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(T,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(x(e))for(var u=0;u<e.length;u++)a=e[u],s=l+oe(a,u),c+=ce(a,r,i,s,o);else if(u=h(e),typeof u==`function`)for(e=u.call(e),u=0;!(a=e.next()).done;)a=a.value,s=l+oe(a,u++),c+=ce(a,r,i,s,o);else if(s===`object`){if(typeof e.then==`function`)return ce(se(e),r,i,a,o);throw r=String(e),Error(`Objects are not valid as a React child (found: `+(r===`[object Object]`?`object with keys {`+Object.keys(e).join(`, `)+`}`:r)+`). If you meant to render a collection of children, use an array instead.`)}return c}function E(e,t,n){if(e==null)return e;var r=[],i=0;return ce(e,r,``,``,function(e){return t.call(n,e,i++)}),r}function le(e){if(e._status===-1){var t=e._result,n=t();n.then(function(t){(e._status===0||e._status===-1)&&(e._status=1,e._result=t,n.status===void 0&&(n.status=`fulfilled`,n.value=t))},function(t){(e._status===0||e._status===-1)&&(e._status=2,e._result=t,n.status===void 0&&(n.status=`rejected`,n.reason=t))}),e._status===-1&&(e._status=0,e._result=n)}if(e._status===1)return e._result.default;throw e._result}var ue=typeof reportError==`function`?reportError:function(e){if(typeof window==`object`&&typeof window.ErrorEvent==`function`){var t=new window.ErrorEvent(`error`,{bubbles:!0,cancelable:!0,message:typeof e==`object`&&e&&typeof e.message==`string`?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process==`object`&&typeof process.emit==`function`){process.emit(`uncaughtException`,e);return}console.error(e)};function de(e){var t=S.T,n={};n.types=t===null?null:t.types,S.T=n;try{var r=e(),i=S.S;i!==null&&i(n,r),typeof r==`object`&&r&&typeof r.then==`function`&&r.then(ne,ue)}catch(e){ue(e)}finally{t!==null&&n.types!==null&&(t.types=n.types),S.T=t}}function fe(e){var t=S.T;if(t!==null){var n=t.types;n===null?t.types=[e]:n.indexOf(e)===-1&&n.push(e)}else de(fe.bind(null,e))}var pe={map:E,forEach:function(e,t,n){E(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return E(e,function(){t++}),t},toArray:function(e){return E(e,function(e){return e})||[]},only:function(e){if(!ae(e))throw Error(`React.Children.only expected to receive a single React element child.`);return e}};e.Activity=f,e.Children=pe,e.Component=y,e.Fragment=r,e.Profiler=a,e.PureComponent=ee,e.StrictMode=i,e.Suspense=l,e.ViewTransition=p,e.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=S,e.__COMPILER_RUNTIME={__proto__:null,c:function(e){return S.H.useMemoCache(e)}},e.addTransitionType=fe,e.cache=function(e){return function(){return e.apply(null,arguments)}},e.cacheSignal=function(){return null},e.cloneElement=function(e,t,n){if(e==null)throw Error(`The argument must be a React element, but you passed `+e+`.`);var r=_({},e.props),i=e.key;if(t!=null)for(a in t.key!==void 0&&(i=``+t.key),t)!re.call(t,a)||a===`key`||a===`__self`||a===`__source`||a===`ref`&&t.ref===void 0||(r[a]=t[a]);var a=arguments.length-2;if(a===1)r.children=n;else if(1<a){for(var o=Array(a),s=0;s<a;s++)o[s]=arguments[s+2];r.children=o}return C(e.type,i,r)},e.createContext=function(e){return e={$$typeof:s,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null},e.Provider=e,e.Consumer={$$typeof:o,_context:e},e},e.createElement=function(e,t,n){var r,i={},a=null;if(t!=null)for(r in t.key!==void 0&&(a=``+t.key),t)re.call(t,r)&&r!==`key`&&r!==`__self`&&r!==`__source`&&(i[r]=t[r]);var o=arguments.length-2;if(o===1)i.children=n;else if(1<o){for(var s=Array(o),c=0;c<o;c++)s[c]=arguments[c+2];i.children=s}if(e&&e.defaultProps)for(r in o=e.defaultProps,o)i[r]===void 0&&(i[r]=o[r]);return C(e,a,i)},e.createRef=function(){return{current:null}},e.forwardRef=function(e){return{$$typeof:c,render:e}},e.isValidElement=ae,e.lazy=function(e){return{$$typeof:d,_payload:{_status:-1,_result:e},_init:le}},e.memo=function(e,t){return{$$typeof:u,type:e,compare:t===void 0?null:t}},e.startTransition=de,e.unstable_useCacheRefresh=function(){return S.H.useCacheRefresh()},e.use=function(e){return S.H.use(e)},e.useActionState=function(e,t,n){return S.H.useActionState(e,t,n)},e.useCallback=function(e,t){return S.H.useCallback(e,t)},e.useContext=function(e){return S.H.useContext(e)},e.useDebugValue=function(){},e.useDeferredValue=function(e,t){return S.H.useDeferredValue(e,t)},e.useEffect=function(e,t){return S.H.useEffect(e,t)},e.useEffectEvent=function(e){return S.H.useEffectEvent(e)},e.useId=function(){return S.H.useId()},e.useImperativeHandle=function(e,t,n){return S.H.useImperativeHandle(e,t,n)},e.useInsertionEffect=function(e,t){return S.H.useInsertionEffect(e,t)},e.useLayoutEffect=function(e,t){return S.H.useLayoutEffect(e,t)},e.useMemo=function(e,t){return S.H.useMemo(e,t)},e.useOptimistic=function(e,t){return S.H.useOptimistic(e,t)},e.useReducer=function(e,t,n){return S.H.useReducer(e,t,n)},e.useRef=function(e){return S.H.useRef(e)},e.useState=function(e){return S.H.useState(e)},e.useSyncExternalStore=function(e,t,n){return S.H.useSyncExternalStore(e,t,n)},e.useTransition=function(){return S.H.useTransition()},e.version=`19.3.1`})),u=o(((e,t)=>{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0<n;){var r=n-1>>>1,a=e[r];if(0<i(a,t))e[r]=t,e[n]=a,n=r;else break a}}function n(e){return e.length===0?null:e[0]}function r(e){if(e.length===0)return null;var t=e[0],n=e.pop();if(n!==t){e[0]=n;a:for(var r=0,a=e.length,o=a>>>1;r<o;){var s=2*(r+1)-1,c=e[s],l=s+1,u=e[l];if(0>i(c,n))l<a&&0>i(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(l<a&&0>i(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function ee(e){if(h=!1,b(e),!m){if(n(c)!==null)m=!0,te||(te=!0,ie());else{var t=n(l);t!==null&&T(ee,t.startTime-e)}}}var te=!1,x=-1,ne=5,S=-1;function re(){return g?!0:!(e.unstable_now()-S<ne)}function C(){if(g=!1,te){var t=e.unstable_now();S=t;var i=!0;try{a:{m=!1,h&&(h=!1,v(x),x=-1),p=!0;var a=f;try{b:{for(b(t),d=n(c);d!==null&&!(d.expirationTime>t&&re());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&T(ee,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?ie():te=!1}}}var ie;if(typeof y==`function`)ie=function(){y(C)};else if(typeof MessageChannel<`u`){var ae=new MessageChannel,w=ae.port2;ae.port1.onmessage=C,ie=function(){w.postMessage(null)}}else ie=function(){_(C,0)};function T(t,n){x=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125<e?console.error(`forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported`):ne=0<e?Math.floor(1e3/e):5},e.unstable_getCurrentPriorityLevel=function(){return f},e.unstable_next=function(e){switch(f){case 1:case 2:case 3:var t=3;break;default:t=f}var n=f;f=t;try{return e()}finally{f=n}},e.unstable_requestPaint=function(){g=!0},e.unstable_runWithPriority=function(e,t){switch(e){case 1:case 2:case 3:case 4:case 5:break;default:e=3}var n=f;f=e;try{return t()}finally{f=n}},e.unstable_scheduleCallback=function(r,i,a){var o=e.unstable_now();switch(typeof a==`object`&&a?(a=a.delay,a=typeof a==`number`&&0<a?o+a:o):a=o,r){case 1:var s=-1;break;case 2:s=250;break;case 5:s=1073741823;break;case 4:s=1e4;break;default:s=5e3}return s=a+s,r={id:u++,callback:i,priorityLevel:r,startTime:a,expirationTime:s,sortIndex:-1},a>o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(x),x=-1):h=!0,T(ee,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,te||(te=!0,ie()))),r},e.unstable_shouldYield=re,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1<arguments.length){t+=`?args[]=`+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+=`&args[]=`+encodeURIComponent(arguments[n])}return`Minified React error #`+e+`; visit `+t+` for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`}function r(){}var i={d:{f:r,r:function(){throw Error(n(522))},D:r,C:r,L:r,m:r,X:r,S:r,M:r},p:0,findDOMNode:null},a=Symbol.for(`react.portal`),o=Symbol.for(`react.recoverable`),s=Symbol.for(`react.optimistic_key`);function c(e,t,n){var r=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:a,key:r==null?null:r===s?s:``+r,children:e,containerInfo:t,implementation:n}}var l=t.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function d(e,t){if(e===`font`)return``;if(typeof t==`string`)return t===`use-credentials`?t:``}e.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=i,e.browser=function(e){return{$$typeof:o,_reason:e}},e.createPortal=function(e,t){var r=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)throw Error(n(299));return c(e,t,null,r)},e.flushSync=function(e){var t=l.T,n=i.p;try{if(l.T=null,i.p=2,e)return e()}finally{l.T=t,i.p=n,i.d.f()}},e.preconnect=function(e,t){typeof e==`string`&&(t?(t=t.crossOrigin,t=typeof t==`string`?t===`use-credentials`?t:``:void 0):t=null,i.d.C(e,t))},e.prefetchDNS=function(e){typeof e==`string`&&i.d.D(e)},e.preinit=function(e,t){if(typeof e==`string`&&t&&typeof t.as==`string`){var n=t.as,r=d(n,t.crossOrigin),a=typeof t.integrity==`string`?t.integrity:void 0,o=typeof t.fetchPriority==`string`?t.fetchPriority:void 0;n===`style`?i.d.S(e,typeof t.precedence==`string`?t.precedence:void 0,{crossOrigin:r,integrity:a,fetchPriority:o}):n===`script`&&i.d.X(e,{crossOrigin:r,integrity:a,fetchPriority:o,nonce:typeof t.nonce==`string`?t.nonce:void 0})}},e.preinitModule=function(e,t){if(typeof e==`string`){if(typeof t==`object`&&t){if(t.as==null||t.as===`script`){var n=d(t.as,t.crossOrigin);i.d.M(e,{crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0,fetchPriority:typeof t.fetchPriority==`string`?t.fetchPriority:void 0})}}else t??i.d.M(e)}},e.preload=function(e,t){if(typeof e==`string`&&typeof t==`object`&&t&&typeof t.as==`string`){var n=t.as,r=d(n,t.crossOrigin);i.d.L(e,n,{crossOrigin:r,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0,type:typeof t.type==`string`?t.type:void 0,fetchPriority:typeof t.fetchPriority==`string`?t.fetchPriority:void 0,referrerPolicy:typeof t.referrerPolicy==`string`?t.referrerPolicy:void 0,imageSrcSet:typeof t.imageSrcSet==`string`?t.imageSrcSet:void 0,imageSizes:typeof t.imageSizes==`string`?t.imageSizes:void 0,media:typeof t.media==`string`?t.media:void 0})}},e.preloadModule=function(e,t){if(typeof e==`string`){if(t){var n=d(t.as,t.crossOrigin);i.d.m(e,{as:typeof t.as==`string`&&t.as!==`script`?t.as:void 0,crossOrigin:n,integrity:typeof t.integrity==`string`?t.integrity:void 0,nonce:typeof t.nonce==`string`?t.nonce:void 0,fetchPriority:typeof t.fetchPriority==`string`?t.fetchPriority:void 0})}else i.d.m(e)}},e.requestFormReset=function(e){i.d.r(e)},e.unstable_batchedUpdates=function(e,t){return e(t)},e.useFormState=function(e,t,n){return l.H.useFormState(e,t,n)},e.useFormStatus=function(){return l.H.useHostTransitionStatus()},e.version=`19.3.1`})),m=o(((e,t)=>{function n(){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<`u`&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE==`function`)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1<arguments.length){t+=`?args[]=`+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+=`&args[]=`+encodeURIComponent(arguments[n])}return`Minified React error #`+e+`; visit `+t+` for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`}function a(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function o(e){for(var t=e,n=t;n&&!n.alternate;)t=n,t.flags&4098&&(e=t.return),n=t.return;for(;t.return;)t=t.return;return t.tag===3?e:null}function s(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function c(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function l(e){if(o(e)!==e)throw Error(i(188))}function d(e){var t=e.alternate;if(!t){if(t=o(e),t===null)throw Error(i(188));return t===e?e:null}for(var n=e,r=t;;){var a=n.return;if(a===null)break;var s=a.alternate;if(s===null){if(r=a.return,r!==null){n=r;continue}break}if(a.child===s.child){for(s=a.child;s;){if(s===n)return l(a),e;if(s===r)return l(a),t;s=s.sibling}throw Error(i(188))}if(n.return!==r.return)n=a,r=s;else{for(var c=!1,u=a.child;u;){if(u===n){c=!0,n=a,r=s;break}if(u===r){c=!0,r=a,n=s;break}u=u.sibling}if(!c){for(u=s.child;u;){if(u===n){c=!0,n=s,r=a;break}if(u===r){c=!0,r=s,n=a;break}u=u.sibling}if(!c)throw Error(i(189))}}if(n.alternate!==r)throw Error(i(190))}if(n.tag!==3)throw Error(i(188));return n.stateNode.current===n?e:t}function p(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=p(e),t!==null)return t;e=e.sibling}return null}function h(e,t,n,r,i,a){for(;e!==null;){if((e.tag===5||e.tag===27||e.tag===6)&&n(e,r,i,a)||(e.tag!==22||e.memoizedState===null)&&(t||e.tag!==5&&e.tag!==27)&&h(e.child,t,n,r,i,a))return!0;e=e.sibling}return!1}function g(e){for(e=e.return;e!==null;){if(e.tag===3||e.tag===5||e.tag===27)return e;e=e.return}return null}function _(e){var t=!1;for(e=e.return;e!==null&&(e.tag===4&&(t=!0),e.tag!==3&&e.tag!==5&&e.tag!==27);)e=e.return;return t}function v(e){var t=[null,null],n=g(e);return n===null||y(t,e,n.child,{foundSelf:!1}),t}function y(e,t,n,r){for(;n!==null;){if(n===t)r.foundSelf=!0;else if(n.tag===5||n.tag===27||n.tag===6){if(r.foundSelf)return e[1]=n,!0;e[0]=n}else if((n.tag!==22||n.memoizedState===null)&&y(e,t,n.child,r))return!0;n=n.sibling}return!1}function b(e){switch(e.tag){case 5:case 27:case 6:return e.stateNode;case 3:return e.stateNode.containerInfo;default:throw Error(i(559))}}var ee=null,te=null;function x(e,t,n){return e===n||e===t&&(ee=e,!0)}function ne(e,t,n){return e===n?(te=e,!1):e===t&&(te!==null&&(ee=e),!0)}function S(e){if(e===null)return null;do e=e===null?null:e.return;while(e&&e.tag!==5&&e.tag!==27&&e.tag!==3);return e||null}function re(e,t,n){for(var r=0,i=e;i;i=n(i))r++;i=0;for(var a=t;a;a=n(a))i++;for(;0<r-i;)e=n(e),r--;for(;0<i-r;)t=n(t),i--;for(;r--;){if(e===t||t!==null&&e===t.alternate)return e;e=n(e),t=n(t)}return null}var C=Object.assign,ie=Symbol.for(`react.element`),ae=Symbol.for(`react.transitional.element`),w=Symbol.for(`react.portal`),T=Symbol.for(`react.fragment`),oe=Symbol.for(`react.strict_mode`),se=Symbol.for(`react.profiler`),ce=Symbol.for(`react.consumer`),E=Symbol.for(`react.context`),le=Symbol.for(`react.forward_ref`),ue=Symbol.for(`react.suspense`),de=Symbol.for(`react.suspense_list`),fe=Symbol.for(`react.memo`),pe=Symbol.for(`react.lazy`),me=Symbol.for(`react.activity`),he=Symbol.for(`react.legacy_hidden`),ge=Symbol.for(`react.memo_cache_sentinel`),_e=Symbol.for(`react.view_transition`),ve=Symbol.for(`react.recoverable`),ye=Symbol.iterator;function be(e){return typeof e!=`object`||!e?null:(e=ye&&e[ye]||e[`@@iterator`],typeof e==`function`?e:null)}var xe=Symbol.for(`react.client.reference`);function Se(e){if(e==null)return null;if(typeof e==`function`)return e.$$typeof===xe?null:e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case T:return`Fragment`;case se:return`Profiler`;case oe:return`StrictMode`;case ue:return`Suspense`;case de:return`SuspenseList`;case me:return`Activity`;case _e:return`ViewTransition`}if(typeof e==`object`)switch(e.$$typeof){case w:return`Portal`;case E:return e.displayName||`Context`;case ce:return(e._context.displayName||`Context`)+`.Consumer`;case le:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case fe:return t=e.displayName||null,t===null?Se(e.type)||`Memo`:t;case pe:t=e._payload,e=e._init;try{return Se(e(t))}catch{}}return null}var Ce=Array.isArray,D=n.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,O=r.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,we={pending:!1,data:null,method:null,action:null},Te=[],Ee=-1;function De(e){return{current:e}}function Oe(e){0>Ee||(e.current=Te[Ee],Te[Ee]=null,Ee--)}function k(e,t){Ee++,Te[Ee]=e.current,e.current=t}var ke=De(null),Ae=De(null),je=De(null),Me=De(null);function Ne(e,t){switch(k(je,t),k(Ae,e),k(ke,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?up(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=up(t),e=dp(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}Oe(ke),k(ke,e)}function Pe(){Oe(ke),Oe(Ae),Oe(je)}function Fe(e){var t=e.memoizedState;t!==null&&(sh._currentValue=t.memoizedState,k(Me,e)),t=ke.current;var n=dp(t,e.type);t!==n&&(k(Ae,e),k(ke,n))}function Ie(e){Ae.current===e&&(Oe(ke),Oe(Ae)),Me.current===e&&(Oe(Me),sh._currentValue=we)}var Le,Re;function ze(e){if(Le===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);Le=t&&t[1]||``,Re=-1<e.stack.indexOf(`
2
+ at`)?` (<anonymous>)`:-1<e.stack.indexOf(`@`)?`@unknown:0:0`:``}return`
3
+`+Le+e+Re}var Be=!1;function Ve(e,t){if(!e||Be)return``;Be=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var r={DetermineComponentFrameRoot:function(){try{if(t){var n=function(){throw Error()};if(Object.defineProperty(n.prototype,"props",{set:function(){throw Error()}}),typeof Reflect==`object`&&Reflect.construct){try{Reflect.construct(n,[])}catch(e){var r=e}Reflect.construct(e,[],n)}else{try{n.call()}catch(e){r=e}n=!1;try{var i=Object.getOwnPropertyDescriptor(e.prototype,`props`);Object.defineProperty(e.prototype,"props",{configurable:!0,set:function(){throw Error()}}),n=!0,new e}finally{n&&(i===void 0?delete e.prototype.props:Object.defineProperty(e.prototype,"props",i))}}}else{try{throw Error()}catch(e){r=e}(n=e())&&typeof n.catch==`function`&&n.catch(function(){})}}catch(e){if(e&&r&&typeof e.stack==`string`)return[e.stack,r.stack]}return[null,null]}};r.DetermineComponentFrameRoot.displayName=`DetermineComponentFrameRoot`;var i=Object.getOwnPropertyDescriptor(r.DetermineComponentFrameRoot,`name`);i&&i.configurable&&Object.defineProperty(r.DetermineComponentFrameRoot,"name",{value:`DetermineComponentFrameRoot`});var a=r.DetermineComponentFrameRoot(),o=a[0],s=a[1];if(o&&s){var c=o.split(`
4
+`),l=s.split(`
5
+`);for(i=r=0;r<c.length&&!c[r].includes(`DetermineComponentFrameRoot`);)r++;for(;i<l.length&&!l[i].includes(`DetermineComponentFrameRoot`);)i++;if(r===c.length||i===l.length)for(r=c.length-1,i=l.length-1;1<=r&&0<=i&&c[r]!==l[i];)i--;for(;1<=r&&0<=i;r--,i--)if(c[r]!==l[i]){if(r!==1||i!==1)do if(r--,i--,0>i||c[r]!==l[i]){var u=`
6
+`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(`<anonymous>`)&&(u=u.replace(`<anonymous>`,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Be=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?ze(n):``}function He(e,t){switch(e.tag){case 26:case 27:case 5:return ze(e.type);case 16:return ze(`Lazy`);case 13:return e.child!==t&&t!==null?ze(`Suspense Fallback`):ze(`Suspense`);case 19:return ze(`SuspenseList`);case 0:case 15:return Ve(e.type,!1);case 11:return Ve(e.type.render,!1);case 1:return Ve(e.type,!0);case 31:return ze(`Activity`);case 30:return ze(`ViewTransition`);default:return``}}function Ue(e){try{var t=``,n=null;do t+=He(e,n),n=e,e=e.return;while(e);return t}catch(e){return`
7
+Error generating stack: `+e.message+`
8
+`+e.stack}}var We=Object.prototype.hasOwnProperty,Ge=t.unstable_scheduleCallback,Ke=t.unstable_cancelCallback,qe=t.unstable_shouldYield,Je=t.unstable_requestPaint,Ye=t.unstable_now,Xe=t.unstable_getCurrentPriorityLevel,Ze=t.unstable_ImmediatePriority,Qe=t.unstable_UserBlockingPriority,$e=t.unstable_NormalPriority,et=t.unstable_LowPriority,tt=t.unstable_IdlePriority,nt=t.log,rt=t.unstable_setDisableYieldValue,it=null,at=null;function ot(e){if(typeof nt==`function`&&rt(e),at&&typeof at.setStrictMode==`function`)try{at.setStrictMode(it,e)}catch{}}var st=Math.clz32?Math.clz32:ut,ct=Math.log,lt=Math.LN2;function ut(e){return e>>>=0,e===0?32:31-(ct(e)/lt|0)|0}var dt=256,ft=262144,pt=4194304;function mt(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&-e;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function ht(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=mt(n))):i=mt(o):i=mt(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=mt(n))):i=mt(o)):i=mt(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function gt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function _t(e,t){t&8&&(t|=t&32);var n=e.entangledLanes;if(n!==0)for(e=e.entanglements,n&=t;0<n;){var r=31-st(n),i=1<<r;t|=e[r],n&=~i}return t}function vt(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function yt(){var e=pt;return pt<<=1,!(pt&62914560)&&(pt=4194304),e}function bt(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function xt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function St(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0<n;){var u=31-st(n),d=1<<u;s[u]=0,c[u]=-1;var f=l[u];if(f!==null)for(l[u]=null,u=0;u<f.length;u++){var p=f[u];p!==null&&(p.lane&=-536870913)}n&=~d}r!==0&&Ct(e,r,0),a!==0&&i===0&&e.tag!==0&&(e.suspendedLanes|=a&~(o&~t))}function Ct(e,t,n){e.pendingLanes|=t,e.suspendedLanes&=~t;var r=31-st(t);e.entangledLanes|=t,e.entanglements[r]=e.entanglements[r]|1073741824|n&261930}function wt(e,t){var n=e.entangledLanes|=t;for(e=e.entanglements;n;){var r=31-st(n),i=1<<r;i&t|e[r]&t&&(e[r]|=t),n&=~i}}function Tt(e,t){var n=t&-t;return n=n&42?1:Et(n),(n&(e.suspendedLanes|t))===0?n:0}function Et(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function Dt(e){return e&=-e,2<e?8<e?e&134217727?32:268435456:8:2}function Ot(){var e=O.p;return e===0?(e=window.event,e===void 0?32:Ch(e.type)):e}function kt(e,t){var n=O.p;try{return O.p=e,t()}finally{O.p=n}}var At=Math.random().toString(36).slice(2),jt=`__reactFiber$`+At,Mt=`__reactProps$`+At,Nt=`__reactContainer$`+At,Pt=`__reactEvents$`+At,Ft=`__reactListeners$`+At,It=`__reactHandles$`+At,Lt=`__reactResources$`+At,Rt=`__reactMarker$`+At,zt=`__reactLoad$`+At;function Bt(e){delete e[jt],delete e[Mt],delete e[Ft],delete e[It]}function Vt(e){var t;if(t=e[jt])return t;for(var n=e.parentNode;n;){if(t=n[Nt]||n[jt]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=fm(e);e!==null;){if(n=e[jt])return n;e=fm(e)}return t}e=n,n=e.parentNode}return null}function Ht(e){if(e=e[jt]||e[Nt]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function Ut(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(i(33))}function Wt(e){var t=e[Lt];return t||=e[Lt]={hoistableStyles:new Map,hoistableScripts:new Map},t}function A(e){e[Rt]=!0}function Gt(e){e[zt]=void 0}var Kt=new Set,qt={};function Jt(e,t){Yt(e,t),Yt(e+`Capture`,t)}function Yt(e,t){for(qt[e]=t,e=0;e<t.length;e++)Kt.add(t[e])}var Xt=RegExp(`^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD][:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$`),Zt={},Qt={};function $t(e){return We.call(Qt,e)?!0:We.call(Zt,e)?!1:Xt.test(e)?Qt[e]=!0:(Zt[e]=!0,!1)}var j=!1;function en(){var e=j;return j=!1,e}function tn(e,t,n){if($t(t)){if(n===null)e.removeAttribute(t);else{switch(typeof n){case`undefined`:case`function`:case`symbol`:e.removeAttribute(t);return;case`boolean`:var r=t.toLowerCase().slice(0,5);if(r!==`data-`&&r!==`aria-`){e.removeAttribute(t);return}}e.setAttribute(t,n)}}}function nn(e,t,n){if(n===null)e.removeAttribute(t);else{switch(typeof n){case`undefined`:case`function`:case`symbol`:case`boolean`:e.removeAttribute(t);return}e.setAttribute(t,n)}}function rn(e,t,n,r){if(r===null)e.removeAttribute(n);else{switch(typeof r){case`undefined`:case`function`:case`symbol`:case`boolean`:e.removeAttribute(n);return}e.setAttributeNS(t,n,r)}}function an(e){switch(typeof e){case`bigint`:case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function on(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function sn(e,t,n){var r=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);if(!e.hasOwnProperty(t)&&r!==void 0&&typeof r.get==`function`&&typeof r.set==`function`){var i=r.get,a=r.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){n=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:r.enumerable}),{getValue:function(){return n},setValue:function(e){n=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function cn(e){if(!e._valueTracker){var t=on(e)?`checked`:`value`;e._valueTracker=sn(e,t,``+e[t])}}function ln(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=on(e)?e.checked?`true`:`false`:e.value),e=r,e!==n&&(t.setValue(e),!0)}var un=/[\n"\\]/g;function dn(e){return e.replace(un,function(e){return`\\`+e.charCodeAt(0).toString(16)+` `})}function fn(e,t,n,r,i,a,o,s){e.name=``,o!=null&&typeof o!=`function`&&typeof o!=`symbol`&&typeof o!=`boolean`?e.type=o:e.removeAttribute(`type`),t==null?o!==`submit`&&o!==`reset`||e.removeAttribute(`value`):o===`number`?(t===0&&e.value===``||e.value!=t)&&(e.value=``+an(t)):e.value!==``+an(t)&&(e.value=``+an(t)),t==null?n==null?r!=null&&e.removeAttribute(`value`):mn(e,an(n)):o===`number`&&e.value==t?mn(e,an(e.value)):mn(e,an(t)),i==null&&a!=null&&(e.defaultChecked=!!a),i!=null&&(e.checked=i&&typeof i!=`function`&&typeof i!=`symbol`),s!=null&&typeof s!=`function`&&typeof s!=`symbol`&&typeof s!=`boolean`?e.name=``+an(s):e.removeAttribute(`name`)}function pn(e,t,n,r,i,a,o,s){if(a!=null&&typeof a!=`function`&&typeof a!=`symbol`&&typeof a!=`boolean`&&(e.type=a),t!=null||n!=null){if(!(a!==`submit`&&a!==`reset`||t!=null)){cn(e);return}n=n==null?``:``+an(n),t=t==null?n:``+an(t),s||t===e.value||(e.value=t),e.defaultValue=t}r??=i,r=typeof r!=`function`&&typeof r!=`symbol`&&!!r,e.checked=s?e.checked:!!r,e.defaultChecked=!!r,o!=null&&typeof o!=`function`&&typeof o!=`symbol`&&typeof o!=`boolean`&&(e.name=o),cn(e)}function mn(e,t){e.defaultValue!==``+t&&(e.defaultValue=``+t)}function hn(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i<n.length;i++)t[`$`+n[i]]=!0;for(n=0;n<e.length;n++)i=t.hasOwnProperty(`$`+e[n].value),e[n].selected!==i&&(e[n].selected=i),i&&r&&(e[n].defaultSelected=!0)}else{for(n=``+an(n),t=null,i=0;i<e.length;i++){if(e[i].value===n){e[i].selected=!0,r&&(e[i].defaultSelected=!0);return}t!==null||e[i].disabled||(t=e[i])}t!==null&&(t.selected=!0)}}function gn(e,t,n){t!=null&&(t=``+an(t),t!==e.value&&(e.value=t),n==null)?e.defaultValue!==t&&(e.defaultValue=t):e.defaultValue=n==null?``:``+an(n)}function _n(e,t,n,r){if(t==null){if(r!=null){if(n!=null)throw Error(i(92));if(Ce(r)){if(1<r.length)throw Error(i(93));r=r[0]}n=r}n??=``,t=n}n=an(t),e.defaultValue=n,r=e.textContent,r===n&&r!==``&&r!==null&&(e.value=r),cn(e)}function vn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var yn=new Set(`animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp`.split(` `));function bn(e,t,n){var r=t.indexOf(`--`)===0;n==null||typeof n==`boolean`||n===``?r?e.setProperty(t,``):t===`float`?e.cssFloat=``:e[t]=``:r?e.setProperty(t,n):typeof n!=`number`||n===0||yn.has(t)?t===`float`?e.cssFloat=n:e[t]=(``+n).trim():e[t]=n+`px`}function xn(e,t,n){if(t!=null&&typeof t!=`object`)throw Error(i(62));if(e=e.style,n!=null){for(var r in n)!n.hasOwnProperty(r)||t!=null&&t.hasOwnProperty(r)||(r.indexOf(`--`)===0?e.setProperty(r,``):r===`float`?e.cssFloat=``:e[r]=``,j=!0);for(var a in t)r=t[a],t.hasOwnProperty(a)&&n[a]!==r&&(bn(e,a,r),j=!0)}else for(var o in t)t.hasOwnProperty(o)&&bn(e,o,t[o])}function Sn(e){if(e.indexOf(`-`)===-1)return!1;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Cn=new Map([[`acceptCharset`,`accept-charset`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`],[`crossOrigin`,`crossorigin`],[`accentHeight`,`accent-height`],[`alignmentBaseline`,`alignment-baseline`],[`arabicForm`,`arabic-form`],[`baselineShift`,`baseline-shift`],[`capHeight`,`cap-height`],[`clipPath`,`clip-path`],[`clipRule`,`clip-rule`],[`colorInterpolation`,`color-interpolation`],[`colorInterpolationFilters`,`color-interpolation-filters`],[`colorProfile`,`color-profile`],[`colorRendering`,`color-rendering`],[`dominantBaseline`,`dominant-baseline`],[`enableBackground`,`enable-background`],[`fillOpacity`,`fill-opacity`],[`fillRule`,`fill-rule`],[`floodColor`,`flood-color`],[`floodOpacity`,`flood-opacity`],[`fontFamily`,`font-family`],[`fontSize`,`font-size`],[`fontSizeAdjust`,`font-size-adjust`],[`fontStretch`,`font-stretch`],[`fontStyle`,`font-style`],[`fontVariant`,`font-variant`],[`fontWeight`,`font-weight`],[`glyphName`,`glyph-name`],[`glyphOrientationHorizontal`,`glyph-orientation-horizontal`],[`glyphOrientationVertical`,`glyph-orientation-vertical`],[`horizAdvX`,`horiz-adv-x`],[`horizOriginX`,`horiz-origin-x`],[`imageRendering`,`image-rendering`],[`letterSpacing`,`letter-spacing`],[`lightingColor`,`lighting-color`],[`markerEnd`,`marker-end`],[`markerMid`,`marker-mid`],[`markerStart`,`marker-start`],[`maskType`,`mask-type`],[`overlinePosition`,`overline-position`],[`overlineThickness`,`overline-thickness`],[`paintOrder`,`paint-order`],[`panose-1`,`panose-1`],[`pointerEvents`,`pointer-events`],[`renderingIntent`,`rendering-intent`],[`shapeRendering`,`shape-rendering`],[`stopColor`,`stop-color`],[`stopOpacity`,`stop-opacity`],[`strikethroughPosition`,`strikethrough-position`],[`strikethroughThickness`,`strikethrough-thickness`],[`strokeDasharray`,`stroke-dasharray`],[`strokeDashoffset`,`stroke-dashoffset`],[`strokeLinecap`,`stroke-linecap`],[`strokeLinejoin`,`stroke-linejoin`],[`strokeMiterlimit`,`stroke-miterlimit`],[`strokeOpacity`,`stroke-opacity`],[`strokeWidth`,`stroke-width`],[`textAnchor`,`text-anchor`],[`textDecoration`,`text-decoration`],[`textRendering`,`text-rendering`],[`transformOrigin`,`transform-origin`],[`underlinePosition`,`underline-position`],[`underlineThickness`,`underline-thickness`],[`unicodeBidi`,`unicode-bidi`],[`unicodeRange`,`unicode-range`],[`unitsPerEm`,`units-per-em`],[`vAlphabetic`,`v-alphabetic`],[`vHanging`,`v-hanging`],[`vIdeographic`,`v-ideographic`],[`vMathematical`,`v-mathematical`],[`vectorEffect`,`vector-effect`],[`vertAdvY`,`vert-adv-y`],[`vertOriginX`,`vert-origin-x`],[`vertOriginY`,`vert-origin-y`],[`wordSpacing`,`word-spacing`],[`writingMode`,`writing-mode`],[`xmlnsXlink`,`xmlns:xlink`],[`xHeight`,`x-height`]]),wn=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i;function Tn(e){return wn.test(``+e)?`javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')`:e}function En(){}var Dn=null;function On(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var kn=null,An=null;function jn(e){var t=Ht(e);if(t&&(e=t.stateNode)){var n=e[Mt]||null;a:switch(e=t.stateNode,t.type){case`input`:if(fn(e,n.value,n.defaultValue,n.defaultValue,n.checked,n.defaultChecked,n.type,n.name),t=n.name,n.type===`radio`&&t!=null){for(n=e;n.parentNode;)n=n.parentNode;for(n=n.querySelectorAll(`input[name="`+dn(``+t)+`"][type="radio"]`),t=0;t<n.length;t++){var r=n[t];if(r!==e&&r.form===e.form){var a=r[Mt]||null;if(!a)throw Error(i(90));fn(r,a.value,a.defaultValue,a.defaultValue,a.checked,a.defaultChecked,a.type,a.name)}}for(t=0;t<n.length;t++)r=n[t],r.form===e.form&&ln(r)}break a;case`textarea`:gn(e,n.value,n.defaultValue);break a;case`select`:t=n.value,t!=null&&hn(e,!!n.multiple,t,!1)}}}var Mn=!1;function Nn(e,t,n){if(Mn)return e(t,n);Mn=!0;try{return e(t)}finally{if(Mn=!1,(kn!==null||An!==null)&&(zd(),kn&&(t=kn,e=An,An=kn=null,jn(t),e)))for(t=0;t<e.length;t++)jn(e[t])}}function Pn(e,t){var n=e.stateNode;if(n===null)return null;var r=n[Mt]||null;if(r===null)return null;n=r[t];a:switch(t){case`onClick`:case`onClickCapture`:case`onDoubleClick`:case`onDoubleClickCapture`:case`onMouseDown`:case`onMouseDownCapture`:case`onMouseMove`:case`onMouseMoveCapture`:case`onMouseUp`:case`onMouseUpCapture`:case`onMouseEnter`:(r=!r.disabled)||(e=e.type,r=e!==`button`&&e!==`input`&&e!==`select`&&e!==`textarea`),e=!r;break a;default:e=!1}if(e)return null;if(n&&typeof n!=`function`)throw Error(i(231,t,typeof n));return n}var Fn=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0,In=!1;if(Fn)try{var Ln={};Object.defineProperty(Ln,"passive",{get:function(){In=!0}}),window.addEventListener(`test`,Ln,Ln),window.removeEventListener(`test`,Ln,Ln)}catch{In=!1}var Rn=null,zn=null,Bn=null;function Vn(){if(Bn)return Bn;var e,t=zn,n=t.length,r,i=`value`in Rn?Rn.value:Rn.textContent,a=i.length;for(e=0;e<n&&t[e]===i[e];e++);var o=n-e;for(r=1;r<=o&&t[n-r]===i[a-r];r++);return Bn=i.slice(e,1<r?1-r:void 0)}function Hn(e){var t=e.keyCode;return`charCode`in e?(e=e.charCode,e===0&&t===13&&(e=13)):e=t,e===10&&(e=13),32<=e||e===13?e:0}function Un(){return!0}function Wn(){return!1}function Gn(e){function t(t,n,r,i,a){for(var o in this._reactName=t,this._targetInst=r,this.type=n,this.nativeEvent=i,this.target=a,this.currentTarget=null,e)e.hasOwnProperty(o)&&(t=e[o],this[o]=t?t(i):i[o]);return this.isDefaultPrevented=(i.defaultPrevented==null?!1===i.returnValue:i.defaultPrevented)?Un:Wn,this.isPropagationStopped=Wn,this}return C(t.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e&&(e.preventDefault?e.preventDefault():typeof e.returnValue!=`unknown`&&(e.returnValue=!1),this.isDefaultPrevented=Un)},stopPropagation:function(){var e=this.nativeEvent;e&&(e.stopPropagation?e.stopPropagation():typeof e.cancelBubble!=`unknown`&&(e.cancelBubble=!0),this.isPropagationStopped=Un)},persist:function(){},isPersistent:Un}),t}var Kn={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},qn=Gn(Kn),Jn=C({},Kn,{view:0,detail:0}),Yn=Gn(Jn),Xn,Zn,Qn,M=C({},Jn,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:lr,button:0,buttons:0,relatedTarget:function(e){return e.relatedTarget===void 0?e.fromElement===e.srcElement?e.toElement:e.fromElement:e.relatedTarget},movementX:function(e){return`movementX`in e?e.movementX:(e!==Qn&&(Qn&&e.type===`mousemove`?(Xn=e.screenX-Qn.screenX,Zn=e.screenY-Qn.screenY):Zn=Xn=0,Qn=e),Xn)},movementY:function(e){return`movementY`in e?e.movementY:Zn}}),$n=Gn(M),er=Gn(C({},M,{dataTransfer:0})),tr=Gn(C({},Jn,{relatedTarget:0})),nr=Gn(C({},Kn,{animationName:0,elapsedTime:0,pseudoElement:0})),rr=Gn(C({},Kn,{clipboardData:function(e){return`clipboardData`in e?e.clipboardData:window.clipboardData}})),ir=Gn(C({},Kn,{data:0})),ar={Esc:`Escape`,Spacebar:` `,Left:`ArrowLeft`,Up:`ArrowUp`,Right:`ArrowRight`,Down:`ArrowDown`,Del:`Delete`,Win:`OS`,Menu:`ContextMenu`,Apps:`ContextMenu`,Scroll:`ScrollLock`,MozPrintableKey:`Unidentified`},or={8:`Backspace`,9:`Tab`,12:`Clear`,13:`Enter`,16:`Shift`,17:`Control`,18:`Alt`,19:`Pause`,20:`CapsLock`,27:`Escape`,32:` `,33:`PageUp`,34:`PageDown`,35:`End`,36:`Home`,37:`ArrowLeft`,38:`ArrowUp`,39:`ArrowRight`,40:`ArrowDown`,45:`Insert`,46:`Delete`,112:`F1`,113:`F2`,114:`F3`,115:`F4`,116:`F5`,117:`F6`,118:`F7`,119:`F8`,120:`F9`,121:`F10`,122:`F11`,123:`F12`,144:`NumLock`,145:`ScrollLock`,224:`Meta`},sr={Alt:`altKey`,Control:`ctrlKey`,Meta:`metaKey`,Shift:`shiftKey`};function cr(e){var t=this.nativeEvent;return t.getModifierState?t.getModifierState(e):(e=sr[e])?!!t[e]:!1}function lr(){return cr}var ur=Gn(C({},Jn,{key:function(e){if(e.key){var t=ar[e.key]||e.key;if(t!==`Unidentified`)return t}return e.type===`keypress`?(e=Hn(e),e===13?`Enter`:String.fromCharCode(e)):e.type===`keydown`||e.type===`keyup`?or[e.keyCode]||`Unidentified`:``},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,repeat:0,locale:0,getModifierState:lr,charCode:function(e){return e.type===`keypress`?Hn(e):0},keyCode:function(e){return e.type===`keydown`||e.type===`keyup`?e.keyCode:0},which:function(e){return e.type===`keypress`?Hn(e):e.type===`keydown`||e.type===`keyup`?e.keyCode:0}})),dr=Gn(C({},M,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0})),fr=Gn(C({},Kn,{submitter:0})),pr=Gn(C({},Jn,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,ctrlKey:0,shiftKey:0,getModifierState:lr})),mr=Gn(C({},Kn,{propertyName:0,elapsedTime:0,pseudoElement:0})),hr=Gn(C({},M,{deltaX:function(e){return`deltaX`in e?e.deltaX:`wheelDeltaX`in e?-e.wheelDeltaX:0},deltaY:function(e){return`deltaY`in e?e.deltaY:`wheelDeltaY`in e?-e.wheelDeltaY:`wheelDelta`in e?-e.wheelDelta:0},deltaZ:0,deltaMode:0})),gr=Gn(C({},Kn,{newState:0,oldState:0,source:0})),_r=[9,13,27,32],vr=Fn&&`CompositionEvent`in window,yr=null;Fn&&`documentMode`in document&&(yr=document.documentMode);var br=Fn&&`TextEvent`in window&&!yr,xr=Fn&&(!vr||yr&&8<yr&&11>=yr),Sr=` `,Cr=!1;function wr(e,t){switch(e){case`keyup`:return _r.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Tr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Er=!1;function Dr(e,t){switch(e){case`compositionend`:return Tr(t);case`keypress`:return t.which===32?(Cr=!0,Sr):null;case`textInput`:return e=t.data,e===Sr&&Cr?null:e;default:return null}}function Or(e,t){if(Er)return e===`compositionend`||!vr&&wr(e,t)?(e=Vn(),Bn=zn=Rn=null,Er=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1<t.char.length)return t.char;if(t.which)return String.fromCharCode(t.which)}return null;case`compositionend`:return xr&&t.locale!==`ko`?null:t.data;default:return null}}var kr={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Ar(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t===`input`?!!kr[e.type]:t===`textarea`}function jr(e,t,n,r){kn?An?An.push(r):An=[r]:kn=r,t=Jf(t,`onChange`),0<t.length&&(n=new qn(`onChange`,`change`,null,n,r),e.push({event:n,listeners:t}))}var Mr=null,Nr=null;function Pr(e){Vf(e,0)}function Fr(e){if(ln(Ut(e)))return e}function Ir(e,t){if(e===`change`)return t}var Lr=!1;if(Fn){var Rr;if(Fn){var zr=`oninput`in document;if(!zr){var Br=document.createElement(`div`);Br.setAttribute(`oninput`,`return;`),zr=typeof Br.oninput==`function`}Rr=zr}else Rr=!1;Lr=Rr&&(!document.documentMode||9<document.documentMode)}function Vr(){Mr&&(Mr.detachEvent(`onpropertychange`,Hr),Nr=Mr=null)}function Hr(e){if(e.propertyName===`value`&&Fr(Nr)){var t=[];jr(t,Nr,e,On(e)),Nn(Pr,t)}}function Ur(e,t,n){e===`focusin`?(Vr(),Mr=t,Nr=n,Mr.attachEvent(`onpropertychange`,Hr)):e===`focusout`&&Vr()}function Wr(e){if(e===`selectionchange`||e===`keyup`||e===`keydown`)return Fr(Nr)}function Gr(e,t){if(e===`click`)return Fr(t)}function Kr(e,t){if(e===`input`||e===`change`)return Fr(t)}function qr(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var Jr=typeof Object.is==`function`?Object.is:qr;function Yr(e,t){if(Jr(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;var n=Object.keys(e),r=Object.keys(t);if(n.length!==r.length)return!1;for(r=0;r<n.length;r++){var i=n[r];if(!We.call(t,i)||!Jr(e[i],t[i]))return!1}return!0}function Xr(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function Zr(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function Qr(e,t){var n=Zr(e);e=0;for(var r;n;){if(n.nodeType===3){if(r=e+n.textContent.length,e<=t&&r>=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Zr(n)}}function $r(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?$r(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function ei(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Xr(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Xr(e.document)}return t}function ti(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var ni=Fn&&`documentMode`in document&&11>=document.documentMode,ri=null,ii=null,ai=null,oi=!1;function si(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;oi||ri==null||ri!==Xr(r)||(r=ri,`selectionStart`in r&&ti(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),ai&&Yr(ai,r)||(ai=r,r=Jf(ii,`onSelect`),0<r.length&&(t=new qn(`onSelect`,`select`,null,t,n),e.push({event:t,listeners:r}),t.target=ri)))}function ci(e,t){var n={};return n[e.toLowerCase()]=t.toLowerCase(),n[`Webkit`+e]=`webkit`+t,n[`Moz`+e]=`moz`+t,n}var li={animationend:ci(`Animation`,`AnimationEnd`),animationiteration:ci(`Animation`,`AnimationIteration`),animationstart:ci(`Animation`,`AnimationStart`),transitionrun:ci(`Transition`,`TransitionRun`),transitionstart:ci(`Transition`,`TransitionStart`),transitioncancel:ci(`Transition`,`TransitionCancel`),transitionend:ci(`Transition`,`TransitionEnd`)},ui={},di={};Fn&&(di=document.createElement(`div`).style,`AnimationEvent`in window||(delete li.animationend.animation,delete li.animationiteration.animation,delete li.animationstart.animation),`TransitionEvent`in window||delete li.transitionend.transition);function fi(e){if(ui[e])return ui[e];if(!li[e])return e;var t=li[e],n;for(n in t)if(t.hasOwnProperty(n)&&n in di)return ui[e]=t[n];return e}var pi=fi(`animationend`),mi=fi(`animationiteration`),hi=fi(`animationstart`),gi=fi(`transitionrun`),_i=fi(`transitionstart`),vi=fi(`transitioncancel`),yi=fi(`transitionend`),bi=new Map,xi=`abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error fullscreenChange fullscreenError gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel`.split(` `);xi.push(`scrollEnd`);function Si(e,t){bi.set(e,t),Jt(t,[e])}var Ci=0;function wi(e,t){if(e.name!=null&&e.name!==`auto`)return e.name;if(t.autoName!==null)return t.autoName;e=bd.identifierPrefix;var n=Ci++;return e=`_`+e+`t_`+n.toString(32)+`_`,t.autoName=e}function Ti(e){if(e==null||typeof e==`string`)return e;var t=null,n=Od;if(n!==null)for(var r=0;r<n.length;r++){var i=e[n[r]];if(i!=null){if(i===`none`)return`none`;t=t==null?i:t+(` `+i)}}return t??e.default}function Ei(e,t){return e=Ti(e),t=Ti(t),t==null?e===`auto`?null:e:t===`auto`?null:t}var Di=typeof reportError==`function`?reportError:function(e){if(typeof window==`object`&&typeof window.ErrorEvent==`function`){var t=new window.ErrorEvent(`error`,{bubbles:!0,cancelable:!0,message:typeof e==`object`&&e&&typeof e.message==`string`?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if(typeof process==`object`&&typeof process.emit==`function`){process.emit(`uncaughtException`,e);return}console.error(e)},Oi=[],ki=0,Ai=0;function ji(){for(var e=ki,t=Ai=ki=0;t<e;){var n=Oi[t];Oi[t++]=null;var r=Oi[t];Oi[t++]=null;var i=Oi[t];Oi[t++]=null;var a=Oi[t];if(Oi[t++]=null,r!==null&&i!==null){var o=r.pending;o===null?i.next=i:(i.next=o.next,o.next=i),r.pending=i}a!==0&&Fi(n,i,a)}}function Mi(e,t,n,r){Oi[ki++]=e,Oi[ki++]=t,Oi[ki++]=n,Oi[ki++]=r,Ai|=r,e.lanes|=r,e=e.alternate,e!==null&&(e.lanes|=r)}function Ni(e,t,n,r){return Mi(e,t,n,r),Ii(e)}function Pi(e,t){return Mi(e,null,null,t),Ii(e)}function Fi(e,t,n){e.lanes|=n;var r=e.alternate;r!==null&&(r.lanes|=n);for(var i=!1,a=e.return;a!==null;)a.childLanes|=n,r=a.alternate,r!==null&&(r.childLanes|=n),a.tag===22&&(e=a.stateNode,e===null||e._visibility&1||(i=!0)),e=a,a=a.return;return e.tag===3?(a=e.stateNode,i&&t!==null&&(i=31-st(n),e=a.hiddenUpdates,r=e[i],r===null?e[i]=[t]:r.push(t),t.lane=n|536870912),a):null}function Ii(e){if(50<kd)throw kd=0,Ad=null,Error(i(185));for(var t=e.return;t!==null;)e=t,t=e.return;return e.tag===3?e.stateNode:null}var Li={};function Ri(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.refCleanup=this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function zi(e,t,n,r){return new Ri(e,t,n,r)}function Bi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Vi(e,t){var n=e.alternate;return n===null?(n=zi(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&1206910976,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n.refCleanup=e.refCleanup,n}function Hi(e,t){e.flags&=1206910978;var n=e.alternate;return n===null?(e.childLanes=0,e.lanes=t,e.child=null,e.subtreeFlags=0,e.memoizedProps=null,e.memoizedState=null,e.updateQueue=null,e.dependencies=null,e.stateNode=null):(e.childLanes=n.childLanes,e.lanes=n.lanes,e.child=n.child,e.subtreeFlags=0,e.deletions=null,e.memoizedProps=n.memoizedProps,e.memoizedState=n.memoizedState,e.updateQueue=n.updateQueue,e.type=n.type,t=n.dependencies,e.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext}),e}function Ui(e,t,n,r,a,o){var s=0;if(r=e,typeof r==`function`)Bi(r)&&(s=1);else if(typeof r==`string`)s=qm(e,n,ke.current)?26:e===`html`||e===`head`||e===`body`?27:5;else a:switch(r){case me:return e=zi(31,n,t,a),e.elementType=me,e.lanes=o,e;case T:return Wi(n.children,a,o,t);case oe:s=8,a|=24;break;case se:return e=zi(12,n,t,a|2),e.elementType=se,e.lanes=o,e;case ue:return e=zi(13,n,t,a),e.elementType=ue,e.lanes=o,e;case de:return e=zi(19,n,t,a),e.elementType=de,e.lanes=o,e;case he:case _e:return e=a|32,e=zi(30,n,t,e),e.elementType=_e,e.lanes=o,e.stateNode={autoName:null,paired:null,clones:null,ref:null},e;default:if(typeof r==`object`&&r)switch(r.$$typeof){case E:s=10;break a;case ce:s=9;break a;case le:s=11;break a;case fe:s=14;break a;case pe:s=16,r=null;break a}s=29,n=Error(i(130,e===null?`null`:typeof e,``)),r=null}return t=zi(s,n,t,a),t.elementType=e,t.type=r,t.lanes=o,t}function Wi(e,t,n,r){return e=zi(7,e,r,t),e.lanes=n,e}function Gi(e,t,n){return e=zi(6,e,null,t),e.lanes=n,e}function Ki(e){var t=zi(18,null,null,0);return t.stateNode=e,t}function qi(e,t,n){return t=zi(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}var Ji=new WeakMap;function Yi(e,t){if(typeof e==`object`&&e){var n=Ji.get(e);return n===void 0?(t={value:e,source:t,stack:Ue(t)},Ji.set(e,t),t):n}return{value:e,source:t,stack:Ue(t)}}var Xi=[],Zi=0,Qi=null,$i=0,ea=[],ta=0,na=null,ra=1,ia=``;function aa(e,t){Xi[Zi++]=$i,Xi[Zi++]=Qi,Qi=e,$i=t}function oa(e,t,n){ea[ta++]=ra,ea[ta++]=ia,ea[ta++]=na,na=e;var r=ra;e=ia;var i=32-st(r)-1;r&=~(1<<i),n+=1;var a=32-st(t)+i;if(30<a){var o=i-i%5;a=(r&(1<<o)-1).toString(32),r>>=o,i-=o,ra=1<<32-st(t)+i|n<<i|r,ia=a+e}else ra=1<<a|n<<i|r,ia=e}function sa(e){e.return!==null&&(aa(e,1),oa(e,1,0))}function ca(e){for(;e===Qi;)Qi=Xi[--Zi],Xi[Zi]=null,$i=Xi[--Zi],Xi[Zi]=null;for(;e===na;)na=ea[--ta],ea[ta]=null,ia=ea[--ta],ea[ta]=null,ra=ea[--ta],ea[ta]=null}function la(e,t){ea[ta++]=ra,ea[ta++]=ia,ea[ta++]=na,ra=t.id,ia=t.overflow,na=e}var ua=null,N=null,P=!1,da=null,fa=!1,pa=Error(i(519));function ma(e){throw ba(Yi(Error(i(418,1<arguments.length&&arguments[1]!==void 0&&arguments[1]?`text`:`HTML`,``)),e)),pa}function ha(e){var t=e.stateNode,n=e.type,r=e.memoizedProps;switch(t[jt]=e,t[Mt]=r,n){case`dialog`:Q(`cancel`,t),Q(`close`,t);break;case`iframe`:case`object`:case`embed`:Q(`load`,t);break;case`video`:case`audio`:for(n=0;n<zf.length;n++)Q(zf[n],t);break;case`source`:Q(`error`,t);break;case`img`:case`image`:case`link`:Q(`error`,t),Q(`load`,t);break;case`details`:Q(`toggle`,t);break;case`input`:Q(`invalid`,t),pn(t,r.value,r.defaultValue,r.checked,r.defaultChecked,r.type,r.name,!0);break;case`select`:Q(`invalid`,t);break;case`textarea`:Q(`invalid`,t),_n(t,r.value,r.defaultValue,r.children)}n=r.children,typeof n!=`string`&&typeof n!=`number`&&typeof n!=`bigint`||t.textContent===``+n||!0===r.suppressHydrationWarning||ep(t.textContent,n)?(r.popover!=null&&(Q(`beforetoggle`,t),Q(`toggle`,t)),r.onScroll!=null&&Q(`scroll`,t),r.onScrollEnd!=null&&Q(`scrollend`,t),r.onClick!=null&&(t.onclick=En),t=!0):t=!1,t||ma(e,!0)}function ga(e){for(ua=e.return;ua;)switch(ua.tag){case 5:case 31:case 13:fa=!1;return;case 27:case 3:fa=!0;return;default:ua=ua.return}}function _a(e){if(e!==ua)return!1;if(!P)return ga(e),P=!0,!1;var t=e.tag,n;if((n=t!==3&&t!==27)&&((n=t===5)&&(n=e.type,n=n===`form`||n===`button`||pp(e.type,e.memoizedProps)),n=!n),n&&N&&ma(e),ga(e),t===13){if(e=e.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(317));N=dm(e)}else if(t===31){if(e=e.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(317));N=dm(e)}else t===27?(t=N,Sp(e.type)?(e=um,um=null,N=e):N=t):N=ua?lm(e.stateNode.nextSibling):null;return!0}function va(){N=ua=null,P=!1}function ya(){var e=da;return e!==null&&(pd===null?pd=e:pd.push.apply(pd,e),da=null),e}function ba(e){da===null?da=[e]:da.push(e)}var xa=De(null),Sa=null,Ca=null;function wa(e,t,n){k(xa,t._currentValue),t._currentValue=n}function Ta(e){e._currentValue=xa.current,Oe(xa)}function Ea(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Da(e,t,n,r){var a=e.child;for(a!==null&&(a.return=e);a!==null;){var o=a.dependencies;if(o!==null){var s=a.child;o=o.firstContext;a:for(;o!==null;){var c=o;o=a;for(var l=0;l<t.length;l++)if(c.context===t[l]){o.lanes|=n,c=o.alternate,c!==null&&(c.lanes|=n),Ea(o.return,n,e),r||(s=null);break a}o=c.next}}else if(a.tag===18){if(s=a.return,s===null)throw Error(i(341));s.lanes|=n,o=s.alternate,o!==null&&(o.lanes|=n),Ea(s,n,e),s=null}else a.tag===13&&a.memoizedState!==null&&a.memoizedState.dehydrated===null?(a.lanes|=n,s=a.alternate,s!==null&&(s.lanes|=n),Ea(a.return,n,e),s=a.child,s=s===null?null:s.sibling):s=a.child;if(s!==null)s.return=a;else for(s=a;s!==null;){if(s===e){s=null;break}if(a=s.sibling,a!==null){a.return=s.return,s=a;break}s=s.return}a=s}}function Oa(e,t,n,r){e=null;for(var a=t,o=!1;a!==null;){if(!o){if(a.flags&524288)o=!0;else if(a.flags&262144)break}if(a.tag===10){var s=a.alternate;if(s===null)throw Error(i(387));if(s=s.memoizedProps,s!==null){var c=a.type;Jr(a.pendingProps.value,s.value)||(e===null?e=[c]:e.push(c))}}else if(a===Me.current){if(s=a.alternate,s===null)throw Error(i(387));s.memoizedState.memoizedState!==a.memoizedState.memoizedState&&(e===null?e=[sh]:e.push(sh))}a=a.return}return e!==null&&Da(t,e,n,r),t.flags|=262144,e!==null}function ka(e){for(e=e.firstContext;e!==null;){if(!Jr(e.context._currentValue,e.memoizedValue))return!0;e=e.next}return!1}function Aa(e){Sa=e,Ca=null,e=e.dependencies,e!==null&&(e.firstContext=null)}function ja(e){return Na(Sa,e)}function Ma(e,t){return Sa===null&&Aa(e),Na(e,t)}function Na(e,t){var n=t._currentValue;if(t={context:t,memoizedValue:n,next:null},Ca===null){if(e===null)throw Error(i(308));Ca=t,e.dependencies={lanes:0,firstContext:t},e.flags|=524288}else Ca=Ca.next=t;return n}var Pa=typeof AbortController<`u`?AbortController:function(){var e=[],t=this.signal={aborted:!1,addEventListener:function(t,n){e.push(n)}};this.abort=function(){t.aborted=!0,e.forEach(function(e){return e()})}},Fa=t.unstable_scheduleCallback,Ia=t.unstable_NormalPriority,F={$$typeof:E,Consumer:null,Provider:null,_currentValue:null,_currentValue2:null,_threadCount:0};function La(){return{controller:new Pa,data:new Map,refCount:0}}function Ra(e){e.refCount--,e.refCount===0&&Fa(Ia,function(){e.controller.abort()})}function za(e,t){if(e.pendingLanes&4194048){var n=e.transitionTypes;for(n===null&&(n=e.transitionTypes=[]),e=0;e<t.length;e++){var r=t[e];n.indexOf(r)===-1&&n.push(r)}}}var Ba=null;function Va(e){var t=e.transitionTypes;return e.transitionTypes=null,t}var Ha=null,Ua=0,Wa=0,Ga=null;function Ka(e,t){if(Ha===null){var n=Ha=[];Ua=0,Wa=Pf(),Ga={status:`pending`,value:void 0,then:function(e){n.push(e)}}}return Ua++,t.then(qa,qa),t}function qa(){if(--Ua===0&&(Ba=null,Ha!==null)){Ga!==null&&(Ga.status=`fulfilled`);var e=Ha;Ha=null,Wa=0,Ga=null;for(var t=0;t<e.length;t++)(0,e[t])()}}function Ja(e,t){var n=[],r={status:`pending`,value:null,reason:null,then:function(e){n.push(e)}};return e.then(function(){r.status=`fulfilled`,r.value=t;for(var e=0;e<n.length;e++)(0,n[e])(t)},function(e){for(r.status=`rejected`,r.reason=e,e=0;e<n.length;e++)(0,n[e])(void 0)}),r}var Ya=D.S;D.S=function(e,t){if(gd=Ye(),typeof t==`object`&&t&&typeof t.then==`function`&&Ka(e,t),Ba!==null)for(var n=bf;n!==null;)za(n,Ba),n=n.next;if(n=e.types,n!==null){for(var r=bf;r!==null;)za(r,n),r=r.next;if(Wa!==0){r=Ba,r===null&&(r=Ba=[]);for(var i=0;i<n.length;i++){var a=n[i];r.indexOf(a)===-1&&r.push(a)}}}Ya!==null&&Ya(e,t)};var Xa=De(null);function Za(){var e=Xa.current;return e===null?G.pooledCache:e}function Qa(e,t){t===null?k(Xa,Xa.current):k(Xa,t.pool)}function $a(){var e=Za();return e===null?null:{parent:F._currentValue,pool:e}}var eo=Error(i(460)),to=Error(i(474)),no=Error(i(542)),ro={then:function(){}};function io(e){return e=e.status,e===`fulfilled`||e===`rejected`}function ao(e,t,n){switch(n=e[n],n===void 0?e.push(t):n!==t&&(t.then(En,En),t=n),t.status){case`fulfilled`:return t.value;case`rejected`:throw e=t.reason,lo(e),e===void 0&&!(`reason`in t)?Error(i(600)):e;default:if(typeof t.status==`string`)t.then(En,En);else{if(e=G,e!==null&&100<e.shellSuspendCounter)throw Error(i(482));e=t,e.status=`pending`,e.then(function(e){if(t.status===`pending`){var n=t;n.status=`fulfilled`,n.value=e}},function(e){if(t.status===`pending`){var n=t;n.status=`rejected`,n.reason=e}})}switch(t.status){case`fulfilled`:return t.value;case`rejected`:throw e=t.reason,lo(e),e}throw so=t,eo}}function oo(e){try{var t=e._init;return t(e._payload)}catch(e){throw typeof e==`object`&&e&&typeof e.then==`function`?(so=e,eo):e}}var so=null;function co(){if(so===null)throw Error(i(459));var e=so;return so=null,e}function lo(e){if(e===eo||e===no)throw Error(i(483))}var uo=null,fo=0;function po(e){var t=fo;return fo+=1,uo===null&&(uo=[]),ao(uo,e,t)}function mo(e,t){t=t.props.ref,e.ref=t===void 0?null:t}function ho(e,t){throw t.$$typeof===ie?Error(i(525)):(e=Object.prototype.toString.call(t),Error(i(31,e===`[object Object]`?`object with keys {`+Object.keys(t).join(`, `)+`}`:e)))}function go(e){function t(t,n){if(e){var r=t.deletions;r===null?(t.deletions=[n],t.flags|=16):r.push(n)}}function n(n,r){if(!e)return null;for(;r!==null;)t(n,r),r=r.sibling;return null}function r(e){for(var t=new Map;e!==null;)e.key===null?t.set(e.index,e):t.set(e.key,e),e=e.sibling;return t}function a(e,t){return e=Vi(e,t),e.index=0,e.sibling=null,e}function o(t,n,r){return t.index=r,e?(r=t.alternate,r===null?(t.flags|=134217730,n):(r=r.index,r<n?(t.flags|=2,n):r)):(t.flags|=1048576,n)}function s(t){return e&&t.alternate===null&&(t.flags|=134217730),t}function c(e,t,n,r){return t===null||t.tag!==6?(t=Gi(n,e.mode,r),t.return=e,t):(t=a(t,n),t.return=e,t)}function l(e,t,n,r){var i=n.type;return i===T?(e=d(e,t,n.props.children,r,n.key),mo(e,n),e):t!==null&&(t.elementType===i||typeof i==`object`&&i&&i.$$typeof===pe&&oo(i)===t.type)?(t=a(t,n.props),mo(t,n),t.return=e,t):(t=Ui(n.type,n.key,n.props,null,e.mode,r),mo(t,n),t.return=e,t)}function u(e,t,n,r){return t===null||t.tag!==4||t.stateNode.containerInfo!==n.containerInfo||t.stateNode.implementation!==n.implementation?(t=qi(n,e.mode,r),t.return=e,t):(t=a(t,n.children||[]),t.return=e,t)}function d(e,t,n,r,i){return t===null||t.tag!==7?(t=Wi(n,e.mode,r,i),t.return=e,t):(t=a(t,n),t.return=e,t)}function f(e,t,n){if(typeof t==`string`&&t!==``||typeof t==`number`||typeof t==`bigint`)return t=Gi(``+t,e.mode,n),t.return=e,t;if(typeof t==`object`&&t){switch(t.$$typeof){case ae:return n=Ui(t.type,t.key,t.props,null,e.mode,n),mo(n,t),n.return=e,n;case w:return t=qi(t,e.mode,n),t.return=e,t;case pe:return t=oo(t),f(e,t,n)}if(Ce(t)||be(t))return t=Wi(t,e.mode,n,null),t.return=e,t;if(typeof t.then==`function`)return f(e,po(t),n);if(t.$$typeof===E)return f(e,Ma(e,t),n);ho(e,t)}return null}function p(e,t,n,r){var i=t===null?null:t.key;if(typeof n==`string`&&n!==``||typeof n==`number`||typeof n==`bigint`)return i===null?c(e,t,``+n,r):null;if(typeof n==`object`&&n){switch(n.$$typeof){case ae:return n.key===i?l(e,t,n,r):null;case w:return n.key===i?u(e,t,n,r):null;case pe:return n=oo(n),p(e,t,n,r)}if(Ce(n)||be(n))return i===null?d(e,t,n,r,null):null;if(typeof n.then==`function`)return p(e,t,po(n),r);if(n.$$typeof===E)return p(e,t,Ma(e,n),r);ho(e,n)}return null}function m(e,t,n,r,i){if(typeof r==`string`&&r!==``||typeof r==`number`||typeof r==`bigint`)return e=e.get(n)||null,c(t,e,``+r,i);if(typeof r==`object`&&r){switch(r.$$typeof){case ae:return e=e.get(r.key===null?n:r.key)||null,l(t,e,r,i);case w:return e=e.get(r.key===null?n:r.key)||null,u(t,e,r,i);case pe:return r=oo(r),m(e,t,n,r,i)}if(Ce(r)||be(r))return e=e.get(n)||null,d(t,e,r,i,null);if(typeof r.then==`function`)return m(e,t,n,po(r),i);if(r.$$typeof===E)return m(e,t,n,Ma(t,r),i);ho(t,r)}return null}function h(i,a,s,c){for(var l=null,u=null,d=a,h=a=0,g=null;d!==null&&h<s.length;h++){d.index>h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),P&&aa(i,h),l;if(d===null){for(;h<s.length;h++)d=f(i,s[h],c),d!==null&&(a=o(d,a,h),u===null?l=d:u.sibling=d,u=d);return P&&aa(i,h),l}for(d=r(d);h<s.length;h++)g=m(d,i,h,s[h],c),g!==null&&(e&&(_=g.alternate,_!==null&&d.delete(_.key===null?h:_.key)),a=o(g,a,h),u===null?l=g:u.sibling=g,u=g);return e&&d.forEach(function(e){return t(i,e)}),P&&aa(i,h),l}function g(a,s,c,l){if(c==null)throw Error(i(151));for(var u=null,d=null,h=s,g=s=0,_=null,v=c.next();h!==null&&!v.done;g++,v=c.next()){h.index>g?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),P&&aa(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return P&&aa(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&(_=v.alternate,_!==null&&h.delete(_.key===null?g:_.key)),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),P&&aa(a,g),u}function _(e,r,o,c){if(typeof o==`object`&&o&&o.type===T&&o.key===null&&o.props.ref===void 0&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case ae:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===T){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),mo(c,o),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===pe&&oo(l)===r.type){n(e,r.sibling),c=a(r,o.props),mo(c,o),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}o.type===T?(c=Wi(o.props.children,e.mode,c,o.key),mo(c,o),c.return=e,e=c):(c=Ui(o.type,o.key,o.props,null,e.mode,c),mo(c,o),c.return=e,e=c)}return s(e);case w:a:{for(l=o.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}c=qi(o,e.mode,c),c.return=e,e=c}return s(e);case pe:return o=oo(o),_(e,r,o,c)}if(Ce(o))return h(e,r,o,c);if(be(o)){if(l=be(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return _(e,r,po(o),c);if(o.$$typeof===E)return _(e,r,Ma(e,o),c);ho(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=Gi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{fo=0;var i=_(e,t,n,r);return uo=null,i}catch(t){if(t===eo||t===no)throw t;var a=zi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var _o=go(!0),vo=go(!1),yo=!1;function bo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function xo(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function So(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Co(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,W&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=Ii(e),Fi(e,null,n),t}return Mi(e,r,t,n),Ii(e)}function wo(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,wt(e,n)}}function To(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n}else e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Eo=!1;function Do(){if(Eo){var e=Ga;if(e!==null)throw e}}function Oo(e,t,n,r){Eo=!1;var i=e.updateQueue;yo=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(q&f)===f:(r&f)===f){f!==0&&f===Wa&&(Eo=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,h=s;f=t;var g=n;switch(h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(g,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(g,d,f):m,f==null)break a;d=C({},d,f);break a;case 2:yo=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),sd|=o,e.lanes=o,e.memoizedState=d}}function ko(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Ao(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;e<n.length;e++)ko(n[e],t)}var jo=De(null),Mo=De(0);function No(e,t){e=od,k(Mo,e),k(jo,t),od=e|t.baseLanes}function Po(){k(Mo,od),k(jo,jo.current)}function Fo(){od=Mo.current,Oe(jo),Oe(Mo)}var Io=De(null),Lo=null;function Ro(e){var t=e.alternate;k(Uo,Uo.current&1),k(Io,e),Lo===null&&(t===null||jo.current!==null||t.memoizedState!==null)&&(Lo=e)}function zo(e){k(Uo,Uo.current),k(Io,e),Lo===null&&(Lo=e)}function Bo(e){e.tag===22?(k(Uo,Uo.current),k(Io,e),Lo===null&&(Lo=e)):Vo()}function Vo(){k(Uo,Uo.current),k(Io,Io.current)}function Ho(e){Oe(Io),Lo===e&&(Lo=null),Oe(Uo)}var Uo=De(0);function Wo(e,t){k(Io,Io.current),k(Uo,t)}function Go(e){Oe(Uo),Oe(Io),Lo===e&&(Lo=null)}function Ko(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||om(n)||sm(n)))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==`independent`){if(t.flags&128)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var qo=0,I=null,L=null,Jo=null,Yo=!1,Xo=!1,Zo=!1,Qo=0,$o=0,es=null,ts=0;function R(){throw Error(i(321))}function ns(e,t){if(t===null)return!1;for(var n=0;n<t.length&&n<e.length;n++)if(!Jr(e[n],t[n]))return!1;return!0}function rs(e,t,n,r,i,a){return qo=a,I=t,t.memoizedState=null,t.updateQueue=null,t.lanes=0,D.H=e===null||e.memoizedState===null?yc:bc,Zo=!1,a=n(r,i),Zo=!1,Xo&&(a=as(t,n,r,i)),is(e),a}function is(e){D.H=vc;var t=L!==null&&L.next!==null;if(qo=0,Jo=L=I=null,Yo=!1,$o=0,es=null,t)throw Error(i(300));e===null||B||(e=e.dependencies,e!==null&&ka(e)&&(B=!0))}function as(e,t,n,r){I=e;var a=0;do{if(Xo&&(es=null),$o=0,Xo=!1,25<=a)throw Error(i(301));if(a+=1,Jo=L=null,e.updateQueue!=null){var o=e.updateQueue;o.lastEffect=null,o.events=null,o.stores=null,o.memoCache!=null&&(o.memoCache.index=0)}D.H=xc,o=t(n,r)}while(Xo);return o}function os(){var e=D.H,t=e.useState()[0];return t=typeof t.then==`function`?fs(t):t,e=e.useState()[0],(L===null?null:L.memoizedState)!==e&&(I.flags|=1024),t}function ss(){var e=Qo!==0;return Qo=0,e}function cs(e,t,n){t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~n}function ls(e){if(Yo){for(e=e.memoizedState;e!==null;){var t=e.queue;t!==null&&(t.pending=null),e=e.next}Yo=!1}qo=0,Jo=L=I=null,Xo=!1,$o=Qo=0,es=null}function us(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return Jo===null?I.memoizedState=Jo=e:Jo=Jo.next=e,Jo}function z(){if(L===null){var e=I.alternate;e=e===null?null:e.memoizedState}else e=L.next;var t=Jo===null?I.memoizedState:Jo.next;if(t!==null)Jo=t,L=e;else{if(e===null)throw I.alternate===null?Error(i(467)):Error(i(310));L=e,e={memoizedState:L.memoizedState,baseState:L.baseState,baseQueue:L.baseQueue,queue:L.queue,next:null},Jo===null?I.memoizedState=Jo=e:Jo=Jo.next=e}return Jo}function ds(){return{lastEffect:null,events:null,stores:null,memoCache:null}}function fs(e){var t=$o;return $o+=1,es===null&&(es=[]),e=ao(es,e,t),t=I,(Jo===null?t.memoizedState:Jo.next)===null&&(t=t.alternate,D.H=t===null||t.memoizedState===null?yc:bc),e}function ps(e){if(typeof e==`object`&&e){if(typeof e.then==`function`)return fs(e);if(e.$$typeof===ve)return;if(e.$$typeof===E)return ja(e)}throw Error(i(438,String(e)))}function ms(e){var t=null,n=I.updateQueue;if(n!==null&&(t=n.memoCache),t==null){var r=I.alternate;r!==null&&(r=r.updateQueue,r!==null&&(r=r.memoCache,r!=null&&(t={data:r.data.map(function(e){return e.slice()}),index:0})))}if(t??={data:[],index:0},n===null&&(n=ds(),I.updateQueue=n),n.memoCache=t,n=t.data[t.index],n===void 0)for(n=t.data[t.index]=Array(e),r=0;r<e;r++)n[r]=ge;return t.index++,n}function hs(e,t){return typeof t==`function`?t(e):t}function gs(e){return _s(z(),L,e)}function _s(e,t,n){var r=e.queue;if(r===null)throw Error(i(311));r.lastRenderedReducer=n;var a=e.baseQueue,o=r.pending;if(o!==null){if(a!==null){var s=a.next;a.next=o.next,o.next=s}t.baseQueue=a=o,r.pending=null}if(o=e.baseState,a===null)e.memoizedState=o;else{t=a.next;var c=s=null,l=null,u=t,d=!1;do{var f=u.lane&-536870913;if(f===u.lane?(qo&f)===f:(q&f)===f){var p=u.revertLane;if(p===0)l!==null&&(l=l.next={lane:0,revertLane:0,gesture:null,action:u.action,hasEagerState:u.hasEagerState,eagerState:u.eagerState,next:null}),f===Wa&&(d=!0);else if((qo&p)===p){u=u.next,p===Wa&&(d=!0);continue}else f={lane:0,revertLane:u.revertLane,gesture:null,action:u.action,hasEagerState:u.hasEagerState,eagerState:u.eagerState,next:null},l===null?(c=l=f,s=o):l=l.next=f,I.lanes|=p,sd|=p;f=u.action,Zo&&n(o,f),o=u.hasEagerState?u.eagerState:n(o,f)}else p={lane:f,revertLane:u.revertLane,gesture:u.gesture,action:u.action,hasEagerState:u.hasEagerState,eagerState:u.eagerState,next:null},l===null?(c=l=p,s=o):l=l.next=p,I.lanes|=f,sd|=f;u=u.next}while(u!==null&&u!==t);if(l===null?s=o:l.next=c,!Jr(o,e.memoizedState)&&(B=!0,d&&(n=Ga,n!==null)))throw n;e.memoizedState=o,e.baseState=s,e.baseQueue=l,r.lastRenderedState=o}return a===null&&(r.lanes=0),[e.memoizedState,r.dispatch]}function vs(e){var t=z(),n=t.queue;if(n===null)throw Error(i(311));n.lastRenderedReducer=e;var r=n.dispatch,a=n.pending,o=t.memoizedState;if(a!==null){n.pending=null;var s=a=a.next;do o=e(o,s.action),s=s.next;while(s!==a);Jr(o,t.memoizedState)||(B=!0),t.memoizedState=o,t.baseQueue===null&&(t.baseState=o),n.lastRenderedState=o}return[o,r]}function ys(e,t,n){var r=I,a=z(),o=P;if(o){if(n===void 0)throw Error(i(407));n=n()}else n=t();var s=!Jr((L||a).memoizedState,n);if(s&&(a.memoizedState=n,B=!0),a=a.queue,Ws(Ss.bind(null,r,a,e),[e]),e=a.getSnapshot!==t||s||Jo!==null&&!!(Jo.memoizedState.tag&1),zs(e?9:8,{destroy:void 0},xs.bind(null,r,a,n,t),null),e){if(r.flags|=2048,G===null)throw Error(i(349));o||qo&127||bs(r,t,n)}return n}function bs(e,t,n){e.flags|=16384,e={getSnapshot:t,value:n},t=I.updateQueue,t===null?(t=ds(),I.updateQueue=t,t.stores=[e]):(n=t.stores,n===null?t.stores=[e]:n.push(e))}function xs(e,t,n,r){t.value=n,t.getSnapshot=r,Cs(t)&&ws(e)}function Ss(e,t,n){return n(function(){Cs(t)&&ws(e)})}function Cs(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!Jr(e,n)}catch{return!0}}function ws(e){var t=Pi(e,2);t!==null&&Pd(t,e,2)}function Ts(e){var t=us();if(typeof e==`function`){var n=e;if(e=n(),Zo){ot(!0);try{n()}finally{ot(!1)}}}return t.memoizedState=t.baseState=e,t.queue={pending:null,lanes:0,dispatch:null,lastRenderedReducer:hs,lastRenderedState:e},t}function Es(e,t,n,r){return e.baseState=n,_s(e,L,typeof r==`function`?r:hs)}function Ds(e,t,n,r,a){if(hc(e))throw Error(i(485));if(e=t.action,e!==null){var o={payload:a,action:e,next:null,isTransition:!0,status:`pending`,value:null,reason:null,listeners:[],then:function(e){o.listeners.push(e)}};D.T===null?o.isTransition=!1:n(!0),r(o),n=t.pending,n===null?(o.next=t.pending=o,Os(t,o)):(o.next=n.next,t.pending=n.next=o)}}function Os(e,t){var n=t.action,r=t.payload,i=e.state;if(t.isTransition){var a=D.T,o={};o.types=a===null?null:a.types,D.T=o;try{var s=n(i,r),c=D.S;c!==null&&c(o,s),ks(e,t,s)}catch(n){js(e,t,n)}finally{a!==null&&o.types!==null&&(a.types=o.types),D.T=a}}else try{a=n(i,r),ks(e,t,a)}catch(n){js(e,t,n)}}function ks(e,t,n){typeof n==`object`&&n&&typeof n.then==`function`?n.then(function(n){As(e,t,n)},function(n){return js(e,t,n)}):As(e,t,n)}function As(e,t,n){t.status=`fulfilled`,t.value=n,Ms(t),e.state=n,t=e.pending,t!==null&&(n=t.next,n===t?e.pending=null:(n=n.next,t.next=n,Os(e,n)))}function js(e,t,n){var r=e.pending;if(e.pending=null,r!==null){r=r.next;do t.status=`rejected`,t.reason=n,Ms(t),t=t.next;while(t!==r)}e.action=null}function Ms(e){e=e.listeners;for(var t=0;t<e.length;t++)(0,e[t])()}function Ns(e,t){return t}function Ps(e,t){if(P){var n=G.formState;if(n!==null){a:{var r=I;if(P){if(N){b:{for(var i=N,a=fa;i.nodeType!==8;)if(!a||(i=lm(i.nextSibling),i===null)){i=null;break b}a=i.data,i=a===`F!`||a===`F`?i:null}if(i){N=lm(i.nextSibling),r=i.data===`F!`;break a}}ma(r)}r=!1}r&&(t=n[0])}}return n=us(),n.memoizedState=n.baseState=t,r={pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ns,lastRenderedState:t},n.queue=r,n=fc.bind(null,I,r),r.dispatch=n,r=Ts(!1),a=mc.bind(null,I,!1,r.queue),r=us(),i={state:t,dispatch:null,action:e,pending:null},r.queue=i,n=Ds.bind(null,I,i,a,n),i.dispatch=n,r.memoizedState=e,[t,n,!1]}function Fs(e){return Is(z(),L,e)}function Is(e,t,n){if(t=_s(e,t,Ns)[0],e=gs(hs)[0],typeof t==`object`&&t&&typeof t.then==`function`)try{var r=fs(t)}catch(e){throw e===eo?no:e}else r=t;t=z();var i=t.queue,a=i.dispatch;return n!==t.memoizedState&&(I.flags|=2048,zs(9,{destroy:void 0},Ls.bind(null,i,n),null)),[r,a,e]}function Ls(e,t){e.action=t}function Rs(e){var t=z(),n=L;if(n!==null)return Is(t,n,e);z(),t=t.memoizedState,n=z();var r=n.queue.dispatch;return n.memoizedState=e,[t,r,!1]}function zs(e,t,n,r){return e={tag:e,create:n,deps:r,inst:t,next:null},t=I.updateQueue,t===null&&(t=ds(),I.updateQueue=t),n=t.lastEffect,n===null?t.lastEffect=e.next=e:(r=n.next,n.next=e,e.next=r,t.lastEffect=e),e}function Bs(){return z().memoizedState}function Vs(e,t,n,r){var i=us();I.flags|=e,i.memoizedState=zs(1|t,{destroy:void 0},n,r===void 0?null:r)}function Hs(e,t,n,r){var i=z();r=r===void 0?null:r;var a=i.memoizedState.inst;L!==null&&r!==null&&ns(r,L.memoizedState.deps)?i.memoizedState=zs(t,a,n,r):(I.flags|=e,i.memoizedState=zs(1|t,a,n,r))}function Us(e,t){Vs(8390656,8,e,t)}function Ws(e,t){Hs(2048,8,e,t)}function Gs(e){I.flags|=4;var t=I.updateQueue;if(t===null)t=ds(),I.updateQueue=t,t.events=[e];else{var n=t.events;n===null?t.events=[e]:n.push(e)}}function Ks(e){var t=z().memoizedState;return Gs({ref:t,nextImpl:e}),function(){if(W&2)throw Error(i(440));return t.impl.apply(void 0,arguments)}}function qs(e,t){return Hs(4,2,e,t)}function Js(e,t){return Hs(4,4,e,t)}function Ys(e,t){if(typeof t==`function`){e=e();var n=t(e);return function(){typeof n==`function`?n():t(null)}}if(t!=null)return e=e(),t.current=e,function(){t.current=null}}function Xs(e,t,n){n=n==null?null:n.concat([e]),Hs(4,4,Ys.bind(null,t,e),n)}function Zs(){}function Qs(e,t){var n=z();t=t===void 0?null:t;var r=n.memoizedState;return t!==null&&ns(t,r[1])?r[0]:(n.memoizedState=[e,t],e)}function $s(e,t){var n=z();t=t===void 0?null:t;var r=n.memoizedState;if(t!==null&&ns(t,r[1]))return r[0];if(r=e(),Zo){ot(!0);try{e()}finally{ot(!1)}}return n.memoizedState=[r,t],r}function ec(e,t,n){return n===void 0||qo&1073741824&&!(q&261930)?e.memoizedState=t:(e.memoizedState=n,e=Md(),I.lanes|=e,sd|=e,n)}function tc(e,t,n,r){return Jr(n,t)?n:jo.current===null?!(qo&106)||qo&1073741824&&!(q&261930)?(B=!0,e.memoizedState=n):(e=Md(),I.lanes|=e,sd|=e,t):(e=ec(e,n,r),Jr(e,t)||(B=!0),e)}function nc(e,t,n,r,i){var a=O.p;O.p=a!==0&&8>a?a:8;var o=D.T,s={};s.types=o===null?null:o.types,D.T=s,mc(e,!1,t,n);try{var c=i(),l=D.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?pc(e,t,Ja(c,r),jd(e)):pc(e,t,r,jd(e))}catch(n){pc(e,t,{then:function(){},status:`rejected`,reason:n},jd())}finally{O.p=a,o!==null&&s.types!==null&&(o.types=s.types),D.T=o}}function rc(){}function ic(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=ac(e).queue;nc(e,a,t,we,n===null?rc:function(){return oc(e),n(r)})}function ac(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:we,baseState:we,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hs,lastRenderedState:we},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hs,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function oc(e){var t=ac(e);t.next===null&&(t=e.alternate.memoizedState),pc(e,t.next.queue,{},jd())}function sc(){return ja(sh)}function cc(){return z().memoizedState}function lc(){return z().memoizedState}function uc(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=jd();e=So(n);var r=Co(t,e,n);r!==null&&(Pd(r,t,n),wo(r,t,n)),t={cache:La()},e.payload=t;return}t=t.return}}function dc(e,t,n){var r=jd();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},hc(e)?gc(t,n):(n=Ni(e,t,n,r),n!==null&&(Pd(n,e,r),_c(n,t,r)))}function fc(e,t,n){pc(e,t,n,jd())}function pc(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(hc(e))gc(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Jr(s,o))return Mi(e,t,i,0),G===null&&ji(),!1}catch{}if(n=Ni(e,t,i,r),n!==null)return Pd(n,e,r),_c(n,t,r),!0}return!1}function mc(e,t,n,r){if(r={lane:2,revertLane:Pf(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},hc(e)){if(t)throw Error(i(479))}else t=Ni(e,n,r,2),t!==null&&Pd(t,e,2)}function hc(e){var t=e.alternate;return e===I||t!==null&&t===I}function gc(e,t){Xo=Yo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function _c(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,wt(e,n)}}var vc={readContext:ja,use:ps,useCallback:R,useContext:R,useEffect:R,useImperativeHandle:R,useLayoutEffect:R,useInsertionEffect:R,useMemo:R,useReducer:R,useRef:R,useState:R,useDebugValue:R,useDeferredValue:R,useTransition:R,useSyncExternalStore:R,useId:R,useHostTransitionStatus:R,useFormState:R,useActionState:R,useOptimistic:R,useMemoCache:R,useCacheRefresh:R,useEffectEvent:R},yc={readContext:ja,use:ps,useCallback:function(e,t){return us().memoizedState=[e,t===void 0?null:t],e},useContext:ja,useEffect:Us,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),Vs(4194308,4,Ys.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Vs(4194308,4,e,t)},useInsertionEffect:function(e,t){Vs(4,2,e,t)},useMemo:function(e,t){var n=us();t=t===void 0?null:t;var r=e();if(Zo){ot(!0);try{e()}finally{ot(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=us();if(n!==void 0){var i=n(t);if(Zo){ot(!0);try{n(t)}finally{ot(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=dc.bind(null,I,e),[r.memoizedState,e]},useRef:function(e){var t=us();return e={current:e},t.memoizedState=e},useState:function(e){e=Ts(e);var t=e.queue,n=fc.bind(null,I,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Zs,useDeferredValue:function(e,t){return ec(us(),e,t)},useTransition:function(){var e=Ts(!1);return e=nc.bind(null,I,e.queue,!0,!1),us().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=I,a=us();if(P){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),G===null)throw Error(i(349));q&127||bs(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Us(Ss.bind(null,r,o,e),[e]),r.flags|=2048,zs(9,{destroy:void 0},xs.bind(null,r,o,n,t),null),n},useId:function(){var e=us(),t=G.identifierPrefix;if(P){var n=ia,r=ra;n=(r&~(1<<32-st(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=Qo++,0<n&&(t+=`H`+n.toString(32)),t+=`_`}else n=ts++,t=`_`+t+`r_`+n.toString(32)+`_`;return e.memoizedState=t},useHostTransitionStatus:sc,useFormState:Ps,useActionState:Ps,useOptimistic:function(e){var t=us();t.memoizedState=t.baseState=e;var n={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return t.queue=n,t=mc.bind(null,I,!0,n),n.dispatch=t,[e,t]},useMemoCache:ms,useCacheRefresh:function(){return us().memoizedState=uc.bind(null,I)},useEffectEvent:function(e){var t=us(),n={impl:e};return t.memoizedState=n,function(){if(W&2)throw Error(i(440));return n.impl.apply(void 0,arguments)}}},bc={readContext:ja,use:ps,useCallback:Qs,useContext:ja,useEffect:Ws,useImperativeHandle:Xs,useInsertionEffect:qs,useLayoutEffect:Js,useMemo:$s,useReducer:gs,useRef:Bs,useState:function(){return gs(hs)},useDebugValue:Zs,useDeferredValue:function(e,t){return tc(z(),L.memoizedState,e,t)},useTransition:function(){var e=gs(hs)[0],t=z().memoizedState;return[typeof e==`boolean`?e:fs(e),t]},useSyncExternalStore:ys,useId:cc,useHostTransitionStatus:sc,useFormState:Fs,useActionState:Fs,useOptimistic:function(e,t){return Es(z(),L,e,t)},useMemoCache:ms,useCacheRefresh:lc,useEffectEvent:Ks},xc={readContext:ja,use:ps,useCallback:Qs,useContext:ja,useEffect:Ws,useImperativeHandle:Xs,useInsertionEffect:qs,useLayoutEffect:Js,useMemo:$s,useReducer:vs,useRef:Bs,useState:function(){return vs(hs)},useDebugValue:Zs,useDeferredValue:function(e,t){var n=z();return L===null?ec(n,e,t):tc(n,L.memoizedState,e,t)},useTransition:function(){var e=vs(hs)[0],t=z().memoizedState;return[typeof e==`boolean`?e:fs(e),t]},useSyncExternalStore:ys,useId:cc,useHostTransitionStatus:sc,useFormState:Rs,useActionState:Rs,useOptimistic:function(e,t){var n=z();return L===null?(n.baseState=e,[e,n.queue.dispatch]):Es(n,L,e,t)},useMemoCache:ms,useCacheRefresh:lc,useEffectEvent:Ks};function Sc(e,t,n,r){t=e.memoizedState,n=n(r,t),n=n==null?t:C({},t,n),e.memoizedState=n,e.lanes===0&&(e.updateQueue.baseState=n)}var Cc={enqueueSetState:function(e,t,n){e=e._reactInternals;var r=jd(),i=So(r);i.payload=t,n!=null&&(i.callback=n),t=Co(e,i,r),t!==null&&(Pd(t,e,r),wo(t,e,r))},enqueueReplaceState:function(e,t,n){e=e._reactInternals;var r=jd(),i=So(r);i.tag=1,i.payload=t,n!=null&&(i.callback=n),t=Co(e,i,r),t!==null&&(Pd(t,e,r),wo(t,e,r))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var n=jd(),r=So(n);r.tag=2,t!=null&&(r.callback=t),t=Co(e,r,n),t!==null&&(Pd(t,e,n),wo(t,e,n))}};function wc(e,t,n,r,i,a,o){return e=e.stateNode,typeof e.shouldComponentUpdate==`function`?e.shouldComponentUpdate(r,a,o):t.prototype&&t.prototype.isPureReactComponent?!Yr(n,r)||!Yr(i,a):!0}function Tc(e,t,n,r){e=t.state,typeof t.componentWillReceiveProps==`function`&&t.componentWillReceiveProps(n,r),typeof t.UNSAFE_componentWillReceiveProps==`function`&&t.UNSAFE_componentWillReceiveProps(n,r),t.state!==e&&Cc.enqueueReplaceState(t,t.state,null)}function Ec(e,t){var n=t;if(`ref`in t)for(var r in n={},t)r!==`ref`&&(n[r]=t[r]);if(e=e.defaultProps)for(var i in n===t&&(n=C({},n)),e)n[i]===void 0&&(n[i]=e[i]);return n}function Dc(e){Di(e)}function Oc(e){console.error(e)}function kc(e){Di(e)}function Ac(e,t){try{var n=e.onUncaughtError;n(t.value,{componentStack:t.stack})}catch(e){setTimeout(function(){throw e})}}function jc(e,t,n){try{var r=e.onCaughtError;r(n.value,{componentStack:n.stack,errorBoundary:t.tag===1?t.stateNode:null})}catch(e){setTimeout(function(){throw e})}}function Mc(e,t,n){return n=So(n),n.tag=3,n.payload={element:null},n.callback=function(){Ac(e,t)},n}function Nc(e){return e=So(e),e.tag=3,e}function Pc(e,t,n,r){var i=n.type.getDerivedStateFromError;if(typeof i==`function`){var a=r.value;e.payload=function(){return i(a)},e.callback=function(){jc(t,n,r)}}var o=n.stateNode;o!==null&&typeof o.componentDidCatch==`function`&&(e.callback=function(){jc(t,n,r),typeof i!=`function`&&(yd===null?yd=new Set([this]):yd.add(this));var e=r.stack;this.componentDidCatch(r.value,{componentStack:e===null?``:e})})}function Fc(e,t,n,r,a){if(n.flags|=32768,typeof r==`object`&&r&&typeof r.then==`function`){if(t=n.alternate,t!==null&&Oa(t,n,a,!0),n=Io.current,n!==null){switch(n.tag){case 31:case 13:case 19:return Lo===null?Kd():n.alternate===null&&Y===0&&(Y=3),n.flags&=-257,n.flags|=65536,n.lanes=a,r===ro?n.flags|=16384:(t=n.updateQueue,t===null?n.updateQueue=new Set([r]):t.add(r),mf(e,r,a)),!1;case 22:return n.flags|=65536,r===ro?n.flags|=16384:(t=n.updateQueue,t===null?(t={transitions:null,markerInstances:null,retryQueue:new Set([r])},n.updateQueue=t):(n=t.retryQueue,n===null?t.retryQueue=new Set([r]):n.add(r)),mf(e,r,a)),!1}throw Error(i(435,n.tag))}return mf(e,r,a),Kd(),!1}if(P)return t=Io.current,t===null?(r!==pa&&(t=Error(i(423),{cause:r}),ba(Yi(t,n))),e=e.current.alternate,e.flags|=65536,a&=-a,e.lanes|=a,r=Yi(r,n),a=Mc(e.stateNode,r,a),To(e,a),Y!==4&&(Y=2)):(!(t.flags&65536)&&(t.flags|=256),t.flags|=65536,t.lanes=a,r!==pa&&(e=Error(i(422),{cause:r}),ba(Yi(e,n)))),!1;var o=Error(i(520),{cause:r});if(o=Yi(o,n),fd===null?fd=[o]:fd.push(o),Y!==4&&(Y=2),t===null)return!0;r=Yi(r,n),n=t;do{switch(n.tag){case 3:return n.flags|=65536,e=a&-a,n.lanes|=e,e=Mc(n.stateNode,r,e),To(n,e),!1;case 1:if(t=n.type,o=n.stateNode,!(n.flags&128)&&(typeof t.getDerivedStateFromError==`function`||o!==null&&typeof o.componentDidCatch==`function`&&(yd===null||!yd.has(o))))return n.flags|=65536,a&=-a,n.lanes|=a,a=Nc(a),Pc(a,e,n,r),To(n,a),!1;break;case 22:if(n.memoizedState!==null)return n.flags|=65536,!1}n=n.return}while(n!==null);return!1}var Ic=Error(i(461)),B=!1;function Lc(e,t,n,r){t.child=e===null?vo(t,null,n,r):_o(t,e.child,n,r)}function Rc(e,t,n,r,i){n=n.render;var a=t.ref;if(`ref`in r){var o={};for(var s in r)s!==`ref`&&(o[s]=r[s])}else o=r;return Aa(t),r=rs(e,t,n,o,a,i),s=ss(),e!==null&&!B?(cs(e,t,i),fl(e,t,i)):(P&&s&&sa(t),t.flags|=1,Lc(e,t,r,i),t.child)}function zc(e,t,n,r,i){if(e===null){var a=n.type;return typeof a==`function`&&!Bi(a)&&a.defaultProps===void 0&&n.compare===null?(t.tag=15,t.type=a,Bc(e,t,a,r,i)):(e=Ui(n.type,null,r,t,t.mode,i),e.ref=t.ref,e.return=t,t.child=e)}if(a=e.child,!pl(e,i)){var o=a.memoizedProps;if(n=n.compare,n=n===null?Yr:n,n(o,r)&&e.ref===t.ref)return fl(e,t,i)}return t.flags|=1,e=Vi(a,r),e.ref=t.ref,e.return=t,t.child=e}function Bc(e,t,n,r,i){if(e!==null){var a=e.memoizedProps;if(Yr(a,r)&&e.ref===t.ref){if(B=!1,t.pendingProps=r=a,pl(e,i))e.flags&131072&&(B=!0);else return t.lanes=e.lanes,fl(e,t,i)}}return Jc(e,t,n,r,i)}function Vc(e,t,n,r){var i=r.children,a=e===null?null:e.memoizedState;if(e===null&&t.stateNode===null&&(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),r.mode===`hidden`){if(t.flags&128){if(a=a===null?n:a.baseLanes|n,e!==null){for(r=t.child=e.child,i=0;r!==null;)i=i|r.lanes|r.childLanes,r=r.sibling;r=i&~a}else r=0,t.child=null;return Uc(e,t,a,n,r)}if(n&536870912)t.memoizedState={baseLanes:0,cachePool:null},e!==null&&Qa(t,a===null?null:a.cachePool),a===null?Po():No(t,a),Bo(t);else return r=t.lanes=536870912,Uc(e,t,a===null?n:a.baseLanes|n,n,r)}else a===null?(e!==null&&Qa(t,null),Po(),Vo()):(Qa(t,a.cachePool),No(t,a),Vo(),t.memoizedState=null);return Lc(e,t,i,n),t.child}function Hc(e,t){return e!==null&&e.tag===22||t.stateNode!==null||(t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),t.sibling}function Uc(e,t,n,r,i){var a=Za();return a=a===null?null:{parent:F._currentValue,pool:a},t.memoizedState={baseLanes:n,cachePool:a},e!==null&&Qa(t,null),Po(),Bo(t),e!==null&&Oa(e,t,r,!0),t.childLanes=i,null}function Wc(e,t){return t=rl({mode:t.mode,children:t.children},e.mode),t.ref=e.ref,e.child=t,t.return=e,t}function Gc(e,t,n){return _o(t,e.child,null,n),e=Wc(t,t.pendingProps),e.flags|=2,Ho(t),t.memoizedState=null,e}function Kc(e,t,n){var r=t.pendingProps,a=!!(t.flags&128);if(t.flags&=-129,e===null){if(P){if(r.mode===`hidden`)return e=Wc(t,r),t.lanes=536870912,e.memoizedState={baseLanes:0,cachePool:null},Hc(null,e);if(zo(t),(e=N)?(e=am(e,fa),e=e!==null&&e.data===`&`?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:na===null?null:{id:ra,overflow:ia},retryLane:536870912,hydrationErrors:null},n=Ki(e),n.return=t,t.child=n,ua=t,N=null)):e=null,e===null)throw ma(t);return t.lanes=536870912,null}return Wc(t,r)}var o=e.memoizedState;if(o!==null){var s=o.dehydrated;if(zo(t),a){if(t.flags&256)t.flags&=-257,t=Gc(e,t,n);else if(t.memoizedState!==null)t.child=e.child,t.flags|=128,t=null;else throw Error(i(558))}else if(B||Oa(e,t,n,!1),a=(n&e.childLanes)!==0,B||a){if(jo.current===null){if(r=G,r!==null&&(s=Tt(r,n),s!==0&&s!==o.retryLane))throw o.retryLane=s,Pi(e,s),Pd(r,e,s),Ic;Kd()}t=Gc(e,t,n)}else e=o.treeContext,N=lm(s.nextSibling),ua=t,P=!0,da=null,fa=!1,e!==null&&la(t,e),t=Wc(t,r),t.flags|=134221824;return t}return e=Vi(e.child,{mode:r.mode,children:r.children}),e.ref=t.ref,t.child=e,e.return=t,e}function qc(e,t){var n=t.ref;if(n===null)e!==null&&e.ref!==null&&(t.flags|=4194816);else{if(typeof n!=`function`&&typeof n!=`object`)throw Error(i(284));(e===null||e.ref!==n)&&(t.flags|=4194816)}}function Jc(e,t,n,r,i){return Aa(t),n=rs(e,t,n,r,void 0,i),r=ss(),e!==null&&!B?(cs(e,t,i),fl(e,t,i)):(P&&r&&sa(t),t.flags|=1,Lc(e,t,n,i),t.child)}function Yc(e,t,n,r,i,a){return Aa(t),t.updateQueue=null,n=as(t,r,n,i),is(e),r=ss(),e!==null&&!B?(cs(e,t,a),fl(e,t,a)):(P&&r&&sa(t),t.flags|=1,Lc(e,t,n,a),t.child)}function Xc(e,t,n,r,i){if(Aa(t),t.stateNode===null){var a=Li,o=n.contextType;typeof o==`object`&&o&&(a=ja(o)),a=new n(r,a),t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,a.updater=Cc,t.stateNode=a,a._reactInternals=t,a=t.stateNode,a.props=r,a.state=t.memoizedState,a.refs={},bo(t),o=n.contextType,a.context=typeof o==`object`&&o?ja(o):Li,a.state=t.memoizedState,o=n.getDerivedStateFromProps,typeof o==`function`&&(Sc(t,n,o,r),a.state=t.memoizedState),typeof n.getDerivedStateFromProps==`function`||typeof a.getSnapshotBeforeUpdate==`function`||typeof a.UNSAFE_componentWillMount!=`function`&&typeof a.componentWillMount!=`function`||(o=a.state,typeof a.componentWillMount==`function`&&a.componentWillMount(),typeof a.UNSAFE_componentWillMount==`function`&&a.UNSAFE_componentWillMount(),o!==a.state&&Cc.enqueueReplaceState(a,a.state,null),Oo(t,r,a,i),Do(),a.state=t.memoizedState),typeof a.componentDidMount==`function`&&(t.flags|=4194308),r=!0}else if(e===null){a=t.stateNode;var s=t.memoizedProps,c=Ec(n,s);a.props=c;var l=a.context,u=n.contextType;o=Li,typeof u==`object`&&u&&(o=ja(u));var d=n.getDerivedStateFromProps;u=typeof d==`function`||typeof a.getSnapshotBeforeUpdate==`function`,s=t.pendingProps!==s,u||typeof a.UNSAFE_componentWillReceiveProps!=`function`&&typeof a.componentWillReceiveProps!=`function`||(s||l!==o)&&Tc(t,a,r,o),yo=!1;var f=t.memoizedState;a.state=f,Oo(t,r,a,i),Do(),l=t.memoizedState,s||f!==l||yo?(typeof d==`function`&&(Sc(t,n,d,r),l=t.memoizedState),(c=yo||wc(t,n,c,r,f,l,o))?(u||typeof a.UNSAFE_componentWillMount!=`function`&&typeof a.componentWillMount!=`function`||(typeof a.componentWillMount==`function`&&a.componentWillMount(),typeof a.UNSAFE_componentWillMount==`function`&&a.UNSAFE_componentWillMount()),typeof a.componentDidMount==`function`&&(t.flags|=4194308)):(typeof a.componentDidMount==`function`&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=l),a.props=r,a.state=l,a.context=o,r=c):(typeof a.componentDidMount==`function`&&(t.flags|=4194308),r=!1)}else{a=t.stateNode,xo(e,t),o=t.memoizedProps,u=Ec(n,o),a.props=u,d=t.pendingProps,f=a.context,l=n.contextType,c=Li,typeof l==`object`&&l&&(c=ja(l)),s=n.getDerivedStateFromProps,(l=typeof s==`function`||typeof a.getSnapshotBeforeUpdate==`function`)||typeof a.UNSAFE_componentWillReceiveProps!=`function`&&typeof a.componentWillReceiveProps!=`function`||(o!==d||f!==c)&&Tc(t,a,r,c),yo=!1,f=t.memoizedState,a.state=f,Oo(t,r,a,i),Do();var p=t.memoizedState;o!==d||f!==p||yo||e!==null&&e.dependencies!==null&&ka(e.dependencies)?(typeof s==`function`&&(Sc(t,n,s,r),p=t.memoizedState),(u=yo||wc(t,n,u,r,f,p,c)||e!==null&&e.dependencies!==null&&ka(e.dependencies))?(l||typeof a.UNSAFE_componentWillUpdate!=`function`&&typeof a.componentWillUpdate!=`function`||(typeof a.componentWillUpdate==`function`&&a.componentWillUpdate(r,p,c),typeof a.UNSAFE_componentWillUpdate==`function`&&a.UNSAFE_componentWillUpdate(r,p,c)),typeof a.componentDidUpdate==`function`&&(t.flags|=4),typeof a.getSnapshotBeforeUpdate==`function`&&(t.flags|=1024)):(typeof a.componentDidUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=4),typeof a.getSnapshotBeforeUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=p),a.props=r,a.state=p,a.context=c,r=u):(typeof a.componentDidUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=4),typeof a.getSnapshotBeforeUpdate!=`function`||o===e.memoizedProps&&f===e.memoizedState||(t.flags|=1024),r=!1)}return a=r,qc(e,t),r=!!(t.flags&128),a||r?(a=t.stateNode,n=r&&typeof n.getDerivedStateFromError!=`function`?null:a.render(),t.flags|=1,e!==null&&r?(t.child=_o(t,e.child,null,i),t.child=_o(t,null,n,i)):Lc(e,t,n,i),t.memoizedState=a.state,e=t.child):e=fl(e,t,i),e}function Zc(e,t,n,r){return va(),t.flags|=256,Lc(e,t,n,r),t.child}var Qc={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function $c(e){return{baseLanes:e,cachePool:$a()}}function el(e,t,n){return e=e===null?0:e.childLanes&~n,t&&(e|=ud),e}function tl(e,t,n){var r=t.pendingProps,i=!1,a=!!(t.flags&128),o;if((o=a)||(o=e!==null&&e.memoizedState===null?!1:!!(Uo.current&2)),o&&(i=!0,t.flags&=-129),o=!!(t.flags&32),t.flags&=-33,e===null){if(P){if(i?Ro(t):Vo(),(e=N)?(e=am(e,fa),e=e!==null&&e.data!==`&`?e:null,e!==null&&(t.memoizedState={dehydrated:e,treeContext:na===null?null:{id:ra,overflow:ia},retryLane:536870912,hydrationErrors:null},n=Ki(e),n.return=t,t.child=n,ua=t,N=null)):e=null,e===null)throw ma(t);return t.lanes=sm(e)?32:536870912,null}return a=r.children,r=r.fallback,i?(Vo(),i=t.mode,a=rl({mode:`hidden`,children:a},i),r=Wi(r,i,n,null),a.return=t,r.return=t,a.sibling=r,t.child=a,r=t.child,r.memoizedState=$c(n),r.childLanes=el(e,o,n),t.memoizedState=Qc,Hc(null,r)):(Ro(t),nl(t,a))}var s=e.memoizedState;if(s!==null){var c=s.dehydrated;if(c!==null)return al(e,t,a,o,r,c,s,n)}return i?(Vo(),i=r.fallback,a=t.mode,s=e.child,c=s.sibling,r=Vi(s,{mode:`hidden`,children:r.children}),r.subtreeFlags=s.subtreeFlags&1206910976,c===null?(i=Wi(i,a,n,null),i.flags|=2):i=Vi(c,i),i.return=t,r.return=t,r.sibling=i,t.child=r,Hc(null,r),r=t.child,i=e.child.memoizedState,i===null?i=$c(n):(a=i.cachePool,a===null?a=$a():(s=F._currentValue,a=a.parent===s?a:{parent:s,pool:s}),i={baseLanes:i.baseLanes|n,cachePool:a}),r.memoizedState=i,r.childLanes=el(e,o,n),t.memoizedState=Qc,Hc(e.child,r)):(Ro(t),n=e.child,e=n.sibling,n=Vi(n,{mode:`visible`,children:r.children}),n.return=t,n.sibling=null,e!==null&&(o=t.deletions,o===null?(t.deletions=[e],t.flags|=16):o.push(e)),t.child=n,t.memoizedState=null,n)}function nl(e,t){return t=rl({mode:`visible`,children:t},e.mode),t.return=e,e.child=t}function rl(e,t){return e=zi(22,e,null,t),e.lanes=0,e}function il(e,t,n){return _o(t,e.child,null,n),e=nl(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function al(e,t,n,r,a,o,s,c){if(n)return t.flags&256?(Ro(t),t.flags&=-257,il(e,t,c)):t.memoizedState===null?(Vo(),o=a.fallback,s=t.mode,a=rl({mode:`visible`,children:a.children},s),o=Wi(o,s,c,null),o.flags|=2,a.return=t,o.return=t,a.sibling=o,t.child=a,_o(t,e.child,null,c),a=t.child,a.memoizedState=$c(c),a.childLanes=el(e,r,c),t.memoizedState=Qc,Hc(null,a)):(Vo(),t.child=e.child,t.flags|=128,null);if(Ro(t),sm(o)){if(r=o.nextSibling&&o.nextSibling.dataset,r)var l=r.dgst;return r=l,r!==``&&(a=Error(i(419)),a.stack=``,a.digest=r,ba({value:a,source:null,stack:null})),il(e,t,c)}if(B||Oa(e,t,c,!1),r=(c&e.childLanes)!==0,B||r){if(jo.current!==null)return il(e,t,c);if(r=G,r!==null&&(a=Tt(r,c),a!==0&&a!==s.retryLane))throw s.retryLane=a,Pi(e,a),Pd(r,e,a),Ic;return om(o)||Kd(),il(e,t,c)}return om(o)?(t.flags|=192,t.child=e.child,null):(e=s.treeContext,N=lm(o.nextSibling),ua=t,P=!0,da=null,fa=!1,e!==null&&la(t,e),t=nl(t,a.children),t.flags|=134221824,t)}function ol(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),Ea(e.return,t,n)}function sl(e){for(var t=null;e!==null;){var n=e.alternate;n!==null&&Ko(n)===null&&(t=e),e=e.sibling}return t}function cl(e,t,n,r,i,a){var o=e.memoizedState;o===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:i,treeForkCount:a}:(o.isBackwards=t,o.rendering=null,o.renderingStartTime=0,o.last=r,o.tail=n,o.tailMode=i,o.treeForkCount=a)}function ll(e){var t=e.child;for(e.child=null;t!==null;){var n=t.sibling;t.sibling=e.child,e.child=t,t=n}}function ul(e,t,n){var r=t.pendingProps,i=r.revealOrder,a=r.tail;r=r.children;var o=Uo.current;if(t.flags&128)return Wo(t,o),null;var s=!!(o&2);if(s?(o=o&1|2,t.flags|=128):o&=1,Wo(t,o),i===`backwards`&&e!==null?(ll(e),Lc(e,t,r,n),ll(e)):Lc(e,t,r,n),r=P?$i:0,!s&&e!==null&&e.flags&128)a:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&ol(e,n,t);else if(e.tag===19)ol(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break a;for(;e.sibling===null;){if(e.return===null||e.return===t)break a;e=e.return}e.sibling.return=e.return,e=e.sibling}switch(i){case`backwards`:n=sl(t.child),n===null?(i=t.child,t.child=null):(i=n.sibling,n.sibling=null,ll(t)),cl(t,!0,i,null,a,r);break;case`unstable_legacy-backwards`:for(n=null,i=t.child,t.child=null;i!==null;){if(e=i.alternate,e!==null&&Ko(e)===null){t.child=i;break}e=i.sibling,i.sibling=n,n=i,i=e}cl(t,!0,n,null,a,r);break;case`together`:cl(t,!1,null,null,void 0,r);break;case`independent`:t.memoizedState=null;break;default:n=sl(t.child),n===null?(i=t.child,t.child=null):(i=n.sibling,n.sibling=null),cl(t,!1,i,n,a,r)}return t.child}function dl(e,t,n){var r=t.pendingProps;return wa(t,t.type,r.value),Lc(e,t,r.children,n),t.child}function fl(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),sd|=t.lanes,(n&t.childLanes)===0){if(e!==null){if(Oa(e,t,n,!1),(n&t.childLanes)===0)return null}else return null}if(e!==null&&t.child!==e.child)throw Error(i(153));if(t.child!==null){for(e=t.child,n=Vi(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=Vi(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function pl(e,t){return(e.lanes&t)!==0||(e=e.dependencies,!!(e!==null&&ka(e)))}function ml(e,t,n){switch(t.tag){case 3:Ne(t,t.stateNode.containerInfo),wa(t,F,e.memoizedState.cache),va();break;case 27:case 5:Fe(t);break;case 4:Ne(t,t.stateNode.containerInfo);break;case 10:wa(t,t.type,t.memoizedProps.value);break;case 31:if(t.memoizedState!==null)return t.flags|=128,zo(t),null;break;case 13:var r=t.memoizedState;if(r!==null){if(r.dehydrated!==null)return Ro(t),t.flags|=128,null;r=Oa(e,t,n,!1);var i=t.child.childLanes;return r||(n&i)!==0?tl(e,t,n):(Ro(t),e=fl(e,t,n),e===null?null:e.sibling)}Ro(t);break;case 19:if(t.flags&128)return ul(e,t,n);if(i=!!(e.flags&128),r=(n&t.childLanes)!==0,r||=(Oa(e,t,n,!1),(n&t.childLanes)!==0),i){if(r)return ul(e,t,n);t.flags|=128}if(i=t.memoizedState,i!==null&&(i.rendering=null,i.tail=null,i.lastEffect=null),Wo(t,Uo.current),r)break;return null;case 22:return t.lanes=0,Vc(e,t,n,t.pendingProps);case 24:wa(t,F,e.memoizedState.cache)}return fl(e,t,n)}function hl(e,t,n){if(e!==null){if(e.memoizedProps!==t.pendingProps)B=!0;else{if(!pl(e,n)&&!(t.flags&128))return B=!1,ml(e,t,n);B=!!(e.flags&131072)}}else B=!1,P&&t.flags&1048576&&oa(t,$i,t.index);switch(t.lanes=0,t.tag){case 16:a:{var r=t.pendingProps;if(e=oo(t.elementType),t.type=e,typeof e==`function`)Bi(e)?(r=Ec(e,r),t.tag=1,t=Xc(null,t,e,r,n)):(t.tag=0,t=Jc(null,t,e,r,n));else{if(e!=null){var a=e.$$typeof;if(a===le){t.tag=11,t=Rc(null,t,e,r,n);break a}if(a===fe){t.tag=14,t=zc(null,t,e,r,n);break a}if(a===E){t.tag=10,t.type=e,t=dl(null,t,n);break a}}throw t=Se(e)||e,Error(i(306,t,``))}}return t;case 0:return Jc(e,t,t.type,t.pendingProps,n);case 1:return r=t.type,a=Ec(r,t.pendingProps),Xc(e,t,r,a,n);case 3:a:{if(Ne(t,t.stateNode.containerInfo),e===null)throw Error(i(387));r=t.pendingProps;var o=t.memoizedState;a=o.element,xo(e,t),Oo(t,r,null,n);var s=t.memoizedState;if(r=s.cache,wa(t,F,r),r!==o.cache&&Da(t,[F],n,!0),Do(),r=s.element,o.isDehydrated){if(o={element:r,isDehydrated:!1,cache:s.cache},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){t=Zc(e,t,r,n);break a}if(r!==a){a=Yi(Error(i(424)),t),ba(a),t=Zc(e,t,r,n);break a}switch(e=t.stateNode.containerInfo,e.nodeType){case 9:e=e.body;break;default:e=e.nodeName===`HTML`?e.ownerDocument.body:e}for(N=lm(e.firstChild),ua=t,P=!0,da=null,fa=!0,n=vo(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|134221824,n=n.sibling}else{if(va(),r===a){t=fl(e,t,n);break a}Lc(e,t,r,n)}t=t.child}return t;case 26:return qc(e,t),e===null?(n=Nm(t.type,null,t.pendingProps,null))?t.memoizedState=n:P||(t.stateNode=fp(t.type,t.pendingProps,je.current,t)):t.memoizedState=Nm(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return Fe(t),e===null&&P&&(r=t.stateNode=hm(t.type,t.pendingProps,je.current),ua=t,fa=!0,a=N,Sp(t.type)?(um=a,N=lm(r.firstChild)):N=a),Lc(e,t,t.pendingProps.children,n),qc(e,t),e===null&&(t.flags|=4194304),t.child;case 5:return e===null&&P&&((a=r=N)&&(r=rm(r,t.type,t.pendingProps,fa),r===null?a=!1:(t.stateNode=r,ua=t,N=lm(r.firstChild),fa=!1,a=!0)),a||ma(t)),Fe(t),a=t.type,o=t.pendingProps,s=e===null?null:e.memoizedProps,r=o.children,pp(a,o)?r=null:s!==null&&pp(a,s)&&(t.flags|=32),t.memoizedState!==null&&(a=rs(e,t,os,null,null,n),sh._currentValue=a),qc(e,t),Lc(e,t,r,n),t.child;case 6:return e===null&&P&&((e=n=N)&&(n=im(n,t.pendingProps,fa),n===null?e=!1:(t.stateNode=n,ua=t,N=null,e=!0)),e||ma(t)),null;case 13:return tl(e,t,n);case 4:return Ne(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=_o(t,null,r,n):Lc(e,t,r,n),t.child;case 11:return Rc(e,t,t.type,t.pendingProps,n);case 7:return r=t.pendingProps,qc(e,t),Lc(e,t,r,n),t.child;case 8:return Lc(e,t,t.pendingProps.children,n),t.child;case 12:return Lc(e,t,t.pendingProps.children,n),t.child;case 10:return dl(e,t,n);case 9:return a=t.type._context,r=t.pendingProps.children,Aa(t),a=ja(a),r=r(a),t.flags|=1,Lc(e,t,r,n),t.child;case 14:return zc(e,t,t.type,t.pendingProps,n);case 15:return Bc(e,t,t.type,t.pendingProps,n);case 19:return ul(e,t,n);case 31:return Kc(e,t,n);case 22:return Vc(e,t,n,t.pendingProps);case 24:return Aa(t),r=ja(F),e===null?(a=Za(),a===null&&(a=G,o=La(),a.pooledCache=o,o.refCount++,o!==null&&(a.pooledCacheLanes|=n),a=o),t.memoizedState={parent:r,cache:a},bo(t),wa(t,F,a)):((e.lanes&n)!==0&&(xo(e,t),Oo(t,null,null,n),Do()),a=e.memoizedState,o=t.memoizedState,a.parent===r?(r=o.cache,wa(t,F,r),r!==a.cache&&Da(t,[F],n,!0)):(a={parent:r,cache:r},t.memoizedState=a,t.lanes===0&&(t.memoizedState=t.updateQueue.baseState=a),wa(t,F,r))),Lc(e,t,t.pendingProps.children,n),t.child;case 30:return t.stateNode===null&&(t.stateNode={autoName:null,paired:null,clones:null,ref:null}),r=t.pendingProps,r.name!=null&&r.name!==`auto`?t.flags|=e===null?18882560:18874368:P&&sa(t),e!==null&&e.memoizedProps.name!==r.name?t.flags|=4194816:qc(e,t),Lc(e,t,r.children,n),t.child;case 29:throw t.pendingProps}throw Error(i(156,t.tag))}function gl(e){e.flags|=4}function _l(e,t,n,r,i){var a;if((a=!!(e.mode&32))&&(a=n===null?Jm(t,r):Jm(t,r)&&(r.src!==n.src||r.srcSet!==n.srcSet)),a){if(e.flags|=16777216,(i&335544128)===i){if(e.stateNode.complete)e.flags|=8192;else if(Ud())e.flags|=8192;else throw so=ro,to}}else e.flags&=-16777217}function vl(e,t){if(t.type!==`stylesheet`||t.state.loading&4)e.flags&=-16777217;else if(e.flags|=16777216,!Ym(t)){if(Ud())e.flags|=8192;else throw so=ro,to}}function yl(e,t){t!==null&&(e.flags|=4),e.flags&16384&&(t=e.tag===22?536870912:yt(),e.lanes|=t,dd|=t)}function bl(e,t){if(!P)switch(e.tailMode){case`visible`:break;case`collapsed`:for(var n=e.tail,r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null;break;default:for(t=e.tail,n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null}}function V(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var i=e.child;i!==null;)n|=i.lanes|i.childLanes,r|=i.subtreeFlags&1206910976,r|=i.flags&1206910976,i.return=e,i=i.sibling;else for(i=e.child;i!==null;)n|=i.lanes|i.childLanes,r|=i.subtreeFlags,r|=i.flags,i.return=e,i=i.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function xl(e,t,n){var r=t.pendingProps;switch(ca(t),t.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return V(t),null;case 1:return V(t),null;case 3:return n=t.stateNode,r=null,e!==null&&(r=e.memoizedState.cache),t.memoizedState.cache!==r&&(t.flags|=2048),Ta(F),Pe(),n.pendingContext&&(n.context=n.pendingContext,n.pendingContext=null),(e===null||e.child===null)&&(_a(t)?gl(t):e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,ya())),V(t),null;case 26:var a=t.type,o=t.memoizedState;return e===null?(gl(t),o===null?(V(t),_l(t,a,null,r,n)):(V(t),vl(t,o))):o?o===e.memoizedState?(V(t),t.flags&=-16777217):(gl(t),V(t),vl(t,o)):(e=e.memoizedProps,e!==r&&gl(t),V(t),_l(t,a,e,r,n)),null;case 27:if(Ie(t),n=je.current,a=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==r&&gl(t);else{if(!r){if(t.stateNode===null)throw Error(i(166));return V(t),t.subtreeFlags&=-33554433,null}e=ke.current,_a(t)?ha(t,e):(e=hm(a,r,n),t.stateNode=e,gl(t))}return V(t),t.subtreeFlags&=-33554433,null;case 5:if(Ie(t),a=t.type,e!==null&&t.stateNode!=null)e.memoizedProps!==r&&gl(t);else{if(!r){if(t.stateNode===null)throw Error(i(166));return V(t),t.subtreeFlags&=-33554433,null}if(o=ke.current,_a(t))ha(t,o);else{var s=lp(je.current);switch(o){case 1:o=s.createElementNS(`http://www.w3.org/2000/svg`,a);break;case 2:o=s.createElementNS(`http://www.w3.org/1998/Math/MathML`,a);break;default:switch(a){case`svg`:o=s.createElementNS(`http://www.w3.org/2000/svg`,a);break;case`math`:o=s.createElementNS(`http://www.w3.org/1998/Math/MathML`,a);break;case`script`:o=s.createElement(`div`),o.innerHTML=`<script><\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[jt]=t,o[Mt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(np(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&gl(t)}}return V(t),t.subtreeFlags&=-33554433,_l(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&gl(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=je.current,_a(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=ua,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[jt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||ep(e.nodeValue,n)),e||ma(t,!0)}else e=lp(e).createTextNode(r),e[jt]=t,t.stateNode=e}return V(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=_a(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[jt]=t}else va(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;V(t),e=!1}else n=ya(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(Ho(t),t):(Ho(t),null);if(t.flags&128)throw Error(i(558))}return V(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=_a(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[jt]=t}else va(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;V(t),a=!1}else a=ya(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(Ho(t),t):(Ho(t),null)}return Ho(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),yl(t,t.updateQueue),V(t),null);case 4:return Pe(),e===null&&Wf(t.stateNode.containerInfo),t.flags|=67108864,V(t),null;case 10:return Ta(t.type),V(t),null;case 19:if(Go(t),r=t.memoizedState,r===null)return V(t),null;if(a=!!(t.flags&128),o=r.rendering,o===null){if(a)bl(r,!1);else{if(Y!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=Ko(e),o!==null){for(t.flags|=128,bl(r,!1),e=o.updateQueue,t.updateQueue=e,yl(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)Hi(n,e),n=n.sibling;return Wo(t,Uo.current&1|2),P&&aa(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ye()>_d&&(t.flags|=128,a=!0,bl(r,!1),t.lanes=4194304)}}else{if(!a){if(e=Ko(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,yl(t,e),bl(r,!0),r.tail===null&&r.tailMode!==`collapsed`&&r.tailMode!==`visible`&&!o.alternate&&!P)return V(t),null}else 2*Ye()-r.renderingStartTime>_d&&n!==536870912&&(t.flags|=128,a=!0,bl(r,!1),t.lanes=4194304)}r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}if(r.tail!==null){e=r.tail;a:{for(n=e;n!==null;){if(n.alternate!==null){n=!1;break a}n=n.sibling}n=!0}return r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ye(),e.sibling=null,o=Uo.current,o=a?o&1|2:o&1,r.tailMode===`visible`||r.tailMode===`collapsed`||!n||P?Wo(t,o):(n=o,k(Io,t),k(Uo,n),Lo===null&&(Lo=t)),P&&aa(t,r.treeForkCount),e}return V(t),null;case 22:case 23:return Ho(t),Fo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(V(t),t.subtreeFlags&6&&(t.flags|=8192)):V(t),n=t.updateQueue,n!==null&&yl(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&Oe(Xa),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ta(F),V(t),null;case 25:return null;case 30:return t.flags|=33554432,V(t),null}throw Error(i(156,t.tag))}function Sl(e,t){switch(ca(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ta(F),Pe(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Ie(t),null;case 31:if(t.memoizedState!==null){if(Ho(t),t.alternate===null)throw Error(i(340));va()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Ho(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));va()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Go(t),e=t.flags,e&65536?(t.flags=e&-65537|128,e=t.memoizedState,e!==null&&(e.rendering=null,e.tail=null),t.flags|=4,t):null;case 4:return Pe(),null;case 10:return Ta(t.type),null;case 22:case 23:return Ho(t),Fo(),e!==null&&Oe(Xa),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ta(F),null;case 25:return null;default:return null}}function Cl(e,t){switch(ca(t),t.tag){case 3:Ta(F),Pe();break;case 26:case 27:case 5:Ie(t);break;case 4:Pe();break;case 31:t.memoizedState!==null&&Ho(t);break;case 13:Ho(t);break;case 19:Go(t);break;case 10:Ta(t.type);break;case 22:case 23:Ho(t),Fo(),e!==null&&Oe(Xa);break;case 24:Ta(F)}}function wl(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Tl(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function El(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ao(t,n)}catch(t){Z(e,e.return,t)}}}function Dl(e,t,n){n.props=Ec(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Ol(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:var i=e.stateNode,a=wi(e.memoizedProps,i);(i.ref===null||i.ref.name!==a)&&(i.ref=Pp(a)),r=i.ref;break;case 7:if(e.stateNode===null){var o=new Fp(e);h(e.child,!1,Qp,o,void 0,void 0),e.stateNode=o}r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function kl(e,t){var n=e.ref,r=e.refCleanup;if(n!==null){if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}}function Al(e,t){if((e.tag===5||e.tag===27||e.tag===6)&&e.alternate===null&&t!==null)for(var n=0;n<t.length;n++)em(e.stateNode,t[n])}function jl(e){for(var t=e.return;t!==null&&(Pl(t)&&em(e.stateNode,t.stateNode),!Nl(t));)t=t.return}function Ml(e){for(var t=e.return;t!==null&&(Pl(t)&&tm(e.stateNode,t.stateNode),!Nl(t));)t=t.return}function Nl(e){return e.tag===5||e.tag===3||e.tag===27}function Pl(e){return e&&e.tag===7&&e.stateNode!==null}function Fl(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Il(e,t,n){try{var r=e.stateNode;ip(r,e.type,n,t),r[Mt]=t}catch(t){Z(e,e.return,t)}}function Ll(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Sp(e.type)||e.tag===4}function Rl(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Ll(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Sp(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function zl(e,t,n,r){var i=e.tag;if(i===5||i===6)i=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(i,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(i),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=En)),Al(e,r),j=!0;else if(i!==4&&(i===27&&(Al(e,r),r=null,Sp(e.type)&&(n=e.stateNode,t=null)),e=e.child,e!==null))for(zl(e,t,n,r),e=e.sibling;e!==null;)zl(e,t,n,r),e=e.sibling}function Bl(e,t,n,r){var i=e.tag;if(i===5||i===6)i=e.stateNode,t?n.insertBefore(i,t):n.appendChild(i),Al(e,r),j=!0;else if(i!==4&&(i===27&&(Al(e,r),r=null,Sp(e.type)&&(n=e.stateNode)),e=e.child,e!==null))for(Bl(e,t,n,r),e=e.sibling;e!==null;)Bl(e,t,n,r),e=e.sibling}function Vl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);np(t,r,n),t[jt]=e,t[Mt]=n}catch(t){Z(e,e.return,t)}}var Hl=!1,Ul=null;function Wl(e){(e.tag===30||e.subtreeFlags&33554432)&&(Hl=!0)}var Gl=null;function Kl(){var e=Gl;return Gl=null,e}var ql=0;function Jl(e,t,n,r,i){return ql=0,Yl(e.child,t,n,r,i)}function Yl(e,t,n,r,i){for(var a=!1;e!==null;){if(e.tag===5){var o=e.stateNode;if(r!==null){var s=Op(o);r.push(s),s.view&&(a=!0)}else a||Op(o).view&&(a=!0);Hl=!0,Tp(o,ql===0?t:t+`_`+ql,n),ql++}else(e.tag!==22||e.memoizedState===null)&&(e.tag===30&&i||Yl(e.child,t,n,r,i)&&(a=!0));e=e.sibling}return a}function Xl(e,t){for(;e!==null;)e.tag===5?Ep(e.stateNode,e.memoizedProps):(e.tag!==22||e.memoizedState===null)&&(e.tag===30&&t||Xl(e.child,t)),e=e.sibling}function Zl(e){if(e.subtreeFlags&18874368)for(e=e.child;e!==null;){if((e.tag!==22||e.memoizedState===null)&&(Zl(e),e.tag===30&&e.flags&18874368&&e.stateNode.paired)){var t=e.memoizedProps;if(t.name==null||t.name===`auto`)throw Error(i(544));var n=t.name;t=Ei(t.default,t.share),t!==`none`&&(Jl(e,n,t,null,!1)||Xl(e.child,!1))}e=e.sibling}}function Ql(e,t){if(e.tag===30){var n=e.stateNode,r=e.memoizedProps,i=wi(r,n),a=Ei(r.default,n.paired?r.share:r.enter);a===`none`?Zl(e):Jl(e,i,a,null,!1)?(Zl(e),n.paired||t||Nd(e,r.onEnter)):Xl(e.child,!1)}else if(e.subtreeFlags&33554432)for(e=e.child;e!==null;)Ql(e,t),e=e.sibling;else Zl(e)}function $l(e){if(Ul!==null&&Ul.size!==0){var t=Ul;if(e.subtreeFlags&18874368)for(e=e.child;e!==null;){if(e.tag!==22||e.memoizedState===null){if(e.tag===30&&e.flags&18874368){var n=e.memoizedProps,r=n.name;if(r!=null&&r!==`auto`){var i=t.get(r);if(i!==void 0){var a=Ei(n.default,n.share);if(a!==`none`&&(Jl(e,r,a,null,!1)?(a=e.stateNode,i.paired=a,a.paired=i,Nd(e,n.onShare)):Xl(e.child,!1)),t.delete(r),t.size===0)break}}}$l(e)}e=e.sibling}}}function eu(e){if(e.tag===30){var t=e.memoizedProps,n=wi(t,e.stateNode),r=Ul===null?void 0:Ul.get(n),i=Ei(t.default,r===void 0?t.exit:t.share);i!==`none`&&(Jl(e,n,i,null,!1)?r===void 0?Nd(e,t.onExit):(i=e.stateNode,r.paired=i,i.paired=r,Ul.delete(n),Nd(e,t.onShare)):Xl(e.child,!1)),Ul!==null&&$l(e)}else if(e.subtreeFlags&33554432)for(e=e.child;e!==null;)eu(e),e=e.sibling;else Ul!==null&&$l(e)}function tu(e){for(e=e.child;e!==null;){if(e.tag===30){var t=e.memoizedProps,n=wi(t,e.stateNode);t=Ei(t.default,t.update),e.flags&=-5,t!==`none`&&Jl(e,n,t,e.memoizedState=[],!1)}else e.subtreeFlags&33554432&&tu(e);e=e.sibling}}function nu(e){if(e.subtreeFlags&18874368)for(e=e.child;e!==null;){if(e.tag!==22||e.memoizedState===null){if(e.tag===30&&e.flags&18874368){var t=e.stateNode;t.paired!==null&&(t.paired=null,Xl(e.child,!1))}nu(e)}e=e.sibling}}function ru(e){if(e.tag===30)e.stateNode.paired=null,Xl(e.child,!1),nu(e);else if(e.subtreeFlags&33554432)for(e=e.child;e!==null;)ru(e),e=e.sibling;else nu(e)}function iu(e){for(e=e.child;e!==null;)e.tag===30?Xl(e.child,!1):e.subtreeFlags&33554432&&iu(e),e=e.sibling}function au(e,t,n,r,i,a,o){for(var s=!1;t!==null;){if(t.tag===5){var c=t.stateNode;if(a!==null&&ql<a.length){var l=a[ql],u=Op(c);(l.view||u.view)&&(s=!0);var d;if(d=!(e.flags&4)){if(u.clip)d=!0;else{d=l.rect;var f=u.rect;d=d.y!==f.y||d.x!==f.x||d.height!==f.height||d.width!==f.width}}d&&(e.flags|=4),u.abs?u=!l.abs:(l=l.rect,u=u.rect,u=l.height!==u.height||l.width!==u.width),u&&(e.flags|=32)}else e.flags|=32;e.flags&4&&Tp(c,ql===0?n:n+`_`+ql,i),s&&e.flags&4||(Gl===null&&(Gl=[]),Gl.push(c,ql===0?r:r+`_`+ql,t.memoizedProps)),ql++}else(t.tag!==22||t.memoizedState===null)&&(t.tag===30&&o?e.flags|=t.flags&32:au(e,t.child,n,r,i,a,o)&&(s=!0));t=t.sibling}return s}function ou(e,t){for(e=e.child;e!==null;){if(e.tag===30){var n=e.memoizedProps,r=e.stateNode,i=wi(n,r),a=Ei(n.default,n.update);if(t){r=r.clones;var o=r===null?null:r.map(kp)}else o=e.memoizedState,e.memoizedState=null;r=e;var s=e.child;ql=0,i=au(r,s,i,i,a,o,!1),e.flags&4&&i&&(t||Nd(e,n.onUpdate))}else e.subtreeFlags&33554432&&ou(e,t);e=e.sibling}}var su=!1,H=!1,cu=!1,lu=!1,uu=typeof WeakSet==`function`?WeakSet:Set,du=null,fu=!1,pu=!1,mu=!1,hu=!1;function gu(e,t,n){if(e=e.containerInfo,sp=gh,e=ei(e),ti(e)){if(`selectionStart`in e)var r={start:e.selectionStart,end:e.selectionEnd};else a:{r=(r=e.ownerDocument)&&r.defaultView||window;var i=r.getSelection&&r.getSelection();if(i&&i.rangeCount!==0){r=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{r.nodeType,o.nodeType}catch{r=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==r||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===r&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}r=c===-1||l===-1?null:{start:c,end:l}}else r=null}r||={start:0,end:0}}else r=null;for(cp={focusedElem:e,selectionRange:r},gh=!1,n=(n&335544064)===n,du=t,t=n?9270:1024;du!==null;){if(e=du,n&&(r=e.deletions,r!==null))for(a=0;a<r.length;a++)n&&eu(r[a]);if(e.alternate===null&&e.flags&2)n&&Wl(e),_u(n);else{if(e.tag===22){if(r=e.alternate,e.memoizedState!==null){r!==null&&r.memoizedState===null&&n&&eu(r),_u(n);continue}if(r!==null&&r.memoizedState!==null){n&&Wl(e),_u(n);continue}}r=e.child,(e.subtreeFlags&t)!==0&&r!==null?(r.return=e,du=r):(n&&tu(e),_u(n))}}Ul=null}function _u(e){for(;du!==null;){var t=du,n=e,r=t.alternate,a=t.flags;switch(t.tag){case 0:case 11:case 15:break;case 1:if(a&1024&&r!==null){n=void 0,a=r.memoizedProps,r=r.memoizedState;var o=t.stateNode;try{var s=Ec(t.type,a);n=o.getSnapshotBeforeUpdate(s,r),o.__reactInternalSnapshotBeforeUpdate=n}catch(e){Z(t,t.return,e)}}break;case 3:if(a&1024){if(r=t.stateNode.containerInfo,n=r.nodeType,n===9)nm(r);else if(n===1)switch(r.nodeName){case`HEAD`:case`HTML`:case`BODY`:nm(r);break;default:r.textContent=``}}break;case 5:case 26:case 27:case 6:case 4:case 17:break;case 30:n&&r!==null&&(n=wi(r.memoizedProps,r.stateNode),a=t.memoizedProps,a=Ei(a.default,a.update),a!==`none`&&Jl(r,n,a,r.memoizedState=[],!0));break;default:if(a&1024)throw Error(i(163))}if(r=t.sibling,r!==null){r.return=t.return,du=r;break}du=t.return}}function vu(e,t,n){var r=n.flags;switch(n.tag){case 0:case 11:case 15:Lu(e,n),r&4&&wl(5,n);break;case 1:if(Lu(e,n),r&4){if(e=n.stateNode,t===null)try{e.componentDidMount()}catch(e){Z(n,n.return,e)}else{var i=Ec(n.type,t.memoizedProps);t=t.memoizedState;try{e.componentDidUpdate(i,t,e.__reactInternalSnapshotBeforeUpdate)}catch(e){Z(n,n.return,e)}}}r&64&&El(n),r&512&&Ol(n,n.return);break;case 3:if(Lu(e,n),r&64&&(e=n.updateQueue,e!==null)){if(t=null,n.child!==null)switch(n.child.tag){case 27:case 5:t=n.child.stateNode;break;case 1:t=n.child.stateNode}try{Ao(e,t)}catch(e){Z(n,n.return,e)}}break;case 27:t===null&&r&4&&Vl(n);case 26:case 5:Lu(e,n),t===null&&r&4&&Fl(n),r&512&&Ol(n,n.return);break;case 12:Lu(e,n);break;case 31:Lu(e,n),r&4&&Eu(e,n);break;case 13:Lu(e,n),r&4&&Du(e,n),r&64&&(e=n.memoizedState,e!==null&&(e=e.dehydrated,e!==null&&(n=_f.bind(null,n),cm(e,n))));break;case 22:if(r=n.memoizedState!==null||su,!r){var a=t!==null&&t.memoizedState!==null||H;t=su,i=H,su=r,(H=a)&&!i?(r=2,n.subtreeFlags&8772&&(r|=1),zu(e,n,r)):Lu(e,n),su=t,H=i}break;case 30:Lu(e,n),r&512&&Ol(n,n.return);break;case 7:r&512&&Ol(n,n.return);default:Lu(e,n)}}function yu(e,t){for(e=e.child;e!==null;)bu(e,t),e=e.sibling}function bu(e,t){switch(e.tag){case 5:case 26:try{var n=e.stateNode;if(t){var r=n.style;typeof r.setProperty==`function`?r.setProperty(`display`,`none`,`important`):r.display=`none`}else{var i=e.stateNode,a=e.memoizedProps.style,o=a!=null&&a.hasOwnProperty(`display`)?a.display:null;i.style.display=o==null||typeof o==`boolean`?``:(``+o).trim()}}catch(t){Z(e,e.return,t)}xu(e,t);break;case 6:try{e.stateNode.nodeValue=t?``:e.memoizedProps,j=!0}catch(t){Z(e,e.return,t)}break;case 18:try{var s=e.stateNode;t?wp(s,!0):wp(e.stateNode,!1)}catch(t){Z(e,e.return,t)}break;case 22:case 23:e.memoizedState===null&&yu(e,t);break;default:yu(e,t)}}function xu(e,t){if(e.subtreeFlags&67108864)for(e=e.child;e!==null;){a:{var n=e,r=t;switch(n.tag){case 4:bu(n,r);break a;case 22:n.memoizedState===null&&xu(n,r);break a;default:xu(n,r)}}e=e.sibling}}function Su(e){var t=e.alternate;t!==null&&(e.alternate=null,Su(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&Bt(t)),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}var U=null,Cu=!1;function wu(e,t,n){for(n=n.child;n!==null;)Tu(e,t,n),n=n.sibling}function Tu(e,t,n){if(at&&typeof at.onCommitFiberUnmount==`function`)try{at.onCommitFiberUnmount(it,n)}catch{}switch(n.tag){case 26:H||kl(n,t),wu(e,t,n),n.memoizedState?n.memoizedState.count--:n.stateNode&&!H&&(n=n.stateNode,n.parentNode.removeChild(n));break;case 27:H||kl(n,t),Ml(n);var r=U,i=Cu;Sp(n.type)&&(U=n.stateNode,Cu=!1),wu(e,t,n),gm(n.stateNode,n.type,n.memoizedProps),U=r,Cu=i;break;case 5:H||kl(n,t),Ml(n);case 6:if(n.tag===6&&Ml(n),r=U,i=Cu,U=null,wu(e,t,n),U=r,Cu=i,U!==null){if(Cu)try{(U.nodeType===9?U.body:U.nodeName===`HTML`?U.ownerDocument.body:U).removeChild(n.stateNode),j=!0}catch(e){Z(n,t,e)}else try{U.removeChild(n.stateNode),j=!0}catch(e){Z(n,t,e)}}break;case 18:U!==null&&(Cu?(e=U,Cp(e.nodeType===9?e.body:e.nodeName===`HTML`?e.ownerDocument.body:e,n.stateNode),Hh(e)):Cp(U,n.stateNode));break;case 4:r=U,i=Cu,U=n.stateNode.containerInfo,Cu=!0,wu(e,t,n),U=r,Cu=i;break;case 0:case 11:case 14:case 15:Tl(2,n,t),H||Tl(4,n,t),wu(e,t,n);break;case 1:H||(kl(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`&&Dl(n,t,r)),wu(e,t,n);break;case 21:wu(e,t,n);break;case 22:H=(r=H)||n.memoizedState!==null,wu(e,t,n),H=r;break;case 30:kl(n,t),wu(e,t,n);break;case 7:H||kl(n,t),wu(e,t,n);break;default:wu(e,t,n)}}function Eu(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null))){e=e.dehydrated;try{Hh(e)}catch(e){Z(t,t.return,e)}}}function Du(e,t){if(t.memoizedState===null&&(e=t.alternate,e!==null&&(e=e.memoizedState,e!==null&&(e=e.dehydrated,e!==null))))try{Hh(e)}catch(e){Z(t,t.return,e)}}function Ou(e){switch(e.tag){case 31:case 13:case 19:var t=e.stateNode;return t===null&&(t=e.stateNode=new uu),t;case 22:return e=e.stateNode,t=e._retryCache,t===null&&(t=e._retryCache=new uu),t;default:throw Error(i(435,e.tag))}}function ku(e,t){var n=Ou(e);t.forEach(function(t){if(!n.has(t)){n.add(t);var r=vf.bind(null,e,t);t.then(r,r)}})}function Au(e,t,n){var r=t.deletions;if(r!==null)for(var a=0;a<r.length;a++){var o=r[a],s=e,c=t,l=c;a:for(;l!==null;){switch(l.tag){case 27:if(Sp(l.type)){U=l.stateNode,Cu=!1;break a}break;case 5:U=l.stateNode,Cu=!1;break a;case 3:case 4:U=l.stateNode.containerInfo,Cu=!0;break a}l=l.return}if(U===null)throw Error(i(160));Tu(s,c,o),U=null,Cu=!1,s=o.alternate,s!==null&&(s.return=null),o.return=null}if(t.subtreeFlags&13886)for(t=t.child;t!==null;)Mu(t,e,n),t=t.sibling}var ju=null;function Mu(e,t,n){var r=e.alternate,a=e.flags;switch(e.tag){case 0:case 11:case 14:case 15:if(a&4&&(r=e.updateQueue,r=r===null?null:r.events,r!==null))for(var o=0;o<r.length;o++){var s=r[o];s.ref.impl=s.nextImpl}Au(t,e,n),Nu(e),a&4&&(Tl(3,e,e.return),wl(3,e),Tl(5,e,e.return));break;case 1:Au(t,e,n),Nu(e),a&512&&(H||r===null||kl(r,r.return)),a&64&&su&&(e=e.updateQueue,e!==null&&(t=e.callbacks,t!==null&&(n=e.shared.hiddenCallbacks,e.shared.hiddenCallbacks=n===null?t:n.concat(t))));break;case 26:if(o=ju,Au(t,e,n),Nu(e),a&512&&(H||r===null||kl(r,r.return)),a&4){if(a=r===null?null:r.memoizedState,n=e.memoizedState,r===null){if(n===null){if(e.stateNode===null){if(su)e.stateNode=fp(e.type,e.memoizedProps,t.containerInfo,e);else{a:{t=e.type,n=e.memoizedProps,a=o.ownerDocument||o;b:switch(t){case`title`:r=a.getElementsByTagName(`title`)[0],(!r||r[Rt]||r[jt]||r.namespaceURI===`http://www.w3.org/2000/svg`||r.hasAttribute(`itemprop`))&&(r=a.createElement(t),a.head.insertBefore(r,a.querySelector(`head > title`))),np(r,t,n),r[jt]=e,A(r),t=r;break a;case`link`:if(o=Gm(`link`,`href`,a).get(t+(n.href||``))){for(s=0;s<o.length;s++)if(r=o[s],r.getAttribute(`href`)===(n.href==null||n.href===``?null:n.href)&&r.getAttribute(`rel`)===(n.rel==null?null:n.rel)&&r.getAttribute(`title`)===(n.title==null?null:n.title)&&r.getAttribute(`crossorigin`)===(n.crossOrigin==null?null:n.crossOrigin)){o.splice(s,1);break b}}r=a.createElement(t),np(r,t,n),a.head.appendChild(r);break;case`meta`:if(o=Gm(`meta`,`content`,a).get(t+(n.content||``))){for(s=0;s<o.length;s++)if(r=o[s],r.getAttribute(`content`)===(n.content==null?null:``+n.content)&&r.getAttribute(`name`)===(n.name==null?null:n.name)&&r.getAttribute(`property`)===(n.property==null?null:n.property)&&r.getAttribute(`http-equiv`)===(n.httpEquiv==null?null:n.httpEquiv)&&r.getAttribute(`charset`)===(n.charSet==null?null:n.charSet)){o.splice(s,1);break b}}r=a.createElement(t),np(r,t,n),a.head.appendChild(r);break;default:throw Error(i(468,t))}r[jt]=e,A(r),t=r}e.stateNode=t}}else su||Km(o,e.type,e.stateNode)}else e.stateNode=Bm(o,n,e.memoizedProps)}else a===n?n===null&&e.stateNode!==null&&Il(e,e.memoizedProps,r.memoizedProps):(a===null?(t=r.stateNode,t===null||H||t.parentNode.removeChild(t)):a.count--,n===null?su||Km(o,e.type,e.stateNode):Bm(o,n,e.memoizedProps))}break;case 27:Au(t,e,n),Nu(e),a&512&&(H||r===null||kl(r,r.return)),r!==null&&a&4&&Il(e,e.memoizedProps,r.memoizedProps);break;case 5:if(o=cu,cu=!1,Au(t,e,n),cu=o,Nu(e),a&512&&(H||r===null||kl(r,r.return)),e.flags&32){t=e.stateNode;try{vn(t,``),j=!0}catch(t){Z(e,e.return,t)}}a&4&&e.stateNode!=null&&(t=e.memoizedProps,Il(e,t,r===null?t:r.memoizedProps)),a&1024&&(lu=!0);break;case 6:if(Au(t,e,n),Nu(e),a&4){if(e.stateNode===null)throw Error(i(162));t=e.memoizedProps,n=e.stateNode;try{n.nodeValue=t,j=!0}catch(t){Z(e,e.return,t)}}break;case 3:if(j=!1,Wm=null,o=ju,ju=bm(t.containerInfo),Au(t,e,n),ju=o,Nu(e),a&4&&r!==null&&r.memoizedState.isDehydrated)try{Hh(t.containerInfo)}catch(t){Z(e,e.return,t)}lu&&(lu=!1,Pu(e)),j=!1;break;case 4:a=cu,cu=su,r=en(),o=ju,ju=bm(e.stateNode.containerInfo),Au(t,e,n),Nu(e),ju=o,j&&pu&&(mu=!0),j=r,cu=a;break;case 12:Au(t,e,n),Nu(e);break;case 31:Au(t,e,n),Nu(e),a&4&&(t=e.updateQueue,t!==null&&(e.updateQueue=null,ku(e,t)));break;case 13:Au(t,e,n),Nu(e),e.child.flags&8192&&e.memoizedState!==null!=(r!==null&&r.memoizedState!==null)&&(hd=Ye()),a&4&&(t=e.updateQueue,t!==null&&(e.updateQueue=null,ku(e,t)));break;case 22:o=e.memoizedState!==null,s=r!==null&&r.memoizedState!==null;var c=su,l=H,u=cu;su=c||o,cu=u||o,H=l||s,Au(t,e,n),H=l,cu=u,su=c,Nu(e),a&8192&&(t=e.stateNode,t._visibility=o?t._visibility&-2:t._visibility|1,!o||r===null||s||su||H||(t=s||H,n=su,r=H,su=o||su,H=t,Ru(e,2),su=n,H=r),!o&&cu||yu(e,o)),a&4&&(t=e.updateQueue,t!==null&&(n=t.retryQueue,n!==null&&(t.retryQueue=null,ku(e,n))));break;case 19:Au(t,e,n),Nu(e),a&4&&(t=e.updateQueue,t!==null&&(e.updateQueue=null,ku(e,t)));break;case 30:a&512&&(H||r===null||kl(r,r.return)),a=en(),o=pu,s=(n&335544064)===n,c=e.memoizedProps,pu=s&&Ei(c.default,c.update)!==`none`,Au(t,e,n),Nu(e),s&&r!==null&&j&&(e.flags|=4),pu=o,j=a;break;case 21:break;case 7:a&512&&(H||r===null||kl(r,r.return)),r&&r.stateNode!==null&&(r.stateNode._fragmentFiber=e);default:Au(t,e,n),Nu(e)}}function Nu(e){var t=e.flags;if(t&2){try{for(var n,r=e.return;r!==null;){if(Ll(r)){n=r;break}r=r.return}r=null;for(var a=e.return;a!==null;){if(Pl(a)){var o=a.stateNode;r===null?r=[o]:r.push(o)}if(Nl(a))break;a=a.return}var s=r;if(n==null)throw Error(i(160));switch(n.tag){case 27:var c=n.stateNode;Bl(e,Rl(e),c,s);break;case 5:var l=n.stateNode;n.flags&32&&(vn(l,``),n.flags&=-33),Bl(e,Rl(e),l,s);break;case 3:case 4:var u=n.stateNode.containerInfo;zl(e,Rl(e),u,s);break;default:throw Error(i(161))}}catch(t){Z(e,e.return,t)}e.flags&=-3}t&4096&&(e.flags&=-4097)}function Pu(e){if(e.subtreeFlags&1024)for(e=e.child;e!==null;){var t=e;Pu(t),t.tag===5&&t.flags&1024&&(t=t.stateNode,gh=!0,t.reset(),gh=!1),e=e.sibling}}function Fu(e,t){if(t.subtreeFlags&9270)for(t=t.child;t!==null;)Iu(t,e),t=t.sibling;else ou(t,!1)}function Iu(e,t){var n=e.alternate;if(n===null)Ql(e,!1);else switch(e.tag){case 3:if(hu=fu=!1,Kl(),Fu(t,e),!fu&&!mu){if(e=Gl,e!==null)for(var r=0;r<e.length;r+=3){n=e[r];var i=e[r+1];Ep(n,e[r+2]),n=n.ownerDocument.documentElement,n!==null&&n.animate({opacity:[0,0],pointerEvents:[`none`,`none`]},{duration:0,fill:`forwards`,pseudoElement:`::view-transition-group(`+i+`)`})}e=t.containerInfo,e=e.nodeType===9?e.documentElement:e.ownerDocument.documentElement,e!==null&&e.style.viewTransitionName===``&&(e.style.viewTransitionName=`none`,e.animate({opacity:[0,0],pointerEvents:[`none`,`none`]},{duration:0,fill:`forwards`,pseudoElement:`::view-transition-group(root)`}),e.animate({width:[0,0],height:[0,0]},{duration:0,fill:`forwards`,pseudoElement:`::view-transition`})),hu=!0}Gl=null;break;case 5:Fu(t,e);break;case 4:r=fu,fu=!1,Fu(t,e),fu&&(mu=!0),fu=r;break;case 22:e.memoizedState===null&&(n.memoizedState===null?Fu(t,e):Ql(e,!1));break;case 30:r=fu,i=Kl(),fu=!1,Fu(t,e),fu&&(e.flags|=4);var a=e.memoizedProps,o=e.stateNode;t=wi(a,o),o=wi(n.memoizedProps,o);var s=Ei(a.default,a.update);s===`none`?t=!1:(a=n.memoizedState,n.memoizedState=null,n=e.child,ql=0,t=au(e,n,t,o,s,a,!0),ql!==(a===null?0:a.length)&&(e.flags|=32)),e.flags&4&&t?(Nd(e,e.memoizedProps.onUpdate),Gl=i):i!==null&&(i.push.apply(i,Gl),Gl=i),fu=e.flags&32?!0:r;break;default:Fu(t,e)}}function Lu(e,t){if(t.subtreeFlags&8772)for(t=t.child;t!==null;)vu(e,t.alternate,t),t=t.sibling}function Ru(e,t){for(e=e.child;e!==null;){var n=e,r=t;switch(n.tag){case 0:case 11:case 14:case 15:Tl(4,n,n.return),Ru(n,r);break;case 1:kl(n,n.return);var i=n.stateNode;typeof i.componentWillUnmount==`function`&&Dl(n,n.return,i),Ru(n,r);break;case 27:r&2&&gm(n.stateNode,n.type,n.memoizedProps);case 5:kl(n,n.return),n.tag!==5&&n.tag!==27||Ml(n),Ru(n,r);break;case 6:Ml(n);break;case 26:kl(n,n.return),i=n.stateNode,n.memoizedState!==null||i===null||H||i.parentNode.removeChild(i),Ru(n,r);break;case 22:n.memoizedState===null&&Ru(n,r);break;case 30:kl(n,n.return),Ru(n,r);break;case 7:kl(n,n.return);default:Ru(n,r)}e=e.sibling}}function zu(e,t,n){for(n=t.subtreeFlags&8772?n:n&-2,t=t.child;t!==null;){var r=t.alternate,i=e,a=t,o=a.flags,s=!!(n&1);switch(a.tag){case 0:case 11:case 15:zu(i,a,n),wl(4,a);break;case 1:if(zu(i,a,n),r=a,i=r.stateNode,typeof i.componentDidMount==`function`)try{i.componentDidMount()}catch(e){Z(r,r.return,e)}if(r=a,i=r.updateQueue,i!==null){var c=r.stateNode;try{var l=i.shared.hiddenCallbacks;if(l!==null)for(i.shared.hiddenCallbacks=null,i=0;i<l.length;i++)ko(l[i],c)}catch(e){Z(r,r.return,e)}}s&&o&64&&El(a),Ol(a,a.return);break;case 27:n&2&&Vl(a);case 5:a.tag!==5&&a.tag!==27||jl(a),zu(i,a,n),s&&r===null&&o&4&&Fl(a),Ol(a,a.return);break;case 6:jl(a);break;case 26:c=a.stateNode,a.memoizedState!==null||c===null||su||Km(bm(c.ownerDocument),a.type,c),zu(i,a,n),s&&r===null&&o&4&&Fl(a),Ol(a,a.return);break;case 12:zu(i,a,n);break;case 31:zu(i,a,n),s&&o&4&&Eu(i,a);break;case 13:zu(i,a,n),s&&o&4&&Du(i,a);break;case 22:a.memoizedState===null&&zu(i,a,n),Ol(a,a.return);break;case 30:zu(i,a,n),Ol(a,a.return);break;case 7:Ol(a,a.return);default:zu(i,a,n)}t=t.sibling}}function Bu(e,t){var n=null;e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==n&&(e!=null&&e.refCount++,n!=null&&Ra(n))}function Vu(e,t){e=null,t.alternate!==null&&(e=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==e&&(t.refCount++,e!=null&&Ra(e))}function Hu(e,t,n,r){var i=(n&335544064)===n;if(t.subtreeFlags&(i?10262:10256))for(t=t.child;t!==null;)Uu(e,t,n,r),t=t.sibling;else i&&iu(t)}function Uu(e,t,n,r){var i=(n&335544064)===n;i&&t.alternate===null&&t.return!==null&&t.return.alternate!==null&&ru(t);var a=t.flags;switch(t.tag){case 0:case 11:case 15:Hu(e,t,n,r),a&2048&&wl(9,t);break;case 1:Hu(e,t,n,r);break;case 3:Hu(e,t,n,r),i&&hu&&(e=e.containerInfo,e=e.nodeType===9?e.body:e.nodeName===`HTML`?e.ownerDocument.body:e,e.style.viewTransitionName===`root`&&(e.style.viewTransitionName=``),e=e.ownerDocument.documentElement,e!==null&&e.style.viewTransitionName===`none`&&(e.style.viewTransitionName=``)),a&2048&&(a=null,t.alternate!==null&&(a=t.alternate.memoizedState.cache),t=t.memoizedState.cache,t!==a&&(t.refCount++,a!=null&&Ra(a)));break;case 12:if(a&2048){Hu(e,t,n,r),a=t.stateNode;try{var o=t.memoizedProps,s=o.id,c=o.onPostCommit;typeof c==`function`&&c(s,t.alternate===null?`mount`:`update`,a.passiveEffectDuration,-0)}catch(e){Z(t,t.return,e)}}else Hu(e,t,n,r);break;case 31:Hu(e,t,n,r);break;case 13:Hu(e,t,n,r);break;case 23:break;case 22:o=t.stateNode,s=t.alternate,t.memoizedState===null?(i&&s!==null&&s.memoizedState!==null&&ru(t),o._visibility&2?Hu(e,t,n,r):(o._visibility|=2,Wu(e,t,n,r,!!(t.subtreeFlags&10256)||!1))):(i&&s!==null&&s.memoizedState===null&&ru(s),o._visibility&2?Hu(e,t,n,r):Gu(e,t)),a&2048&&Bu(s,t);break;case 24:Hu(e,t,n,r),a&2048&&Vu(t.alternate,t);break;case 30:i&&(a=t.alternate,a!==null&&(Xl(a.child,!0),Xl(t.child,!0))),Hu(e,t,n,r);break;default:Hu(e,t,n,r)}}function Wu(e,t,n,r,i){for(i&&=!!(t.subtreeFlags&10256)||!1,t=t.child;t!==null;){var a=e,o=t,s=n,c=r,l=o.flags;switch(o.tag){case 0:case 11:case 15:Wu(a,o,s,c,i),wl(8,o);break;case 23:break;case 22:var u=o.stateNode;o.memoizedState===null?(u._visibility|=2,Wu(a,o,s,c,i)):u._visibility&2?Wu(a,o,s,c,i):Gu(a,o),i&&l&2048&&Bu(o.alternate,o);break;case 24:Wu(a,o,s,c,i),i&&l&2048&&Vu(o.alternate,o);break;default:Wu(a,o,s,c,i)}t=t.sibling}}function Gu(e,t){if(t.subtreeFlags&10256)for(t=t.child;t!==null;){var n=e,r=t,i=r.flags;switch(r.tag){case 22:Gu(n,r),i&2048&&Bu(r.alternate,r);break;case 24:Gu(n,r),i&2048&&Vu(r.alternate,r);break;default:Gu(n,r)}t=t.sibling}}var Ku=8192;function qu(e,t,n){if(e.subtreeFlags&Ku)for(e=e.child;e!==null;)Ju(e,t,n),e=e.sibling}function Ju(e,t,n){switch(e.tag){case 26:qu(e,t,n),e.flags&Ku&&(e.memoizedState===null?(e=e.stateNode,(t&335544128)===t&&Zm(n,e)):Qm(n,ju,e.memoizedState,e.memoizedProps));break;case 5:qu(e,t,n),e.flags&Ku&&(e=e.stateNode,(t&335544128)===t&&Zm(n,e));break;case 3:case 4:var r=ju;ju=bm(e.stateNode.containerInfo),qu(e,t,n),ju=r;break;case 22:e.memoizedState===null&&(r=e.alternate,r!==null&&r.memoizedState!==null?(r=Ku,Ku=16777216,qu(e,t,n),Ku=r):qu(e,t,n));break;case 30:if((e.flags&Ku)!==0&&(r=e.memoizedProps.name,r!=null&&r!==`auto`)){var i=e.stateNode;i.paired=null,Ul===null&&(Ul=new Map),Ul.set(r,i)}qu(e,t,n);break;default:qu(e,t,n)}}function Yu(e){var t=e.alternate;if(t!==null&&(e=t.child,e!==null)){t.child=null;do t=e.sibling,e.sibling=null,e=t;while(e!==null)}}function Xu(e){var t=e.deletions;if(e.flags&16){if(t!==null)for(var n=0;n<t.length;n++){var r=t[n];du=r,$u(r,e)}Yu(e)}if(e.subtreeFlags&10256)for(e=e.child;e!==null;)Zu(e),e=e.sibling}function Zu(e){switch(e.tag){case 0:case 11:case 15:Xu(e),e.flags&2048&&Tl(9,e,e.return);break;case 3:Xu(e);break;case 12:Xu(e);break;case 22:var t=e.stateNode;e.memoizedState!==null&&t._visibility&2&&(e.return===null||e.return.tag!==13)?(t._visibility&=-3,Qu(e)):Xu(e);break;default:Xu(e)}}function Qu(e){var t=e.deletions;if(e.flags&16){if(t!==null)for(var n=0;n<t.length;n++){var r=t[n];du=r,$u(r,e)}Yu(e)}for(e=e.child;e!==null;){switch(t=e,t.tag){case 0:case 11:case 15:Tl(8,t,t.return),Qu(t);break;case 22:n=t.stateNode,n._visibility&2&&(n._visibility&=-3,Qu(t));break;default:Qu(t)}e=e.sibling}}function $u(e,t){for(;du!==null;){var n=du;switch(n.tag){case 0:case 11:case 15:Tl(8,n,t);break;case 23:case 22:if(n.memoizedState!==null&&n.memoizedState.cachePool!==null){var r=n.memoizedState.cachePool.pool;r!=null&&r.refCount++}break;case 24:Ra(n.memoizedState.cache)}if(r=n.child,r!==null)r.return=n,du=r;else a:for(n=e;du!==null;){r=du;var i=r.sibling,a=r.return;if(Su(r),r===n){du=null;break a}if(i!==null){i.return=a,du=i;break a}du=a}}}var ed={getCacheForType:function(e){var t=ja(F),n=t.data.get(e);return n===void 0&&(n=e(),t.data.set(e,n)),n},cacheSignal:function(){return ja(F).controller.signal}},td=typeof WeakMap==`function`?WeakMap:Map,W=0,G=null,K=null,q=0,J=0,nd=null,rd=!1,id=!1,ad=!1,od=0,Y=0,sd=0,cd=0,ld=0,ud=0,dd=0,fd=null,pd=null,md=!1,hd=0,gd=0,_d=1/0,vd=null,yd=null,X=0,bd=null,xd=null,Sd=0,Cd=0,wd=null,Td=null,Ed=null,Dd=null,Od=null,kd=0,Ad=null;function jd(){return W&2&&q!==0?q&-q:D.T===null?Ot():Pf()}function Md(){if(ud===0){if(!(q&536870912)||P){var e=ft;ft<<=1,!(ft&3932160)&&(ft=262144),ud=e}else ud=536870912}return e=Io.current,e!==null&&(e.flags|=32),ud}function Nd(e,t){if(t!=null){var n=e.stateNode,r=n.ref;r===null&&(r=n.ref=Pp(wi(e.memoizedProps,n))),Dd===null&&(Dd=[]),Dd.push(t.bind(null,r))}}function Pd(e,t,n){(e===G&&(J===2||J===9)||e.cancelPendingCommit!==null)&&(Vd(e,0),Rd(e,q,ud,!1)),xt(e,n),(!(W&2)||e!==G)&&(e===G&&(!(W&2)&&(cd|=n),Y===4&&Rd(e,q,ud,!1)),Ef(e))}function Fd(e,t,n){if(W&6)throw Error(i(327));var r=!n&&!(t&127)&&(t&e.expiredLanes)===0||gt(e,t),a=r?Yd(e,t):qd(e,t,!0),o=r;do{if(a===0){id&&!r&&Rd(e,t,0,!1);break}if(n=e.current.alternate,o&&!Ld(n))a=qd(e,t,!1),o=!1;else{if(a===2){if(o=t,e.errorRecoveryDisabledLanes&o)var s=0;else s=e.pendingLanes&-536870913,s=s===0?s&536870912?536870912:0:s;if(s!==0){t=s;a:{var c=e;a=fd;var l=c.current.memoizedState.isDehydrated;if(l&&(Vd(c,s).flags|=256),s=qd(c,s,!1),s!==2&&s!==6){if(ad&&!l){c.errorRecoveryDisabledLanes|=o,cd|=o,a=4;break a}o=pd,pd=a,o!==null&&(pd===null?pd=o:pd.push.apply(pd,o))}a=s}if(o=!1,a!==2)continue}}if(a===1){Vd(e,0),Rd(e,t,0,!0);break}a:{switch(r=e,o=a,o){case 0:case 1:throw Error(i(345));case 4:if((t&4194048)!==t&&(t&62914560)!==t)break;case 6:Rd(r,t,ud,!rd);break a;case 2:pd=null;break;case 3:case 5:break;default:throw Error(i(329))}if((t&62914560)===t&&(a=hd+300-Ye(),10<a)){if(Rd(r,t,ud,!rd),ht(r,0,!0)!==0)break a;Sd=t,r.timeoutHandle=gp(Id.bind(null,r,n,pd,vd,md,t,ud,cd,dd,rd,o,`Throttled`,-0,0),a);break a}Id(r,n,pd,vd,md,t,ud,cd,dd,rd,o,null,-0,0)}break}}while(1);Ef(e)}function Id(e,t,n,r,i,a,o,s,c,l,u,d,f,p){e.timeoutHandle=-1;var m=t.subtreeFlags,h=(a&335544064)===a;d=null,(h||m&8192||(m&16785408)==16785408)&&(d={stylesheets:null,count:0,imgCount:0,imgBytes:0,suspenseyImages:[],waitingForImages:!0,waitingForViewTransition:!1,unsuspend:En},Ul=null,Ju(t,a,d),h&&(m=d,h=e.containerInfo,h=(h.nodeType===9?h:h.ownerDocument).__reactViewTransition,h!=null&&(m.count++,m.waitingForViewTransition=!0,m=nh.bind(m),h.finished.then(m,m))),m=(a&62914560)===a?hd-Ye():(a&4194048)===a?gd-Ye():0,m=eh(d,m),m!==null)?(Sd=a,e.cancelPendingCommit=m(nf.bind(null,e,t,a,n,r,i,o,s,c,l,u,d,null,f,p)),Rd(e,a,o,!l)):nf(e,t,a,n,r,i,o,s,c,l,u,d)}function Ld(e){for(var t=e;;){var n=t.tag;if((n===0||n===11||n===15)&&t.flags&16384&&(n=t.updateQueue,n!==null&&(n=n.stores,n!==null)))for(var r=0;r<n.length;r++){var i=n[r],a=i.getSnapshot;i=i.value;try{if(!Jr(a(),i))return!1}catch{return!1}}if(n=t.child,t.subtreeFlags&16384&&n!==null)n.return=t,t=n;else{if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return!0;t=t.return}t.sibling.return=t.return,t=t.sibling}}return!0}function Rd(e,t,n,r){t=_t(e,t),t&=~ld,t&=~cd,e.suspendedLanes|=t,e.pingedLanes&=~t,r&&(e.warmLanes|=t),r=e.expirationTimes;for(var i=t;0<i;){var a=31-st(i),o=1<<a;r[a]=-1,i&=~o}n!==0&&Ct(e,n,t)}function zd(){return W&6?!0:(Df(0,!1),!1)}function Bd(){if(K!==null){if(J===0)var e=K.return;else e=K,Ca=Sa=null,ls(e),uo=null,fo=0,e=K;for(;e!==null;)Cl(e.alternate,e),e=e.return;K=null}}function Vd(e,t){var n=e.timeoutHandle;return n!==-1&&(e.timeoutHandle=-1,_p(n)),n=e.cancelPendingCommit,n!==null&&(e.cancelPendingCommit=null,n()),Sd=0,Bd(),G=e,K=n=Vi(e.current,null),q=t,J=0,nd=null,rd=!1,id=gt(e,t),ad=!1,dd=ud=ld=cd=sd=Y=0,pd=fd=null,md=!1,od=_t(e,t),ji(),n}function Hd(e,t){I=null,D.H=vc,t===eo||t===no?(t=co(),J=3):t===to?(t=co(),J=4):J=t===Ic?8:typeof t==`object`&&t&&typeof t.then==`function`?6:1,nd=t,K===null&&(Y=1,Ac(e,Yi(t,e.current)))}function Ud(){var e=Io.current;return e===null?!0:(q&4194048)===q?Lo===null:(q&62914560)===q||q&536870912?e===Lo:!1}function Wd(){var e=D.H;return D.H=vc,e===null?vc:e}function Gd(){var e=D.A;return D.A=ed,e}function Kd(){Y=4,rd||(q&4194048)!==q&&Io.current!==null||(id=!0),!(sd&134217727)&&!(cd&134217727)||G===null||Rd(G,q,ud,!1)}function qd(e,t,n){var r=W;W|=2;var i=Wd(),a=Gd();(G!==e||q!==t)&&(vd=null,Vd(e,t)),t=!1;var o=Y;a:do try{if(J!==0&&K!==null){var s=K,c=nd;switch(J){case 8:Bd(),o=6;break a;case 3:case 2:case 9:case 6:Io.current===null&&(t=!0);var l=J;if(J=0,nd=null,$d(e,s,c,l),n&&id){o=0;break a}break;default:l=J,J=0,nd=null,$d(e,s,c,l)}}Jd(),o=Y;break}catch(t){Hd(e,t)}while(1);return t&&e.shellSuspendCounter++,Ca=Sa=null,W=r,D.H=i,D.A=a,K===null&&(G=null,q=0,ji()),o}function Jd(){for(;K!==null;)Zd(K)}function Yd(e,t){var n=W;W|=2;var r=Wd(),a=Gd();G!==e||q!==t?(vd=null,_d=Ye()+500,Vd(e,t)):id=gt(e,t);a:do try{if(J!==0&&K!==null){t=K;var o=nd;b:switch(J){case 1:J=0,nd=null,$d(e,t,o,1);break;case 2:case 9:if(io(o)){J=0,nd=null,Qd(t);break}t=function(){J!==2&&J!==9||G!==e||(J=7),Ef(e)},o.then(t,t);break a;case 3:J=7;break a;case 4:J=5;break a;case 7:io(o)?(J=0,nd=null,Qd(t)):(J=0,nd=null,$d(e,t,o,7));break;case 5:var s=null;switch(K.tag){case 26:s=K.memoizedState;case 5:case 27:var c=K;if(s?Ym(s):c.stateNode.complete){J=0,nd=null;var l=c.sibling;if(l!==null)K=l;else{var u=c.return;u===null?K=null:(K=u,ef(u))}break b}}J=0,nd=null,$d(e,t,o,5);break;case 6:J=0,nd=null,$d(e,t,o,6);break;case 8:Bd(),Y=6;break a;default:throw Error(i(462))}}Xd();break}catch(t){Hd(e,t)}while(1);return Ca=Sa=null,D.H=r,D.A=a,W=n,K===null?(G=null,q=0,ji(),Y):0}function Xd(){for(;K!==null&&!qe();)Zd(K)}function Zd(e){var t=hl(e.alternate,e,od);e.memoizedProps=e.pendingProps,t===null?ef(e):K=t}function Qd(e){var t=e,n=t.alternate;switch(t.tag){case 15:case 0:t=Yc(n,t,t.pendingProps,t.type,void 0,q);break;case 11:t=Yc(n,t,t.pendingProps,t.type.render,t.ref,q);break;case 5:ls(t);var r=t;r===ua&&(P?(ga(r),r.tag===5&&r.stateNode!=null&&(N=r.stateNode)):(ga(r),P=!0));default:Cl(n,t),t=K=Hi(t,od),t=hl(n,t,od)}e.memoizedProps=e.pendingProps,t===null?ef(e):K=t}function $d(e,t,n,r){Ca=Sa=null,ls(t),uo=null,fo=0;var i=t.return;try{if(Fc(e,i,t,n,q)){Y=1,Ac(e,Yi(n,e.current)),K=null;return}}catch(t){if(i!==null)throw K=i,t;Y=1,Ac(e,Yi(n,e.current)),K=null;return}t.flags&32768?(P||r===1?e=!0:id||q&536870912?e=!1:(rd=e=!0,(r===2||r===9||r===3||r===6)&&(r=Io.current,r!==null&&r.tag===13&&(r.flags|=16384))),tf(t,e)):ef(t)}function ef(e){var t=e;do{if(t.flags&32768){tf(t,rd);return}e=t.return;var n=xl(t.alternate,t,od);if(n!==null){K=n;return}if(t=t.sibling,t!==null){K=t;return}K=t=e}while(t!==null);Y===0&&(Y=5)}function tf(e,t){do{var n=Sl(e.alternate,e);if(n!==null){n.flags&=32767,K=n;return}if(n=e.return,n!==null&&(n.flags|=32768,n.subtreeFlags=0,n.deletions=null),!t&&(e=e.sibling,e!==null)){K=e;return}K=e=n}while(e!==null);Y=6,K=null}function nf(e,t,n,r,a,o,s,c,l,u,d,f){e.cancelPendingCommit=null;do df();while(X!==0);if(W&6)throw Error(i(327));if(t!==null){if(t===e.current)throw Error(i(177));e===G&&(K=G=null,q=0),xd=t,bd=e,Sd=n,wd=a,Td=r,rf(e,t,n,s,c,l,f)}}function rf(e,t,n,r,i,a,o){var s=t.lanes|t.childLanes;if(Cd=s,s|=Ai,St(e,n,s,r,i,a),Dd=null,(n&335544064)===n?(Od=Va(e),r=10262):(Od=null,r=10256),(t.subtreeFlags&r)!==0||(t.flags&r)!==0?(e.callbackNode=null,e.callbackPriority=0,yf($e,function(){return ff(),null})):(e.callbackNode=null,e.callbackPriority=0),Hl=!1,r=!!(t.flags&13878),t.subtreeFlags&13878||r){r=D.T,D.T=null,i=O.p,O.p=2,a=W,W|=4;try{gu(e,t,n)}finally{W=a,O.p=i,D.T=r}}X=1,Hl?Ed=Mp(o,e.containerInfo,Od,sf,cf,of,lf,ff,af,null,null):(sf(),cf(),lf())}function af(e){if(X!==0){var t=bd.onRecoverableError;t(e,{componentStack:null})}}function of(){X===3&&(X=0,Iu(xd,bd),X=4)}function sf(){if(X===1){X=0;var e=bd,t=xd,n=Sd,r=!!(t.flags&13878);if(t.subtreeFlags&13878||r){r=D.T,D.T=null;var i=O.p;O.p=2;var a=W;W|=4;try{pu=mu=!1,Mu(t,e,n),n=cp;var o=ei(e.containerInfo),s=n.focusedElem,c=n.selectionRange;if(o!==s&&s&&s.ownerDocument&&$r(s.ownerDocument.documentElement,s)){if(c!==null&&ti(s)){var l=c.start,u=c.end;if(u===void 0&&(u=l),`selectionStart`in s)s.selectionStart=l,s.selectionEnd=Math.min(u,s.value.length);else{var d=s.ownerDocument||document,f=d&&d.defaultView||window;if(f.getSelection){var p=f.getSelection(),m=s.textContent.length,h=Math.min(c.start,m),g=c.end===void 0?h:Math.min(c.end,m);!p.extend&&h>g&&(o=g,g=h,h=o);var _=Qr(s,h),v=Qr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;s<d.length;s++){var b=d[s];b.element.scrollLeft=b.left,b.element.scrollTop=b.top}}gh=!!sp,cp=sp=null}finally{W=a,O.p=i,D.T=r}}e.current=t,X=2}}function cf(){if(X===2){X=0;var e=bd,t=xd,n=!!(t.flags&8772);if(t.subtreeFlags&8772||n){n=D.T,D.T=null;var r=O.p;O.p=2;var i=W;W|=4;try{vu(e,t.alternate,t)}finally{W=i,O.p=r,D.T=n}}X=3}}function lf(){if(X===4||X===3){X=0;var e=Ed;Ed=null,Je();var t=bd,n=xd,r=Sd,i=Td,a=(r&335544064)===r?10262:10256;if((n.subtreeFlags&a)!==0||(n.flags&a)!==0?X=5:(X=0,xd=bd=null,uf(t,t.pendingLanes)),a=t.pendingLanes,a===0&&(yd=null),Dt(r),n=n.stateNode,at&&typeof at.onCommitFiberRoot==`function`)try{at.onCommitFiberRoot(it,n,void 0,(n.current.flags&128)==128)}catch{}if(i!==null){n=D.T,a=O.p,O.p=2,D.T=null;try{for(var o=t.onRecoverableError,s=0;s<i.length;s++){var c=i[s];o(c.value,{componentStack:c.stack})}}finally{D.T=n,O.p=a}}if(i=Dd,o=Od,Od=null,i!==null&&(Dd=null,o===null&&(o=[]),e!==null))for(c=0;c<i.length;c++)n=(0,i[c])(o),n!==void 0&&e.finished.finally(n);Sd&3&&df(),Ef(t),a=t.pendingLanes,r&261930&&a&42?t===Ad?kd++:(kd=0,Ad=t):(kd=0,Ad=null),Df(0,!1)}}function uf(e,t){(e.pooledCacheLanes&=t)===0&&(t=e.pooledCache,t!=null&&(e.pooledCache=null,Ra(t)))}function df(){return Ed!==null&&(Ed.skipTransition(),Ed=null),sf(),cf(),lf(),ff()}function ff(){if(X!==5)return!1;var e=bd,t=Cd;Cd=0;var n=Dt(Sd),r=D.T,a=O.p;try{O.p=32>n?32:n,D.T=null,n=wd,wd=null;var o=bd,s=Sd;if(X=0,xd=bd=null,Sd=0,W&6)throw Error(i(331));var c=W;if(W|=4,Zu(o.current),Uu(o,o.current,s,n),W=c,Df(0,!1),at&&typeof at.onPostCommitFiberRoot==`function`)try{at.onPostCommitFiberRoot(it,o)}catch{}return!0}finally{O.p=a,D.T=r,uf(e,t)}}function pf(e,t,n){t=Yi(n,t),t=Mc(e.stateNode,t,2),e=Co(e,t,2),e!==null&&(xt(e,2),Ef(e))}function Z(e,t,n){if(e.tag===3)pf(e,e,n);else for(;t!==null;){if(t.tag===3){pf(t,e,n);break}if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(yd===null||!yd.has(r))){e=Yi(n,e),n=Nc(2),r=Co(t,n,2),r!==null&&(Pc(n,r,t,e),xt(r,2),Ef(r));break}}t=t.return}}function mf(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new td;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(ad=!0,i.add(n),e=hf.bind(null,e,t,n),t.then(e,e))}function hf(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,G===e&&(q&n)===n&&(Y===4||Y===3&&(q&62914560)===q&&300>Ye()-hd?W&2?ld|=n:Vd(e,0):ld|=n,dd===q&&(dd=0)),Ef(e)}function gf(e,t){t===0&&(t=yt()),e=Pi(e,t),e!==null&&(xt(e,t),Ef(e))}function _f(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),gf(e,n)}function vf(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),gf(e,n)}function yf(e,t){return Ge(e,t)}var bf=null,xf=null,Sf=!1,Cf=!1,wf=!1,Tf=0;function Ef(e){e!==xf&&e.next===null&&(xf===null?bf=xf=e:xf=xf.next=e),Cf=!0,Sf||(Sf=!0,Nf())}function Df(e,t){if(!wf&&Cf){wf=!0;do for(var n=!1,r=bf;r!==null;){if(!t){if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-st(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,Mf(r,a))}else a=q,a=ht(r,r===G?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||gt(r,a)||(n=!0,Mf(r,a))}r=r.next}while(n);wf=!1}}function Of(){kf()}function kf(){Cf=Sf=!1;var e=0;Tf!==0&&hp()&&(e=Tf);for(var t=Ye(),n=null,r=bf;r!==null;){var i=r.next,a=Af(r,t);a===0?(r.next=null,n===null?bf=i:n.next=i,i===null&&(xf=n)):(n=r,(e!==0||a&3)&&(Cf=!0)),r=i}X!==0&&X!==5||Df(e,!1),Tf!==0&&(Tf=0)}function Af(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0<a;){var o=31-st(a),s=1<<o,c=i[o];c===-1?((s&n)===0||(s&r)!==0)&&(i[o]=vt(s,t)):c<=t&&(e.expiredLanes|=s),a&=~s}if(t=G,n=q,n=ht(e,e===t?n:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),r=e.callbackNode,n===0||e===t&&(J===2||J===9)||e.cancelPendingCommit!==null)return r!==null&&r!==null&&Ke(r),e.callbackNode=null,e.callbackPriority=0;if(!(n&3)||gt(e,n)){if(t=n&-n,t===e.callbackPriority)return t;switch(r!==null&&Ke(r),Dt(n)){case 2:case 8:n=Qe;break;case 32:n=$e;break;case 268435456:n=tt;break;default:n=$e}return r=jf.bind(null,e),n=Ge(n,r),e.callbackPriority=t,e.callbackNode=n,t}return r!==null&&r!==null&&Ke(r),e.callbackPriority=2,e.callbackNode=null,2}function jf(e,t){if(X!==0&&X!==5)return e.callbackNode=null,e.callbackPriority=0,null;var n=e.callbackNode;if(df()&&e.callbackNode!==n)return null;var r=q;return r=ht(e,e===G?r:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),r===0?null:(Fd(e,r,t),Af(e,Ye()),e.callbackNode!=null&&e.callbackNode===n?jf.bind(null,e):null)}function Mf(e,t){if(df())return null;Fd(e,t,!0)}function Nf(){bp(function(){W&6?Ge(Ze,Of):kf()})}function Pf(){if(Tf===0){var e=Wa;e===0&&(e=dt,dt<<=1,!(dt&261888)&&(dt=256)),Tf=e}return Tf}function Ff(e){return e==null||typeof e==`symbol`||typeof e==`boolean`?null:typeof e==`function`?e:Tn(e)}function If(e,t,n,r,i){if(t===`submit`&&n&&n.stateNode===i){var a=Ff((i[Mt]||null).action),o=r.submitter;o&&(t=(t=o[Mt]||null)?Ff(t.formAction):o.getAttribute(`formAction`),t!==null&&(a=t,o=null));var s=new qn(`action`,`action`,null,r,i);e.push({event:s,listeners:[{instance:null,listener:function(){if(r.defaultPrevented){if(Tf!==0){var e=new FormData(i,o);ic(n,{pending:!0,data:e,method:i.method,action:a},null,e)}}else typeof a==`function`&&(s.preventDefault(),e=new FormData(i,o),ic(n,{pending:!0,data:e,method:i.method,action:a},a,e))},currentTarget:i}]})}}for(var Lf=0;Lf<xi.length;Lf++){var Rf=xi[Lf];Si(Rf.toLowerCase(),`on`+(Rf[0].toUpperCase()+Rf.slice(1)))}Si(pi,`onAnimationEnd`),Si(mi,`onAnimationIteration`),Si(hi,`onAnimationStart`),Si(`dblclick`,`onDoubleClick`),Si(`focusin`,`onFocus`),Si(`focusout`,`onBlur`),Si(gi,`onTransitionRun`),Si(_i,`onTransitionStart`),Si(vi,`onTransitionCancel`),Si(yi,`onTransitionEnd`),Yt(`onMouseEnter`,[`mouseout`,`mouseover`]),Yt(`onMouseLeave`,[`mouseout`,`mouseover`]),Yt(`onPointerEnter`,[`pointerout`,`pointerover`]),Yt(`onPointerLeave`,[`pointerout`,`pointerover`]),Jt(`onChange`,`change click focusin focusout input keydown keyup selectionchange`.split(` `)),Jt(`onSelect`,`focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange`.split(` `)),Jt(`onBeforeInput`,[`compositionend`,`keypress`,`textInput`,`paste`]),Jt(`onCompositionEnd`,`compositionend focusout keydown keypress keyup mousedown`.split(` `)),Jt(`onCompositionStart`,`compositionstart focusout keydown keypress keyup mousedown`.split(` `)),Jt(`onCompositionUpdate`,`compositionupdate focusout keydown keypress keyup mousedown`.split(` `));var zf=`abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting`.split(` `),Bf=new Set(`beforetoggle cancel close invalid load scroll scrollend toggle`.split(` `).concat(zf));function Vf(e,t){t=!!(t&4);for(var n=0;n<e.length;n++){var r=e[n],i=r.event;r=r.listeners;a:{var a=void 0;if(t)for(var o=r.length-1;0<=o;o--){var s=r[o],c=s.instance,l=s.currentTarget;if(s=s.listener,c!==a&&i.isPropagationStopped())break a;a=s,i.currentTarget=l;try{a(i)}catch(e){Di(e)}i.currentTarget=null,a=c}else for(o=0;o<r.length;o++){if(s=r[o],c=s.instance,l=s.currentTarget,s=s.listener,c!==a&&i.isPropagationStopped())break a;a=s,i.currentTarget=l;try{a(i)}catch(e){Di(e)}i.currentTarget=null,a=c}}}}function Q(e,t){var n=t[Pt];n===void 0&&(n=t[Pt]=new Set);var r=e+`__bubble`;n.has(r)||(Gf(t,e,2,!1),n.add(r))}function Hf(e,t,n){var r=0;t&&(r|=4),Gf(n,e,r,t)}var Uf=`_reactListening`+Math.random().toString(36).slice(2);function Wf(e){if(!e[Uf]){e[Uf]=!0,Kt.forEach(function(t){t!==`selectionchange`&&(Bf.has(t)||Hf(t,!1,e),Hf(t,!0,e))});var t=e.nodeType===9?e:e.ownerDocument;t===null||t[Uf]||(t[Uf]=!0,Hf(`selectionchange`,!1,t))}}function Gf(e,t,n,r){switch(Ch(t)){case 2:var i=_h;break;case 8:i=vh;break;default:i=yh}n=i.bind(null,t,n,e),i=void 0,!In||t!==`touchstart`&&t!==`touchmove`&&t!==`wheel`||(i=!0),r?i===void 0?e.addEventListener(t,n,!0):e.addEventListener(t,n,{capture:!0,passive:i}):i===void 0?e.addEventListener(t,n,!1):e.addEventListener(t,n,{passive:i})}function Kf(e,t,n,r,i){var a=r;if(!(t&1)&&!(t&2)&&r!==null)a:for(;;){if(r===null)return;var s=r.tag;if(s===3||s===4){var c=r.stateNode.containerInfo;if(c===i)break;if(s===4)for(s=r.return;s!==null;){var l=s.tag;if((l===3||l===4)&&s.stateNode.containerInfo===i)return;s=s.return}for(;c!==null;){if(s=Vt(c),s===null)return;if(l=s.tag,l===5||l===6||l===26||l===27){r=a=s;continue a}c=c.parentNode}}r=r.return}Nn(function(){var r=a,i=On(n),s=[];a:{var c=bi.get(e);if(c!==void 0){var l=qn,u=e;switch(e){case`keypress`:if(Hn(n)===0)break a;case`keydown`:case`keyup`:l=ur;break;case`focusin`:u=`focus`,l=tr;break;case`focusout`:u=`blur`,l=tr;break;case`beforeblur`:case`afterblur`:l=tr;break;case`click`:if(n.button===2)break a;case`auxclick`:case`dblclick`:case`mousedown`:case`mousemove`:case`mouseup`:case`mouseout`:case`mouseover`:case`contextmenu`:l=$n;break;case`drag`:case`dragend`:case`dragenter`:case`dragexit`:case`dragleave`:case`dragover`:case`dragstart`:case`drop`:l=er;break;case`touchcancel`:case`touchend`:case`touchmove`:case`touchstart`:l=pr;break;case pi:case mi:case hi:l=nr;break;case yi:l=mr;break;case`scroll`:case`scrollend`:l=Yn;break;case`wheel`:l=hr;break;case`copy`:case`cut`:case`paste`:l=rr;break;case`gotpointercapture`:case`lostpointercapture`:case`pointercancel`:case`pointerdown`:case`pointermove`:case`pointerout`:case`pointerover`:case`pointerup`:l=dr;break;case`submit`:l=fr;break;case`toggle`:case`beforetoggle`:l=gr}var d=!!(t&4),f=!d&&(e===`scroll`||e===`scrollend`),p=d?c===null?null:c+`Capture`:c;d=[];for(var m=r,h;m!==null;){var g=m;if(h=g.stateNode,g=g.tag,g!==5&&g!==26&&g!==27||h===null||p===null||(g=Pn(m,p),g!=null&&d.push(qf(m,g,h))),f)break;m=m.return}0<d.length&&(c=new l(c,u,null,n,i),s.push({event:c,listeners:d}))}}if(!(t&7)){a:{if(l=e===`mouseover`||e===`pointerover`,c=e===`mouseout`||e===`pointerout`,l&&n!==Dn&&(u=n.relatedTarget||n.fromElement)&&(Vt(u)||u[Nt]))break a;(c||l)&&(u=i.window===i?i:(l=i.ownerDocument)?l.defaultView||l.parentWindow:window,c?(l=n.relatedTarget||n.toElement,c=r,l=l?Vt(l):null,l!==null&&(f=o(l),d=l.tag,l!==f||d!==5&&d!==27&&d!==6)&&(l=null)):(c=null,l=r),c!==l&&(d=$n,g=`onMouseLeave`,p=`onMouseEnter`,m=`mouse`,(e===`pointerout`||e===`pointerover`)&&(d=dr,g=`onPointerLeave`,p=`onPointerEnter`,m=`pointer`),f=c==null?u:Ut(c),h=l==null?u:Ut(l),u=new d(g,m+`leave`,c,n,i),u.target=f,u.relatedTarget=h,g=null,Vt(i)===r&&(d=new d(p,m+`enter`,l,n,i),d.target=h,d.relatedTarget=f,g=d),f=g,d=c&&l?re(c,l,Yf):null,c!==null&&Xf(s,u,c,d,!1),l!==null&&f!==null&&Xf(s,f,l,d,!0)))}a:{if(c=r?Ut(r):window,l=c.nodeName&&c.nodeName.toLowerCase(),l===`select`||l===`input`&&c.type===`file`)var _=Ir;else if(Ar(c)){if(Lr)_=Kr;else{_=Wr;var v=Ur}}else l=c.nodeName,!l||l.toLowerCase()!==`input`||c.type!==`checkbox`&&c.type!==`radio`?r&&Sn(r.elementType)&&(_=Ir):_=Gr;if(_&&=_(e,r)){jr(s,_,n,i);break a}v&&v(e,c,r)}switch(v=r?Ut(r):window,e){case`focusin`:(Ar(v)||v.contentEditable===`true`)&&(ri=v,ii=r,ai=null);break;case`focusout`:ai=ii=ri=null;break;case`mousedown`:oi=!0;break;case`contextmenu`:case`mouseup`:case`dragend`:oi=!1,si(s,n,i);break;case`selectionchange`:if(ni)break;case`keydown`:case`keyup`:si(s,n,i)}var y;if(vr)b:{switch(e){case`compositionstart`:var b=`onCompositionStart`;break b;case`compositionend`:b=`onCompositionEnd`;break b;case`compositionupdate`:b=`onCompositionUpdate`;break b}b=void 0}else Er?wr(e,n)&&(b=`onCompositionEnd`):e===`keydown`&&n.keyCode===229&&(b=`onCompositionStart`);b&&(xr&&n.locale!==`ko`&&(Er||b!==`onCompositionStart`?b===`onCompositionEnd`&&Er&&(y=Vn()):(Rn=i,zn=`value`in Rn?Rn.value:Rn.textContent,Er=!0)),v=Jf(r,b),0<v.length&&(b=new ir(b,e,null,n,i),s.push({event:b,listeners:v}),y?b.data=y:(y=Tr(n),y!==null&&(b.data=y)))),(y=br?Dr(e,n):Or(e,n))&&(b=Jf(r,`onBeforeInput`),0<b.length&&(v=new ir(`onBeforeInput`,`beforeinput`,null,n,i),s.push({event:v,listeners:b}),v.data=y)),If(s,e,r,n,i)}Vf(s,t)})}function qf(e,t,n){return{instance:e,listener:t,currentTarget:n}}function Jf(e,t){for(var n=t+`Capture`,r=[];e!==null;){var i=e,a=i.stateNode;if(i=i.tag,i!==5&&i!==26&&i!==27||a===null||(i=Pn(e,n),i!=null&&r.unshift(qf(e,i,a)),i=Pn(e,t),i!=null&&r.push(qf(e,i,a))),e.tag===3)return r;e=e.return}return[]}function Yf(e){if(e===null)return null;do e=e.return;while(e&&e.tag!==5&&e.tag!==27);return e||null}function Xf(e,t,n,r,i){for(var a=t._reactName,o=[];n!==null&&n!==r;){var s=n,c=s.alternate,l=s.stateNode;if(s=s.tag,c!==null&&c===r)break;s!==5&&s!==26&&s!==27||l===null||(c=l,i?(l=Pn(n,a),l!=null&&o.unshift(qf(n,l,c))):i||(l=Pn(n,a),l!=null&&o.push(qf(n,l,c)))),n=n.return}o.length!==0&&e.push({event:t,listeners:o})}var Zf=/\r\n?/g,Qf=/\u0000|\uFFFD/g;function $f(e){return(typeof e==`string`?e:``+e).replace(Zf,`
9
+`).replace(Qf,``)}function ep(e,t){return t=$f(t),$f(e)===t}function $(e,t,n,r,a,o){switch(n){case`children`:if(typeof r==`string`)t===`body`||t===`textarea`&&r===``||vn(e,r);else if(typeof r==`number`||typeof r==`bigint`)t!==`body`&&vn(e,``+r);else return;break;case`className`:nn(e,`class`,r);break;case`tabIndex`:nn(e,`tabindex`,r);break;case`dir`:case`role`:case`viewBox`:case`width`:case`height`:nn(e,n,r);break;case`style`:xn(e,r,o);return;case`data`:if(t!==`object`){nn(e,`data`,r);break}case`src`:case`href`:if(r===``&&(t!==`a`||n!==`href`)||r==null||typeof r==`function`||typeof r==`symbol`||typeof r==`boolean`){e.removeAttribute(n);break}r=Tn(r),e.setAttribute(n,r);break;case`action`:case`formAction`:if(typeof r==`function`){e.setAttribute(n,`javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')`);break}if(typeof o==`function`&&(n===`formAction`?(t!==`input`&&$(e,t,`name`,a.name,a,null),$(e,t,`formEncType`,a.formEncType,a,null),$(e,t,`formMethod`,a.formMethod,a,null),$(e,t,`formTarget`,a.formTarget,a,null)):($(e,t,`encType`,a.encType,a,null),$(e,t,`method`,a.method,a,null),$(e,t,`target`,a.target,a,null))),r==null||typeof r==`symbol`||typeof r==`boolean`){e.removeAttribute(n);break}r=Tn(r),e.setAttribute(n,r);break;case`onClick`:r!=null&&(e.onclick=En);return;case`onScroll`:r!=null&&Q(`scroll`,e);return;case`onScrollEnd`:r!=null&&Q(`scrollend`,e);return;case`dangerouslySetInnerHTML`:if(r!=null){if(typeof r!=`object`||!(`__html`in r))throw Error(i(61));if(n=r.__html,n!=null){if(a.children!=null)throw Error(i(60));o?.__html!==n&&(e.innerHTML=n)}}break;case`multiple`:e.multiple=r&&typeof r!=`function`&&typeof r!=`symbol`;break;case`muted`:e.muted=r&&typeof r!=`function`&&typeof r!=`symbol`;break;case`suppressContentEditableWarning`:case`suppressHydrationWarning`:case`defaultValue`:case`defaultChecked`:case`innerHTML`:case`ref`:break;case`autoFocus`:break;case`xlinkHref`:if(r==null||typeof r==`function`||typeof r==`boolean`||typeof r==`symbol`){e.removeAttribute(`xlink:href`);break}n=Tn(r),e.setAttributeNS(`http://www.w3.org/1999/xlink`,`xlink:href`,n);break;case`contentEditable`:case`spellCheck`:case`draggable`:case`value`:case`autoReverse`:case`externalResourcesRequired`:case`focusable`:case`preserveAlpha`:r!=null&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,r):e.removeAttribute(n);break;case`inert`:case`allowFullScreen`:case`async`:case`autoPlay`:case`controls`:case`credentialless`:case`default`:case`defer`:case`disabled`:case`disablePictureInPicture`:case`disableRemotePlayback`:case`formNoValidate`:case`hidden`:case`loop`:case`noModule`:case`noValidate`:case`open`:case`playsInline`:case`readOnly`:case`required`:case`reversed`:case`scoped`:case`seamless`:case`itemScope`:r&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,``):e.removeAttribute(n);break;case`capture`:case`download`:!0===r?e.setAttribute(n,``):!1!==r&&r!=null&&typeof r!=`function`&&typeof r!=`symbol`?e.setAttribute(n,r):e.removeAttribute(n);break;case`cols`:case`rows`:case`size`:case`span`:r!=null&&typeof r!=`function`&&typeof r!=`symbol`&&!isNaN(r)&&1<=r?e.setAttribute(n,r):e.removeAttribute(n);break;case`rowSpan`:case`start`:r==null||typeof r==`function`||typeof r==`symbol`||isNaN(r)?e.removeAttribute(n):e.setAttribute(n,r);break;case`popover`:Q(`beforetoggle`,e),Q(`toggle`,e),tn(e,`popover`,r);break;case`xlinkActuate`:rn(e,`http://www.w3.org/1999/xlink`,`xlink:actuate`,r);break;case`xlinkArcrole`:rn(e,`http://www.w3.org/1999/xlink`,`xlink:arcrole`,r);break;case`xlinkRole`:rn(e,`http://www.w3.org/1999/xlink`,`xlink:role`,r);break;case`xlinkShow`:rn(e,`http://www.w3.org/1999/xlink`,`xlink:show`,r);break;case`xlinkTitle`:rn(e,`http://www.w3.org/1999/xlink`,`xlink:title`,r);break;case`xlinkType`:rn(e,`http://www.w3.org/1999/xlink`,`xlink:type`,r);break;case`xmlBase`:rn(e,`http://www.w3.org/XML/1998/namespace`,`xml:base`,r);break;case`xmlLang`:rn(e,`http://www.w3.org/XML/1998/namespace`,`xml:lang`,r);break;case`xmlSpace`:rn(e,`http://www.w3.org/XML/1998/namespace`,`xml:space`,r);break;case`is`:tn(e,`is`,r);break;case`innerText`:case`textContent`:return;default:if(!(2<n.length)||n[0]!==`o`&&n[0]!==`O`||n[1]!==`n`&&n[1]!==`N`)n=Cn.get(n)||n,tn(e,n,r);else return}j=!0}function tp(e,t,n,r,a,o){switch(n){case`style`:xn(e,r,o);return;case`dangerouslySetInnerHTML`:if(r!=null){if(typeof r!=`object`||!(`__html`in r))throw Error(i(61));if(n=r.__html,n!=null){if(a.children!=null)throw Error(i(60));o?.__html!==n&&(e.innerHTML=n)}}break;case`children`:if(typeof r==`string`)vn(e,r);else if(typeof r==`number`||typeof r==`bigint`)vn(e,``+r);else return;break;case`onScroll`:r!=null&&Q(`scroll`,e);return;case`onScrollEnd`:r!=null&&Q(`scrollend`,e);return;case`onClick`:r!=null&&(e.onclick=En);return;case`suppressContentEditableWarning`:case`suppressHydrationWarning`:case`innerHTML`:case`ref`:return;case`innerText`:case`textContent`:return;default:if(!qt.hasOwnProperty(n))a:{if(n[0]===`o`&&n[1]===`n`&&(a=n.endsWith(`Capture`),o=n.slice(2,a?n.length-7:void 0),t=e[Mt]||null,t=t==null?null:t[n],typeof t==`function`&&e.removeEventListener(o,t,a),typeof r==`function`)){typeof t!=`function`&&t!==null&&(n in e?e[n]=null:e.hasAttribute(n)&&e.removeAttribute(n)),e.addEventListener(o,r,a);break a}j=!0,n in e?e[n]=r:!0===r?e.setAttribute(n,``):tn(e,n,r)}return}j=!0}function np(e,t,n){switch(t){case`div`:case`span`:case`svg`:case`path`:case`a`:case`g`:case`p`:case`li`:break;case`img`:Q(`error`,e),Q(`load`,e);var r=!1,a=!1,o;for(o in n)if(n.hasOwnProperty(o)){var s=n[o];if(s!=null)switch(o){case`src`:r=!0;break;case`srcSet`:a=!0;break;case`children`:case`dangerouslySetInnerHTML`:throw Error(i(137,t));default:$(e,t,o,s,n,null)}}a&&$(e,t,`srcSet`,n.srcSet,n,null),r&&$(e,t,`src`,n.src,n,null);return;case`input`:Q(`invalid`,e);var c=o=s=a=null,l=null,u=null;for(r in n)if(n.hasOwnProperty(r)){var d=n[r];if(d!=null)switch(r){case`name`:a=d;break;case`type`:s=d;break;case`checked`:l=d;break;case`defaultChecked`:u=d;break;case`value`:o=d;break;case`defaultValue`:c=d;break;case`children`:case`dangerouslySetInnerHTML`:if(d!=null)throw Error(i(137,t));break;default:$(e,t,r,d,n,null)}}pn(e,o,c,l,u,s,a,!1);return;case`select`:for(a in Q(`invalid`,e),r=s=o=null,n)if(n.hasOwnProperty(a)&&(c=n[a],c!=null))switch(a){case`value`:o=c;break;case`defaultValue`:s=c;break;case`multiple`:r=c;default:$(e,t,a,c,n,null)}t=o,n=s,e.multiple=!!r,t==null?n!=null&&hn(e,!!r,n,!0):hn(e,!!r,t,!1);return;case`textarea`:for(s in Q(`invalid`,e),o=a=r=null,n)if(n.hasOwnProperty(s)&&(c=n[s],c!=null))switch(s){case`value`:r=c;break;case`defaultValue`:a=c;break;case`children`:o=c;break;case`dangerouslySetInnerHTML`:if(c!=null)throw Error(i(91));break;default:$(e,t,s,c,n,null)}_n(e,r,a,o);return;case`option`:for(l in n)if(n.hasOwnProperty(l)&&(r=n[l],r!=null))switch(l){case`selected`:e.selected=r&&typeof r!=`function`&&typeof r!=`symbol`;break;default:$(e,t,l,r,n,null)}return;case`dialog`:Q(`beforetoggle`,e),Q(`toggle`,e),Q(`cancel`,e),Q(`close`,e);break;case`iframe`:case`object`:Q(`load`,e);break;case`video`:case`audio`:for(r=0;r<zf.length;r++)Q(zf[r],e);break;case`image`:Q(`error`,e),Q(`load`,e);break;case`details`:Q(`toggle`,e);break;case`embed`:case`source`:case`link`:Q(`error`,e),Q(`load`,e);case`area`:case`base`:case`br`:case`col`:case`hr`:case`keygen`:case`meta`:case`param`:case`track`:case`wbr`:case`menuitem`:for(u in n)if(n.hasOwnProperty(u)&&(r=n[u],r!=null))switch(u){case`children`:case`dangerouslySetInnerHTML`:throw Error(i(137,t));default:$(e,t,u,r,n,null)}return;default:if(Sn(t)){for(d in n)n.hasOwnProperty(d)&&(r=n[d],r!==void 0&&tp(e,t,d,r,n,void 0));return}}for(c in n)n.hasOwnProperty(c)&&(r=n[c],r!=null&&$(e,t,c,r,n,null))}var rp={};function ip(e,t,n,r){switch(t){case`div`:case`span`:case`svg`:case`path`:case`a`:case`g`:case`p`:case`li`:break;case`input`:var a=null,o=null,s=null,c=null,l=null,u=null,d=null;for(m in n){var f=n[m];if(n.hasOwnProperty(m)&&f!=null)switch(m){case`checked`:break;case`value`:break;case`defaultValue`:l=f;default:r.hasOwnProperty(m)||$(e,t,m,null,r,f)}}for(var p in r){var m=r[p];if(f=n[p],r.hasOwnProperty(p)&&(m!=null||f!=null))switch(p){case`type`:m!==f&&(j=!0),o=m;break;case`name`:m!==f&&(j=!0),a=m;break;case`checked`:m!==f&&(j=!0),u=m;break;case`defaultChecked`:m!==f&&(j=!0),d=m;break;case`value`:m!==f&&(j=!0),s=m;break;case`defaultValue`:m!==f&&(j=!0),c=m;break;case`children`:case`dangerouslySetInnerHTML`:if(m!=null)throw Error(i(137,t));break;default:m!==f&&$(e,t,p,m,r,f)}}fn(e,s,c,l,u,d,o,a);return;case`select`:for(o in m=s=c=p=null,n)if(l=n[o],n.hasOwnProperty(o)&&l!=null)switch(o){case`value`:break;case`multiple`:m=l;default:r.hasOwnProperty(o)||$(e,t,o,null,r,l)}for(a in r)if(o=r[a],l=n[a],r.hasOwnProperty(a)&&(o!=null||l!=null))switch(a){case`value`:o!==l&&(j=!0),p=o;break;case`defaultValue`:o!==l&&(j=!0),c=o;break;case`multiple`:o!==l&&(j=!0),s=o;default:o!==l&&$(e,t,a,o,r,l)}t=c,n=s,r=m,p==null?!!r!=!!n&&(t==null?hn(e,!!n,n?[]:``,!1):hn(e,!!n,t,!0)):hn(e,!!n,p,!1);return;case`textarea`:for(c in m=p=null,n)if(a=n[c],n.hasOwnProperty(c)&&a!=null&&!r.hasOwnProperty(c))switch(c){case`value`:break;case`children`:break;default:$(e,t,c,null,r,a)}for(s in r)if(a=r[s],o=n[s],r.hasOwnProperty(s)&&(a!=null||o!=null))switch(s){case`value`:a!==o&&(j=!0),p=a;break;case`defaultValue`:a!==o&&(j=!0),m=a;break;case`children`:break;case`dangerouslySetInnerHTML`:if(a!=null)throw Error(i(91));break;default:a!==o&&$(e,t,s,a,r,o)}gn(e,p,m);return;case`option`:for(var h in n)if(p=n[h],n.hasOwnProperty(h)&&p!=null&&!r.hasOwnProperty(h))switch(h){case`selected`:e.selected=!1;break;default:$(e,t,h,null,r,p)}for(l in r)if(p=r[l],m=n[l],r.hasOwnProperty(l)&&p!==m&&(p!=null||m!=null))switch(l){case`selected`:p!==m&&(j=!0),e.selected=p&&typeof p!=`function`&&typeof p!=`symbol`;break;default:$(e,t,l,p,r,m)}return;case`img`:case`link`:case`area`:case`base`:case`br`:case`col`:case`embed`:case`hr`:case`keygen`:case`meta`:case`param`:case`source`:case`track`:case`wbr`:case`menuitem`:for(var g in n)p=n[g],n.hasOwnProperty(g)&&p!=null&&!r.hasOwnProperty(g)&&$(e,t,g,null,r,p);for(u in r)if(p=r[u],m=n[u],r.hasOwnProperty(u)&&p!==m&&(p!=null||m!=null))switch(u){case`children`:case`dangerouslySetInnerHTML`:if(p!=null)throw Error(i(137,t));break;default:$(e,t,u,p,r,m)}return;default:if(Sn(t)){for(var _ in n)p=n[_],n.hasOwnProperty(_)&&p!==void 0&&!r.hasOwnProperty(_)&&tp(e,t,_,void 0,r,p);for(d in r)p=r[d],m=n[d],!r.hasOwnProperty(d)||p===m||p===void 0&&m===void 0||tp(e,t,d,p,r,m);return}}for(var v in n)p=n[v],n.hasOwnProperty(v)&&p!=null&&!r.hasOwnProperty(v)&&$(e,t,v,null,r,p);for(f in r)p=r[f],m=n[f],!r.hasOwnProperty(f)||p===m||p==null&&m==null||$(e,t,f,p,r,m)}function ap(e){switch(e){case`css`:case`script`:case`font`:case`img`:case`image`:case`input`:case`link`:return!0;default:return!1}}function op(){if(typeof performance.getEntriesByType==`function`){for(var e=0,t=0,n=performance.getEntriesByType(`resource`),r=0;r<n.length;r++){var i=n[r],a=i.transferSize,o=i.initiatorType,s=i.duration;if(a&&s&&ap(o)){for(o=0,s=i.responseEnd,r+=1;r<n.length;r++){var c=n[r],l=c.startTime;if(l>s)break;var u=c.transferSize,d=c.initiatorType;u&&ap(d)&&(c=c.responseEnd,o+=u*(c<s?1:(s-l)/(c-l)))}if(--r,t+=8*(a+o)/(i.duration/1e3),e++,10<e)break}}if(0<e)return t/e/1e6}return navigator.connection&&(e=navigator.connection.downlink,typeof e==`number`)?e:5}var sp=null,cp=null;function lp(e){return e.nodeType===9?e:e.ownerDocument}function up(e){switch(e){case`http://www.w3.org/2000/svg`:return 1;case`http://www.w3.org/1998/Math/MathML`:return 2;default:return 0}}function dp(e,t){if(e===0)switch(t){case`svg`:return 1;case`math`:return 2;default:return 0}return e===1&&t===`foreignObject`?0:e}function fp(e,t,n,r){return n=lp(n).createElement(e),n[jt]=r,n[Mt]=t,np(n,e,t),A(n),n}function pp(e,t){return e===`textarea`||e===`noscript`||typeof t.children==`string`||typeof t.children==`number`||typeof t.children==`bigint`||typeof t.dangerouslySetInnerHTML==`object`&&t.dangerouslySetInnerHTML!==null&&t.dangerouslySetInnerHTML.__html!=null}var mp=null;function hp(){var e=window.event;return e&&e.type===`popstate`?e!==mp&&(mp=e,!0):(mp=null,!1)}var gp=typeof setTimeout==`function`?setTimeout:void 0,_p=typeof clearTimeout==`function`?clearTimeout:void 0,vp=typeof Promise==`function`?Promise:void 0,yp=typeof requestAnimationFrame==`function`?requestAnimationFrame:gp,bp=typeof queueMicrotask==`function`?queueMicrotask:vp===void 0?gp:function(e){return vp.resolve(null).then(e).catch(xp)};function xp(e){setTimeout(function(){throw e})}function Sp(e){return e===`head`}function Cp(e,t){var n=t,r=0;do{var i=n.nextSibling;if(e.removeChild(n),i&&i.nodeType===8){if(n=i.data,n===`/$`||n===`/&`){if(r===0){e.removeChild(i),Hh(t);return}r--}else if(n===`$`||n===`$?`||n===`$~`||n===`$!`||n===`&`)r++;else if(n===`html`)_m(e.ownerDocument.documentElement);else if(n===`head`){n=e.ownerDocument.head,_m(n);for(var a=n.firstChild;a;){var o=a.nextSibling,s=a.nodeName;a[Rt]||s===`SCRIPT`||s===`STYLE`||s===`LINK`&&a.rel.toLowerCase()===`stylesheet`||n.removeChild(a),a=o}}else n===`body`&&_m(e.ownerDocument.body)}n=i}while(n);Hh(t)}function wp(e,t){var n=e;e=0;do{var r=n.nextSibling;if(n.nodeType===1?t?(n._stashedDisplay=n.style.display,n.style.display=`none`):(n.style.display=n._stashedDisplay||``,n.getAttribute(`style`)===``&&n.removeAttribute(`style`)):n.nodeType===3&&(t?(n._stashedText=n.nodeValue,n.nodeValue=``):n.nodeValue=n._stashedText||``),r&&r.nodeType===8){if(n=r.data,n===`/$`){if(e===0)break;e--}else n!==`$`&&n!==`$?`&&n!==`$~`&&n!==`$!`||e++}n=r}while(n)}function Tp(e,t,n){if(t=CSS.escape(t)===t?t:`r-`+btoa(t).replace(/=/g,``),e.style.viewTransitionName=t,n!=null&&(e.style.viewTransitionClass=n),n=getComputedStyle(e),n.display===`inline`){if(t=e.getClientRects(),t.length===1)var r=1;else for(var i=r=0;i<t.length;i++){var a=t[i];0<a.width&&0<a.height&&r++}r===1&&(e=e.style,e.display=t.length===1?`inline-block`:`block`,e.marginTop=`-`+n.paddingTop,e.marginBottom=`-`+n.paddingBottom)}}function Ep(e,t){e=e.style,t=t.style;var n=t==null?null:t.hasOwnProperty(`viewTransitionName`)?t.viewTransitionName:t.hasOwnProperty(`view-transition-name`)?t[`view-transition-name`]:null;e.viewTransitionName=n==null||typeof n==`boolean`?``:(``+n).trim(),n=t==null?null:t.hasOwnProperty(`viewTransitionClass`)?t.viewTransitionClass:t.hasOwnProperty(`view-transition-class`)?t[`view-transition-class`]:null,e.viewTransitionClass=n==null||typeof n==`boolean`?``:(``+n).trim(),e.display===`inline-block`&&(t==null?e.display=e.margin=``:(n=t.display,e.display=n==null||typeof n==`boolean`?``:n,n=t.margin,n==null?(n=t.hasOwnProperty(`marginTop`)?t.marginTop:t[`margin-top`],e.marginTop=n==null||typeof n==`boolean`?``:n,t=t.hasOwnProperty(`marginBottom`)?t.marginBottom:t[`margin-bottom`],e.marginBottom=t==null||typeof t==`boolean`?``:t):e.margin=n))}function Dp(e,t,n){return n=n.ownerDocument.defaultView,{rect:e,abs:t.position===`absolute`||t.position===`fixed`,clip:t.clipPath!==`none`||t.overflow!==`visible`||t.filter!==`none`||t.mask!==`none`||t.mask!==`none`||t.borderRadius!==`0px`,view:0<=e.bottom&&0<=e.right&&e.top<=n.innerHeight&&e.left<=n.innerWidth}}function Op(e){return Dp(e.getBoundingClientRect(),getComputedStyle(e),e)}function kp(e){var t=e.getBoundingClientRect();t=new DOMRect(t.x+2e4,t.y+2e4,t.width,t.height);var n=getComputedStyle(e);return Dp(t,n,e)}function Ap(e){return e.documentElement.clientHeight}function jp(e){this.addEventListener(`load`,e),this.addEventListener(`error`,e)}function Mp(e,t,n,r,i,a,o,s,c){var l=t.nodeType===9?t:t.ownerDocument;try{var u=l.startViewTransition({update:function(){var t=l.defaultView,n=t.navigation&&t.navigation.transition,o=l.fonts.status;r();var s=[];if(o===`loaded`&&(Ap(l),l.fonts.status===`loading`&&s.push(l.fonts.ready)),o=s.length,e!==null)for(var c=e.suspenseyImages,u=0,d=0;d<c.length;d++){var f=c[d];if(!f.complete){var p=f.getBoundingClientRect();if(0<p.bottom&&0<p.right&&p.top<t.innerHeight&&p.left<t.innerWidth){if(u+=Xm(f),u>$m){s.length=o;break}f=new Promise(jp.bind(f)),s.push(f)}}}if(0<s.length)return t=Promise.race([Promise.all(s),new Promise(function(e){return setTimeout(e,500)})]).then(i,i),(n?Promise.allSettled([n.finished,t]):t).then(a,a);if(i(),n)return n.finished.then(a,a);a()},types:n});l.__reactViewTransition=u;var d=[];return u.ready.then(function(){for(var e=l.documentElement.getAnimations({subtree:!0}),t=0;t<e.length;t++){var n=e[t],r=n.effect,i=r.pseudoElement;if(i!=null&&i.startsWith(`::view-transition`)){d.push(n),n=r.getKeyframes();for(var a=i=void 0,s=!0,c=0;c<n.length;c++){var u=n[c],f=u.width;if(i===void 0)i=f;else if(i!==f){s=!1;break}if(f=u.height,a===void 0)a=f;else if(a!==f){s=!1;break}delete u.width,delete u.height,u.transform===`none`&&delete u.transform}s&&i!==void 0&&a!==void 0&&(r.setKeyframes(n),s=getComputedStyle(r.target,r.pseudoElement),s.width!==i||s.height!==a)&&(s=n[0],s.width=i,s.height=a,s=n[n.length-1],s.width=i,s.height=a,r.setKeyframes(n))}}o()},function(e){l.__reactViewTransition===u&&(l.__reactViewTransition=null);try{if(typeof e==`object`&&e)switch(e.name){case`InvalidStateError`:(e.message===`View transition was skipped because document visibility state is hidden.`||e.message===`Skipping view transition because document visibility state has become hidden.`||e.message===`Skipping view transition because viewport size changed.`||e.message===`Transition was aborted because of invalid state`)&&(e=null)}e!==null&&c(e)}finally{r(),i(),o()}}),u.finished.finally(function(){for(var e=0;e<d.length;e++)d[e].cancel();l.__reactViewTransition===u&&(l.__reactViewTransition=null),s()}),u}catch{return r(),i(),o(),null}}function Np(e,t){this._scope=document.documentElement,this._selector=`::view-transition-`+e+`(`+t+`)`}Np.prototype.animate=function(e,t){return t=typeof t==`number`?{duration:t}:C({},t),t.pseudoElement=this._selector,this._scope.animate(e,t)},Np.prototype.getAnimations=function(){for(var e=this._scope,t=this._selector,n=e.getAnimations({subtree:!0}),r=[],i=0;i<n.length;i++){var a=n[i].effect;a!==null&&a.target===e&&a.pseudoElement===t&&r.push(n[i])}return r},Np.prototype.getComputedStyle=function(){return getComputedStyle(this._scope,this._selector)};function Pp(e){return{name:e,group:new Np(`group`,e),imagePair:new Np(`image-pair`,e),old:new Np(`old`,e),new:new Np(`new`,e)}}function Fp(e){this._fragmentFiber=e,this._observers=this._eventListeners=null}Fp.prototype.addEventListener=function(e,t,n){var r=null,i=null;if(!(n!=null&&typeof n!=`boolean`&&(r=n.signal||null,r!==null&&r.aborted))){this._eventListeners===null&&(this._eventListeners=[]);var a=this._eventListeners;if(Bp(a,e,t,n)===-1){var o=this,s=t;n!=null&&typeof n!=`boolean`&&!0===n.once&&(s=function(r){o.removeEventListener(e,t,n),typeof t==`function`?t.call(this,r):t.handleEvent(r)}),r!==null&&(i=o.removeEventListener.bind(o,e,t,n),r.addEventListener(`abort`,i,{once:!0}),i=r.removeEventListener.bind(r,`abort`,i)),r=Rp(n),a.push({type:e,listener:t,optionsOrUseCapture:n,attachedListener:s,cleanup:i}),h(this._fragmentFiber.child,!1,Ip,e,s,r)}this._eventListeners=a}};function Ip(e,t,n,r){return b(e).addEventListener(t,n,r),!1}Fp.prototype.removeEventListener=function(e,t,n){var r=this._eventListeners;if(r!==null&&(t=Bp(r,e,t,n),t!==-1)){var i=r[t];n=i.attachedListener;var a=i.cleanup;i=Rp(i.optionsOrUseCapture),h(this._fragmentFiber.child,!1,Lp,e,n,i),r.splice(t,1),a!==null&&a()}};function Lp(e,t,n,r){return b(e).removeEventListener(t,n,r),!1}function Rp(e){return e!=null&&typeof e!=`boolean`&&(!0===e.once||e.signal instanceof AbortSignal)?{capture:e.capture,passive:e.passive}:e}function zp(e){return e==null?`c=0`:typeof e==`boolean`?`c=`+(e?`1`:`0`):`c=`+(e.capture?`1`:`0`)}function Bp(e,t,n,r){if(e.length===0)return-1;r=zp(r);for(var i=0;i<e.length;i++){var a=e[i];if(a.type===t&&a.listener===n&&zp(a.optionsOrUseCapture)===r)return i}return-1}Fp.prototype.dispatchEvent=function(e){var t=g(this._fragmentFiber);if(t===null)return!0;t=b(t);var n=this._eventListeners;if(n!==null&&0<n.length||!e.bubbles){var r=t.nodeType===9?t.createComment(``):document.createTextNode(``);if(n)for(var i=0;i<n.length;i++){var a=n[i];r.addEventListener(a.type,a.attachedListener,Rp(a.optionsOrUseCapture))}if(t.appendChild(r),e=r.dispatchEvent(e),n)for(i=0;i<n.length;i++)a=n[i],r.removeEventListener(a.type,a.attachedListener,Rp(a.optionsOrUseCapture));return t.removeChild(r),e}return t.dispatchEvent(e)},Fp.prototype.focus=function(e){h(this._fragmentFiber.child,!0,Vp,e,void 0,void 0)};function Vp(e,t){return e.tag!==6&&(e=b(e),pm(e,t))}Fp.prototype.focusLast=function(e){var t=[];h(this._fragmentFiber.child,!0,Hp,t,void 0,void 0);for(var n=t.length-1;0<=n&&!Vp(t[n],e);n--);};function Hp(e,t){return t.push(e),!1}Fp.prototype.blur=function(){var e=g(this._fragmentFiber);e!==null&&(e=b(e),e=lp(e).activeElement,e!==null&&h(this._fragmentFiber.child,!1,Up,e,void 0,void 0))};function Up(e,t){return e.tag!==6&&(e=b(e),e===t||e.contains(t)?(t.blur(),!0):!1)}Fp.prototype.observeUsing=function(e){this._observers===null&&(this._observers=new Set),this._observers.add(e),h(this._fragmentFiber.child,!1,Wp,e,void 0,void 0)};function Wp(e,t){return e.tag===6||(e=b(e),t.observe(e)),!1}Fp.prototype.unobserveUsing=function(e){var t=this._observers;if(t!==null&&t.has(e)){t.delete(e),h(this._fragmentFiber.child,!1,Gp,e,void 0,void 0);for(var n=t=0;n<Kp.length;n++){var r=Kp[n];r.fragmentInstance===this&&r.observer===e?e.unobserve(r.instance):Kp[t++]=r}Kp.length=t}};function Gp(e,t){return e.tag===6||(e=b(e),t.unobserve(e)),!1}var Kp=[],qp=!1;function Jp(e,t,n){Kp.push({fragmentInstance:e,observer:t,instance:n}),qp||(qp=!0,mm(function(){qp=!1;var e=Kp;Kp=[];for(var t=0;t<e.length;t++){var n=e[t];n.observer.unobserve(n.instance)}}))}Fp.prototype.getClientRects=function(){var e=[];return h(this._fragmentFiber.child,!1,Yp,e,void 0,void 0),e};function Yp(e,t){if(e.tag===6){e=e.stateNode;var n=e.ownerDocument.createRange();n.selectNodeContents(e),t.push.apply(t,n.getClientRects())}else e=b(e),t.push.apply(t,e.getClientRects());return!1}Fp.prototype.getRootNode=function(e){var t=g(this._fragmentFiber);return t===null?this:b(t).getRootNode(e)},Fp.prototype.compareDocumentPosition=function(e){var t=g(this._fragmentFiber);if(t===null)return Node.DOCUMENT_POSITION_DISCONNECTED;var n=[];h(this._fragmentFiber.child,!1,Hp,n,void 0,void 0);var r=b(t);if(n.length===0){if(n=r,_(this._fragmentFiber)){a:{for(t=this._fragmentFiber.return;t!==null;){if(t.tag===4){t=t.stateNode.containerInfo;break a}if(t.tag===3||t.tag===5||t.tag===27)break;t=t.return}t=null}t!=null&&(n=t)}t=this._fragmentFiber;var i=r=n.compareDocumentPosition(e);return n===e?i=Node.DOCUMENT_POSITION_CONTAINS:r&Node.DOCUMENT_POSITION_CONTAINED_BY&&(n=v(t)[1],n===null?i=Node.DOCUMENT_POSITION_PRECEDING:(e=b(n).compareDocumentPosition(e),i=e===0||e&Node.DOCUMENT_POSITION_FOLLOWING?Node.DOCUMENT_POSITION_FOLLOWING:Node.DOCUMENT_POSITION_PRECEDING)),i|=Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC}t=b(n[0]),i=b(n[n.length-1]);var a=_(this._fragmentFiber)?t.parentElement:r;if(a==null)return Node.DOCUMENT_POSITION_DISCONNECTED;r=a.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_CONTAINED_BY,a=a.compareDocumentPosition(i)&Node.DOCUMENT_POSITION_CONTAINED_BY;var o=t.compareDocumentPosition(e),s=i.compareDocumentPosition(e),c=o&Node.DOCUMENT_POSITION_CONTAINED_BY||s&Node.DOCUMENT_POSITION_CONTAINED_BY;return s=r&&a&&o&Node.DOCUMENT_POSITION_FOLLOWING&&s&Node.DOCUMENT_POSITION_PRECEDING,t=r&&t===e||a&&i===e||c||s?Node.DOCUMENT_POSITION_CONTAINED_BY:!r&&t===e||!a&&i===e?Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC:o,t&Node.DOCUMENT_POSITION_DISCONNECTED||t&Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC||Xp(t,this._fragmentFiber,n[0],n[n.length-1],e)?t:Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC};function Xp(e,t,n,r,i){var a=Vt(i);if(e&Node.DOCUMENT_POSITION_CONTAINED_BY){if(n=!!a)a:{for(;a!==null;){if(a.tag===7&&(a===t||a.alternate===t)){n=!0;break a}a=a.return}n=!1}return n}if(e&Node.DOCUMENT_POSITION_CONTAINS){if(a===null)return a=i.ownerDocument,i===a||i===a.documentElement||i===a.body;a:{for(a=t,t=g(t);a!==null;){if(!(a.tag!==5&&a.tag!==3&&a.tag!==27||a!==t&&a.alternate!==t)){a=!0;break a}a=a.return}a=!1}return a}return e&Node.DOCUMENT_POSITION_PRECEDING?((t=!!a)&&!(t=a===n)&&(t=re(n,a,S),t===null?t=!1:(h(t,!0,x,a,n),a=ee,ee=null,t=a!==null)),t):e&Node.DOCUMENT_POSITION_FOLLOWING?((t=!!a)&&!(t=a===r)&&(t=re(r,a,S),t===null?t=!1:(h(t,!0,ne,a,r),a=ee,te=ee=null,t=a!==null)),t):!1}function Zp(e,t){var n=e.ownerDocument.createRange();n.selectNodeContents(e),e=n.getBoundingClientRect(),window.scrollTo(window.scrollX+e.left,t?window.scrollY+e.top:window.scrollY+e.bottom-window.innerHeight)}Fp.prototype.scrollIntoView=function(e){if(typeof e==`object`)throw Error(i(566));var t=[];h(this._fragmentFiber.child,!1,Hp,t,void 0,void 0);var n=!1!==e;if(t.length===0){var r=v(this._fragmentFiber);if(r=n?r[1]||r[0]||g(this._fragmentFiber):r[0]||r[1],r===null)return;if(r.tag===6){e=b(r),Zp(e,n);return}if(r=b(r),r.nodeType!==9){if(r.nodeType===11){n=`host`in r?r.host:null,n!==null&&n.scrollIntoView(e);return}r.scrollIntoView(e)}}for(r=n?t.length-1:0;r!==(n?-1:t.length);){var a=t[r];a.tag===6?(a=b(a),Zp(a,n)):b(a).scrollIntoView(e),r+=n?-1:1}};function Qp(e,t){return e=b(e),$p(e,t),!1}function $p(e,t){e.reactFragments??=new Set,e.reactFragments.add(t)}function em(e,t){var n=t._eventListeners;if(n!==null)for(var r=0;r<n.length;r++){var i=n[r];e.addEventListener(i.type,i.attachedListener,Rp(i.optionsOrUseCapture))}e.nodeType!==3&&(n=t._observers,n!==null&&n.forEach(function(n){for(var r=0,i=0;i<Kp.length;i++){var a=Kp[i];(a.fragmentInstance!==t||a.observer!==n||a.instance!==e)&&(Kp[r++]=a)}Kp.length=r,n.observe(e)}),$p(e,t))}function tm(e,t){var n=t._eventListeners;if(n!==null)for(var r=0;r<n.length;r++){var i=n[r];e.removeEventListener(i.type,i.attachedListener,Rp(i.optionsOrUseCapture))}e.nodeType!==3&&(n=t._observers,n!==null&&n.forEach(function(n){typeof n.rootMargin==`string`?Jp(t,n,e):n.unobserve(e)}),e.reactFragments!=null&&e.reactFragments.delete(t))}function nm(e){var t=e.firstChild;for(t&&t.nodeType===10&&(t=t.nextSibling);t;){var n=t;switch(t=t.nextSibling,n.nodeName){case`HTML`:case`HEAD`:case`BODY`:nm(n),Bt(n);continue;case`SCRIPT`:case`STYLE`:continue;case`LINK`:if(n.rel.toLowerCase()===`stylesheet`)continue}e.removeChild(n)}}function rm(e,t,n,r){for(;e.nodeType===1;){var i=n;if(e.nodeName.toLowerCase()!==t.toLowerCase()){if(!r&&(e.nodeName!==`INPUT`||e.type!==`hidden`))break}else if(!r){if(t===`input`&&e.type===`hidden`){var a=i.name==null?null:``+i.name;if(i.type===`hidden`&&e.getAttribute(`name`)===a)return e}else return e}else if(!e[Rt])switch(t){case`meta`:if(!e.hasAttribute(`itemprop`))break;return e;case`link`:if(a=e.getAttribute(`rel`),a===`stylesheet`&&e.hasAttribute(`data-precedence`)||a!==i.rel||e.getAttribute(`href`)!==(i.href==null||i.href===``?null:i.href)||e.getAttribute(`crossorigin`)!==(i.crossOrigin==null?null:i.crossOrigin)||e.getAttribute(`title`)!==(i.title==null?null:i.title))break;return e;case`style`:if(e.hasAttribute(`data-precedence`))break;return e;case`script`:if(a=e.getAttribute(`src`),(a!==(i.src==null?null:i.src)||e.getAttribute(`type`)!==(i.type==null?null:i.type)||e.getAttribute(`crossorigin`)!==(i.crossOrigin==null?null:i.crossOrigin))&&a&&e.hasAttribute(`async`)&&!e.hasAttribute(`itemprop`))break;return e;default:return e}if(e=lm(e.nextSibling),e===null)break}return null}function im(e,t,n){if(t===``)return null;for(;e.nodeType!==3;)if((e.nodeType!==1||e.nodeName!==`INPUT`||e.type!==`hidden`)&&!n||(e=lm(e.nextSibling),e===null))return null;return e}function am(e,t){for(;e.nodeType!==8;)if((e.nodeType!==1||e.nodeName!==`INPUT`||e.type!==`hidden`)&&!t||(e=lm(e.nextSibling),e===null))return null;return e}function om(e){return e.data===`$?`||e.data===`$~`}function sm(e){return e.data===`$!`||e.data===`$?`&&e.ownerDocument.readyState!==`loading`}function cm(e,t){var n=e.ownerDocument;if(e.data===`$~`)e._reactRetry=t;else if(e.data!==`$?`||n.readyState!==`loading`)t();else{var r=function(){t(),n.removeEventListener(`DOMContentLoaded`,r)};n.addEventListener(`DOMContentLoaded`,r),e._reactRetry=r}}function lm(e){for(;e!=null;e=e.nextSibling){var t=e.nodeType;if(t===1||t===3)break;if(t===8){if(t=e.data,t===`$`||t===`$!`||t===`$?`||t===`$~`||t===`&`||t===`F!`||t===`F`)break;if(t===`/$`||t===`/&`)return null}}return e}var um=null;function dm(e){e=e.nextSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n===`/$`||n===`/&`){if(t===0)return lm(e.nextSibling);t--}else n!==`$`&&n!==`$!`&&n!==`$?`&&n!==`$~`&&n!==`&`||t++}e=e.nextSibling}return null}function fm(e){e=e.previousSibling;for(var t=0;e;){if(e.nodeType===8){var n=e.data;if(n===`$`||n===`$!`||n===`$?`||n===`$~`||n===`&`){if(t===0)return e;t--}else n!==`/$`&&n!==`/&`||t++}e=e.previousSibling}return null}function pm(e,t){function n(){r=!0}if(e.ownerDocument.activeElement===e)return!0;var r=!1;try{e.ownerDocument.addEventListener(`focus`,n,!0),(e.focus||HTMLElement.prototype.focus).call(e,t)}finally{e.ownerDocument.removeEventListener(`focus`,n,!0)}return r}function mm(e){yp(function(){yp(function(t){return e(t)})})}function hm(e,t,n){switch(t=lp(n),e){case`html`:if(e=t.documentElement,!e)throw Error(i(452));return e;case`head`:if(e=t.head,!e)throw Error(i(453));return e;case`body`:if(e=t.body,!e)throw Error(i(454));return e;default:throw Error(i(451))}}function gm(e,t,n){for(var r in n){var i=n[r];n.hasOwnProperty(r)&&i!=null&&$(e,t,r,null,rp,i)}n.dangerouslySetInnerHTML!=null&&(e.textContent=``),e.onclick===En&&(e.onclick=null),Bt(e)}function _m(e){for(var t=e.attributes;t.length;)e.removeAttributeNode(t[0]);Bt(e)}var vm=new Map,ym=new Set;function bm(e){if(typeof e.getRootNode==`function`){var t=e.getRootNode();if(t.nodeType===9||t.nodeType===11)return t}return e.nodeType===9?e:e.ownerDocument}var xm=O.d;O.d={f:Sm,r:Cm,D:Em,C:Dm,L:Om,m:km,X:jm,S:Am,M:Mm};function Sm(){var e=xm.f(),t=zd();return e||t}function Cm(e){var t=Ht(e);t!==null&&t.tag===5&&t.type===`form`?oc(t):xm.r(e)}var wm=typeof document>`u`?null:document;function Tm(e,t,n){var r=wm;if(r&&typeof t==`string`&&t){var i=dn(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),ym.has(i)||(ym.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),np(t,`link`,e),A(t),r.head.appendChild(t)))}}function Em(e){xm.D(e),Tm(`dns-prefetch`,e,null)}function Dm(e,t){xm.C(e,t),Tm(`preconnect`,e,t)}function Om(e,t,n){xm.L(e,t,n);var r=wm;if(r&&e&&t){var i=`link[rel="preload"][as="`+dn(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+dn(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+dn(n.imageSizes)+`"]`)):i+=`[href="`+dn(e)+`"]`;var a=i;switch(t){case`style`:a=Pm(e);break;case`script`:a=Rm(e)}if(!(vm.has(a)||(e=C({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),vm.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Fm(a))||t===`script`&&r.querySelector(zm(a))))){var o=r.createElement(`link`);np(o,`link`,e),t===`style`&&(o[zt]=!0,o.onload=o.onerror=function(){Gt(o)}),A(o),r.head.appendChild(o)}}}function km(e,t){xm.m(e,t);var n=wm;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+dn(r)+`"][href="`+dn(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Rm(e)}if(!vm.has(a)&&(e=C({rel:`modulepreload`,href:e},t),vm.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(zm(a)))return}r=n.createElement(`link`),np(r,`link`,e),A(r),n.head.appendChild(r)}}}function Am(e,t,n){xm.S(e,t,n);var r=wm;if(r&&e){var i=Wt(r).hoistableStyles,a=Pm(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Fm(a)))s.loading=5;else{e=C({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=vm.get(a))&&Hm(e,n);var c=o=r.createElement(`link`);A(c),np(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Vm(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function jm(e,t){xm.X(e,t);var n=wm;if(n&&e){var r=Wt(n).hoistableScripts,i=Rm(e),a=r.get(i);a||(a=n.querySelector(zm(i)),a||(e=C({src:e,async:!0},t),(t=vm.get(i))&&Um(e,t),a=n.createElement(`script`),A(a),np(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Mm(e,t){xm.M(e,t);var n=wm;if(n&&e){var r=Wt(n).hoistableScripts,i=Rm(e),a=r.get(i);a||(a=n.querySelector(zm(i)),a||(e=C({src:e,async:!0,type:`module`},t),(t=vm.get(i))&&Um(e,t),a=n.createElement(`script`),A(a),np(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Nm(e,t,n,r){var a=(a=je.current)?bm(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(n=Pm(n.href),t=Wt(a).hoistableStyles,r=t.get(n),r||(r={type:`style`,instance:null,count:0,state:null},t.set(n,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Pm(n.href);var o=Wt(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(Fm(e)))?o._p||(s.instance=o,s.state.loading=5):(o=vm.get(e),o||(o={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},vm.set(e,o)),Lm(a,e,o,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(n=Rm(n),t=Wt(a).hoistableScripts,r=t.get(n),r||(r={type:`script`,instance:null,count:0,state:null},t.set(n,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Pm(e){return`href="`+dn(e)+`"`}function Fm(e){return`link[rel="stylesheet"][`+e+`]`}function Im(e){return C({},e,{"data-precedence":e.precedence,precedence:null})}function Lm(e,t,n,r){if(t=e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)){if(!0!==t[zt]){r.loading=1;return}}else t=e.createElement(`link`),t[zt]=!0,t.onload=t.onerror=Gt.bind(null,t),np(t,`link`,n),A(t),e.head.appendChild(t);r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2})}function Rm(e){return`[src="`+dn(e)+`"]`}function zm(e){return`script[async]`+e}function Bm(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+dn(n.href)+`"]`);if(r)return t.instance=r,A(r),r;var a=C({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),A(r),np(r,`style`,a),Vm(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Pm(n.href);var o=e.querySelector(Fm(a));if(o)return t.state.loading|=4,t.instance=o,A(o),o;r=Im(n),(a=vm.get(a))&&Hm(r,a),o=(e.ownerDocument||e).createElement(`link`),A(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),np(o,`link`,r),t.state.loading|=4,Vm(o,n.precedence,e),t.instance=o;case`script`:return o=Rm(n.src),(a=e.querySelector(zm(o)))?(t.instance=a,A(a),a):(r=n,(a=vm.get(o))&&(r=C({},n),Um(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),A(a),np(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Vm(r,n.precedence,e));return t.instance}function Vm(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o<r.length;o++){var s=r[o];if(s.dataset.precedence===t)a=s;else if(a!==i)break}a?a.parentNode.insertBefore(e,a.nextSibling):(t=n.nodeType===9?n.head:n,t.insertBefore(e,t.firstChild))}function Hm(e,t){e.crossOrigin??=t.crossOrigin,e.referrerPolicy??=t.referrerPolicy,e.title??=t.title}function Um(e,t){e.crossOrigin??=t.crossOrigin,e.referrerPolicy??=t.referrerPolicy,e.integrity??=t.integrity}var Wm=null;function Gm(e,t,n){if(Wm===null){var r=new Map,i=Wm=new Map;i.set(n,r)}else i=Wm,r=i.get(n),r||(r=new Map,i.set(n,r));if(r.has(e))return r;for(r.set(e,null),n=n.getElementsByTagName(e),i=0;i<n.length;i++){var a=n[i];if(!(a[Rt]||a[jt]||e===`link`&&a.getAttribute(`rel`)===`stylesheet`)&&a.namespaceURI!==`http://www.w3.org/2000/svg`){var o=a.getAttribute(t)||``;o=e+o;var s=r.get(o);s?s.push(a):r.set(o,[a])}}return r}function Km(e,t,n){e=e.ownerDocument||e,e.head.insertBefore(n,t===`title`?e.querySelector(`head > title`):null)}function qm(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Jm(e,t){return e===`img`&&t.src!=null&&t.src!==``&&t.onLoad==null&&t.loading!==`lazy`}function Ym(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Xm(e){return(e.width||100)*(e.height||100)*(typeof devicePixelRatio==`number`?devicePixelRatio:1)*.25}function Zm(e,t){typeof t.decode==`function`&&(e.imgCount++,t.complete||(e.imgBytes+=Xm(t),e.suspenseyImages.push(t)),e=rh.bind(e),t.decode().then(e,e))}function Qm(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Pm(r.href),a=t.querySelector(Fm(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=nh.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,A(a);return}a=t.ownerDocument||t,r=Im(r),(i=vm.get(i))&&Hm(r,i),a=a.createElement(`link`),A(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),np(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=nh.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var $m=0;function eh(e,t){return e.stylesheets&&e.count===0&&ah(e,e.stylesheets),0<e.count||0<e.imgCount?function(n){var r=setTimeout(function(){if(e.stylesheets&&ah(e,e.stylesheets),e.unsuspend){var t=e.unsuspend;e.unsuspend=null,t()}},6e4+t);0<e.imgBytes&&$m===0&&($m=62500*op());var i=setTimeout(function(){if(e.waitingForImages=!1,e.count===0&&(e.stylesheets&&ah(e,e.stylesheets),e.unsuspend)){var t=e.unsuspend;e.unsuspend=null,t()}},(e.imgBytes>$m?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function th(e){if(e.count===0&&(e.imgCount===0||!e.waitingForImages)){if(e.stylesheets)ah(e,e.stylesheets);else if(e.unsuspend){var t=e.unsuspend;e.unsuspend=null,t()}}}function nh(){this.count--,th(this)}function rh(){this.imgCount--,th(this)}var ih=null;function ah(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,ih=new Map,t.forEach(oh,e),ih=null,nh.call(e))}function oh(e,t){if(!(t.state.loading&4)){var n=ih.get(e);if(n)var r=n.get(null);else{n=new Map,ih.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a<i.length;a++){var o=i[a];(o.nodeName===`LINK`||o.getAttribute(`media`)!==`not all`)&&(n.set(o.dataset.precedence,o),r=o)}r&&n.set(null,r)}i=t.instance,o=i.getAttribute(`data-precedence`),a=n.get(o)||r,a===r&&n.set(null,i),n.set(o,i),this.count++,r=nh.bind(this),i.addEventListener(`load`,r),i.addEventListener(`error`,r),a?a.parentNode.insertBefore(i,a.nextSibling):(e=e.nodeType===9?e.head:e,e.insertBefore(i,e.firstChild)),t.state.loading|=4}}var sh={$$typeof:E,Provider:null,Consumer:null,_currentValue:we,_currentValue2:we,_threadCount:0};function ch(e,t,n,r,i,a,o,s,c){this.tag=1,this.containerInfo=e,this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.next=this.pendingContext=this.context=this.cancelPendingCommit=null,this.callbackPriority=0,this.expirationTimes=bt(-1),this.entangledLanes=this.shellSuspendCounter=this.errorRecoveryDisabledLanes=this.expiredLanes=this.warmLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=bt(0),this.hiddenUpdates=bt(null),this.identifierPrefix=r,this.onUncaughtError=i,this.onCaughtError=a,this.onRecoverableError=o,this.pooledCache=null,this.pooledCacheLanes=0,this.formState=c,this.transitionTypes=null,this.incompleteTransitions=new Map}function lh(e,t,n,r,i,a,o,s,c,l,u,d){return e=new ch(e,t,n,o,c,l,u,d,s),t=1,!0===a&&(t|=24),a=zi(3,null,null,t),e.current=a,a.stateNode=e,t=La(),t.refCount++,e.pooledCache=t,t.refCount++,a.memoizedState={element:r,isDehydrated:n,cache:t},bo(a),e}function uh(e){return e?(e=Li,e):Li}function dh(e,t,n,r,i,a){i=uh(i),r.context===null?r.context=i:r.pendingContext=i,r=So(t),r.payload={element:n},a=a===void 0?null:a,a!==null&&(r.callback=a),n=Co(e,r,t),n!==null&&(Pd(n,e,t),wo(n,e,t))}function fh(e,t){if(e=e.memoizedState,e!==null&&e.dehydrated!==null){var n=e.retryLane;e.retryLane=n!==0&&n<t?n:t}}function ph(e,t){fh(e,t),(e=e.alternate)&&fh(e,t)}function mh(e){if(e.tag===13||e.tag===31){var t=Pi(e,67108864);t!==null&&Pd(t,e,67108864),ph(e,67108864)}}function hh(e){if(e.tag===13||e.tag===31){var t=jd();t=Et(t);var n=Pi(e,t);n!==null&&Pd(n,e,t),ph(e,t)}}var gh=!0;function _h(e,t,n,r){var i=D.T;D.T=null;var a=O.p;try{O.p=2,yh(e,t,n,r)}finally{O.p=a,D.T=i}}function vh(e,t,n,r){var i=D.T;D.T=null;var a=O.p;try{O.p=8,yh(e,t,n,r)}finally{O.p=a,D.T=i}}function yh(e,t,n,r){if(gh){var i=bh(r);if(i===null)Kf(e,t,r,xh,n),Mh(e,r);else if(Ph(i,e,t,n,r))r.stopPropagation();else if(Mh(e,r),t&4&&-1<jh.indexOf(e)){for(;i!==null;){var a=Ht(i);if(a!==null)switch(a.tag){case 3:if(a=a.stateNode,a.current.memoizedState.isDehydrated){var o=mt(a.pendingLanes);if(o!==0){var s=a;for(s.pendingLanes|=2,s.entangledLanes|=2;o;){var c=1<<31-st(o);s.entanglements[1]|=c,o&=~c}Ef(a),!(W&6)&&(_d=Ye()+500,Df(0,!1))}}break;case 31:case 13:s=Pi(a,2),s!==null&&Pd(s,a,2),zd(),ph(a,2)}if(a=bh(r),a===null&&Kf(e,t,r,xh,n),a===i)break;i=a}i!==null&&r.stopPropagation()}else Kf(e,t,r,null,n)}}function bh(e){return e=On(e),Sh(e)}var xh=null;function Sh(e){if(xh=null,e=Vt(e),e!==null){var t=o(e);if(t===null)e=null;else{var n=t.tag;if(n===13){if(e=s(t),e!==null)return e;e=null}else if(n===31){if(e=c(t),e!==null)return e;e=null}else if(n===3){if(t.stateNode.current.memoizedState.isDehydrated)return t.tag===3?t.stateNode.containerInfo:null;e=null}else t!==e&&(e=null)}}return xh=e,null}function Ch(e){switch(e){case`beforetoggle`:case`cancel`:case`click`:case`close`:case`contextmenu`:case`copy`:case`cut`:case`auxclick`:case`dblclick`:case`dragend`:case`dragstart`:case`drop`:case`focusin`:case`focusout`:case`input`:case`invalid`:case`keydown`:case`keypress`:case`keyup`:case`mousedown`:case`mouseup`:case`paste`:case`pause`:case`play`:case`pointercancel`:case`pointerdown`:case`pointerup`:case`ratechange`:case`reset`:case`seeked`:case`submit`:case`toggle`:case`touchcancel`:case`touchend`:case`touchstart`:case`volumechange`:case`change`:case`selectionchange`:case`textInput`:case`compositionstart`:case`compositionend`:case`compositionupdate`:case`beforeblur`:case`afterblur`:case`beforeinput`:case`blur`:case`fullscreenchange`:case`fullscreenerror`:case`focus`:case`hashchange`:case`popstate`:case`select`:case`selectstart`:return 2;case`drag`:case`dragenter`:case`dragexit`:case`dragleave`:case`dragover`:case`mousemove`:case`mouseout`:case`mouseover`:case`pointermove`:case`pointerout`:case`pointerover`:case`resize`:case`scroll`:case`touchmove`:case`wheel`:case`mouseenter`:case`mouseleave`:case`pointerenter`:case`pointerleave`:return 8;case`message`:switch(Xe()){case Ze:return 2;case Qe:return 8;case $e:case et:return 32;case tt:return 268435456;default:return 32}default:return 32}}var wh=!1,Th=null,Eh=null,Dh=null,Oh=new Map,kh=new Map,Ah=[],jh=`mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset`.split(` `);function Mh(e,t){switch(e){case`focusin`:case`focusout`:Th=null;break;case`dragenter`:case`dragleave`:Eh=null;break;case`mouseover`:case`mouseout`:Dh=null;break;case`pointerover`:case`pointerout`:Oh.delete(t.pointerId);break;case`gotpointercapture`:case`lostpointercapture`:kh.delete(t.pointerId)}}function Nh(e,t,n,r,i,a){return e===null||e.nativeEvent!==a?(e={blockedOn:t,domEventName:n,eventSystemFlags:r,nativeEvent:a,targetContainers:[i]},t!==null&&(t=Ht(t),t!==null&&mh(t)),e):(e.eventSystemFlags|=r,t=e.targetContainers,i!==null&&t.indexOf(i)===-1&&t.push(i),e)}function Ph(e,t,n,r,i){switch(t){case`focusin`:return Th=Nh(Th,e,t,n,r,i),!0;case`dragenter`:return Eh=Nh(Eh,e,t,n,r,i),!0;case`mouseover`:return Dh=Nh(Dh,e,t,n,r,i),!0;case`pointerover`:var a=i.pointerId;return Oh.set(a,Nh(Oh.get(a)||null,e,t,n,r,i)),!0;case`gotpointercapture`:return a=i.pointerId,kh.set(a,Nh(kh.get(a)||null,e,t,n,r,i)),!0}return!1}function Fh(e){var t=Vt(e.target);if(t!==null){var n=o(t);if(n!==null){if(t=n.tag,t===13){if(t=s(n),t!==null){e.blockedOn=t,kt(e.priority,function(){hh(n)});return}}else if(t===31){if(t=c(n),t!==null){e.blockedOn=t,kt(e.priority,function(){hh(n)});return}}else if(t===3&&n.stateNode.current.memoizedState.isDehydrated){e.blockedOn=n.tag===3?n.stateNode.containerInfo:null;return}}}e.blockedOn=null}function Ih(e){if(e.blockedOn!==null)return!1;for(var t=e.targetContainers;0<t.length;){var n=bh(e.nativeEvent);if(n===null){n=e.nativeEvent;var r=new n.constructor(n.type,n);Dn=r,n.target.dispatchEvent(r),Dn=null}else return t=Ht(n),t!==null&&mh(t),e.blockedOn=n,!1;t.shift()}return!0}function Lh(e,t,n){Ih(e)&&n.delete(t)}function Rh(){wh=!1,Th!==null&&Ih(Th)&&(Th=null),Eh!==null&&Ih(Eh)&&(Eh=null),Dh!==null&&Ih(Dh)&&(Dh=null),Oh.forEach(Lh),kh.forEach(Lh)}function zh(e,n){e.blockedOn===n&&(e.blockedOn=null,wh||(wh=!0,t.unstable_scheduleCallback(t.unstable_NormalPriority,Rh)))}var Bh=null;function Vh(e){Bh!==e&&(Bh=e,t.unstable_scheduleCallback(t.unstable_NormalPriority,function(){Bh===e&&(Bh=null);for(var t=0;t<e.length;t+=3){var n=e[t],r=e[t+1],i=e[t+2];if(typeof r!=`function`){if(Sh(r||n)===null)continue;break}var a=Ht(n);a!==null&&(e.splice(t,3),t-=3,ic(a,{pending:!0,data:i,method:n.method,action:r},r,i))}}))}function Hh(e){function t(t){return zh(t,e)}Th!==null&&zh(Th,e),Eh!==null&&zh(Eh,e),Dh!==null&&zh(Dh,e),Oh.forEach(t),kh.forEach(t);for(var n=0;n<Ah.length;n++){var r=Ah[n];r.blockedOn===e&&(r.blockedOn=null)}for(;0<Ah.length&&(n=Ah[0],n.blockedOn===null);)Fh(n),n.blockedOn===null&&Ah.shift();if(n=(e.ownerDocument||e).$$reactFormReplay,n!=null)for(r=0;r<n.length;r+=3){var i=n[r],a=n[r+1],o=i[Mt]||null;if(typeof a==`function`)o||Vh(n);else if(o){var s=null;if(a&&a.hasAttribute(`formAction`)){if(i=a,o=a[Mt]||null)s=o.formAction;else if(Sh(i)!==null)continue}else s=o.action;typeof s==`function`?n[r+1]=s:(n.splice(r,3),r-=3),Vh(n)}}}function Uh(){function e(e){e.canIntercept&&e.info===`react-transition`&&e.intercept({handler:function(){return new Promise(function(e){return i=e})},focusReset:`manual`,scroll:`manual`})}function t(){i!==null&&(i(),i=null),r||setTimeout(n,20)}function n(){if(!r&&!navigation.transition){var e=navigation.currentEntry;e&&e.url!=null&&navigation.navigate(e.url,{state:e.getState(),info:`react-transition`,history:`replace`})}}if(typeof navigation==`object`){var r=!1,i=null;return navigation.addEventListener(`navigate`,e),navigation.addEventListener(`navigatesuccess`,t),navigation.addEventListener(`navigateerror`,t),setTimeout(n,100),function(){r=!0,navigation.removeEventListener(`navigate`,e),navigation.removeEventListener(`navigatesuccess`,t),navigation.removeEventListener(`navigateerror`,t),i!==null&&(i(),i=null)}}}function Wh(e){this._internalRoot=e}Gh.prototype.render=Wh.prototype.render=function(e){var t=this._internalRoot;if(t===null)throw Error(i(409));var n=t.current;dh(n,jd(),e,t,null,null)},Gh.prototype.unmount=Wh.prototype.unmount=function(){var e=this._internalRoot;if(e!==null){this._internalRoot=null;var t=e.containerInfo;dh(e.current,2,null,e,null,null),zd(),t[Nt]=null}};function Gh(e){this._internalRoot=e}Gh.prototype.unstable_scheduleHydration=function(e){if(e){var t=Ot();e={blockedOn:null,target:e,priority:t};for(var n=0;n<Ah.length&&t!==0&&t<Ah[n].priority;n++);Ah.splice(n,0,e),n===0&&Fh(e)}};var Kh=n.version;if(Kh!==`19.3.1`)throw Error(i(527,Kh,`19.3.1`));O.findDOMNode=function(e){var t=e._reactInternals;if(t===void 0)throw typeof e.render==`function`?Error(i(188)):(e=Object.keys(e).join(`,`),Error(i(268,e)));return e=d(t),e=e===null?null:p(e),e=e===null?null:e.stateNode,e};var qh={bundleType:0,version:`19.3.1`,rendererPackageName:`react-dom`,currentDispatcherRef:D,reconcilerVersion:`19.3.1`};if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<`u`){var Jh=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(!Jh.isDisabled&&Jh.supportsFiber)try{it=Jh.inject(qh),at=Jh}catch{}}e.createRoot=function(e,t){if(!a(e))throw Error(i(299));var n=!1,r=``,o=Dc,s=Oc,c=kc;return t!=null&&(!0===t.unstable_strictMode&&(n=!0),t.identifierPrefix!==void 0&&(r=t.identifierPrefix),t.onUncaughtError!==void 0&&(o=t.onUncaughtError),t.onCaughtError!==void 0&&(s=t.onCaughtError),t.onRecoverableError!==void 0&&(c=t.onRecoverableError)),t=lh(e,1,!1,null,null,n,r,null,o,s,c,Uh),e[Nt]=t.current,Wf(e),new Wh(t)}})),g=o(((e,t)=>{function n(){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<`u`&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE==`function`)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=`modulepreload`,v=function(e){return`/`+e},y={},b=function(e){return e.pathname.endsWith(`.css`)},ee=function(e,t,n){if(t in e)return e[t];let r=n();if(!r){e[t]=void 0;return}let i=r.then(()=>{e[t]=void 0},n=>{throw e[t]=void 0,n});return e[t]=i,i},te=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e,i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?new URL(import.meta.resolve(e)):new URL(e,import.meta.url)}r=o(t.map(t=>{t=v(t,n);let r=s(t),i=b(r);return ee(y,r.href,()=>{if(e===void 0){e={all:new Set,styles:new Set};let t=document.getElementsByTagName(`link`);for(let n=t.length-1;n>=0;n--){let r=t[n];e.all.add(r.href),r.rel===`stylesheet`&&e.styles.add(r.href)}}if((i?e.styles:e.all).has(r.href))return;let t=document.createElement(`link`);if(t.rel=i?`stylesheet`:_,i||(t.as=`script`),t.crossOrigin=``,t.href=r.href,a&&t.setAttribute(`nonce`,a),document.head.appendChild(t),i)return new Promise((e,n)=>{t.addEventListener(`load`,e),t.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${r}`)))})})}).filter(e=>e!==void 0))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},x=c(u(),1),ne=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,S=/^[\\/]{2}/;function re(e,t){return t+e.replace(/\\/g,`/`)}var C=`popstate`;function ie(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function ae(e={}){function t(e,t){let{pathname:n=`/`,search:r=``,hash:i=``}=le(e.location.hash.substring(1));return!n.startsWith(`/`)&&!n.startsWith(`.`)&&(n=`/`+n),ce(``,{pathname:n,search:r,hash:i},t.state&&t.state.usr||null,t.state&&t.state.key||`default`)}function n(e,t){let n=e.document.querySelector(`base`),r=``;if(n&&n.getAttribute(`href`)){let t=e.location.href,n=t.indexOf(`#`);r=n===-1?t:t.slice(0,n)}return r+`#`+(typeof t==`string`?t:E(t))}function r(e,t){T(e.pathname.charAt(0)===`/`,`relative pathnames are not supported in hash history.push(${JSON.stringify(t)})`)}return ue(t,n,r,e)}function w(e,t){if(e===!1||e==null)throw Error(t)}function T(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function oe(){return Math.random().toString(36).substring(2,10)}function se(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function ce(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?le(t):t,state:n,key:t&&t.key||r||oe(),mask:i}}function E({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function le(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function ue(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=ie(e)?e:ce(h.location,e,t);n&&n(r,e),l=u()+1;let d=se(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=ie(e)?e:ce(h.location,e,t);n&&n(r,e),l=u();let i=se(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return de(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(C,d),c=e,()=>{i.removeEventListener(C,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function de(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),w(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:E(t);return i=i.replace(/ $/,`%20`),!n&&S.test(i)&&(i=r+i),new URL(i,r)}function fe(e,t,n=`/`){return pe(e,t,n,!1)}function pe(e,t,n,r,i){let a=ke((typeof t==`string`?le(t):t).pathname||`/`,n);if(a==null)return null;let o=i??me(e),s=null,c=k(a);for(let e=0;s==null&&e<o.length;++e)s=Te(o[e],c,r);return s}function me(e){let t=he(e);return _e(t),t}function he(e,t=[],n=[],r=``,i=!1){let a=(e,a,o=i,s)=>{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;w(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=Le([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(w(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),he(e.children,t,u,l,o)),(e.path!=null||e.index)&&t.push({path:l,score:O(l,e.index),routesMeta:u.map((e,t)=>{let[n,r]=Oe(e.relativePath,e.caseSensitive,t===u.length-1);return{...e,matcher:n,compiledParams:r}})})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of ge(e.path))a(e,t,!0,n)}),t}function ge(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=ge(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function _e(e){e.sort((e,t)=>e.score===t.score?we(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var ve=/^:[\w-]+$/,ye=3,be=2,xe=1,Se=10,Ce=-2,D=e=>e===`*`;function O(e,t){let n=e.split(`/`),r=n.length;return n.some(D)&&(r+=Ce),t&&(r+=be),n.filter(e=>!D(e)).reduce((e,t)=>e+(ve.test(t)?ye:t===``?xe:Se),r)}function we(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function Te(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e<r.length;++e){let s=r[e],c=e===r.length-1,l=a===`/`?t:t.slice(a.length)||`/`,u={path:s.relativePath,caseSensitive:s.caseSensitive,end:c},d=s.matcher&&s.compiledParams?De(u,l,s.matcher,s.compiledParams):Ee(u,l),f=s.route;if(!d&&c&&n&&!r[r.length-1].route.index&&(d=Ee({path:s.relativePath,caseSensitive:s.caseSensitive,end:!1},l)),!d)return null;Object.assign(i,d.params),o.push({params:i,pathname:Le([a,d.pathname]),pathnameBase:ze(Le([a,d.pathnameBase])),route:f}),d.pathnameBase!==`/`&&(a=Le([a,d.pathnameBase]))}return o}function Ee(e,t){typeof e==`string`&&(e={path:e,caseSensitive:!1,end:!0});let[n,r]=Oe(e.path,e.caseSensitive,e.end);return De(e,t,n,r)}function De(e,t,n,r){let i=t.match(n);if(!i)return null;let a=i[0],o=Re(a,1),s=i.slice(1);return{params:r.reduce((e,{paramName:t,isOptional:n},r)=>{if(t===`*`){let e=s[r]||``;o=Re(a.slice(0,a.length-e.length),1)}let i=s[r];return e[t]=n&&!i?void 0:(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function Oe(e,t=!1,n=!0){T(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function k(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return T(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function ke(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}function Ae(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?le(e):e,a;return n?(n=Ie(n),a=n.startsWith(`/`)||n.startsWith(`\\`)?je(n.substring(1),`/`):je(n,t)):a=t,{pathname:a,search:Be(r),hash:Ve(i)}}function je(e,t){let n=Re(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function Me(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in <Link to="..."> and the router will parse it for you.`}function Ne(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Pe(e){let t=Ne(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function Fe(e,t,n,r=!1){let i;typeof e==`string`?i=le(e):(i={...e},w(!i.pathname||!i.pathname.includes(`?`),Me(`?`,`pathname`,`search`,i)),w(!i.pathname||!i.pathname.includes(`#`),Me(`#`,`pathname`,`hash`,i)),w(!i.search||!i.search.includes(`#`),Me(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=Ae(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var Ie=e=>e.replace(/[\\/]{2,}/g,`/`),Le=e=>Ie(e.join(`/`));function Re(e,t=0){let n=e.length;for(;n>t&&e.charCodeAt(n-1)===47;)n--;return n===e.length?e:e.slice(0,n)}var ze=e=>Re(e).replace(/^\/*/,`/`),Be=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,Ve=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,He=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function Ue(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function We(e){return Le(e.map(e=>e.route.path).filter(Boolean))||`/`}var Ge=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Ke(e,t){let n=e;if(typeof n!=`string`||!ne.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(Ge)try{let e=new URL(window.location.href),r=S.test(n)?new URL(re(n,e.protocol)):new URL(n),a=ke(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{T(!1,`<Link to="${n}"> contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var qe=new URL(`http://localhost`);function Je(e){if(e.createURL)return e.createURL(`/`);try{return new URL(e.createHref(`/`),qe)}catch{return qe}}function Ye(e,t){return e.origin===t.origin&&(e.origin!==`null`||e.protocol===t.protocol&&e.host===t.host)}function Xe(e,t){if(e.startsWith(`//`))return!0;let n=t.protocol.toLowerCase();return e.toLowerCase().startsWith(n)?t.host===``||e.slice(n.length).startsWith(`//`):!1}function Ze(e,t,n,r){let i=null;try{i=e==null?null:new URL(e,n)}catch{}let a=new URL(t,n),o=i!=null&&!Ye(i,n),s=!Ye(a,n);if(r===`reject`){if(o||s)throw Error(`External navigation is not allowed`)}else if(s&&(i==null||!Xe(e,i)||!Ye(i,a)))throw Error(`External navigation is not allowed`)}var Qe=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Qe);var $e=[`GET`,...Qe];new Set($e);var et=[`about:`,`blob:`,`chrome:`,`chrome-untrusted:`,`content:`,`data:`,`devtools:`,`file:`,`filesystem:`,`javascript:`];function tt(e){try{return et.includes(new URL(e).protocol)}catch{return!1}}var nt=x.createContext(null);nt.displayName=`DataRouter`;var rt=x.createContext(null);rt.displayName=`DataRouterState`;var it=x.createContext(!1);function at(){return x.useContext(it)}var ot=x.createContext({isTransitioning:!1});ot.displayName=`ViewTransition`;var st=x.createContext(new Map);st.displayName=`Fetchers`;var ct=x.createContext(null);ct.displayName=`Await`;var lt=x.createContext(null);lt.displayName=`Navigation`;var ut=x.createContext(null);ut.displayName=`Location`;var dt=x.createContext({outlet:null,matches:[],isDataRoute:!1});dt.displayName=`Route`;var ft=x.createContext(null);ft.displayName=`RouteError`;var pt=`REACT_ROUTER_ERROR`,mt=`REDIRECT`,ht=`ROUTE_ERROR_RESPONSE`;function gt(e){if(e.startsWith(`${pt}:${mt}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function _t(e){if(e.startsWith(`${pt}:${ht}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new He(t.status,t.statusText,t.data)}catch{}}function vt(e,{relative:t}={}){w(yt(),`useHref() may be used only in the context of a <Router> component.`);let{basename:n,navigator:r}=x.useContext(lt),{hash:i,pathname:a,search:o}=Et(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:Le([n,a])),r.createHref({pathname:s,search:o,hash:i})}function yt(){return x.useContext(ut)!=null}function bt(){return w(yt(),`useLocation() may be used only in the context of a <Router> component.`),x.useContext(ut).location}var xt=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function St(e){x.useContext(lt).static||x.useLayoutEffect(e)}function Ct(){let{isDataRoute:e}=x.useContext(dt);return e?Ut():wt()}function wt(){w(yt(),`useNavigate() may be used only in the context of a <Router> component.`);let e=x.useContext(nt),{basename:t,navigator:n}=x.useContext(lt),{matches:r}=x.useContext(dt),{pathname:i}=bt(),a=JSON.stringify(Pe(r)),o=x.useRef(!1);return St(()=>{o.current=!0}),x.useCallback((r,s={})=>{if(T(o.current,xt),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=Fe(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:Le([t,c.pathname])),Ze(typeof r==`string`?r:E(r),n.createHref(c),Je(n),`reject`),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}x.createContext(null);function Tt(){let{matches:e}=x.useContext(dt);return e[e.length-1]?.params??{}}function Et(e,{relative:t}={}){let{matches:n}=x.useContext(dt),{pathname:r}=bt(),i=JSON.stringify(Pe(n));return x.useMemo(()=>Fe(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function Dt(e,t){return Ot(e,t)}function Ot(e,t,n){w(yt(),`useRoutes() may be used only in the context of a <Router> component.`);let{navigator:r}=x.useContext(lt),{matches:i}=x.useContext(dt),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;A(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant <Routes> (or called \`useRoutes()\`) at "${s}" (under <Route path="${e}">) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render.
10
+
11
+Please change the parent <Route path="${e}"> to <Route path="${e===`/`?`*`:`${e}/*`}">.`)}let u=bt(),d;if(t){let e=typeof t==`string`?le(t):t;w(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`<Routes location>\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):fe(e,{pathname:p});T(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),T(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.`);let h=Ft(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:Le([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:Le([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?x.createElement(ut.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function kt(){let e=Ht(),t=Ue(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=x.createElement(x.Fragment,null,x.createElement(`p`,null,`💿 Hey developer 👋`),x.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,x.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,x.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),x.createElement(x.Fragment,null,x.createElement(`h2`,null,`Unexpected Application Error!`),x.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?x.createElement(`pre`,{style:i},n):null,o)}var At=x.createElement(kt,null),jt=class extends x.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=_t(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:x.createElement(dt.Provider,{value:this.props.routeContext},x.createElement(ft.Provider,{value:e,children:this.props.component}));return this.context?x.createElement(Nt,{error:e},t):t}};jt.contextType=it;var Mt=new WeakMap;function Nt({children:e,error:t}){let{basename:n,navigator:r}=x.useContext(lt);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=gt(t.digest);if(e){let i=Mt.get(t);if(i)throw i;let a=Ke(e.location,n),o=a.absoluteURL||a.to;if(Ze(e.location,o,Je(r),`allow-explicit`),tt(o))throw Error(`Invalid redirect location`);if(Ge&&!Mt.get(t)){if(a.isExternal||e.reloadDocument)window.location.href=o;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(a.to,{replace:e.replace}));throw Mt.set(t,n),n}}return x.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${o}`})}}return e}function Pt({routeContext:e,match:t,children:n}){let r=x.useContext(nt);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),x.createElement(dt.Provider,{value:e},n)}function Ft(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);w(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e<i.length;e++){let t=i[e];if((t.route.HydrateFallback||t.route.hydrateFallbackElement)&&(s=e),t.route.id){let{loaderData:e,errors:a}=r,c=t.route.loader&&!e.hasOwnProperty(t.route.id)&&(!a||a[t.route.id]===void 0);if(t.route.lazy||c){n.isStatic&&(o=!0),i=s>=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:We(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||At,o&&(s<0&&c===0?(A(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?x.createElement(n.route.Component,null):n.route.element?n.route.element:e,x.createElement(Pt,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?x.createElement(jt,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function It(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Lt(e){let t=x.useContext(nt);return w(t,It(e)),t}function Rt(e){let t=x.useContext(rt);return w(t,It(e)),t}function zt(e){let t=x.useContext(dt);return w(t,It(e)),t}function Bt(e){let t=zt(e),n=t.matches[t.matches.length-1];return w(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function Vt(){return Bt(`useRouteId`)}function Ht(){let e=x.useContext(ft),t=Rt(`useRouteError`),n=Bt(`useRouteError`);return e===void 0?t.errors?.[n]:e}function Ut(){let{router:e}=Lt(`useNavigate`),t=Bt(`useNavigate`),n=x.useRef(!1);return St(()=>{n.current=!0}),x.useCallback(async(r,i={})=>{T(n.current,xt),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Wt={};function A(e,t,n){!t&&!Wt[e]&&(Wt[e]=!0,T(!1,n))}x.memo(Gt);function Gt({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return Ot(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Kt({to:e,replace:t,state:n,relative:r}){w(yt(),`<Navigate> may be used only in the context of a <Router> component.`);let{static:i,navigator:a}=x.useContext(lt);T(!i,`<Navigate> must not be used on the initial render in a <StaticRouter>. This is a no-op, but you should modify your code so the <Navigate> is only ever rendered in response to some user interaction or state change.`);let{matches:o}=x.useContext(dt),{pathname:s}=bt(),c=Ct(),l=Fe(e,Pe(o),s,r===`path`);Ze(typeof e==`string`?e:E(e),a.createHref(l),Je(a),`reject`);let u=JSON.stringify(l);return x.useEffect(()=>{c(JSON.parse(u),{replace:t,state:n,relative:r})},[c,u,r,t,n]),null}function qt(e){w(!1,`A <Route> is only ever to be used as the child of <Routes> element, never rendered directly. Please wrap your <Route> in a <Routes>.`)}function Jt({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){w(!yt(),`You cannot render a <Router> inside another <Router>. You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=x.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=le(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=x.useMemo(()=>{let e=ke(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return T(h!=null,`<Router basename="${s}"> is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the <Router> won't render anything.`),h==null?null:x.createElement(lt.Provider,{value:c},x.createElement(ut.Provider,{children:t,value:h}))}function Yt({children:e,location:t}){return Dt(Xt(e),t)}x.Component;function Xt(e,t=[]){let n=[];return x.Children.forEach(e,(e,r)=>{if(!x.isValidElement(e))return;let i=[...t,r];if(e.type===x.Fragment){n.push.apply(n,Xt(e.props.children,i));return}w(e.type===qt,`[${typeof e.type==`string`?e.type:e.type.name}] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`),w(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Xt(e.props.children,i)),n.push(a)}),n}var Zt=`get`,Qt=`application/x-www-form-urlencoded`;function $t(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function j(e){return $t(e)&&e.tagName.toLowerCase()===`button`}function en(e){return $t(e)&&e.tagName.toLowerCase()===`form`}function tn(e){return $t(e)&&e.tagName.toLowerCase()===`input`}function nn(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function rn(e,t){return e.button===0&&(!t||t===`_self`)&&!nn(e)}var an=null;function on(){if(an===null)try{new FormData(document.createElement(`form`),0),an=!1}catch{an=!0}return an}var sn=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function cn(e){return e!=null&&!sn.has(e)?(T(!1,`"${e}" is not a valid \`encType\` for \`<Form>\`/\`<fetcher.Form>\` and will default to "${Qt}"`),null):e}function ln(e,t){let n,r,i,a,o;if(en(e)){let o=e.getAttribute(`action`);r=o?ke(o,t):null,n=e.getAttribute(`method`)||Zt,i=cn(e.getAttribute(`enctype`))||Qt,a=new FormData(e)}else if(j(e)||tn(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a <button> or <input type="submit"> without a <form>`);let s=e.getAttribute(`formaction`)||o.getAttribute(`action`);if(r=s?ke(s,t):null,n=e.getAttribute(`formmethod`)||o.getAttribute(`method`)||Zt,i=cn(e.getAttribute(`formenctype`))||cn(o.getAttribute(`enctype`))||Qt,a=new FormData(o,e),!on()){let{name:t,type:n,value:r}=e;if(n===`image`){let e=t?`${t}.`:``;a.append(`${e}x`,`0`),a.append(`${e}y`,`0`)}else t&&a.append(t,r)}}else if($t(e))throw Error(`Cannot submit element that is not <form>, <button>, or <input type="submit|image">`);else n=Zt,r=null,i=Qt,o=e;return a&&i===`text/plain`&&(o=a,a=void 0),{action:r,method:n.toLowerCase(),encType:i,formData:a,body:o}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);function un(e,t){if(e===!1||e==null)throw Error(t)}function dn(e,t,n,r){let i=typeof e==`string`?new URL(e,typeof window>`u`?`server://singlefetch/`:window.location.origin):e;return i.pathname=n?i.pathname.endsWith(`/`)?`${i.pathname}_.${r}`:`${i.pathname}.${r}`:i.pathname===`/`?`_root.${r}`:t&&ke(i.pathname,t)===`/`?`${Re(t)}/_root.${r}`:`${Re(i.pathname)}.${r}`,i}async function fn(e,t){if(e.id in t)return t[e.id];try{let n=await te(()=>import(e.module),[]);return t[e.id]=n,n}catch(t){return console.error(`Error loading route module \`${e.module}\`, reloading page...`),console.error(t),window.__reactRouterContext&&window.__reactRouterContext.isSpaMode,window.location.reload(),new Promise(()=>{})}}function pn(e){return e!=null&&typeof e.page==`string`}function mn(e){return e==null?!1:e.href==null?e.rel===`preload`&&typeof e.imageSrcSet==`string`&&typeof e.imageSizes==`string`:typeof e.rel==`string`&&typeof e.href==`string`}async function hn(e,t,n){return bn((await Promise.all(e.map(async e=>{let r=t.routes[e.route.id];if(r){let e=await fn(r,n);return e.links?e.links():[]}return[]}))).flat(1).filter(mn).filter(e=>e.rel===`stylesheet`||e.rel===`preload`).map(e=>e.rel===`stylesheet`?{...e,rel:`prefetch`,as:`style`}:{...e,rel:`prefetch`}))}function gn(e,t,n,r,i,a){let o=(e,t)=>!n[t]||e.route.id!==n[t].route.id,s=(e,t)=>n[t].pathname!==e.pathname||n[t].route.path?.endsWith(`*`)&&n[t].params[`*`]!==e.params[`*`];return a===`assets`?t.filter((e,t)=>o(e,t)||s(e,t)):a===`data`?t.filter((t,a)=>{let c=r.routes[t.route.id];if(!c||!c.hasLoader)return!1;if(o(t,a)||s(t,a))return!0;if(t.route.shouldRevalidate){let r=t.route.shouldRevalidate({currentUrl:new URL(i.pathname+i.search+i.hash,window.origin),currentParams:n[0]?.params||{},nextUrl:new URL(e,window.origin),nextParams:t.params,defaultShouldRevalidate:!0});if(typeof r==`boolean`)return r}return!0}):[]}function _n(e,t,{includeHydrateFallback:n}={}){return vn(e.map(e=>{let r=t.routes[e.route.id];if(!r)return[];let i=[r.module];return r.clientActionModule&&(i=i.concat(r.clientActionModule)),r.clientLoaderModule&&(i=i.concat(r.clientLoaderModule)),n&&r.hydrateFallbackModule&&(i=i.concat(r.hydrateFallbackModule)),r.imports&&(i=i.concat(r.imports)),i}).flat(1))}function vn(e){return[...new Set(e)]}function yn(e){let t={},n=Object.keys(e).sort();for(let r of n)t[r]=e[r];return t}function bn(e,t){let n=new Set,r=new Set(t);return e.reduce((e,i)=>{if(t&&!pn(i)&&i.as===`script`&&i.href&&r.has(i.href))return e;let a=JSON.stringify(yn(i));return n.has(a)||(n.add(a),e.push({key:a,link:i})),e},[])}function xn(){let e=x.useContext(nt);return un(e,`You must render this element inside a <DataRouterContext.Provider> element`),e}function Sn(){let e=x.useContext(rt);return un(e,`You must render this element inside a <DataRouterStateContext.Provider> element`),e}var Cn=x.createContext(void 0);Cn.displayName=`FrameworkContext`;function wn(){let e=x.useContext(Cn);return un(e,`You must render this element inside a <HydratedRouter> element`),e}function Tn(e,t){let n=x.useContext(Cn),[r,i]=x.useState(!1),[a,o]=x.useState(!1),{onFocus:s,onBlur:c,onMouseEnter:l,onMouseLeave:u,onTouchStart:d}=t,f=x.useRef(null);x.useEffect(()=>{if(e===`render`&&o(!0),e===`viewport`){let e=new IntersectionObserver(e=>{e.forEach(e=>{o(e.isIntersecting)})},{threshold:.5});return f.current&&e.observe(f.current),()=>{e.disconnect()}}},[e]),x.useEffect(()=>{if(r){let e=setTimeout(()=>{o(!0)},100);return()=>{clearTimeout(e)}}},[r]);let p=()=>{i(!0)},m=()=>{i(!1),o(!1)};return n?e===`intent`?[a,f,{onFocus:En(s,p),onBlur:En(c,m),onMouseEnter:En(l,p),onMouseLeave:En(u,m),onTouchStart:En(d,p)}]:[a,f,{}]:[!1,f,{}]}function En(e,t){return n=>{e&&e(n),n.defaultPrevented||t(n)}}function Dn({page:e,...t}){let n=at(),{nonce:r}=wn(),{router:i}=xn(),a=x.useMemo(()=>fe(i.routes,e,i.basename),[i.routes,e,i.basename]);return a?(t.nonce==null&&r&&(t={...t,nonce:r}),n?x.createElement(kn,{page:e,matches:a,...t}):x.createElement(An,{page:e,matches:a,...t})):null}function On(e){let{manifest:t,routeModules:n}=wn(),[r,i]=x.useState([]);return x.useEffect(()=>{let r=!1;return hn(e,t,n).then(e=>{r||i(e)}),()=>{r=!0}},[e,t,n]),r}function kn({page:e,matches:t,...n}){let r=bt(),{future:i}=wn(),{basename:a}=xn(),o=x.useMemo(()=>{if(e===r.pathname+r.search+r.hash)return[];let n=dn(e,a,i.v8_trailingSlashAwareDataRequests,`rsc`),o=!1,s=[];for(let e of t)typeof e.route.shouldRevalidate==`function`?o=!0:s.push(e.route.id);return o&&s.length>0&&n.searchParams.set(`_routes`,s.join(`,`)),[n.pathname+n.search]},[a,i.v8_trailingSlashAwareDataRequests,e,r,t]);return x.createElement(x.Fragment,null,o.map(e=>x.createElement(`link`,{key:e,rel:`prefetch`,as:`fetch`,href:e,...n})))}function An({page:e,matches:t,...n}){let r=bt(),{future:i,manifest:a,routeModules:o}=wn(),{basename:s}=xn(),{loaderData:c,matches:l}=Sn(),u=x.useMemo(()=>gn(e,t,l,a,r,`data`),[e,t,l,a,r]),d=x.useMemo(()=>gn(e,t,l,a,r,`assets`),[e,t,l,a,r]),f=x.useMemo(()=>{if(e===r.pathname+r.search+r.hash)return[];let n=new Set,l=!1;if(t.forEach(e=>{let t=a.routes[e.route.id];t&&t.hasLoader&&(!u.some(t=>t.route.id===e.route.id)&&e.route.id in c&&o[e.route.id]?.shouldRevalidate||t.hasClientLoader?l=!0:n.add(e.route.id))}),n.size===0)return[];let d=dn(e,s,i.v8_trailingSlashAwareDataRequests,`data`);return l&&n.size>0&&d.searchParams.set(`_routes`,t.filter(e=>n.has(e.route.id)).map(e=>e.route.id).join(`,`)),[d.pathname+d.search]},[s,i.v8_trailingSlashAwareDataRequests,c,r,a,u,t,e,o]),p=x.useMemo(()=>_n(d,a),[d,a]),m=On(d);return x.createElement(x.Fragment,null,f.map(e=>x.createElement(`link`,{key:e,rel:`prefetch`,as:`fetch`,href:e,...n})),p.map(e=>x.createElement(`link`,{key:e,rel:`modulepreload`,href:e,...n})),m.map(({key:e,link:t})=>x.createElement(`link`,{key:e,nonce:n.nonce,...t,crossOrigin:t.crossOrigin??n.crossOrigin})))}function jn(...e){return t=>{e.forEach(e=>{typeof e==`function`?e(t):e!=null&&(e.current=t)})}}x.Component;var Mn=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;try{Mn&&(window.__reactRouterVersion=`7.18.4`)}catch{}function Nn({basename:e,children:t,useTransitions:n,window:r}){let i=x.useRef();i.current??=ae({window:r,v5Compat:!0});let a=i.current,[o,s]=x.useState({action:a.action,location:a.location}),c=x.useCallback(e=>{n===!1?s(e):x.startTransition(()=>s(e))},[n]);return x.useLayoutEffect(()=>a.listen(c),[a,c]),x.createElement(Jt,{basename:e,children:t,location:o.location,navigationType:o.action,navigator:a,useTransitions:n})}var Pn=x.forwardRef(function({onClick:e,discover:t=`render`,prefetch:n=`none`,relative:r,reloadDocument:i,replace:a,mask:o,state:s,target:c,to:l,preventScrollReset:u,viewTransition:d,defaultShouldRevalidate:f,...p},m){let{basename:h,navigator:g,useTransitions:_}=x.useContext(lt),v=typeof l==`string`&&ne.test(l),y=Ke(l,h);l=y.to;let b=vt(l,{relative:r}),ee=bt(),te=null;if(o){let e=Fe(o,[],ee.mask?ee.mask.pathname:`/`,!0);h!==`/`&&(e.pathname=e.pathname===`/`?h:Le([h,e.pathname])),te=g.createHref(e)}let[S,re,C]=Tn(n,p),ie=zn(l,{replace:a,mask:o,state:s,target:c,preventScrollReset:u,relative:r,viewTransition:d,defaultShouldRevalidate:f,useTransitions:_});function ae(t){e&&e(t),t.defaultPrevented||ie(t)}let w=!(y.isExternal||i),T=x.createElement(`a`,{...p,...C,href:(w?te:void 0)||y.absoluteURL||b,onClick:w?ae:e,ref:jn(m,re),target:c,"data-discover":!v&&t===`render`?`true`:void 0});return S&&!v?x.createElement(x.Fragment,null,T,x.createElement(Dn,{page:b})):T});Pn.displayName=`Link`;var Fn=x.forwardRef(function({"aria-current":e=`page`,caseSensitive:t=!1,className:n=``,end:r=!1,style:i,to:a,viewTransition:o,children:s,...c},l){let u=Et(a,{relative:c.relative}),d=bt(),f=x.useContext(rt),{navigator:p,basename:m}=x.useContext(lt),h=f!=null&&Wn(u)&&o===!0,g=p.encodeLocation?p.encodeLocation(u).pathname:u.pathname,_=d.pathname,v=f&&f.navigation&&f.navigation.location?f.navigation.location.pathname:null;t||(_=_.toLowerCase(),v=v?v.toLowerCase():null,g=g.toLowerCase()),v&&m&&(v=ke(v,m)||v);let y=g!==`/`&&g.endsWith(`/`)?g.length-1:g.length,b=_===g||!r&&_.startsWith(g)&&_.charAt(y)===`/`,ee=v!=null&&(v===g||!r&&v.startsWith(g)&&v.charAt(g.length)===`/`),te={isActive:b,isPending:ee,isTransitioning:h},ne=b?e:void 0,S;S=typeof n==`function`?n(te):[n,b?`active`:null,ee?`pending`:null,h?`transitioning`:null].filter(Boolean).join(` `);let re=typeof i==`function`?i(te):i;return x.createElement(Pn,{...c,"aria-current":ne,className:S,ref:l,style:re,to:a,viewTransition:o},typeof s==`function`?s(te):s)});Fn.displayName=`NavLink`;var In=x.forwardRef(({discover:e=`render`,fetcherKey:t,navigate:n,reloadDocument:r,replace:i,state:a,method:o=Zt,action:s,onSubmit:c,relative:l,preventScrollReset:u,viewTransition:d,defaultShouldRevalidate:f,...p},m)=>{let{useTransitions:h}=x.useContext(lt),g=Hn(),_=Un(s,{relative:l}),v=o.toLowerCase()===`get`?`get`:`post`,y=typeof s==`string`&&ne.test(s);return x.createElement(`form`,{ref:m,method:v,action:_,onSubmit:r?c:e=>{if(c&&c(e),e.defaultPrevented)return;e.preventDefault();let r=e.nativeEvent.submitter,s=r?.getAttribute(`formmethod`)||o,p=()=>g(r||e.currentTarget,{fetcherKey:t,method:s,navigate:n,replace:i,state:a,relative:l,preventScrollReset:u,viewTransition:d,defaultShouldRevalidate:f});h&&n!==!1?x.startTransition(()=>p()):p()},...p,"data-discover":!y&&e===`render`?`true`:void 0})});In.displayName=`Form`;function Ln(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Rn(e){let t=x.useContext(nt);return w(t,Ln(e)),t}function zn(e,{target:t,replace:n,mask:r,state:i,preventScrollReset:a,relative:o,viewTransition:s,defaultShouldRevalidate:c,useTransitions:l}={}){let u=Ct(),d=bt(),f=Et(e,{relative:o});return x.useCallback(p=>{if(rn(p,t)){p.preventDefault();let t=n===void 0?E(d)===E(f):n,m=()=>u(e,{replace:t,mask:r,state:i,preventScrollReset:a,relative:o,viewTransition:s,defaultShouldRevalidate:c});l?x.startTransition(()=>m()):m()}},[d,u,f,n,r,i,t,e,a,o,s,c,l])}var Bn=0,Vn=()=>`__${String(++Bn)}__`;function Hn(){let{router:e}=Rn(`useSubmit`),{basename:t}=x.useContext(lt),n=Vt(),r=e.fetch,i=e.navigate;return x.useCallback(async(e,a={})=>{let{action:o,method:s,encType:c,formData:l,body:u}=ln(e,t);if(a.navigate===!1){let e=a.fetcherKey||Vn();await r(e,n,a.action||o,{defaultShouldRevalidate:a.defaultShouldRevalidate,preventScrollReset:a.preventScrollReset,formData:l,body:u,formMethod:a.method||s,formEncType:a.encType||c,flushSync:a.flushSync})}else await i(a.action||o,{defaultShouldRevalidate:a.defaultShouldRevalidate,preventScrollReset:a.preventScrollReset,formData:l,body:u,formMethod:a.method||s,formEncType:a.encType||c,replace:a.replace,state:a.state,fromRouteId:n,flushSync:a.flushSync,viewTransition:a.viewTransition})},[r,i,t,n])}function Un(e,{relative:t}={}){let{basename:n}=x.useContext(lt),r=x.useContext(dt);w(r,`useFormAction must be used inside a RouteContext`);let[i]=r.matches.slice(-1),a={...Et(e||`.`,{relative:t})},o=bt();if(e==null){a.search=o.search;let e=new URLSearchParams(a.search),t=e.getAll(`index`);if(t.some(e=>e===``)){e.delete(`index`),t.filter(e=>e).forEach(t=>e.append(`index`,t));let n=e.toString();a.search=n?`?${n}`:``}}return(!e||e===`.`)&&i.route.index&&(a.search=a.search?a.search.replace(/^\?/,`?index&`):`?index`),n!==`/`&&(a.pathname=a.pathname===`/`?n:Le([n,a.pathname])),E(a)}function Wn(e,{relative:t}={}){let n=x.useContext(ot);w(n!=null,"`useViewTransitionState` must be used within `react-router-dom`'s `RouterProvider`. Did you accidentally import `RouterProvider` from `react-router`?");let{basename:r}=Rn(`useViewTransitionState`),i=Et(e,{relative:t});if(!n.isTransitioning)return!1;let a=ke(n.currentLocation.pathname,r)||n.currentLocation.pathname,o=ke(n.nextLocation.pathname,r)||n.nextLocation.pathname;return Ee(i.pathname,o)!=null||Ee(i.pathname,a)!=null}var Gn=g();async function Kn(e,t={}){let n=await fetch(`/api/command`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({op:e,args:t})});if(!n.ok){let e=await n.json().catch(()=>({}));throw Error(e.error||n.statusText)}}async function qn(e){let t=await fetch(e,{method:`POST`});if(!t.ok){let e=await t.json().catch(()=>({}));throw Error(e.error||t.statusText)}}var Jn=()=>qn(`/api/sensor-setup/start`),Yn=()=>qn(`/api/sensor-setup/finish`),Xn=()=>qn(`/api/sensor-setup/cancel`);function Zn(e){let t=new EventSource(`/api/events`);return t.onopen=()=>e.onOpen(),t.onerror=()=>e.onError(),t.addEventListener(`control.state`,t=>{e.onControl(JSON.parse(t.data))}),t.addEventListener(`temps.reading`,t=>{e.onTemps(JSON.parse(t.data))}),t.addEventListener(`temps.setup`,t=>{e.onSetup(JSON.parse(t.data))}),()=>t.close()}var Qn=o((e=>{var t=Symbol.for(`react.transitional.element`);function n(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.jsx=n,e.jsxs=n})),M=o(((e,t)=>{t.exports=Qn()}))();function $n({status:e,label:t}){return(0,M.jsxs)(`span`,{className:`status-light`,children:[(0,M.jsx)(`span`,{className:`light light-${e}`,"aria-hidden":`true`}),(0,M.jsx)(`span`,{children:t})]})}function er({connection:e}){let[t,n]={open:[`on`,`Live`],connecting:[`waiting`,`Connecting…`],reconnecting:[`error`,`Reconnecting…`]}[e];return(0,M.jsx)(`div`,{className:`connection`,children:(0,M.jsx)($n,{status:t,label:n})})}function tr({connection:e}){return(0,M.jsxs)(`header`,{className:`app-header`,children:[(0,M.jsxs)(`div`,{className:`brand`,children:[(0,M.jsx)(`span`,{className:`brand-icon`,"aria-hidden":`true`,children:`♨`}),` `,`Hot Tub`]}),(0,M.jsxs)(`nav`,{className:`nav`,children:[(0,M.jsx)(Fn,{className:({isActive:e})=>`nav-tab${e?` active`:``}`,to:`/control`,children:`Main Control`}),(0,M.jsx)(Fn,{className:({isActive:e})=>`nav-tab${e?` active`:``}`,to:`/diagnostic`,children:`Diagnostic`})]}),(0,M.jsx)(er,{connection:e})]})}var nr=[{key:`hot_in`,label:`Hot input`},{key:`tub1`,label:`Tub 1`},{key:`tub2`,label:`Tub 2`},{key:`tub3`,label:`Tub 3`},{key:`tub4`,label:`Tub 4`}],rr=[`tub1`,`tub2`,`tub3`,`tub4`],ir=e=>e!=null;function ar(e){return ir(e)?`${Number(e).toFixed(1)}°F`:`--`}function or(e){return`${Number.isInteger(e)?e:Number(e).toFixed(1)}°F`}function sr(e){return new Date(e).toLocaleTimeString([],{hour:`numeric`,minute:`2-digit`})}function cr(e){return e.heat_mode===`backflush`||e.heat_phase===`backflush`}function lr(e){return e.heat_phase.startsWith(`startup_`)?`Starting (attempt ${e.heat_phase.slice(-1)} of 3)`:{idle:e.heat_mode===`maintain`?`Waiting (in range)`:`Off`,heating:`Heating`,backflush:`Backflushing`,error_startup:`Failed to start`,error_safety:`Blocked by safety check`}[e.heat_phase]||e.heat_phase}function ur(e){let t=e.filter_pump===`on`;return e.filter_mode===`auto`?t?`Auto · running`:e.filter_next_start?`Auto · next ${sr(e.filter_next_start)}`:`Auto · idle`:t?`On`:`Off`}function dr(e){return e.roles??e.probes??{}}function fr(e,t=15){return!e||(Date.now()-new Date(e).getTime())/1e3>t}function pr({kind:e,label:t,running:n}){let r=[0,90,180,270].map(e=>(0,M.jsx)(`ellipse`,{cx:`60`,cy:`36`,rx:`11`,ry:`21`,transform:`rotate(${e} 60 60)`},e));return(0,M.jsxs)(`div`,{className:`pump pump-${e}${n?` running`:``}`,children:[(0,M.jsxs)(`svg`,{className:`pump-svg`,viewBox:`0 0 120 120`,"aria-hidden":`true`,children:[(0,M.jsx)(`rect`,{className:`pump-frame`,x:`4`,y:`4`,width:`112`,height:`112`,rx:`18`}),(0,M.jsx)(`circle`,{className:`pump-housing`,cx:`60`,cy:`60`,r:`48`}),(0,M.jsx)(`g`,{className:`fan-blades`,children:r}),(0,M.jsx)(`circle`,{className:`pump-hub`,cx:`60`,cy:`60`,r:`8`})]}),(0,M.jsx)(`div`,{className:`pump-label`,children:t}),(0,M.jsx)($n,{status:n?`on`:`off`,label:n?`Running`:`Off`})]})}var mr=`M40 64 L360 64 L338 204 Q335 214 324 214 L76 214 Q65 214 62 204 Z`;function hr(e){let t=`M-100 ${e}`;for(let e=0;e<6;e++)t+=` q25 -7 50 0 q25 7 50 0`;return`${t} L500 240 L-100 240 Z`}function gr(){return(0,M.jsxs)(`g`,{className:`duck-bob`,"aria-hidden":`true`,children:[(0,M.jsx)(`ellipse`,{cx:`14`,cy:`18`,rx:`16`,ry:`10`,fill:`#facc15`}),(0,M.jsx)(`path`,{d:`M4 16 q8 -6 14 2`,fill:`none`,stroke:`#eab308`,strokeWidth:`2`,strokeLinecap:`round`}),(0,M.jsx)(`circle`,{cx:`25`,cy:`6`,r:`8`,fill:`#facc15`}),(0,M.jsx)(`path`,{d:`M32 5 l8 2 l-8 3 z`,fill:`#f97316`}),(0,M.jsx)(`circle`,{cx:`27`,cy:`4`,r:`1.6`,fill:`#1e293b`})]})}function _r({tempF:e,steaming:t}){let n=ir(e)?`${e.toFixed(1)}°F`:`--.-°F`,r=[60,110,160,210,260,310,340].map(e=>(0,M.jsx)(`circle`,{className:`rivet`,cx:e,cy:`65`,r:`2.2`},e));return(0,M.jsxs)(`svg`,{className:`tub`,viewBox:`0 0 400 240`,role:`img`,"aria-label":`Tub water temperature`,children:[(0,M.jsxs)(`defs`,{children:[(0,M.jsx)(`clipPath`,{id:`tub-inside`,children:(0,M.jsx)(`path`,{d:mr})}),(0,M.jsxs)(`linearGradient`,{id:`water-grad`,x1:`0`,y1:`0`,x2:`0`,y2:`1`,children:[(0,M.jsx)(`stop`,{offset:`0`,stopColor:`#38bdf8`}),(0,M.jsx)(`stop`,{offset:`1`,stopColor:`#0369a1`})]}),(0,M.jsxs)(`linearGradient`,{id:`steel-grad`,x1:`0`,y1:`0`,x2:`1`,y2:`0`,children:[(0,M.jsx)(`stop`,{offset:`0`,stopColor:`#94a3b8`}),(0,M.jsx)(`stop`,{offset:`0.5`,stopColor:`#f1f5f9`}),(0,M.jsx)(`stop`,{offset:`1`,stopColor:`#94a3b8`})]})]}),(0,M.jsx)(`ellipse`,{className:`ground`,cx:`200`,cy:`222`,rx:`170`,ry:`10`}),(0,M.jsxs)(`g`,{className:`steam${t?` on`:``}`,"aria-hidden":`true`,children:[(0,M.jsx)(`path`,{className:`steam-puff`,d:`M140 52 q-8 -10 0 -20 t0 -20`}),(0,M.jsx)(`path`,{className:`steam-puff`,d:`M200 52 q-8 -10 0 -20 t0 -20`}),(0,M.jsx)(`path`,{className:`steam-puff`,d:`M260 52 q-8 -10 0 -20 t0 -20`})]}),(0,M.jsx)(`path`,{className:`tub-shell`,d:mr}),(0,M.jsxs)(`g`,{clipPath:`url(#tub-inside)`,children:[(0,M.jsx)(`g`,{className:`wave wave-back`,children:(0,M.jsx)(`path`,{d:hr(90),fill:`#7dd3fc`})}),(0,M.jsx)(`g`,{className:`wave wave-front`,children:(0,M.jsx)(`path`,{d:hr(96),fill:`url(#water-grad)`})})]}),(0,M.jsx)(`path`,{className:`tub-wall`,d:mr}),(0,M.jsx)(`rect`,{className:`tub-rim`,x:`22`,y:`58`,width:`356`,height:`14`,rx:`7`,fill:`url(#steel-grad)`}),r,(0,M.jsx)(`g`,{transform:`translate(286 72)`,children:(0,M.jsx)(gr,{})}),(0,M.jsx)(`text`,{className:`tub-temp`,x:`200`,y:`165`,textAnchor:`middle`,children:n}),(0,M.jsx)(`text`,{className:`tub-temp-caption`,x:`200`,y:`190`,textAnchor:`middle`,children:`water`})]})}function vr({error:e,onReset:t}){return(0,M.jsxs)(`div`,{className:`banner banner-error`,role:`alert`,children:[(0,M.jsxs)(`span`,{children:[(0,M.jsx)(`strong`,{children:`Heater problem.`}),` `,e.detail||e.code]}),(0,M.jsx)(`button`,{type:`button`,className:`btn btn-small`,onClick:t,children:`Reset (set Sleep)`})]})}function yr({control:e}){let t=e.tub_avg_f,n=e.heat_pump===`on`||ir(t)&&t>=95;return(0,M.jsxs)(`div`,{className:`control-layout`,children:[(0,M.jsxs)(`section`,{className:`card tub-card`,children:[(0,M.jsx)(`div`,{className:`banner-slot`,children:e.error?(0,M.jsx)(vr,{error:e.error,onReset:()=>Kn(`set_heat_mode`,{mode:`sleep`})}):null}),(0,M.jsx)(_r,{tempF:t,steaming:n}),(0,M.jsxs)(`div`,{className:`pumps`,children:[(0,M.jsx)(pr,{kind:`heat`,label:`Heat`,running:e.heat_pump===`on`}),(0,M.jsx)(pr,{kind:`filter`,label:`Filter`,running:e.filter_pump===`on`})]})]}),(0,M.jsxs)(`section`,{className:`card settings-card`,children:[(0,M.jsxs)(`div`,{className:`target`,children:[(0,M.jsx)(`span`,{className:`field-label`,children:`Target temperature`}),(0,M.jsxs)(`div`,{className:`target-row`,children:[(0,M.jsx)(`button`,{type:`button`,className:`round-btn`,"aria-label":`Decrease target`,disabled:e.target_f<=50,onClick:()=>Kn(`target_temp_decrement`),children:`−`}),(0,M.jsx)(`div`,{className:`target-value`,children:or(e.target_f)}),(0,M.jsx)(`button`,{type:`button`,className:`round-btn`,"aria-label":`Increase target`,disabled:e.target_f>=110,onClick:()=>Kn(`target_temp_increment`),children:`+`})]})]}),(0,M.jsxs)(`div`,{className:`modes`,children:[(0,M.jsxs)(`label`,{className:`field`,children:[(0,M.jsx)(`span`,{className:`field-label`,children:`Heat mode`}),(0,M.jsxs)(`select`,{value:e.heat_mode===`backflush`?`backflush`:e.heat_mode,onChange:e=>Kn(`set_heat_mode`,{mode:e.target.value}),children:[(0,M.jsx)(`option`,{value:`sleep`,children:`Sleep`}),(0,M.jsx)(`option`,{value:`maintain`,children:`Maintain temperature`}),e.heat_mode===`backflush`?(0,M.jsx)(`option`,{value:`backflush`,disabled:!0,children:`Backflush (running)`}):null]})]}),(0,M.jsxs)(`label`,{className:`field`,children:[(0,M.jsx)(`span`,{className:`field-label`,children:`Filter mode`}),(0,M.jsxs)(`select`,{value:e.filter_mode,onChange:e=>Kn(`set_filter_mode`,{mode:e.target.value}),children:[(0,M.jsx)(`option`,{value:`auto`,children:`Auto (daily schedule)`}),(0,M.jsx)(`option`,{value:`on`,children:`On`}),(0,M.jsx)(`option`,{value:`off`,children:`Off`})]})]})]}),(0,M.jsxs)(`dl`,{className:`summary`,children:[(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`dt`,{children:`Heater`}),(0,M.jsx)(`dd`,{children:lr(e)})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`dt`,{children:`Hot input`}),(0,M.jsx)(`dd`,{children:ar(e.hot_in_f)})]}),(0,M.jsxs)(`div`,{children:[(0,M.jsx)(`dt`,{children:`Filter`}),(0,M.jsx)(`dd`,{children:ur(e)})]})]})]})]})}function br(){return(0,M.jsxs)(`div`,{className:`backflush-anim`,role:`status`,children:[(0,M.jsxs)(`svg`,{viewBox:`0 0 120 120`,"aria-hidden":`true`,children:[(0,M.jsxs)(`g`,{className:`backflush-arrows`,children:[(0,M.jsx)(`path`,{d:`M60 10 A50 50 0 0 1 110 60`}),(0,M.jsx)(`path`,{d:`M60 110 A50 50 0 0 1 10 60`}),(0,M.jsx)(`path`,{className:`arrowhead`,d:`M102 54 L118 54 L110 66 Z`}),(0,M.jsx)(`path`,{className:`arrowhead`,d:`M2 66 L18 66 L10 54 Z`})]}),(0,M.jsx)(`path`,{className:`droplet`,d:`M60 28 C60 28 36 56 36 72 A24 24 0 0 0 84 72 C84 56 60 28 60 28 Z`}),(0,M.jsx)(`circle`,{className:`droplet-eye`,cx:`52`,cy:`70`,r:`3`}),(0,M.jsx)(`circle`,{className:`droplet-eye`,cx:`68`,cy:`70`,r:`3`}),(0,M.jsx)(`path`,{className:`droplet-smile`,d:`M52 80 q8 7 16 0`}),(0,M.jsx)(`circle`,{className:`bubble b1`,cx:`44`,cy:`102`,r:`4`}),(0,M.jsx)(`circle`,{className:`bubble b2`,cx:`60`,cy:`104`,r:`3`}),(0,M.jsx)(`circle`,{className:`bubble b3`,cx:`76`,cy:`102`,r:`4`})]}),(0,M.jsx)(`p`,{children:`Backflush in progress…`})]})}function xr({label:e,value:t,missingStatus:n}){return(0,M.jsxs)(`li`,{className:`probe-row`,children:[(0,M.jsx)($n,{status:ir(t)?`on`:n,label:e}),(0,M.jsx)(`span`,{className:`probe-value`,children:ar(t)})]})}function Sr({control:e,temps:t,backflushRequested:n,onBackflushRequested:r}){let i=Ct(),a=cr(e),o=dr(t);return(0,M.jsxs)(`div`,{className:`diagnostic-layout`,children:[(0,M.jsxs)(`section`,{className:`card`,children:[(0,M.jsx)(`button`,{type:`button`,className:`big-button big-button-backflush`,disabled:a||n,onClick:()=>{r(!0),Kn(`set_heat_mode`,{mode:`backflush`}).catch(()=>r(!1))},children:a?`Backflushing…`:`Backflush`}),(0,M.jsx)(`div`,{className:`backflush-slot`,children:a?(0,M.jsx)(br,{}):(0,M.jsx)(`p`,{className:`hint`,children:`Runs the heat pump briefly to purge the heater line, then returns to Sleep.`})})]}),(0,M.jsxs)(`section`,{className:`card`,children:[(0,M.jsx)(`button`,{type:`button`,className:`big-button big-button-setup`,onClick:()=>i(`/diagnostic/sensor-setup/1`),children:`Sensor Setup`}),(0,M.jsxs)(`div`,{className:`readings`,children:[(0,M.jsxs)(`div`,{className:`readings-header`,children:[(0,M.jsx)(`span`,{className:`field-label`,children:`Sensors`}),(0,M.jsx)(`span`,{className:`setup-state`,children:t.setup_state.replace(/_/g,` `)})]}),(0,M.jsx)(`ul`,{className:`probe-list`,children:nr.map(e=>(0,M.jsx)(xr,{label:e.label,value:o[e.key],missingStatus:`error`},e.key))})]})]})]})}function Cr({step:e}){return(0,M.jsx)(`ol`,{className:`stepper`,children:[`Disconnect`,`First set`,`Second set`].map((t,n)=>(0,M.jsx)(`li`,{className:n+1<e?`done`:n+1===e?`current`:``,children:t},t))})}function wr({label:e,value:t,missingStatus:n}){return(0,M.jsxs)(`li`,{className:`probe-row`,children:[(0,M.jsx)($n,{status:ir(t)?`on`:n,label:e}),(0,M.jsx)(`span`,{className:`probe-value`,children:ar(t)})]})}function Tr({temps:e,firstBundle:t,onFirstBundle:n}){let r=Ct(),{step:i}=Tt(),a=Math.min(3,Math.max(1,Number(i)||1)),o=dr(e),s=()=>r(`/diagnostic`);return(0,M.jsxs)(`section`,{className:`card setup-card`,children:[(0,M.jsx)(`h1`,{children:`Sensor setup`}),(0,M.jsx)(Cr,{step:a}),a===1?(0,M.jsx)(Er,{values:o,onCancel:()=>Xn().finally(s),onContinue:()=>Jn().then(()=>r(`/diagnostic/sensor-setup/2`))}):null,a===2?(0,M.jsx)(Or,{setupState:e.setup_state,values:o,onCancel:()=>Xn().finally(s),onContinue:()=>{let e=ir(o.hot_in),t=rr.some(e=>ir(o[e]));n(e&&t?null:e?`hot`:`water`),r(`/diagnostic/sensor-setup/3`)}}):null,a===3?(0,M.jsx)(kr,{setupState:e.setup_state,values:o,firstBundle:t,onCancel:()=>Xn().finally(s),onFinish:()=>Yn().then(s)}):null]})}function Er({values:e,onContinue:t,onCancel:n}){let r=nr.filter(t=>ir(e[t.key])).length;return(0,M.jsxs)(`div`,{className:`setup-step`,children:[(0,M.jsx)(`h2`,{children:`Disconnect all sensors`}),(0,M.jsx)(`p`,{children:`Unplug both sensor bundles from the enclosure: the hot-input probe and the four water probes.`}),(0,M.jsx)(`div`,{className:`setup-status`,children:r?(0,M.jsx)($n,{status:`waiting`,label:`${r} sensor${r===1?``:`s`} still connected`}):(0,M.jsx)($n,{status:`on`,label:`All sensors disconnected`})}),(0,M.jsxs)(`div`,{className:`actions`,children:[(0,M.jsx)(`button`,{type:`button`,className:`btn btn-secondary`,onClick:n,children:`Cancel`}),(0,M.jsx)(`button`,{type:`button`,className:`btn btn-primary`,onClick:t,children:`Continue`})]})]})}function Dr(e,t){return!!(e===`need_water`||e===`need_hot_in`&&rr.every(e=>ir(t[e])))}function Or({setupState:e,values:t,onContinue:n,onCancel:r}){let i=ir(t.hot_in),a=rr.some(e=>ir(t[e])),o=`Waiting for readings…`;i&&a?o=`Both sets detected`:i?o=`Hot-input sensor detected`:a&&(o=`Water sensors detected`);let s=Dr(e,t);return(0,M.jsxs)(`div`,{className:`setup-step`,children:[(0,M.jsx)(`h2`,{children:`Plug in one set of sensors`}),(0,M.jsx)(`p`,{children:`Connect either the hot-input bundle or the water bundle. Continue unlocks once readings arrive.`}),(0,M.jsx)(`div`,{className:`setup-status`,children:(0,M.jsx)($n,{status:s?`on`:`waiting`,label:o})}),(0,M.jsxs)(`div`,{className:`actions`,children:[(0,M.jsx)(`button`,{type:`button`,className:`btn btn-secondary`,onClick:r,children:`Cancel`}),(0,M.jsx)(`button`,{type:`button`,className:`btn btn-primary`,disabled:!s,onClick:n,children:`Continue`})]})]})}function kr({setupState:e,values:t,firstBundle:n,onFinish:r,onCancel:i}){let a=n===`hot`?`the water sensor bundle (4 probes)`:n===`water`?`the hot-input sensor`:`any remaining sensors`,o=e===`ready`&&nr.every(e=>ir(t[e.key]));return(0,M.jsxs)(`div`,{className:`setup-step`,children:[(0,M.jsx)(`h2`,{children:`Plug in the other set`}),(0,M.jsxs)(`p`,{children:[`Now connect `,a,`. Finish unlocks once every sensor is reporting.`]}),(0,M.jsx)(`ul`,{className:`probe-list`,children:nr.map(e=>(0,M.jsx)(wr,{label:e.label,value:t[e.key],missingStatus:`waiting`},e.key))}),(0,M.jsxs)(`div`,{className:`actions`,children:[(0,M.jsx)(`button`,{type:`button`,className:`btn btn-secondary`,onClick:i,children:`Cancel`}),(0,M.jsx)(`button`,{type:`button`,className:`btn btn-primary`,disabled:!o,onClick:r,children:`Finish`})]})]})}function Ar(){return(0,M.jsx)(`div`,{className:`card loading`,children:`Connecting to the hot tub…`})}function jr(){let[e,t]=(0,x.useState)(`connecting`),[n,r]=(0,x.useState)(null),[i,a]=(0,x.useState)(null),[o,s]=(0,x.useState)(!1),[c,l]=(0,x.useState)(null),u=bt();(0,x.useEffect)(()=>Zn({onOpen:()=>t(`open`),onError:()=>t(`reconnecting`),onControl:e=>{let t=e;r(t),s(e=>e&&!cr(t))},onTemps:e=>a(e),onSetup:()=>{}}),[]);let d=(e!==`open`||fr(n?.ts)||fr(i?.ts))&&e===`open`?`reconnecting`:e;return(0,M.jsxs)(`div`,{className:`app`,children:[(0,M.jsx)(tr,{connection:d}),(0,M.jsx)(`main`,{className:`page${d===`open`?``:` stale`}`,children:n&&i?(0,M.jsxs)(Yt,{children:[(0,M.jsx)(qt,{path:`/`,element:(0,M.jsx)(Kt,{to:`/control`,replace:!0})}),(0,M.jsx)(qt,{path:`/control`,element:(0,M.jsx)(yr,{control:n})}),(0,M.jsx)(qt,{path:`/diagnostic`,element:(0,M.jsx)(Sr,{control:n,temps:i,backflushRequested:o,onBackflushRequested:s})}),(0,M.jsx)(qt,{path:`/diagnostic/sensor-setup/:step`,element:(0,M.jsx)(Tr,{temps:i,firstBundle:c,onFirstBundle:l})}),(0,M.jsx)(qt,{path:`*`,element:(0,M.jsx)(Kt,{to:u.pathname.startsWith(`/diagnostic`)?`/diagnostic`:`/control`,replace:!0})})]}):(0,M.jsx)(Ar,{})})]})}(0,Gn.createRoot)(document.getElementById(`root`)).render((0,M.jsx)(x.StrictMode,{children:(0,M.jsx)(Nn,{children:(0,M.jsx)(jr,{})})}));
... ...
\ No newline at end of file
projects/hot-tub/src/web/frontend/dist/assets/index-CESZI1ev.css
... ...
@@ -0,0 +1 @@
1
+:root{--bg-top:#e0f2fe;--bg-bottom:#f8fafc;--card:#fff;--text:#0f172a;--muted:#64748b;--border:#e2e8f0;--surface:#f8fafc;--accent:#0e7490;--accent-strong:#155e75;--heat:#f97316;--filter:#0ea5e9;--green:#22c55e;--yellow:#eab308;--red:#ef4444;--off:#cbd5e1;--radius:18px;--shadow:0 6px 24px #0f172a14}*{box-sizing:border-box}body{min-height:100vh;color:var(--text);background:linear-gradient(var(--bg-top), var(--bg-bottom)) fixed;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif}.app-header{z-index:10;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-bottom:1px solid var(--border);background:#ffffffd9;flex-wrap:wrap;align-items:center;gap:12px 20px;padding:12px 20px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:8px;font-size:1.25rem;font-weight:700;display:flex}.brand-icon{color:var(--heat);font-size:1.4rem}.nav{background:#f1f5f9;border-radius:999px;gap:4px;padding:4px;display:flex}.nav-tab{color:var(--muted);border-radius:999px;padding:8px 16px;font-weight:600;text-decoration:none}.nav-tab.active{background:var(--card);color:var(--accent-strong);box-shadow:0 1px 4px #0f172a1f}.connection{margin-left:auto;font-size:.85rem}@media (width<=599px){.app-header{padding:10px 12px}.nav{order:3;width:100%}.nav-tab{text-align:center;flex:1}}.page{max-width:1100px;margin:0 auto;padding:20px;transition:opacity .3s}.page.stale{opacity:.55}@media (width<=599px){.page{padding:12px}}.card{border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow);padding:20px}.loading{color:var(--muted);text-align:center;padding:60px 20px}.control-layout,.diagnostic-layout{gap:20px;display:grid}@media (width>=900px){.control-layout{grid-template-columns:minmax(0,2fr) minmax(280px,1fr);align-items:start}}@media (width>=760px){.diagnostic-layout{grid-template-columns:1fr 1fr;align-items:start}}.status-light{color:var(--muted);align-items:center;gap:8px;display:inline-flex}.light{background:var(--off);border-radius:50%;flex-shrink:0;width:14px;height:14px}.light-on{background:var(--green);box-shadow:0 0 0 3px #22c55e33, 0 0 12px var(--green)}.light-waiting{background:var(--yellow);animation:1.2s ease-in-out infinite pulse}.light-error{background:var(--red)}.banner{border-radius:12px;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px 12px;margin-bottom:12px;padding:12px 14px;display:flex}.banner-error{color:#991b1b;background:#fef2f2;border:1px solid #fecaca}.tub{width:100%;max-width:560px;margin:0 auto;display:block}.ground{fill:#0f172a14}.tub-shell{fill:#475569}.tub-wall{fill:none;stroke:#94a3b8;stroke-width:10px;stroke-linejoin:round}.tub-rim{stroke:#64748b;stroke-width:1.5px}.rivet{fill:#64748b}.tub-temp{fill:#fff;paint-order:stroke;stroke:#0369a199;stroke-width:4px;font-size:52px;font-weight:800}.tub-temp-caption{letter-spacing:.2em;text-transform:uppercase;fill:#e0f2fe;font-size:15px}.wave{animation:4s linear infinite wave}.wave-back{opacity:.8;animation-duration:6s;animation-direction:reverse}.duck-bob{transform-box:fill-box;transform-origin:50%;animation:2.4s ease-in-out infinite bob}.steam{opacity:0;transition:opacity 1s}.steam.on{opacity:1}.steam-puff{fill:none;stroke:#cbd5e1;stroke-width:5px;stroke-linecap:round;animation:3s ease-in-out infinite rise}.steam-puff:nth-child(2){animation-delay:1s}.steam-puff:nth-child(3){animation-delay:2s}.pumps{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;max-width:420px;margin:8px auto 0;display:grid}.pump{flex-direction:column;align-items:center;gap:6px;display:flex}.pump-svg{width:96px;height:96px}.pump-frame{fill:#f1f5f9;stroke:var(--border);stroke-width:2px}.pump-housing{fill:#1e293b}.pump-hub{fill:#e2e8f0}.fan-blades{opacity:.5;transform-box:view-box;transform-origin:60px 60px;transition:opacity .3s}.pump-heat .fan-blades{fill:var(--heat)}.pump-filter .fan-blades{fill:var(--filter)}.pump.running .fan-blades{opacity:1;animation:.9s linear infinite spin}.pump-label{font-weight:700}@media (width<=400px){.pump-svg{width:80px;height:80px}}.settings-card{flex-direction:column;gap:22px;display:flex}.field-label{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;margin-bottom:6px;font-size:.8rem;font-weight:700;display:block}.target-row{justify-content:space-between;align-items:center;gap:12px;display:flex}.target-value{color:var(--accent-strong);font-variant-numeric:tabular-nums;font-size:3.25rem;font-weight:800}.round-btn{background:var(--accent);color:#fff;cursor:pointer;touch-action:manipulation;border:none;border-radius:50%;width:60px;height:60px;font-size:2rem;line-height:1}.round-btn:active:not(:disabled){transform:scale(.94)}.modes{flex-direction:column;gap:14px;display:flex}.field select{border:1px solid var(--border);background:var(--surface);width:100%;font:inherit;border-radius:12px;padding:12px 14px;font-size:1.05rem}.summary{gap:10px;margin:0;display:grid}.summary div{border-bottom:1px dashed var(--border);justify-content:space-between;gap:12px;padding-bottom:8px;display:flex}.summary dt{color:var(--muted)}.summary dd{text-align:right;margin:0;font-weight:600}.btn{font:inherit;cursor:pointer;border:none;border-radius:12px;justify-content:center;align-items:center;padding:12px 22px;font-weight:700;display:inline-flex}.btn-primary{background:var(--accent);color:#fff}.btn-secondary{color:var(--text);background:#f1f5f9}.btn-small{color:#fff;background:#991b1b;padding:6px 12px;font-size:.875rem}.big-button{border-radius:var(--radius);width:100%;min-height:110px;box-shadow:var(--shadow);color:#fff;font:inherit;cursor:pointer;border:none;font-size:1.75rem;font-weight:800}.big-button-backflush{background:linear-gradient(135deg,#0ea5e9,#0369a1)}.big-button-setup{background:linear-gradient(135deg,#14b8a6,#0f766e)}.big-button:active:not(:disabled){transform:translateY(1px)}.round-btn:disabled,.btn:disabled,.big-button:disabled{opacity:.4;cursor:not-allowed}.backflush-slot{place-items:center;min-height:190px;margin-top:16px;display:grid}.hint{max-width:320px;color:var(--muted);text-align:center}.backflush-anim{color:var(--accent-strong);text-align:center;font-weight:600}.backflush-anim svg{width:140px;height:140px}.backflush-anim p{margin:6px 0 0}.backflush-arrows{fill:none;stroke:var(--filter);stroke-width:6px;stroke-linecap:round;transform-box:view-box;transform-origin:60px 60px;animation:2s linear infinite spin}.backflush-arrows .arrowhead{fill:var(--filter);stroke:none}.droplet{fill:#38bdf8}.droplet-eye{fill:var(--text)}.droplet-smile{fill:none;stroke:var(--text);stroke-width:2.5px;stroke-linecap:round}.bubble{fill:#7dd3fc;transform-box:fill-box;animation:1.8s ease-in infinite bubble}.bubble.b2{animation-delay:.6s}.bubble.b3{animation-delay:1.2s}.readings{margin-top:20px}.readings-header{justify-content:space-between;align-items:baseline;display:flex}.setup-state{color:var(--muted);text-transform:capitalize;font-size:.85rem}.probe-list{gap:8px;margin:8px 0 0;padding:0;list-style:none;display:grid}.probe-row{background:var(--surface);border-radius:10px;justify-content:space-between;align-items:center;padding:10px 12px;display:flex}.probe-row .status-light{color:var(--text)}.probe-value{font-variant-numeric:tabular-nums;font-weight:600}.setup-card{max-width:640px;margin:0 auto}.setup-card h1{margin:0 0 16px;font-size:1.5rem}.stepper{gap:8px;margin:0;padding:0;list-style:none;display:flex}.stepper li{border-top:4px solid var(--border);color:var(--muted);text-align:center;flex:1;padding-top:10px;font-size:.8rem}.stepper li.done,.stepper li.current{border-color:var(--accent);color:var(--accent-strong);font-weight:700}.setup-step h2{margin:24px 0 8px;font-size:1.25rem}.setup-step p{color:var(--muted);line-height:1.5}.setup-status{background:var(--surface);border-radius:12px;margin:20px 0;padding:16px}.setup-status .status-light{color:var(--text);font-size:1.1rem}.setup-status .light{width:22px;height:22px}.actions{justify-content:space-between;gap:12px;margin-top:24px;display:flex}.mock-panel{z-index:50;max-width:250px;box-shadow:var(--shadow);background:#fffbeb;border:2px dashed #f59e0b;border-radius:12px;font-size:.85rem;position:fixed;bottom:12px;right:12px}.mock-panel summary{color:#92400e;cursor:pointer;padding:8px 12px;font-weight:700}.mock-panel-body{gap:8px;padding:0 12px 12px;display:grid}.mock-panel label{align-items:center;gap:6px;display:flex}.mock-panel button{font:inherit;text-align:left;cursor:pointer;background:#fff;border:1px solid #fcd34d;border-radius:8px;padding:6px 8px}@keyframes spin{to{transform:rotate(360deg)}}@keyframes wave{to{transform:translate(-100px)}}@keyframes bob{50%{transform:translateY(4px)rotate(-4deg)}}@keyframes rise{0%{opacity:0;transform:translateY(8px)}40%{opacity:.9}to{opacity:0;transform:translateY(-14px)}}@keyframes bubble{0%{opacity:0;transform:translateY(0)}20%{opacity:1}to{opacity:0;transform:translateY(-40px)}}@keyframes pulse{50%{opacity:.35}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}
projects/hot-tub/src/web/frontend/dist/favicon.svg
... ...
@@ -0,0 +1 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
... ...
\ No newline at end of file
projects/hot-tub/src/web/frontend/dist/icons.svg
... ...
@@ -0,0 +1,24 @@
1
+<svg xmlns="http://www.w3.org/2000/svg">
2
+ <symbol id="bluesky-icon" viewBox="0 0 16 17">
3
+ <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
4
+ <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
5
+ </symbol>
6
+ <symbol id="discord-icon" viewBox="0 0 20 19">
7
+ <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
8
+ </symbol>
9
+ <symbol id="documentation-icon" viewBox="0 0 21 20">
10
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
11
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
12
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
13
+ </symbol>
14
+ <symbol id="github-icon" viewBox="0 0 19 19">
15
+ <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
16
+ </symbol>
17
+ <symbol id="social-icon" viewBox="0 0 20 20">
18
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
19
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
20
+ </symbol>
21
+ <symbol id="x-icon" viewBox="0 0 19 19">
22
+ <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
23
+ </symbol>
24
+</svg>
projects/hot-tub/src/web/frontend/dist/index.html
... ...
@@ -0,0 +1,14 @@
1
+<!doctype html>
2
+<html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ <script type="module" crossorigin src="/assets/index-BUdY2Uw4.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CESZI1ev.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+</html>
projects/hot-tub/src/web/frontend/index.html
... ...
@@ -0,0 +1,13 @@
1
+<!doctype html>
2
+<html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+</html>
projects/hot-tub/src/web/frontend/package-lock.json
... ...
@@ -0,0 +1,1714 @@
1
+{
2
+ "name": "frontend",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "frontend",
9
+ "version": "0.0.0",
10
+ "dependencies": {
11
+ "react": "^19.3.1",
12
+ "react-dom": "^19.3.1",
13
+ "react-router-dom": "^7.18.4"
14
+ },
15
+ "devDependencies": {
16
+ "@types/node": "^24.19.2",
17
+ "@types/react": "^19.3.0",
18
+ "@types/react-dom": "^19.3.0",
19
+ "@vitejs/plugin-react": "^6.1.2",
20
+ "oxlint": "^1.88.0",
21
+ "typescript": "~7.0.2",
22
+ "vite": "^8.3.4"
23
+ }
24
+ },
25
+ "node_modules/@oxc-project/types": {
26
+ "version": "0.154.0",
27
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
28
+ "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
29
+ "dev": true,
30
+ "license": "MIT",
31
+ "funding": {
32
+ "url": "https://github.com/sponsors/oxc-project"
33
+ }
34
+ },
35
+ "node_modules/@oxlint/binding-android-arm-eabi": {
36
+ "version": "1.89.0",
37
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.89.0.tgz",
38
+ "integrity": "sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==",
39
+ "cpu": [
40
+ "arm"
41
+ ],
42
+ "dev": true,
43
+ "license": "MIT",
44
+ "optional": true,
45
+ "os": [
46
+ "android"
47
+ ],
48
+ "engines": {
49
+ "node": "^20.19.0 || >=22.12.0"
50
+ }
51
+ },
52
+ "node_modules/@oxlint/binding-android-arm64": {
53
+ "version": "1.89.0",
54
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.89.0.tgz",
55
+ "integrity": "sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==",
56
+ "cpu": [
57
+ "arm64"
58
+ ],
59
+ "dev": true,
60
+ "license": "MIT",
61
+ "optional": true,
62
+ "os": [
63
+ "android"
64
+ ],
65
+ "engines": {
66
+ "node": "^20.19.0 || >=22.12.0"
67
+ }
68
+ },
69
+ "node_modules/@oxlint/binding-darwin-arm64": {
70
+ "version": "1.89.0",
71
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.89.0.tgz",
72
+ "integrity": "sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==",
73
+ "cpu": [
74
+ "arm64"
75
+ ],
76
+ "dev": true,
77
+ "license": "MIT",
78
+ "optional": true,
79
+ "os": [
80
+ "darwin"
81
+ ],
82
+ "engines": {
83
+ "node": "^20.19.0 || >=22.12.0"
84
+ }
85
+ },
86
+ "node_modules/@oxlint/binding-darwin-x64": {
87
+ "version": "1.89.0",
88
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.89.0.tgz",
89
+ "integrity": "sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==",
90
+ "cpu": [
91
+ "x64"
92
+ ],
93
+ "dev": true,
94
+ "license": "MIT",
95
+ "optional": true,
96
+ "os": [
97
+ "darwin"
98
+ ],
99
+ "engines": {
100
+ "node": "^20.19.0 || >=22.12.0"
101
+ }
102
+ },
103
+ "node_modules/@oxlint/binding-freebsd-x64": {
104
+ "version": "1.89.0",
105
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.89.0.tgz",
106
+ "integrity": "sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==",
107
+ "cpu": [
108
+ "x64"
109
+ ],
110
+ "dev": true,
111
+ "license": "MIT",
112
+ "optional": true,
113
+ "os": [
114
+ "freebsd"
115
+ ],
116
+ "engines": {
117
+ "node": "^20.19.0 || >=22.12.0"
118
+ }
119
+ },
120
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
121
+ "version": "1.89.0",
122
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.89.0.tgz",
123
+ "integrity": "sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==",
124
+ "cpu": [
125
+ "arm"
126
+ ],
127
+ "dev": true,
128
+ "license": "MIT",
129
+ "optional": true,
130
+ "os": [
131
+ "linux"
132
+ ],
133
+ "engines": {
134
+ "node": "^20.19.0 || >=22.12.0"
135
+ }
136
+ },
137
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
138
+ "version": "1.89.0",
139
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.89.0.tgz",
140
+ "integrity": "sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==",
141
+ "cpu": [
142
+ "arm"
143
+ ],
144
+ "dev": true,
145
+ "license": "MIT",
146
+ "optional": true,
147
+ "os": [
148
+ "linux"
149
+ ],
150
+ "engines": {
151
+ "node": "^20.19.0 || >=22.12.0"
152
+ }
153
+ },
154
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
155
+ "version": "1.89.0",
156
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.89.0.tgz",
157
+ "integrity": "sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==",
158
+ "cpu": [
159
+ "arm64"
160
+ ],
161
+ "dev": true,
162
+ "license": "MIT",
163
+ "optional": true,
164
+ "os": [
165
+ "linux"
166
+ ],
167
+ "engines": {
168
+ "node": "^20.19.0 || >=22.12.0"
169
+ }
170
+ },
171
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
172
+ "version": "1.89.0",
173
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.89.0.tgz",
174
+ "integrity": "sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==",
175
+ "cpu": [
176
+ "arm64"
177
+ ],
178
+ "dev": true,
179
+ "license": "MIT",
180
+ "optional": true,
181
+ "os": [
182
+ "linux"
183
+ ],
184
+ "engines": {
185
+ "node": "^20.19.0 || >=22.12.0"
186
+ }
187
+ },
188
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
189
+ "version": "1.89.0",
190
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.89.0.tgz",
191
+ "integrity": "sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==",
192
+ "cpu": [
193
+ "ppc64"
194
+ ],
195
+ "dev": true,
196
+ "license": "MIT",
197
+ "optional": true,
198
+ "os": [
199
+ "linux"
200
+ ],
201
+ "engines": {
202
+ "node": "^20.19.0 || >=22.12.0"
203
+ }
204
+ },
205
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
206
+ "version": "1.89.0",
207
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.89.0.tgz",
208
+ "integrity": "sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==",
209
+ "cpu": [
210
+ "riscv64"
211
+ ],
212
+ "dev": true,
213
+ "license": "MIT",
214
+ "optional": true,
215
+ "os": [
216
+ "linux"
217
+ ],
218
+ "engines": {
219
+ "node": "^20.19.0 || >=22.12.0"
220
+ }
221
+ },
222
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
223
+ "version": "1.89.0",
224
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.89.0.tgz",
225
+ "integrity": "sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==",
226
+ "cpu": [
227
+ "riscv64"
228
+ ],
229
+ "dev": true,
230
+ "license": "MIT",
231
+ "optional": true,
232
+ "os": [
233
+ "linux"
234
+ ],
235
+ "engines": {
236
+ "node": "^20.19.0 || >=22.12.0"
237
+ }
238
+ },
239
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
240
+ "version": "1.89.0",
241
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.89.0.tgz",
242
+ "integrity": "sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==",
243
+ "cpu": [
244
+ "s390x"
245
+ ],
246
+ "dev": true,
247
+ "license": "MIT",
248
+ "optional": true,
249
+ "os": [
250
+ "linux"
251
+ ],
252
+ "engines": {
253
+ "node": "^20.19.0 || >=22.12.0"
254
+ }
255
+ },
256
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
257
+ "version": "1.89.0",
258
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.89.0.tgz",
259
+ "integrity": "sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==",
260
+ "cpu": [
261
+ "x64"
262
+ ],
263
+ "dev": true,
264
+ "license": "MIT",
265
+ "optional": true,
266
+ "os": [
267
+ "linux"
268
+ ],
269
+ "engines": {
270
+ "node": "^20.19.0 || >=22.12.0"
271
+ }
272
+ },
273
+ "node_modules/@oxlint/binding-linux-x64-musl": {
274
+ "version": "1.89.0",
275
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.89.0.tgz",
276
+ "integrity": "sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==",
277
+ "cpu": [
278
+ "x64"
279
+ ],
280
+ "dev": true,
281
+ "license": "MIT",
282
+ "optional": true,
283
+ "os": [
284
+ "linux"
285
+ ],
286
+ "engines": {
287
+ "node": "^20.19.0 || >=22.12.0"
288
+ }
289
+ },
290
+ "node_modules/@oxlint/binding-openharmony-arm64": {
291
+ "version": "1.89.0",
292
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.89.0.tgz",
293
+ "integrity": "sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==",
294
+ "cpu": [
295
+ "arm64"
296
+ ],
297
+ "dev": true,
298
+ "license": "MIT",
299
+ "optional": true,
300
+ "os": [
301
+ "openharmony"
302
+ ],
303
+ "engines": {
304
+ "node": "^20.19.0 || >=22.12.0"
305
+ }
306
+ },
307
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
308
+ "version": "1.89.0",
309
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.89.0.tgz",
310
+ "integrity": "sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==",
311
+ "cpu": [
312
+ "arm64"
313
+ ],
314
+ "dev": true,
315
+ "license": "MIT",
316
+ "optional": true,
317
+ "os": [
318
+ "win32"
319
+ ],
320
+ "engines": {
321
+ "node": "^20.19.0 || >=22.12.0"
322
+ }
323
+ },
324
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
325
+ "version": "1.89.0",
326
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.89.0.tgz",
327
+ "integrity": "sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==",
328
+ "cpu": [
329
+ "ia32"
330
+ ],
331
+ "dev": true,
332
+ "license": "MIT",
333
+ "optional": true,
334
+ "os": [
335
+ "win32"
336
+ ],
337
+ "engines": {
338
+ "node": "^20.19.0 || >=22.12.0"
339
+ }
340
+ },
341
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
342
+ "version": "1.89.0",
343
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.89.0.tgz",
344
+ "integrity": "sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==",
345
+ "cpu": [
346
+ "x64"
347
+ ],
348
+ "dev": true,
349
+ "license": "MIT",
350
+ "optional": true,
351
+ "os": [
352
+ "win32"
353
+ ],
354
+ "engines": {
355
+ "node": "^20.19.0 || >=22.12.0"
356
+ }
357
+ },
358
+ "node_modules/@rolldown/binding-android-arm-eabi": {
359
+ "version": "1.2.14",
360
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
361
+ "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
362
+ "cpu": [
363
+ "arm"
364
+ ],
365
+ "dev": true,
366
+ "license": "MIT",
367
+ "optional": true,
368
+ "os": [
369
+ "android"
370
+ ],
371
+ "engines": {
372
+ "node": "^20.19.0 || >=22.12.0"
373
+ }
374
+ },
375
+ "node_modules/@rolldown/binding-android-arm64": {
376
+ "version": "1.2.14",
377
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
378
+ "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
379
+ "cpu": [
380
+ "arm64"
381
+ ],
382
+ "dev": true,
383
+ "license": "MIT",
384
+ "optional": true,
385
+ "os": [
386
+ "android"
387
+ ],
388
+ "engines": {
389
+ "node": "^20.19.0 || >=22.12.0"
390
+ }
391
+ },
392
+ "node_modules/@rolldown/binding-darwin-arm64": {
393
+ "version": "1.2.14",
394
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
395
+ "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
396
+ "cpu": [
397
+ "arm64"
398
+ ],
399
+ "dev": true,
400
+ "license": "MIT",
401
+ "optional": true,
402
+ "os": [
403
+ "darwin"
404
+ ],
405
+ "engines": {
406
+ "node": "^20.19.0 || >=22.12.0"
407
+ }
408
+ },
409
+ "node_modules/@rolldown/binding-darwin-x64": {
410
+ "version": "1.2.14",
411
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
412
+ "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
413
+ "cpu": [
414
+ "x64"
415
+ ],
416
+ "dev": true,
417
+ "license": "MIT",
418
+ "optional": true,
419
+ "os": [
420
+ "darwin"
421
+ ],
422
+ "engines": {
423
+ "node": "^20.19.0 || >=22.12.0"
424
+ }
425
+ },
426
+ "node_modules/@rolldown/binding-freebsd-x64": {
427
+ "version": "1.2.14",
428
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
429
+ "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
430
+ "cpu": [
431
+ "x64"
432
+ ],
433
+ "dev": true,
434
+ "license": "MIT",
435
+ "optional": true,
436
+ "os": [
437
+ "freebsd"
438
+ ],
439
+ "engines": {
440
+ "node": "^20.19.0 || >=22.12.0"
441
+ }
442
+ },
443
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
444
+ "version": "1.2.14",
445
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
446
+ "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
447
+ "cpu": [
448
+ "arm"
449
+ ],
450
+ "dev": true,
451
+ "license": "MIT",
452
+ "optional": true,
453
+ "os": [
454
+ "linux"
455
+ ],
456
+ "engines": {
457
+ "node": "^20.19.0 || >=22.12.0"
458
+ }
459
+ },
460
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
461
+ "version": "1.2.14",
462
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
463
+ "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
464
+ "cpu": [
465
+ "arm64"
466
+ ],
467
+ "dev": true,
468
+ "license": "MIT",
469
+ "optional": true,
470
+ "os": [
471
+ "linux"
472
+ ],
473
+ "engines": {
474
+ "node": "^20.19.0 || >=22.12.0"
475
+ }
476
+ },
477
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
478
+ "version": "1.2.14",
479
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
480
+ "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
481
+ "cpu": [
482
+ "arm64"
483
+ ],
484
+ "dev": true,
485
+ "license": "MIT",
486
+ "optional": true,
487
+ "os": [
488
+ "linux"
489
+ ],
490
+ "engines": {
491
+ "node": "^20.19.0 || >=22.12.0"
492
+ }
493
+ },
494
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
495
+ "version": "1.2.14",
496
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
497
+ "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
498
+ "cpu": [
499
+ "ppc64"
500
+ ],
501
+ "dev": true,
502
+ "license": "MIT",
503
+ "optional": true,
504
+ "os": [
505
+ "linux"
506
+ ],
507
+ "engines": {
508
+ "node": "^20.19.0 || >=22.12.0"
509
+ }
510
+ },
511
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
512
+ "version": "1.2.14",
513
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
514
+ "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
515
+ "cpu": [
516
+ "s390x"
517
+ ],
518
+ "dev": true,
519
+ "license": "MIT",
520
+ "optional": true,
521
+ "os": [
522
+ "linux"
523
+ ],
524
+ "engines": {
525
+ "node": "^20.19.0 || >=22.12.0"
526
+ }
527
+ },
528
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
529
+ "version": "1.2.14",
530
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
531
+ "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
532
+ "cpu": [
533
+ "x64"
534
+ ],
535
+ "dev": true,
536
+ "license": "MIT",
537
+ "optional": true,
538
+ "os": [
539
+ "linux"
540
+ ],
541
+ "engines": {
542
+ "node": "^20.19.0 || >=22.12.0"
543
+ }
544
+ },
545
+ "node_modules/@rolldown/binding-linux-x64-musl": {
546
+ "version": "1.2.14",
547
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
548
+ "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
549
+ "cpu": [
550
+ "x64"
551
+ ],
552
+ "dev": true,
553
+ "license": "MIT",
554
+ "optional": true,
555
+ "os": [
556
+ "linux"
557
+ ],
558
+ "engines": {
559
+ "node": "^20.19.0 || >=22.12.0"
560
+ }
561
+ },
562
+ "node_modules/@rolldown/binding-openharmony-arm64": {
563
+ "version": "1.2.14",
564
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
565
+ "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
566
+ "cpu": [
567
+ "arm64"
568
+ ],
569
+ "dev": true,
570
+ "license": "MIT",
571
+ "optional": true,
572
+ "os": [
573
+ "openharmony"
574
+ ],
575
+ "engines": {
576
+ "node": "^20.19.0 || >=22.12.0"
577
+ }
578
+ },
579
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
580
+ "version": "1.2.14",
581
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
582
+ "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
583
+ "cpu": [
584
+ "arm64"
585
+ ],
586
+ "dev": true,
587
+ "license": "MIT",
588
+ "optional": true,
589
+ "os": [
590
+ "win32"
591
+ ],
592
+ "engines": {
593
+ "node": "^20.19.0 || >=22.12.0"
594
+ }
595
+ },
596
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
597
+ "version": "1.2.14",
598
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
599
+ "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
600
+ "cpu": [
601
+ "x64"
602
+ ],
603
+ "dev": true,
604
+ "license": "MIT",
605
+ "optional": true,
606
+ "os": [
607
+ "win32"
608
+ ],
609
+ "engines": {
610
+ "node": "^20.19.0 || >=22.12.0"
611
+ }
612
+ },
613
+ "node_modules/@rolldown/pluginutils": {
614
+ "version": "1.0.1",
615
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
616
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
617
+ "dev": true,
618
+ "license": "MIT"
619
+ },
620
+ "node_modules/@types/node": {
621
+ "version": "24.19.2",
622
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
623
+ "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
624
+ "dev": true,
625
+ "license": "MIT",
626
+ "dependencies": {
627
+ "undici-types": ">=7.24.0 <7.24.7"
628
+ }
629
+ },
630
+ "node_modules/@types/react": {
631
+ "version": "19.3.0",
632
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
633
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
634
+ "dev": true,
635
+ "license": "MIT",
636
+ "dependencies": {
637
+ "csstype": "^3.2.2"
638
+ }
639
+ },
640
+ "node_modules/@types/react-dom": {
641
+ "version": "19.3.0",
642
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
643
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
644
+ "dev": true,
645
+ "license": "MIT",
646
+ "peerDependencies": {
647
+ "@types/react": "^19.3.0"
648
+ }
649
+ },
650
+ "node_modules/@typescript/typescript-aix-ppc64": {
651
+ "version": "7.0.2",
652
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
653
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
654
+ "cpu": [
655
+ "ppc64"
656
+ ],
657
+ "dev": true,
658
+ "license": "Apache-2.0",
659
+ "optional": true,
660
+ "os": [
661
+ "aix"
662
+ ],
663
+ "engines": {
664
+ "node": ">=16.20.0"
665
+ }
666
+ },
667
+ "node_modules/@typescript/typescript-darwin-arm64": {
668
+ "version": "7.0.2",
669
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
670
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
671
+ "cpu": [
672
+ "arm64"
673
+ ],
674
+ "dev": true,
675
+ "license": "Apache-2.0",
676
+ "optional": true,
677
+ "os": [
678
+ "darwin"
679
+ ],
680
+ "engines": {
681
+ "node": ">=16.20.0"
682
+ }
683
+ },
684
+ "node_modules/@typescript/typescript-darwin-x64": {
685
+ "version": "7.0.2",
686
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
687
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
688
+ "cpu": [
689
+ "x64"
690
+ ],
691
+ "dev": true,
692
+ "license": "Apache-2.0",
693
+ "optional": true,
694
+ "os": [
695
+ "darwin"
696
+ ],
697
+ "engines": {
698
+ "node": ">=16.20.0"
699
+ }
700
+ },
701
+ "node_modules/@typescript/typescript-freebsd-arm64": {
702
+ "version": "7.0.2",
703
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
704
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
705
+ "cpu": [
706
+ "arm64"
707
+ ],
708
+ "dev": true,
709
+ "license": "Apache-2.0",
710
+ "optional": true,
711
+ "os": [
712
+ "freebsd"
713
+ ],
714
+ "engines": {
715
+ "node": ">=16.20.0"
716
+ }
717
+ },
718
+ "node_modules/@typescript/typescript-freebsd-x64": {
719
+ "version": "7.0.2",
720
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
721
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
722
+ "cpu": [
723
+ "x64"
724
+ ],
725
+ "dev": true,
726
+ "license": "Apache-2.0",
727
+ "optional": true,
728
+ "os": [
729
+ "freebsd"
730
+ ],
731
+ "engines": {
732
+ "node": ">=16.20.0"
733
+ }
734
+ },
735
+ "node_modules/@typescript/typescript-linux-arm": {
736
+ "version": "7.0.2",
737
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
738
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
739
+ "cpu": [
740
+ "arm"
741
+ ],
742
+ "dev": true,
743
+ "license": "Apache-2.0",
744
+ "optional": true,
745
+ "os": [
746
+ "linux"
747
+ ],
748
+ "engines": {
749
+ "node": ">=16.20.0"
750
+ }
751
+ },
752
+ "node_modules/@typescript/typescript-linux-arm64": {
753
+ "version": "7.0.2",
754
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
755
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
756
+ "cpu": [
757
+ "arm64"
758
+ ],
759
+ "dev": true,
760
+ "license": "Apache-2.0",
761
+ "optional": true,
762
+ "os": [
763
+ "linux"
764
+ ],
765
+ "engines": {
766
+ "node": ">=16.20.0"
767
+ }
768
+ },
769
+ "node_modules/@typescript/typescript-linux-loong64": {
770
+ "version": "7.0.2",
771
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
772
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
773
+ "cpu": [
774
+ "loong64"
775
+ ],
776
+ "dev": true,
777
+ "license": "Apache-2.0",
778
+ "optional": true,
779
+ "os": [
780
+ "linux"
781
+ ],
782
+ "engines": {
783
+ "node": ">=16.20.0"
784
+ }
785
+ },
786
+ "node_modules/@typescript/typescript-linux-mips64el": {
787
+ "version": "7.0.2",
788
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
789
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
790
+ "cpu": [
791
+ "mips64el"
792
+ ],
793
+ "dev": true,
794
+ "license": "Apache-2.0",
795
+ "optional": true,
796
+ "os": [
797
+ "linux"
798
+ ],
799
+ "engines": {
800
+ "node": ">=16.20.0"
801
+ }
802
+ },
803
+ "node_modules/@typescript/typescript-linux-ppc64": {
804
+ "version": "7.0.2",
805
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
806
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
807
+ "cpu": [
808
+ "ppc64"
809
+ ],
810
+ "dev": true,
811
+ "license": "Apache-2.0",
812
+ "optional": true,
813
+ "os": [
814
+ "linux"
815
+ ],
816
+ "engines": {
817
+ "node": ">=16.20.0"
818
+ }
819
+ },
820
+ "node_modules/@typescript/typescript-linux-riscv64": {
821
+ "version": "7.0.2",
822
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
823
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
824
+ "cpu": [
825
+ "riscv64"
826
+ ],
827
+ "dev": true,
828
+ "license": "Apache-2.0",
829
+ "optional": true,
830
+ "os": [
831
+ "linux"
832
+ ],
833
+ "engines": {
834
+ "node": ">=16.20.0"
835
+ }
836
+ },
837
+ "node_modules/@typescript/typescript-linux-s390x": {
838
+ "version": "7.0.2",
839
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
840
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
841
+ "cpu": [
842
+ "s390x"
843
+ ],
844
+ "dev": true,
845
+ "license": "Apache-2.0",
846
+ "optional": true,
847
+ "os": [
848
+ "linux"
849
+ ],
850
+ "engines": {
851
+ "node": ">=16.20.0"
852
+ }
853
+ },
854
+ "node_modules/@typescript/typescript-linux-x64": {
855
+ "version": "7.0.2",
856
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
857
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
858
+ "cpu": [
859
+ "x64"
860
+ ],
861
+ "dev": true,
862
+ "license": "Apache-2.0",
863
+ "optional": true,
864
+ "os": [
865
+ "linux"
866
+ ],
867
+ "engines": {
868
+ "node": ">=16.20.0"
869
+ }
870
+ },
871
+ "node_modules/@typescript/typescript-netbsd-arm64": {
872
+ "version": "7.0.2",
873
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
874
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
875
+ "cpu": [
876
+ "arm64"
877
+ ],
878
+ "dev": true,
879
+ "license": "Apache-2.0",
880
+ "optional": true,
881
+ "os": [
882
+ "netbsd"
883
+ ],
884
+ "engines": {
885
+ "node": ">=16.20.0"
886
+ }
887
+ },
888
+ "node_modules/@typescript/typescript-netbsd-x64": {
889
+ "version": "7.0.2",
890
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
891
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
892
+ "cpu": [
893
+ "x64"
894
+ ],
895
+ "dev": true,
896
+ "license": "Apache-2.0",
897
+ "optional": true,
898
+ "os": [
899
+ "netbsd"
900
+ ],
901
+ "engines": {
902
+ "node": ">=16.20.0"
903
+ }
904
+ },
905
+ "node_modules/@typescript/typescript-openbsd-arm64": {
906
+ "version": "7.0.2",
907
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
908
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
909
+ "cpu": [
910
+ "arm64"
911
+ ],
912
+ "dev": true,
913
+ "license": "Apache-2.0",
914
+ "optional": true,
915
+ "os": [
916
+ "openbsd"
917
+ ],
918
+ "engines": {
919
+ "node": ">=16.20.0"
920
+ }
921
+ },
922
+ "node_modules/@typescript/typescript-openbsd-x64": {
923
+ "version": "7.0.2",
924
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
925
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
926
+ "cpu": [
927
+ "x64"
928
+ ],
929
+ "dev": true,
930
+ "license": "Apache-2.0",
931
+ "optional": true,
932
+ "os": [
933
+ "openbsd"
934
+ ],
935
+ "engines": {
936
+ "node": ">=16.20.0"
937
+ }
938
+ },
939
+ "node_modules/@typescript/typescript-sunos-x64": {
940
+ "version": "7.0.2",
941
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
942
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
943
+ "cpu": [
944
+ "x64"
945
+ ],
946
+ "dev": true,
947
+ "license": "Apache-2.0",
948
+ "optional": true,
949
+ "os": [
950
+ "sunos"
951
+ ],
952
+ "engines": {
953
+ "node": ">=16.20.0"
954
+ }
955
+ },
956
+ "node_modules/@typescript/typescript-win32-arm64": {
957
+ "version": "7.0.2",
958
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
959
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
960
+ "cpu": [
961
+ "arm64"
962
+ ],
963
+ "dev": true,
964
+ "license": "Apache-2.0",
965
+ "optional": true,
966
+ "os": [
967
+ "win32"
968
+ ],
969
+ "engines": {
970
+ "node": ">=16.20.0"
971
+ }
972
+ },
973
+ "node_modules/@typescript/typescript-win32-x64": {
974
+ "version": "7.0.2",
975
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
976
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
977
+ "cpu": [
978
+ "x64"
979
+ ],
980
+ "dev": true,
981
+ "license": "Apache-2.0",
982
+ "optional": true,
983
+ "os": [
984
+ "win32"
985
+ ],
986
+ "engines": {
987
+ "node": ">=16.20.0"
988
+ }
989
+ },
990
+ "node_modules/@vitejs/plugin-react": {
991
+ "version": "6.1.2",
992
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
993
+ "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
994
+ "dev": true,
995
+ "license": "MIT",
996
+ "dependencies": {
997
+ "@rolldown/pluginutils": "^1.0.1"
998
+ },
999
+ "engines": {
1000
+ "node": "^20.19.0 || >=22.12.0"
1001
+ },
1002
+ "peerDependencies": {
1003
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
1004
+ "babel-plugin-react-compiler": "^1.0.0",
1005
+ "oxc-transform-react": "^0.152.0",
1006
+ "vite": "^8.0.0"
1007
+ },
1008
+ "peerDependenciesMeta": {
1009
+ "@rolldown/plugin-babel": {
1010
+ "optional": true
1011
+ },
1012
+ "babel-plugin-react-compiler": {
1013
+ "optional": true
1014
+ },
1015
+ "oxc-transform-react": {
1016
+ "optional": true
1017
+ }
1018
+ }
1019
+ },
1020
+ "node_modules/cookie": {
1021
+ "version": "1.1.1",
1022
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
1023
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
1024
+ "license": "MIT",
1025
+ "engines": {
1026
+ "node": ">=18"
1027
+ },
1028
+ "funding": {
1029
+ "type": "opencollective",
1030
+ "url": "https://opencollective.com/express"
1031
+ }
1032
+ },
1033
+ "node_modules/csstype": {
1034
+ "version": "3.2.3",
1035
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1036
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1037
+ "dev": true,
1038
+ "license": "MIT"
1039
+ },
1040
+ "node_modules/detect-libc": {
1041
+ "version": "2.1.2",
1042
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1043
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1044
+ "dev": true,
1045
+ "license": "Apache-2.0",
1046
+ "engines": {
1047
+ "node": ">=8"
1048
+ }
1049
+ },
1050
+ "node_modules/fdir": {
1051
+ "version": "6.5.0",
1052
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1053
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1054
+ "dev": true,
1055
+ "license": "MIT",
1056
+ "engines": {
1057
+ "node": ">=12.0.0"
1058
+ },
1059
+ "peerDependencies": {
1060
+ "picomatch": "^3 || ^4"
1061
+ },
1062
+ "peerDependenciesMeta": {
1063
+ "picomatch": {
1064
+ "optional": true
1065
+ }
1066
+ }
1067
+ },
1068
+ "node_modules/fsevents": {
1069
+ "version": "2.3.3",
1070
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1071
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1072
+ "dev": true,
1073
+ "hasInstallScript": true,
1074
+ "license": "MIT",
1075
+ "optional": true,
1076
+ "os": [
1077
+ "darwin"
1078
+ ],
1079
+ "engines": {
1080
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1081
+ }
1082
+ },
1083
+ "node_modules/lightningcss": {
1084
+ "version": "1.33.0",
1085
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
1086
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
1087
+ "dev": true,
1088
+ "license": "MPL-2.0",
1089
+ "dependencies": {
1090
+ "detect-libc": "^2.0.3"
1091
+ },
1092
+ "engines": {
1093
+ "node": ">= 12.0.0"
1094
+ },
1095
+ "funding": {
1096
+ "type": "opencollective",
1097
+ "url": "https://opencollective.com/parcel"
1098
+ },
1099
+ "optionalDependencies": {
1100
+ "lightningcss-android-arm64": "1.33.0",
1101
+ "lightningcss-darwin-arm64": "1.33.0",
1102
+ "lightningcss-darwin-x64": "1.33.0",
1103
+ "lightningcss-freebsd-x64": "1.33.0",
1104
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
1105
+ "lightningcss-linux-arm64-gnu": "1.33.0",
1106
+ "lightningcss-linux-arm64-musl": "1.33.0",
1107
+ "lightningcss-linux-x64-gnu": "1.33.0",
1108
+ "lightningcss-linux-x64-musl": "1.33.0",
1109
+ "lightningcss-win32-arm64-msvc": "1.33.0",
1110
+ "lightningcss-win32-x64-msvc": "1.33.0"
1111
+ }
1112
+ },
1113
+ "node_modules/lightningcss-android-arm64": {
1114
+ "version": "1.33.0",
1115
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
1116
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
1117
+ "cpu": [
1118
+ "arm64"
1119
+ ],
1120
+ "dev": true,
1121
+ "license": "MPL-2.0",
1122
+ "optional": true,
1123
+ "os": [
1124
+ "android"
1125
+ ],
1126
+ "engines": {
1127
+ "node": ">= 12.0.0"
1128
+ },
1129
+ "funding": {
1130
+ "type": "opencollective",
1131
+ "url": "https://opencollective.com/parcel"
1132
+ }
1133
+ },
1134
+ "node_modules/lightningcss-darwin-arm64": {
1135
+ "version": "1.33.0",
1136
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
1137
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
1138
+ "cpu": [
1139
+ "arm64"
1140
+ ],
1141
+ "dev": true,
1142
+ "license": "MPL-2.0",
1143
+ "optional": true,
1144
+ "os": [
1145
+ "darwin"
1146
+ ],
1147
+ "engines": {
1148
+ "node": ">= 12.0.0"
1149
+ },
1150
+ "funding": {
1151
+ "type": "opencollective",
1152
+ "url": "https://opencollective.com/parcel"
1153
+ }
1154
+ },
1155
+ "node_modules/lightningcss-darwin-x64": {
1156
+ "version": "1.33.0",
1157
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
1158
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
1159
+ "cpu": [
1160
+ "x64"
1161
+ ],
1162
+ "dev": true,
1163
+ "license": "MPL-2.0",
1164
+ "optional": true,
1165
+ "os": [
1166
+ "darwin"
1167
+ ],
1168
+ "engines": {
1169
+ "node": ">= 12.0.0"
1170
+ },
1171
+ "funding": {
1172
+ "type": "opencollective",
1173
+ "url": "https://opencollective.com/parcel"
1174
+ }
1175
+ },
1176
+ "node_modules/lightningcss-freebsd-x64": {
1177
+ "version": "1.33.0",
1178
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
1179
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
1180
+ "cpu": [
1181
+ "x64"
1182
+ ],
1183
+ "dev": true,
1184
+ "license": "MPL-2.0",
1185
+ "optional": true,
1186
+ "os": [
1187
+ "freebsd"
1188
+ ],
1189
+ "engines": {
1190
+ "node": ">= 12.0.0"
1191
+ },
1192
+ "funding": {
1193
+ "type": "opencollective",
1194
+ "url": "https://opencollective.com/parcel"
1195
+ }
1196
+ },
1197
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
1198
+ "version": "1.33.0",
1199
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
1200
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
1201
+ "cpu": [
1202
+ "arm"
1203
+ ],
1204
+ "dev": true,
1205
+ "license": "MPL-2.0",
1206
+ "optional": true,
1207
+ "os": [
1208
+ "linux"
1209
+ ],
1210
+ "engines": {
1211
+ "node": ">= 12.0.0"
1212
+ },
1213
+ "funding": {
1214
+ "type": "opencollective",
1215
+ "url": "https://opencollective.com/parcel"
1216
+ }
1217
+ },
1218
+ "node_modules/lightningcss-linux-arm64-gnu": {
1219
+ "version": "1.33.0",
1220
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
1221
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
1222
+ "cpu": [
1223
+ "arm64"
1224
+ ],
1225
+ "dev": true,
1226
+ "license": "MPL-2.0",
1227
+ "optional": true,
1228
+ "os": [
1229
+ "linux"
1230
+ ],
1231
+ "engines": {
1232
+ "node": ">= 12.0.0"
1233
+ },
1234
+ "funding": {
1235
+ "type": "opencollective",
1236
+ "url": "https://opencollective.com/parcel"
1237
+ }
1238
+ },
1239
+ "node_modules/lightningcss-linux-arm64-musl": {
1240
+ "version": "1.33.0",
1241
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
1242
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
1243
+ "cpu": [
1244
+ "arm64"
1245
+ ],
1246
+ "dev": true,
1247
+ "license": "MPL-2.0",
1248
+ "optional": true,
1249
+ "os": [
1250
+ "linux"
1251
+ ],
1252
+ "engines": {
1253
+ "node": ">= 12.0.0"
1254
+ },
1255
+ "funding": {
1256
+ "type": "opencollective",
1257
+ "url": "https://opencollective.com/parcel"
1258
+ }
1259
+ },
1260
+ "node_modules/lightningcss-linux-x64-gnu": {
1261
+ "version": "1.33.0",
1262
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
1263
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
1264
+ "cpu": [
1265
+ "x64"
1266
+ ],
1267
+ "dev": true,
1268
+ "license": "MPL-2.0",
1269
+ "optional": true,
1270
+ "os": [
1271
+ "linux"
1272
+ ],
1273
+ "engines": {
1274
+ "node": ">= 12.0.0"
1275
+ },
1276
+ "funding": {
1277
+ "type": "opencollective",
1278
+ "url": "https://opencollective.com/parcel"
1279
+ }
1280
+ },
1281
+ "node_modules/lightningcss-linux-x64-musl": {
1282
+ "version": "1.33.0",
1283
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
1284
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
1285
+ "cpu": [
1286
+ "x64"
1287
+ ],
1288
+ "dev": true,
1289
+ "license": "MPL-2.0",
1290
+ "optional": true,
1291
+ "os": [
1292
+ "linux"
1293
+ ],
1294
+ "engines": {
1295
+ "node": ">= 12.0.0"
1296
+ },
1297
+ "funding": {
1298
+ "type": "opencollective",
1299
+ "url": "https://opencollective.com/parcel"
1300
+ }
1301
+ },
1302
+ "node_modules/lightningcss-win32-arm64-msvc": {
1303
+ "version": "1.33.0",
1304
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
1305
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
1306
+ "cpu": [
1307
+ "arm64"
1308
+ ],
1309
+ "dev": true,
1310
+ "license": "MPL-2.0",
1311
+ "optional": true,
1312
+ "os": [
1313
+ "win32"
1314
+ ],
1315
+ "engines": {
1316
+ "node": ">= 12.0.0"
1317
+ },
1318
+ "funding": {
1319
+ "type": "opencollective",
1320
+ "url": "https://opencollective.com/parcel"
1321
+ }
1322
+ },
1323
+ "node_modules/lightningcss-win32-x64-msvc": {
1324
+ "version": "1.33.0",
1325
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
1326
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
1327
+ "cpu": [
1328
+ "x64"
1329
+ ],
1330
+ "dev": true,
1331
+ "license": "MPL-2.0",
1332
+ "optional": true,
1333
+ "os": [
1334
+ "win32"
1335
+ ],
1336
+ "engines": {
1337
+ "node": ">= 12.0.0"
1338
+ },
1339
+ "funding": {
1340
+ "type": "opencollective",
1341
+ "url": "https://opencollective.com/parcel"
1342
+ }
1343
+ },
1344
+ "node_modules/nanoid": {
1345
+ "version": "3.3.20",
1346
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
1347
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
1348
+ "dev": true,
1349
+ "funding": [
1350
+ {
1351
+ "type": "github",
1352
+ "url": "https://github.com/sponsors/ai"
1353
+ }
1354
+ ],
1355
+ "license": "MIT",
1356
+ "bin": {
1357
+ "nanoid": "bin/nanoid.cjs"
1358
+ },
1359
+ "engines": {
1360
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1361
+ }
1362
+ },
1363
+ "node_modules/oxlint": {
1364
+ "version": "1.89.0",
1365
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.89.0.tgz",
1366
+ "integrity": "sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==",
1367
+ "dev": true,
1368
+ "license": "MIT",
1369
+ "bin": {
1370
+ "oxlint": "bin/oxlint"
1371
+ },
1372
+ "engines": {
1373
+ "node": "^20.19.0 || >=22.12.0"
1374
+ },
1375
+ "funding": {
1376
+ "url": "https://github.com/sponsors/oxc-project"
1377
+ },
1378
+ "optionalDependencies": {
1379
+ "@oxlint/binding-android-arm-eabi": "1.89.0",
1380
+ "@oxlint/binding-android-arm64": "1.89.0",
1381
+ "@oxlint/binding-darwin-arm64": "1.89.0",
1382
+ "@oxlint/binding-darwin-x64": "1.89.0",
1383
+ "@oxlint/binding-freebsd-x64": "1.89.0",
1384
+ "@oxlint/binding-linux-arm-gnueabihf": "1.89.0",
1385
+ "@oxlint/binding-linux-arm-musleabihf": "1.89.0",
1386
+ "@oxlint/binding-linux-arm64-gnu": "1.89.0",
1387
+ "@oxlint/binding-linux-arm64-musl": "1.89.0",
1388
+ "@oxlint/binding-linux-ppc64-gnu": "1.89.0",
1389
+ "@oxlint/binding-linux-riscv64-gnu": "1.89.0",
1390
+ "@oxlint/binding-linux-riscv64-musl": "1.89.0",
1391
+ "@oxlint/binding-linux-s390x-gnu": "1.89.0",
1392
+ "@oxlint/binding-linux-x64-gnu": "1.89.0",
1393
+ "@oxlint/binding-linux-x64-musl": "1.89.0",
1394
+ "@oxlint/binding-openharmony-arm64": "1.89.0",
1395
+ "@oxlint/binding-win32-arm64-msvc": "1.89.0",
1396
+ "@oxlint/binding-win32-ia32-msvc": "1.89.0",
1397
+ "@oxlint/binding-win32-x64-msvc": "1.89.0"
1398
+ },
1399
+ "peerDependencies": {
1400
+ "oxlint-tsgolint": ">=7.0.2003",
1401
+ "vite-plus": "*"
1402
+ },
1403
+ "peerDependenciesMeta": {
1404
+ "oxlint-tsgolint": {
1405
+ "optional": true
1406
+ },
1407
+ "vite-plus": {
1408
+ "optional": true
1409
+ }
1410
+ }
1411
+ },
1412
+ "node_modules/picocolors": {
1413
+ "version": "1.1.1",
1414
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1415
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1416
+ "dev": true,
1417
+ "license": "ISC"
1418
+ },
1419
+ "node_modules/picomatch": {
1420
+ "version": "4.0.7",
1421
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
1422
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
1423
+ "dev": true,
1424
+ "license": "MIT",
1425
+ "engines": {
1426
+ "node": ">=12"
1427
+ },
1428
+ "funding": {
1429
+ "url": "https://github.com/sponsors/jonschlinkert"
1430
+ }
1431
+ },
1432
+ "node_modules/postcss": {
1433
+ "version": "8.5.31",
1434
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
1435
+ "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
1436
+ "dev": true,
1437
+ "funding": [
1438
+ {
1439
+ "type": "opencollective",
1440
+ "url": "https://opencollective.com/postcss/"
1441
+ },
1442
+ {
1443
+ "type": "tidelift",
1444
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1445
+ },
1446
+ {
1447
+ "type": "github",
1448
+ "url": "https://github.com/sponsors/ai"
1449
+ }
1450
+ ],
1451
+ "license": "MIT",
1452
+ "dependencies": {
1453
+ "nanoid": "^3.3.20",
1454
+ "picocolors": "^1.1.1",
1455
+ "source-map-js": "^1.2.2"
1456
+ },
1457
+ "engines": {
1458
+ "node": "^10 || ^12 || >=14"
1459
+ }
1460
+ },
1461
+ "node_modules/react": {
1462
+ "version": "19.3.1",
1463
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
1464
+ "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
1465
+ "license": "MIT",
1466
+ "engines": {
1467
+ "node": ">=0.10.0"
1468
+ }
1469
+ },
1470
+ "node_modules/react-dom": {
1471
+ "version": "19.3.1",
1472
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
1473
+ "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
1474
+ "license": "MIT",
1475
+ "dependencies": {
1476
+ "scheduler": "^0.28.0"
1477
+ },
1478
+ "peerDependencies": {
1479
+ "react": "^19.3.1"
1480
+ }
1481
+ },
1482
+ "node_modules/react-router": {
1483
+ "version": "7.18.4",
1484
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
1485
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
1486
+ "license": "MIT",
1487
+ "dependencies": {
1488
+ "cookie": "^1.0.1",
1489
+ "set-cookie-parser": "^2.6.0"
1490
+ },
1491
+ "engines": {
1492
+ "node": ">=20.0.0"
1493
+ },
1494
+ "peerDependencies": {
1495
+ "react": ">=18",
1496
+ "react-dom": ">=18"
1497
+ },
1498
+ "peerDependenciesMeta": {
1499
+ "react-dom": {
1500
+ "optional": true
1501
+ }
1502
+ }
1503
+ },
1504
+ "node_modules/react-router-dom": {
1505
+ "version": "7.18.4",
1506
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
1507
+ "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
1508
+ "license": "MIT",
1509
+ "dependencies": {
1510
+ "react-router": "7.18.4"
1511
+ },
1512
+ "engines": {
1513
+ "node": ">=20.0.0"
1514
+ },
1515
+ "peerDependencies": {
1516
+ "react": ">=18",
1517
+ "react-dom": ">=18"
1518
+ }
1519
+ },
1520
+ "node_modules/rolldown": {
1521
+ "version": "1.2.14",
1522
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
1523
+ "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
1524
+ "dev": true,
1525
+ "license": "MIT",
1526
+ "dependencies": {
1527
+ "@oxc-project/types": "=0.154.0",
1528
+ "@rolldown/pluginutils": "^1.0.0"
1529
+ },
1530
+ "bin": {
1531
+ "rolldown": "bin/cli.mjs"
1532
+ },
1533
+ "engines": {
1534
+ "node": "^20.19.0 || >=22.12.0"
1535
+ },
1536
+ "optionalDependencies": {
1537
+ "@rolldown/binding-android-arm-eabi": "1.2.14",
1538
+ "@rolldown/binding-android-arm64": "1.2.14",
1539
+ "@rolldown/binding-darwin-arm64": "1.2.14",
1540
+ "@rolldown/binding-darwin-x64": "1.2.14",
1541
+ "@rolldown/binding-freebsd-x64": "1.2.14",
1542
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
1543
+ "@rolldown/binding-linux-arm64-gnu": "1.2.14",
1544
+ "@rolldown/binding-linux-arm64-musl": "1.2.14",
1545
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.14",
1546
+ "@rolldown/binding-linux-s390x-gnu": "1.2.14",
1547
+ "@rolldown/binding-linux-x64-gnu": "1.2.14",
1548
+ "@rolldown/binding-linux-x64-musl": "1.2.14",
1549
+ "@rolldown/binding-openharmony-arm64": "1.2.14",
1550
+ "@rolldown/binding-win32-arm64-msvc": "1.2.14",
1551
+ "@rolldown/binding-win32-x64-msvc": "1.2.14"
1552
+ }
1553
+ },
1554
+ "node_modules/scheduler": {
1555
+ "version": "0.28.0",
1556
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
1557
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
1558
+ "license": "MIT"
1559
+ },
1560
+ "node_modules/set-cookie-parser": {
1561
+ "version": "2.7.2",
1562
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
1563
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
1564
+ "license": "MIT"
1565
+ },
1566
+ "node_modules/source-map-js": {
1567
+ "version": "1.2.2",
1568
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
1569
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
1570
+ "dev": true,
1571
+ "license": "BSD-3-Clause",
1572
+ "engines": {
1573
+ "node": ">=0.10.0"
1574
+ }
1575
+ },
1576
+ "node_modules/tinyglobby": {
1577
+ "version": "0.2.17",
1578
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1579
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1580
+ "dev": true,
1581
+ "license": "MIT",
1582
+ "dependencies": {
1583
+ "fdir": "^6.5.0",
1584
+ "picomatch": "^4.0.4"
1585
+ },
1586
+ "engines": {
1587
+ "node": ">=12.0.0"
1588
+ },
1589
+ "funding": {
1590
+ "url": "https://github.com/sponsors/SuperchupuDev"
1591
+ }
1592
+ },
1593
+ "node_modules/typescript": {
1594
+ "version": "7.0.2",
1595
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
1596
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
1597
+ "dev": true,
1598
+ "license": "Apache-2.0",
1599
+ "bin": {
1600
+ "tsc": "bin/tsc"
1601
+ },
1602
+ "engines": {
1603
+ "node": ">=16.20.0"
1604
+ },
1605
+ "optionalDependencies": {
1606
+ "@typescript/typescript-aix-ppc64": "7.0.2",
1607
+ "@typescript/typescript-darwin-arm64": "7.0.2",
1608
+ "@typescript/typescript-darwin-x64": "7.0.2",
1609
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
1610
+ "@typescript/typescript-freebsd-x64": "7.0.2",
1611
+ "@typescript/typescript-linux-arm": "7.0.2",
1612
+ "@typescript/typescript-linux-arm64": "7.0.2",
1613
+ "@typescript/typescript-linux-loong64": "7.0.2",
1614
+ "@typescript/typescript-linux-mips64el": "7.0.2",
1615
+ "@typescript/typescript-linux-ppc64": "7.0.2",
1616
+ "@typescript/typescript-linux-riscv64": "7.0.2",
1617
+ "@typescript/typescript-linux-s390x": "7.0.2",
1618
+ "@typescript/typescript-linux-x64": "7.0.2",
1619
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
1620
+ "@typescript/typescript-netbsd-x64": "7.0.2",
1621
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
1622
+ "@typescript/typescript-openbsd-x64": "7.0.2",
1623
+ "@typescript/typescript-sunos-x64": "7.0.2",
1624
+ "@typescript/typescript-win32-arm64": "7.0.2",
1625
+ "@typescript/typescript-win32-x64": "7.0.2"
1626
+ }
1627
+ },
1628
+ "node_modules/undici-types": {
1629
+ "version": "7.24.6",
1630
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
1631
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
1632
+ "dev": true,
1633
+ "license": "MIT"
1634
+ },
1635
+ "node_modules/vite": {
1636
+ "version": "8.3.4",
1637
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
1638
+ "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
1639
+ "dev": true,
1640
+ "license": "MIT",
1641
+ "dependencies": {
1642
+ "lightningcss": "^1.33.0",
1643
+ "picomatch": "^4.0.7",
1644
+ "postcss": "^8.5.29",
1645
+ "rolldown": "~1.2.12",
1646
+ "tinyglobby": "^0.2.17"
1647
+ },
1648
+ "bin": {
1649
+ "vite": "bin/vite.js"
1650
+ },
1651
+ "engines": {
1652
+ "node": "^20.19.0 || >=22.12.0"
1653
+ },
1654
+ "funding": {
1655
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1656
+ },
1657
+ "optionalDependencies": {
1658
+ "fsevents": "~2.3.3"
1659
+ },
1660
+ "peerDependencies": {
1661
+ "@types/node": "^20.19.0 || >=22.12.0",
1662
+ "@vitejs/devtools": "^0.7.1",
1663
+ "esbuild": "^0.27.0 || ^0.28.0",
1664
+ "jiti": ">=1.21.0",
1665
+ "less": "^4.0.0",
1666
+ "sass": "^1.70.0",
1667
+ "sass-embedded": "^1.70.0",
1668
+ "stylus": ">=0.54.8",
1669
+ "sugarss": "^5.0.0",
1670
+ "terser": "^5.16.0",
1671
+ "tsx": "^4.8.1",
1672
+ "yaml": "^2.4.2"
1673
+ },
1674
+ "peerDependenciesMeta": {
1675
+ "@types/node": {
1676
+ "optional": true
1677
+ },
1678
+ "@vitejs/devtools": {
1679
+ "optional": true
1680
+ },
1681
+ "esbuild": {
1682
+ "optional": true
1683
+ },
1684
+ "jiti": {
1685
+ "optional": true
1686
+ },
1687
+ "less": {
1688
+ "optional": true
1689
+ },
1690
+ "sass": {
1691
+ "optional": true
1692
+ },
1693
+ "sass-embedded": {
1694
+ "optional": true
1695
+ },
1696
+ "stylus": {
1697
+ "optional": true
1698
+ },
1699
+ "sugarss": {
1700
+ "optional": true
1701
+ },
1702
+ "terser": {
1703
+ "optional": true
1704
+ },
1705
+ "tsx": {
1706
+ "optional": true
1707
+ },
1708
+ "yaml": {
1709
+ "optional": true
1710
+ }
1711
+ }
1712
+ }
1713
+ }
1714
+}
projects/hot-tub/src/web/frontend/package.json
... ...
@@ -0,0 +1,26 @@
1
+{
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "oxlint",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "react": "^19.3.1",
14
+ "react-dom": "^19.3.1",
15
+ "react-router-dom": "^7.18.4"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^24.19.2",
19
+ "@types/react": "^19.3.0",
20
+ "@types/react-dom": "^19.3.0",
21
+ "@vitejs/plugin-react": "^6.1.2",
22
+ "oxlint": "^1.88.0",
23
+ "typescript": "~7.0.2",
24
+ "vite": "^8.3.4"
25
+ }
26
+}
projects/hot-tub/src/web/frontend/public/favicon.svg
... ...
@@ -0,0 +1 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
... ...
\ No newline at end of file
projects/hot-tub/src/web/frontend/public/icons.svg
... ...
@@ -0,0 +1,24 @@
1
+<svg xmlns="http://www.w3.org/2000/svg">
2
+ <symbol id="bluesky-icon" viewBox="0 0 16 17">
3
+ <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
4
+ <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
5
+ </symbol>
6
+ <symbol id="discord-icon" viewBox="0 0 20 19">
7
+ <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
8
+ </symbol>
9
+ <symbol id="documentation-icon" viewBox="0 0 21 20">
10
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
11
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
12
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
13
+ </symbol>
14
+ <symbol id="github-icon" viewBox="0 0 19 19">
15
+ <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
16
+ </symbol>
17
+ <symbol id="social-icon" viewBox="0 0 20 20">
18
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
19
+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
20
+ </symbol>
21
+ <symbol id="x-icon" viewBox="0 0 19 19">
22
+ <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
23
+ </symbol>
24
+</svg>
projects/hot-tub/src/web/frontend/src/App.css
... ...
@@ -0,0 +1,184 @@
1
+.counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+}
19
+
20
+.hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+}
58
+
59
+#center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+}
72
+
73
+#next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+}
97
+
98
+#docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+}
106
+
107
+#next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+}
155
+
156
+#spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+}
163
+
164
+.ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+}
projects/hot-tub/src/web/frontend/src/App.tsx
... ...
@@ -0,0 +1,79 @@
1
+import { useEffect, useState } from 'react'
2
+import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
3
+import { connectEvents } from './api'
4
+import { Header } from './components/Header'
5
+import { ControlPage } from './components/ControlPage'
6
+import { DiagnosticPage } from './components/DiagnosticPage'
7
+import { SensorSetupPage } from './components/SensorSetupPage'
8
+import { isBackflushing, isPayloadStale } from './helpers'
9
+import type { ConnectionState, ControlState, FirstBundle, TempsState } from './types'
10
+
11
+function Loading() {
12
+ return <div className="card loading">Connecting to the hot tub…</div>
13
+}
14
+
15
+export default function App() {
16
+ const [connection, setConnection] = useState<ConnectionState>('connecting')
17
+ const [control, setControl] = useState<ControlState | null>(null)
18
+ const [temps, setTemps] = useState<TempsState | null>(null)
19
+ const [backflushRequested, setBackflushRequested] = useState(false)
20
+ const [firstBundle, setFirstBundle] = useState<FirstBundle>(null)
21
+ const location = useLocation()
22
+
23
+ useEffect(() => {
24
+ return connectEvents({
25
+ onOpen: () => setConnection('open'),
26
+ onError: () => setConnection('reconnecting'),
27
+ onControl: (data) => {
28
+ const c = data as ControlState
29
+ setControl(c)
30
+ setBackflushRequested((prev) => prev && !isBackflushing(c))
31
+ },
32
+ onTemps: (data) => setTemps(data as TempsState),
33
+ onSetup: () => {},
34
+ })
35
+ }, [])
36
+
37
+ const stale =
38
+ connection !== 'open' ||
39
+ isPayloadStale(control?.ts) ||
40
+ isPayloadStale(temps?.ts)
41
+
42
+ const displayConnection: ConnectionState = stale && connection === 'open' ? 'reconnecting' : connection
43
+
44
+ const ready = control && temps
45
+
46
+ return (
47
+ <div className="app">
48
+ <Header connection={displayConnection} />
49
+ <main className={`page${displayConnection === 'open' ? '' : ' stale'}`}>
50
+ {!ready ? (
51
+ <Loading />
52
+ ) : (
53
+ <Routes>
54
+ <Route path="/" element={<Navigate to="/control" replace />} />
55
+ <Route path="/control" element={<ControlPage control={control} />} />
56
+ <Route
57
+ path="/diagnostic"
58
+ element={
59
+ <DiagnosticPage
60
+ control={control}
61
+ temps={temps}
62
+ backflushRequested={backflushRequested}
63
+ onBackflushRequested={setBackflushRequested}
64
+ />
65
+ }
66
+ />
67
+ <Route
68
+ path="/diagnostic/sensor-setup/:step"
69
+ element={
70
+ <SensorSetupPage temps={temps} firstBundle={firstBundle} onFirstBundle={setFirstBundle} />
71
+ }
72
+ />
73
+ <Route path="*" element={<Navigate to={location.pathname.startsWith('/diagnostic') ? '/diagnostic' : '/control'} replace />} />
74
+ </Routes>
75
+ )}
76
+ </main>
77
+ </div>
78
+ )
79
+}
projects/hot-tub/src/web/frontend/src/api.ts
... ...
@@ -0,0 +1,47 @@
1
+export async function sendCommand(op: string, args: Record<string, unknown> = {}): Promise<void> {
2
+ const res = await fetch('/api/command', {
3
+ method: 'POST',
4
+ headers: { 'Content-Type': 'application/json' },
5
+ body: JSON.stringify({ op, args }),
6
+ })
7
+ if (!res.ok) {
8
+ const body = await res.json().catch(() => ({}))
9
+ throw new Error((body as { error?: string }).error || res.statusText)
10
+ }
11
+}
12
+
13
+async function setupPost(path: string): Promise<void> {
14
+ const res = await fetch(path, { method: 'POST' })
15
+ if (!res.ok) {
16
+ const body = await res.json().catch(() => ({}))
17
+ throw new Error((body as { error?: string }).error || res.statusText)
18
+ }
19
+}
20
+
21
+export const startSensorSetup = () => setupPost('/api/sensor-setup/start')
22
+export const finishSensorSetup = () => setupPost('/api/sensor-setup/finish')
23
+export const cancelSensorSetup = () => setupPost('/api/sensor-setup/cancel')
24
+
25
+export type SseHandlers = {
26
+ onOpen: () => void
27
+ onError: () => void
28
+ onControl: (data: unknown) => void
29
+ onTemps: (data: unknown) => void
30
+ onSetup: (data: unknown) => void
31
+}
32
+
33
+export function connectEvents(handlers: SseHandlers): () => void {
34
+ const es = new EventSource('/api/events')
35
+ es.onopen = () => handlers.onOpen()
36
+ es.onerror = () => handlers.onError()
37
+ es.addEventListener('control.state', (e) => {
38
+ handlers.onControl(JSON.parse((e as MessageEvent).data))
39
+ })
40
+ es.addEventListener('temps.reading', (e) => {
41
+ handlers.onTemps(JSON.parse((e as MessageEvent).data))
42
+ })
43
+ es.addEventListener('temps.setup', (e) => {
44
+ handlers.onSetup(JSON.parse((e as MessageEvent).data))
45
+ })
46
+ return () => es.close()
47
+}
projects/hot-tub/src/web/frontend/src/assets/hero.png
... ...
Binary files /dev/null and b/projects/hot-tub/src/web/frontend/src/assets/hero.png differ
projects/hot-tub/src/web/frontend/src/assets/react.svg
... ...
@@ -0,0 +1 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
... ...
\ No newline at end of file
projects/hot-tub/src/web/frontend/src/assets/vite.svg
... ...
@@ -0,0 +1 @@
1
+<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="29.961" y="-17.13" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="37.754" y="3.055" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-13.43" y="-22.082" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="34.321" y="-37.644" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="38.847" y="-10.552" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-15.081" y="6.78" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="22.45" y="-1.645" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="32.919" y="11.36" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter></defs></svg>
projects/hot-tub/src/web/frontend/src/components/ControlPage.tsx
... ...
@@ -0,0 +1,108 @@
1
+import { sendCommand } from '../api'
2
+import { TARGET_MAX, TARGET_MIN, filterLabel, fmtF, fmtTarget, has, phaseLabel } from '../helpers'
3
+import type { ControlState } from '../types'
4
+import { Pump } from './Pump'
5
+import { TubGraphic } from './TubGraphic'
6
+
7
+function ErrorBanner({ error, onReset }: { error: NonNullable<ControlState['error']>; onReset: () => void }) {
8
+ return (
9
+ <div className="banner banner-error" role="alert">
10
+ <span>
11
+ <strong>Heater problem.</strong> {error.detail || error.code}
12
+ </span>
13
+ <button type="button" className="btn btn-small" onClick={onReset}>
14
+ Reset (set Sleep)
15
+ </button>
16
+ </div>
17
+ )
18
+}
19
+
20
+export function ControlPage({ control }: { control: ControlState }) {
21
+ const tub = control.tub_avg_f
22
+ const steaming = control.heat_pump === 'on' || (has(tub) && tub >= 95)
23
+
24
+ return (
25
+ <div className="control-layout">
26
+ <section className="card tub-card">
27
+ <div className="banner-slot">
28
+ {control.error ? (
29
+ <ErrorBanner error={control.error} onReset={() => sendCommand('set_heat_mode', { mode: 'sleep' })} />
30
+ ) : null}
31
+ </div>
32
+ <TubGraphic tempF={tub} steaming={steaming} />
33
+ <div className="pumps">
34
+ <Pump kind="heat" label="Heat" running={control.heat_pump === 'on'} />
35
+ <Pump kind="filter" label="Filter" running={control.filter_pump === 'on'} />
36
+ </div>
37
+ </section>
38
+ <section className="card settings-card">
39
+ <div className="target">
40
+ <span className="field-label">Target temperature</span>
41
+ <div className="target-row">
42
+ <button
43
+ type="button"
44
+ className="round-btn"
45
+ aria-label="Decrease target"
46
+ disabled={control.target_f <= TARGET_MIN}
47
+ onClick={() => sendCommand('target_temp_decrement')}
48
+ >
49
+ −
50
+ </button>
51
+ <div className="target-value">{fmtTarget(control.target_f)}</div>
52
+ <button
53
+ type="button"
54
+ className="round-btn"
55
+ aria-label="Increase target"
56
+ disabled={control.target_f >= TARGET_MAX}
57
+ onClick={() => sendCommand('target_temp_increment')}
58
+ >
59
+ +
60
+ </button>
61
+ </div>
62
+ </div>
63
+ <div className="modes">
64
+ <label className="field">
65
+ <span className="field-label">Heat mode</span>
66
+ <select
67
+ value={control.heat_mode === 'backflush' ? 'backflush' : control.heat_mode}
68
+ onChange={(e) => sendCommand('set_heat_mode', { mode: e.target.value })}
69
+ >
70
+ <option value="sleep">Sleep</option>
71
+ <option value="maintain">Maintain temperature</option>
72
+ {control.heat_mode === 'backflush' ? (
73
+ <option value="backflush" disabled>
74
+ Backflush (running)
75
+ </option>
76
+ ) : null}
77
+ </select>
78
+ </label>
79
+ <label className="field">
80
+ <span className="field-label">Filter mode</span>
81
+ <select
82
+ value={control.filter_mode}
83
+ onChange={(e) => sendCommand('set_filter_mode', { mode: e.target.value })}
84
+ >
85
+ <option value="auto">Auto (daily schedule)</option>
86
+ <option value="on">On</option>
87
+ <option value="off">Off</option>
88
+ </select>
89
+ </label>
90
+ </div>
91
+ <dl className="summary">
92
+ <div>
93
+ <dt>Heater</dt>
94
+ <dd>{phaseLabel(control)}</dd>
95
+ </div>
96
+ <div>
97
+ <dt>Hot input</dt>
98
+ <dd>{fmtF(control.hot_in_f)}</dd>
99
+ </div>
100
+ <div>
101
+ <dt>Filter</dt>
102
+ <dd>{filterLabel(control)}</dd>
103
+ </div>
104
+ </dl>
105
+ </section>
106
+ </div>
107
+ )
108
+}
projects/hot-tub/src/web/frontend/src/components/DiagnosticPage.tsx
... ...
@@ -0,0 +1,107 @@
1
+import { useNavigate } from 'react-router-dom'
2
+import { sendCommand } from '../api'
3
+import { PROBES, fmtF, has, isBackflushing, probeValues } from '../helpers'
4
+import type { ControlState, TempsState } from '../types'
5
+import { StatusLight } from './StatusLight'
6
+
7
+function BackflushAnimation() {
8
+ return (
9
+ <div className="backflush-anim" role="status">
10
+ <svg viewBox="0 0 120 120" aria-hidden="true">
11
+ <g className="backflush-arrows">
12
+ <path d="M60 10 A50 50 0 0 1 110 60" />
13
+ <path d="M60 110 A50 50 0 0 1 10 60" />
14
+ <path className="arrowhead" d="M102 54 L118 54 L110 66 Z" />
15
+ <path className="arrowhead" d="M2 66 L18 66 L10 54 Z" />
16
+ </g>
17
+ <path
18
+ className="droplet"
19
+ d="M60 28 C60 28 36 56 36 72 A24 24 0 0 0 84 72 C84 56 60 28 60 28 Z"
20
+ />
21
+ <circle className="droplet-eye" cx="52" cy="70" r="3" />
22
+ <circle className="droplet-eye" cx="68" cy="70" r="3" />
23
+ <path className="droplet-smile" d="M52 80 q8 7 16 0" />
24
+ <circle className="bubble b1" cx="44" cy="102" r="4" />
25
+ <circle className="bubble b2" cx="60" cy="104" r="3" />
26
+ <circle className="bubble b3" cx="76" cy="102" r="4" />
27
+ </svg>
28
+ <p>Backflush in progress…</p>
29
+ </div>
30
+ )
31
+}
32
+
33
+function ProbeRow({
34
+ label,
35
+ value,
36
+ missingStatus,
37
+}: {
38
+ label: string
39
+ value: number | null | undefined
40
+ missingStatus: string
41
+}) {
42
+ return (
43
+ <li className="probe-row">
44
+ <StatusLight status={has(value) ? 'on' : missingStatus} label={label} />
45
+ <span className="probe-value">{fmtF(value)}</span>
46
+ </li>
47
+ )
48
+}
49
+
50
+export function DiagnosticPage({
51
+ control,
52
+ temps,
53
+ backflushRequested,
54
+ onBackflushRequested,
55
+}: {
56
+ control: ControlState
57
+ temps: TempsState
58
+ backflushRequested: boolean
59
+ onBackflushRequested: (v: boolean) => void
60
+}) {
61
+ const navigate = useNavigate()
62
+ const running = isBackflushing(control)
63
+ const values = probeValues(temps)
64
+
65
+ const startBackflush = () => {
66
+ onBackflushRequested(true)
67
+ sendCommand('set_heat_mode', { mode: 'backflush' }).catch(() => onBackflushRequested(false))
68
+ }
69
+
70
+ return (
71
+ <div className="diagnostic-layout">
72
+ <section className="card">
73
+ <button
74
+ type="button"
75
+ className="big-button big-button-backflush"
76
+ disabled={running || backflushRequested}
77
+ onClick={startBackflush}
78
+ >
79
+ {running ? 'Backflushing…' : 'Backflush'}
80
+ </button>
81
+ <div className="backflush-slot">
82
+ {running ? (
83
+ <BackflushAnimation />
84
+ ) : (
85
+ <p className="hint">Runs the heat pump briefly to purge the heater line, then returns to Sleep.</p>
86
+ )}
87
+ </div>
88
+ </section>
89
+ <section className="card">
90
+ <button type="button" className="big-button big-button-setup" onClick={() => navigate('/diagnostic/sensor-setup/1')}>
91
+ Sensor Setup
92
+ </button>
93
+ <div className="readings">
94
+ <div className="readings-header">
95
+ <span className="field-label">Sensors</span>
96
+ <span className="setup-state">{temps.setup_state.replace(/_/g, ' ')}</span>
97
+ </div>
98
+ <ul className="probe-list">
99
+ {PROBES.map((p) => (
100
+ <ProbeRow key={p.key} label={p.label} value={values[p.key]} missingStatus="error" />
101
+ ))}
102
+ </ul>
103
+ </div>
104
+ </section>
105
+ </div>
106
+ )
107
+}
projects/hot-tub/src/web/frontend/src/components/Header.tsx
... ...
@@ -0,0 +1,43 @@
1
+import { NavLink } from 'react-router-dom'
2
+import type { ConnectionState } from '../types'
3
+import { StatusLight } from './StatusLight'
4
+
5
+function ConnectionBadge({ connection }: { connection: ConnectionState }) {
6
+ const map: Record<ConnectionState, [string, string]> = {
7
+ open: ['on', 'Live'],
8
+ connecting: ['waiting', 'Connecting…'],
9
+ reconnecting: ['error', 'Reconnecting…'],
10
+ }
11
+ const [status, label] = map[connection]
12
+ return (
13
+ <div className="connection">
14
+ <StatusLight status={status} label={label} />
15
+ </div>
16
+ )
17
+}
18
+
19
+export function Header({ connection }: { connection: ConnectionState }) {
20
+ return (
21
+ <header className="app-header">
22
+ <div className="brand">
23
+ <span className="brand-icon" aria-hidden="true">
24
+ ♨
25
+ </span>{' '}
26
+ Hot Tub
27
+ </div>
28
+ <nav className="nav">
29
+ <NavLink className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`} to="/control">
30
+ Main Control
31
+ </NavLink>
32
+ <NavLink
33
+ className={({ isActive }) => `nav-tab${isActive ? ' active' : ''}`}
34
+ to="/diagnostic"
35
+ end={false}
36
+ >
37
+ Diagnostic
38
+ </NavLink>
39
+ </nav>
40
+ <ConnectionBadge connection={connection} />
41
+ </header>
42
+ )
43
+}
projects/hot-tub/src/web/frontend/src/components/Pump.tsx
... ...
@@ -0,0 +1,19 @@
1
+import { StatusLight } from './StatusLight'
2
+
3
+export function Pump({ kind, label, running }: { kind: string; label: string; running: boolean }) {
4
+ const blades = [0, 90, 180, 270].map((a) => (
5
+ <ellipse key={a} cx="60" cy="36" rx="11" ry="21" transform={`rotate(${a} 60 60)`} />
6
+ ))
7
+ return (
8
+ <div className={`pump pump-${kind}${running ? ' running' : ''}`}>
9
+ <svg className="pump-svg" viewBox="0 0 120 120" aria-hidden="true">
10
+ <rect className="pump-frame" x="4" y="4" width="112" height="112" rx="18" />
11
+ <circle className="pump-housing" cx="60" cy="60" r="48" />
12
+ <g className="fan-blades">{blades}</g>
13
+ <circle className="pump-hub" cx="60" cy="60" r="8" />
14
+ </svg>
15
+ <div className="pump-label">{label}</div>
16
+ <StatusLight status={running ? 'on' : 'off'} label={running ? 'Running' : 'Off'} />
17
+ </div>
18
+ )
19
+}
projects/hot-tub/src/web/frontend/src/components/SensorSetupPage.tsx
... ...
@@ -0,0 +1,202 @@
1
+import { useNavigate, useParams } from 'react-router-dom'
2
+import { cancelSensorSetup, finishSensorSetup, startSensorSetup } from '../api'
3
+import { PROBES, WATER_KEYS, fmtF, has, probeValues } from '../helpers'
4
+import type { FirstBundle, TempsState } from '../types'
5
+import { StatusLight } from './StatusLight'
6
+
7
+function Stepper({ step }: { step: number }) {
8
+ const labels = ['Disconnect', 'First set', 'Second set']
9
+ return (
10
+ <ol className="stepper">
11
+ {labels.map((l, i) => (
12
+ <li key={l} className={i + 1 < step ? 'done' : i + 1 === step ? 'current' : ''}>
13
+ {l}
14
+ </li>
15
+ ))}
16
+ </ol>
17
+ )
18
+}
19
+
20
+function ProbeRow({
21
+ label,
22
+ value,
23
+ missingStatus,
24
+}: {
25
+ label: string
26
+ value: number | null | undefined
27
+ missingStatus: string
28
+}) {
29
+ return (
30
+ <li className="probe-row">
31
+ <StatusLight status={has(value) ? 'on' : missingStatus} label={label} />
32
+ <span className="probe-value">{fmtF(value)}</span>
33
+ </li>
34
+ )
35
+}
36
+
37
+export function SensorSetupPage({
38
+ temps,
39
+ firstBundle,
40
+ onFirstBundle,
41
+}: {
42
+ temps: TempsState
43
+ firstBundle: FirstBundle
44
+ onFirstBundle: (v: FirstBundle) => void
45
+}) {
46
+ const navigate = useNavigate()
47
+ const { step: stepParam } = useParams()
48
+ const step = Math.min(3, Math.max(1, Number(stepParam) || 1))
49
+ const values = probeValues(temps)
50
+
51
+ const goDiagnostic = () => navigate('/diagnostic')
52
+
53
+ return (
54
+ <section className="card setup-card">
55
+ <h1>Sensor setup</h1>
56
+ <Stepper step={step} />
57
+ {step === 1 ? (
58
+ <SetupStep1 values={values} onCancel={() => cancelSensorSetup().finally(goDiagnostic)} onContinue={() => startSensorSetup().then(() => navigate('/diagnostic/sensor-setup/2'))} />
59
+ ) : null}
60
+ {step === 2 ? (
61
+ <SetupStep2
62
+ setupState={temps.setup_state}
63
+ values={values}
64
+ onCancel={() => cancelSensorSetup().finally(goDiagnostic)}
65
+ onContinue={() => {
66
+ const hot = has(values.hot_in)
67
+ const water = WATER_KEYS.some((k) => has(values[k]))
68
+ onFirstBundle(hot && water ? null : hot ? 'hot' : 'water')
69
+ navigate('/diagnostic/sensor-setup/3')
70
+ }}
71
+ />
72
+ ) : null}
73
+ {step === 3 ? (
74
+ <SetupStep3
75
+ setupState={temps.setup_state}
76
+ values={values}
77
+ firstBundle={firstBundle}
78
+ onCancel={() => cancelSensorSetup().finally(goDiagnostic)}
79
+ onFinish={() => finishSensorSetup().then(goDiagnostic)}
80
+ />
81
+ ) : null}
82
+ </section>
83
+ )
84
+}
85
+
86
+function SetupStep1({
87
+ values,
88
+ onContinue,
89
+ onCancel,
90
+}: {
91
+ values: Record<string, number | null | undefined>
92
+ onContinue: () => void
93
+ onCancel: () => void
94
+}) {
95
+ const connected = PROBES.filter((p) => has(values[p.key])).length
96
+ return (
97
+ <div className="setup-step">
98
+ <h2>Disconnect all sensors</h2>
99
+ <p>Unplug both sensor bundles from the enclosure: the hot-input probe and the four water probes.</p>
100
+ <div className="setup-status">
101
+ {connected ? (
102
+ <StatusLight status="waiting" label={`${connected} sensor${connected === 1 ? '' : 's'} still connected`} />
103
+ ) : (
104
+ <StatusLight status="on" label="All sensors disconnected" />
105
+ )}
106
+ </div>
107
+ <div className="actions">
108
+ <button type="button" className="btn btn-secondary" onClick={onCancel}>
109
+ Cancel
110
+ </button>
111
+ <button type="button" className="btn btn-primary" onClick={onContinue}>
112
+ Continue
113
+ </button>
114
+ </div>
115
+ </div>
116
+ )
117
+}
118
+
119
+function step2CanContinue(setupState: string, values: Record<string, number | null | undefined>) {
120
+ if (setupState === 'need_water') return true
121
+ if (setupState === 'need_hot_in' && WATER_KEYS.every((k) => has(values[k]))) return true
122
+ return false
123
+}
124
+
125
+function SetupStep2({
126
+ setupState,
127
+ values,
128
+ onContinue,
129
+ onCancel,
130
+}: {
131
+ setupState: string
132
+ values: Record<string, number | null | undefined>
133
+ onContinue: () => void
134
+ onCancel: () => void
135
+}) {
136
+ const hot = has(values.hot_in)
137
+ const water = WATER_KEYS.some((k) => has(values[k]))
138
+ let label = 'Waiting for readings…'
139
+ if (hot && water) label = 'Both sets detected'
140
+ else if (hot) label = 'Hot-input sensor detected'
141
+ else if (water) label = 'Water sensors detected'
142
+ const enabled = step2CanContinue(setupState, values)
143
+
144
+ return (
145
+ <div className="setup-step">
146
+ <h2>Plug in one set of sensors</h2>
147
+ <p>Connect either the hot-input bundle or the water bundle. Continue unlocks once readings arrive.</p>
148
+ <div className="setup-status">
149
+ <StatusLight status={enabled ? 'on' : 'waiting'} label={label} />
150
+ </div>
151
+ <div className="actions">
152
+ <button type="button" className="btn btn-secondary" onClick={onCancel}>
153
+ Cancel
154
+ </button>
155
+ <button type="button" className="btn btn-primary" disabled={!enabled} onClick={onContinue}>
156
+ Continue
157
+ </button>
158
+ </div>
159
+ </div>
160
+ )
161
+}
162
+
163
+function SetupStep3({
164
+ setupState,
165
+ values,
166
+ firstBundle,
167
+ onFinish,
168
+ onCancel,
169
+}: {
170
+ setupState: string
171
+ values: Record<string, number | null | undefined>
172
+ firstBundle: FirstBundle
173
+ onFinish: () => void
174
+ onCancel: () => void
175
+}) {
176
+ const remaining =
177
+ firstBundle === 'hot'
178
+ ? 'the water sensor bundle (4 probes)'
179
+ : firstBundle === 'water'
180
+ ? 'the hot-input sensor'
181
+ : 'any remaining sensors'
182
+ const all = setupState === 'ready' && PROBES.every((p) => has(values[p.key]))
183
+ return (
184
+ <div className="setup-step">
185
+ <h2>Plug in the other set</h2>
186
+ <p>Now connect {remaining}. Finish unlocks once every sensor is reporting.</p>
187
+ <ul className="probe-list">
188
+ {PROBES.map((p) => (
189
+ <ProbeRow key={p.key} label={p.label} value={values[p.key]} missingStatus="waiting" />
190
+ ))}
191
+ </ul>
192
+ <div className="actions">
193
+ <button type="button" className="btn btn-secondary" onClick={onCancel}>
194
+ Cancel
195
+ </button>
196
+ <button type="button" className="btn btn-primary" disabled={!all} onClick={onFinish}>
197
+ Finish
198
+ </button>
199
+ </div>
200
+ </div>
201
+ )
202
+}
projects/hot-tub/src/web/frontend/src/components/StatusLight.tsx
... ...
@@ -0,0 +1,8 @@
1
+export function StatusLight({ status, label }: { status: string; label: React.ReactNode }) {
2
+ return (
3
+ <span className="status-light">
4
+ <span className={`light light-${status}`} aria-hidden="true" />
5
+ <span>{label}</span>
6
+ </span>
7
+ )
8
+}
projects/hot-tub/src/web/frontend/src/components/TubGraphic.tsx
... ...
@@ -0,0 +1,73 @@
1
+import { has } from '../helpers'
2
+
3
+const TUB_INSIDE = 'M40 64 L360 64 L338 204 Q335 214 324 214 L76 214 Q65 214 62 204 Z'
4
+
5
+function wavePath(y: number) {
6
+ let d = `M-100 ${y}`
7
+ for (let i = 0; i < 6; i++) d += ' q25 -7 50 0 q25 7 50 0'
8
+ return `${d} L500 240 L-100 240 Z`
9
+}
10
+
11
+function Duck() {
12
+ return (
13
+ <g className="duck-bob" aria-hidden="true">
14
+ <ellipse cx="14" cy="18" rx="16" ry="10" fill="#facc15" />
15
+ <path d="M4 16 q8 -6 14 2" fill="none" stroke="#eab308" strokeWidth="2" strokeLinecap="round" />
16
+ <circle cx="25" cy="6" r="8" fill="#facc15" />
17
+ <path d="M32 5 l8 2 l-8 3 z" fill="#f97316" />
18
+ <circle cx="27" cy="4" r="1.6" fill="#1e293b" />
19
+ </g>
20
+ )
21
+}
22
+
23
+export function TubGraphic({ tempF, steaming }: { tempF: number | null; steaming: boolean }) {
24
+ const label = has(tempF) ? `${tempF.toFixed(1)}°F` : '--.-°F'
25
+ const rivets = [60, 110, 160, 210, 260, 310, 340].map((x) => (
26
+ <circle key={x} className="rivet" cx={x} cy="65" r="2.2" />
27
+ ))
28
+ return (
29
+ <svg className="tub" viewBox="0 0 400 240" role="img" aria-label="Tub water temperature">
30
+ <defs>
31
+ <clipPath id="tub-inside">
32
+ <path d={TUB_INSIDE} />
33
+ </clipPath>
34
+ <linearGradient id="water-grad" x1="0" y1="0" x2="0" y2="1">
35
+ <stop offset="0" stopColor="#38bdf8" />
36
+ <stop offset="1" stopColor="#0369a1" />
37
+ </linearGradient>
38
+ <linearGradient id="steel-grad" x1="0" y1="0" x2="1" y2="0">
39
+ <stop offset="0" stopColor="#94a3b8" />
40
+ <stop offset="0.5" stopColor="#f1f5f9" />
41
+ <stop offset="1" stopColor="#94a3b8" />
42
+ </linearGradient>
43
+ </defs>
44
+ <ellipse className="ground" cx="200" cy="222" rx="170" ry="10" />
45
+ <g className={`steam${steaming ? ' on' : ''}`} aria-hidden="true">
46
+ <path className="steam-puff" d="M140 52 q-8 -10 0 -20 t0 -20" />
47
+ <path className="steam-puff" d="M200 52 q-8 -10 0 -20 t0 -20" />
48
+ <path className="steam-puff" d="M260 52 q-8 -10 0 -20 t0 -20" />
49
+ </g>
50
+ <path className="tub-shell" d={TUB_INSIDE} />
51
+ <g clipPath="url(#tub-inside)">
52
+ <g className="wave wave-back">
53
+ <path d={wavePath(90)} fill="#7dd3fc" />
54
+ </g>
55
+ <g className="wave wave-front">
56
+ <path d={wavePath(96)} fill="url(#water-grad)" />
57
+ </g>
58
+ </g>
59
+ <path className="tub-wall" d={TUB_INSIDE} />
60
+ <rect className="tub-rim" x="22" y="58" width="356" height="14" rx="7" fill="url(#steel-grad)" />
61
+ {rivets}
62
+ <g transform="translate(286 72)">
63
+ <Duck />
64
+ </g>
65
+ <text className="tub-temp" x="200" y="165" textAnchor="middle">
66
+ {label}
67
+ </text>
68
+ <text className="tub-temp-caption" x="200" y="190" textAnchor="middle">
69
+ water
70
+ </text>
71
+ </svg>
72
+ )
73
+}
projects/hot-tub/src/web/frontend/src/helpers.ts
... ...
@@ -0,0 +1,64 @@
1
+import type { ControlState } from './types'
2
+
3
+export const TARGET_MIN = 50
4
+export const TARGET_MAX = 110
5
+
6
+export const PROBES = [
7
+ { key: 'hot_in', label: 'Hot input' },
8
+ { key: 'tub1', label: 'Tub 1' },
9
+ { key: 'tub2', label: 'Tub 2' },
10
+ { key: 'tub3', label: 'Tub 3' },
11
+ { key: 'tub4', label: 'Tub 4' },
12
+] as const
13
+
14
+export const WATER_KEYS = ['tub1', 'tub2', 'tub3', 'tub4'] as const
15
+
16
+export const has = (v: unknown): v is number => v !== null && v !== undefined
17
+
18
+export function fmtF(v: number | null | undefined): string {
19
+ return has(v) ? `${Number(v).toFixed(1)}°F` : '--'
20
+}
21
+
22
+export function fmtTarget(v: number): string {
23
+ return `${Number.isInteger(v) ? v : Number(v).toFixed(1)}°F`
24
+}
25
+
26
+export function fmtTime(iso: string): string {
27
+ return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
28
+}
29
+
30
+export function isBackflushing(c: ControlState): boolean {
31
+ return c.heat_mode === 'backflush' || c.heat_phase === 'backflush'
32
+}
33
+
34
+export function phaseLabel(control: ControlState): string {
35
+ if (control.heat_phase.startsWith('startup_')) {
36
+ return `Starting (attempt ${control.heat_phase.slice(-1)} of 3)`
37
+ }
38
+ const labels: Record<string, string> = {
39
+ idle: control.heat_mode === 'maintain' ? 'Waiting (in range)' : 'Off',
40
+ heating: 'Heating',
41
+ backflush: 'Backflushing',
42
+ error_startup: 'Failed to start',
43
+ error_safety: 'Blocked by safety check',
44
+ }
45
+ return labels[control.heat_phase] || control.heat_phase
46
+}
47
+
48
+export function filterLabel(c: ControlState): string {
49
+ const running = c.filter_pump === 'on'
50
+ if (c.filter_mode !== 'auto') return running ? 'On' : 'Off'
51
+ if (running) return 'Auto · running'
52
+ return c.filter_next_start ? `Auto · next ${fmtTime(c.filter_next_start)}` : 'Auto · idle'
53
+}
54
+
55
+export function probeValues(temps: { roles?: Record<string, number | null>; probes?: Record<string, number | null> }) {
56
+ const src = temps.roles ?? temps.probes ?? {}
57
+ return src
58
+}
59
+
60
+export function isPayloadStale(ts: string | undefined, staleS = 15): boolean {
61
+ if (!ts) return true
62
+ const age = (Date.now() - new Date(ts).getTime()) / 1000
63
+ return age > staleS
64
+}
projects/hot-tub/src/web/frontend/src/index.css
... ...
@@ -0,0 +1,111 @@
1
+:root {
2
+ --text: #6b6375;
3
+ --text-h: #08060d;
4
+ --bg: #fff;
5
+ --border: #e5e4e7;
6
+ --code-bg: #f4f3ec;
7
+ --accent: #aa3bff;
8
+ --accent-bg: rgba(170, 59, 255, 0.1);
9
+ --accent-border: rgba(170, 59, 255, 0.5);
10
+ --social-bg: rgba(244, 243, 236, 0.5);
11
+ --shadow:
12
+ rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
13
+
14
+ --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
15
+ --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
16
+ --mono: ui-monospace, Consolas, monospace;
17
+
18
+ font: 18px/145% var(--sans);
19
+ letter-spacing: 0.18px;
20
+ color-scheme: light dark;
21
+ color: var(--text);
22
+ background: var(--bg);
23
+ font-synthesis: none;
24
+ text-rendering: optimizeLegibility;
25
+ -webkit-font-smoothing: antialiased;
26
+ -moz-osx-font-smoothing: grayscale;
27
+
28
+ @media (max-width: 1024px) {
29
+ font-size: 16px;
30
+ }
31
+}
32
+
33
+@media (prefers-color-scheme: dark) {
34
+ :root {
35
+ --text: #9ca3af;
36
+ --text-h: #f3f4f6;
37
+ --bg: #16171d;
38
+ --border: #2e303a;
39
+ --code-bg: #1f2028;
40
+ --accent: #c084fc;
41
+ --accent-bg: rgba(192, 132, 252, 0.15);
42
+ --accent-border: rgba(192, 132, 252, 0.5);
43
+ --social-bg: rgba(47, 48, 58, 0.5);
44
+ --shadow:
45
+ rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
46
+ }
47
+
48
+ #social .button-icon {
49
+ filter: invert(1) brightness(2);
50
+ }
51
+}
52
+
53
+#root {
54
+ width: 1126px;
55
+ max-width: 100%;
56
+ margin: 0 auto;
57
+ text-align: center;
58
+ border-inline: 1px solid var(--border);
59
+ min-height: 100svh;
60
+ display: flex;
61
+ flex-direction: column;
62
+ box-sizing: border-box;
63
+}
64
+
65
+body {
66
+ margin: 0;
67
+}
68
+
69
+h1,
70
+h2 {
71
+ font-family: var(--heading);
72
+ font-weight: 500;
73
+ color: var(--text-h);
74
+}
75
+
76
+h1 {
77
+ font-size: 56px;
78
+ letter-spacing: -1.68px;
79
+ margin: 32px 0;
80
+ @media (max-width: 1024px) {
81
+ font-size: 36px;
82
+ margin: 20px 0;
83
+ }
84
+}
85
+h2 {
86
+ font-size: 24px;
87
+ line-height: 118%;
88
+ letter-spacing: -0.24px;
89
+ margin: 0 0 8px;
90
+ @media (max-width: 1024px) {
91
+ font-size: 20px;
92
+ }
93
+}
94
+p {
95
+ margin: 0;
96
+}
97
+
98
+code,
99
+.counter {
100
+ font-family: var(--mono);
101
+ display: inline-flex;
102
+ border-radius: 4px;
103
+ color: var(--text-h);
104
+}
105
+
106
+code {
107
+ font-size: 15px;
108
+ line-height: 135%;
109
+ padding: 4px 8px;
110
+ background: var(--code-bg);
111
+}
projects/hot-tub/src/web/frontend/src/main.tsx
... ...
@@ -0,0 +1,13 @@
1
+import { StrictMode } from 'react'
2
+import { createRoot } from 'react-dom/client'
3
+import { HashRouter } from 'react-router-dom'
4
+import App from './App.tsx'
5
+import './styles.css'
6
+
7
+createRoot(document.getElementById('root')!).render(
8
+ <StrictMode>
9
+ <HashRouter>
10
+ <App />
11
+ </HashRouter>
12
+ </StrictMode>,
13
+)
projects/hot-tub/src/web/frontend/src/styles.css
... ...
@@ -0,0 +1,803 @@
1
+:root {
2
+ --bg-top: #e0f2fe;
3
+ --bg-bottom: #f8fafc;
4
+ --card: #ffffff;
5
+ --text: #0f172a;
6
+ --muted: #64748b;
7
+ --border: #e2e8f0;
8
+ --surface: #f8fafc;
9
+ --accent: #0e7490;
10
+ --accent-strong: #155e75;
11
+ --heat: #f97316;
12
+ --filter: #0ea5e9;
13
+ --green: #22c55e;
14
+ --yellow: #eab308;
15
+ --red: #ef4444;
16
+ --off: #cbd5e1;
17
+ --radius: 18px;
18
+ --shadow: 0 6px 24px rgba(15, 23, 42, 0.08);
19
+}
20
+
21
+* {
22
+ box-sizing: border-box;
23
+}
24
+
25
+body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
29
+ color: var(--text);
30
+ background: linear-gradient(var(--bg-top), var(--bg-bottom)) fixed;
31
+}
32
+
33
+/* ---------- header / nav ---------- */
34
+
35
+.app-header {
36
+ position: sticky;
37
+ top: 0;
38
+ z-index: 10;
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: center;
42
+ gap: 12px 20px;
43
+ padding: 12px 20px;
44
+ background: rgba(255, 255, 255, 0.85);
45
+ backdrop-filter: blur(8px);
46
+ border-bottom: 1px solid var(--border);
47
+}
48
+
49
+.brand {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 8px;
53
+ font-size: 1.25rem;
54
+ font-weight: 700;
55
+}
56
+
57
+.brand-icon {
58
+ color: var(--heat);
59
+ font-size: 1.4rem;
60
+}
61
+
62
+.nav {
63
+ display: flex;
64
+ gap: 4px;
65
+ padding: 4px;
66
+ border-radius: 999px;
67
+ background: #f1f5f9;
68
+}
69
+
70
+.nav-tab {
71
+ padding: 8px 16px;
72
+ border-radius: 999px;
73
+ color: var(--muted);
74
+ font-weight: 600;
75
+ text-decoration: none;
76
+}
77
+
78
+.nav-tab.active {
79
+ background: var(--card);
80
+ color: var(--accent-strong);
81
+ box-shadow: 0 1px 4px rgba(15, 23, 42, 0.12);
82
+}
83
+
84
+.connection {
85
+ margin-left: auto;
86
+ font-size: 0.85rem;
87
+}
88
+
89
+@media (max-width: 599px) {
90
+ .app-header {
91
+ padding: 10px 12px;
92
+ }
93
+ .nav {
94
+ order: 3;
95
+ width: 100%;
96
+ }
97
+ .nav-tab {
98
+ flex: 1;
99
+ text-align: center;
100
+ }
101
+}
102
+
103
+/* ---------- layout ---------- */
104
+
105
+.page {
106
+ max-width: 1100px;
107
+ margin: 0 auto;
108
+ padding: 20px;
109
+ transition: opacity 0.3s;
110
+}
111
+
112
+.page.stale {
113
+ opacity: 0.55;
114
+}
115
+
116
+@media (max-width: 599px) {
117
+ .page {
118
+ padding: 12px;
119
+ }
120
+}
121
+
122
+.card {
123
+ padding: 20px;
124
+ border-radius: var(--radius);
125
+ background: var(--card);
126
+ box-shadow: var(--shadow);
127
+}
128
+
129
+.loading {
130
+ padding: 60px 20px;
131
+ color: var(--muted);
132
+ text-align: center;
133
+}
134
+
135
+.control-layout,
136
+.diagnostic-layout {
137
+ display: grid;
138
+ gap: 20px;
139
+}
140
+
141
+@media (min-width: 900px) {
142
+ .control-layout {
143
+ grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
144
+ align-items: start;
145
+ }
146
+}
147
+
148
+@media (min-width: 760px) {
149
+ .diagnostic-layout {
150
+ grid-template-columns: 1fr 1fr;
151
+ align-items: start;
152
+ }
153
+}
154
+
155
+/* ---------- status lights ---------- */
156
+
157
+.status-light {
158
+ display: inline-flex;
159
+ align-items: center;
160
+ gap: 8px;
161
+ color: var(--muted);
162
+}
163
+
164
+.light {
165
+ flex-shrink: 0;
166
+ width: 14px;
167
+ height: 14px;
168
+ border-radius: 50%;
169
+ background: var(--off);
170
+}
171
+
172
+.light-on {
173
+ background: var(--green);
174
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2), 0 0 12px var(--green);
175
+}
176
+
177
+.light-waiting {
178
+ background: var(--yellow);
179
+ animation: pulse 1.2s ease-in-out infinite;
180
+}
181
+
182
+.light-error {
183
+ background: var(--red);
184
+}
185
+
186
+/* ---------- tub ---------- */
187
+
188
+.banner {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ align-items: center;
192
+ justify-content: space-between;
193
+ gap: 8px 12px;
194
+ margin-bottom: 12px;
195
+ padding: 12px 14px;
196
+ border-radius: 12px;
197
+}
198
+
199
+.banner-error {
200
+ border: 1px solid #fecaca;
201
+ background: #fef2f2;
202
+ color: #991b1b;
203
+}
204
+
205
+.tub {
206
+ display: block;
207
+ width: 100%;
208
+ max-width: 560px;
209
+ margin: 0 auto;
210
+}
211
+
212
+.ground {
213
+ fill: rgba(15, 23, 42, 0.08);
214
+}
215
+
216
+.tub-shell {
217
+ fill: #475569;
218
+}
219
+
220
+.tub-wall {
221
+ fill: none;
222
+ stroke: #94a3b8;
223
+ stroke-width: 10;
224
+ stroke-linejoin: round;
225
+}
226
+
227
+.tub-rim {
228
+ stroke: #64748b;
229
+ stroke-width: 1.5;
230
+}
231
+
232
+.rivet {
233
+ fill: #64748b;
234
+}
235
+
236
+.tub-temp {
237
+ font-size: 52px;
238
+ font-weight: 800;
239
+ fill: #fff;
240
+ paint-order: stroke;
241
+ stroke: rgba(3, 105, 161, 0.6);
242
+ stroke-width: 4px;
243
+}
244
+
245
+.tub-temp-caption {
246
+ font-size: 15px;
247
+ letter-spacing: 0.2em;
248
+ text-transform: uppercase;
249
+ fill: #e0f2fe;
250
+}
251
+
252
+.wave {
253
+ animation: wave 4s linear infinite;
254
+}
255
+
256
+.wave-back {
257
+ opacity: 0.8;
258
+ animation-duration: 6s;
259
+ animation-direction: reverse;
260
+}
261
+
262
+.duck-bob {
263
+ transform-box: fill-box;
264
+ transform-origin: center;
265
+ animation: bob 2.4s ease-in-out infinite;
266
+}
267
+
268
+.steam {
269
+ opacity: 0;
270
+ transition: opacity 1s;
271
+}
272
+
273
+.steam.on {
274
+ opacity: 1;
275
+}
276
+
277
+.steam-puff {
278
+ fill: none;
279
+ stroke: #cbd5e1;
280
+ stroke-width: 5;
281
+ stroke-linecap: round;
282
+ animation: rise 3s ease-in-out infinite;
283
+}
284
+
285
+.steam-puff:nth-child(2) {
286
+ animation-delay: 1s;
287
+}
288
+
289
+.steam-puff:nth-child(3) {
290
+ animation-delay: 2s;
291
+}
292
+
293
+/* ---------- pumps ---------- */
294
+
295
+.pumps {
296
+ display: grid;
297
+ grid-template-columns: repeat(2, minmax(0, 1fr));
298
+ gap: 16px;
299
+ max-width: 420px;
300
+ margin: 8px auto 0;
301
+}
302
+
303
+.pump {
304
+ display: flex;
305
+ flex-direction: column;
306
+ align-items: center;
307
+ gap: 6px;
308
+}
309
+
310
+.pump-svg {
311
+ width: 96px;
312
+ height: 96px;
313
+}
314
+
315
+.pump-frame {
316
+ fill: #f1f5f9;
317
+ stroke: var(--border);
318
+ stroke-width: 2;
319
+}
320
+
321
+.pump-housing {
322
+ fill: #1e293b;
323
+}
324
+
325
+.pump-hub {
326
+ fill: #e2e8f0;
327
+}
328
+
329
+.fan-blades {
330
+ opacity: 0.5;
331
+ transform-box: view-box;
332
+ transform-origin: 60px 60px;
333
+ transition: opacity 0.3s;
334
+}
335
+
336
+.pump-heat .fan-blades {
337
+ fill: var(--heat);
338
+}
339
+
340
+.pump-filter .fan-blades {
341
+ fill: var(--filter);
342
+}
343
+
344
+.pump.running .fan-blades {
345
+ opacity: 1;
346
+ animation: spin 0.9s linear infinite;
347
+}
348
+
349
+.pump-label {
350
+ font-weight: 700;
351
+}
352
+
353
+@media (max-width: 400px) {
354
+ .pump-svg {
355
+ width: 80px;
356
+ height: 80px;
357
+ }
358
+}
359
+
360
+/* ---------- settings ---------- */
361
+
362
+.settings-card {
363
+ display: flex;
364
+ flex-direction: column;
365
+ gap: 22px;
366
+}
367
+
368
+.field-label {
369
+ display: block;
370
+ margin-bottom: 6px;
371
+ color: var(--muted);
372
+ font-size: 0.8rem;
373
+ font-weight: 700;
374
+ letter-spacing: 0.06em;
375
+ text-transform: uppercase;
376
+}
377
+
378
+.target-row {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: space-between;
382
+ gap: 12px;
383
+}
384
+
385
+.target-value {
386
+ color: var(--accent-strong);
387
+ font-size: 3.25rem;
388
+ font-weight: 800;
389
+ font-variant-numeric: tabular-nums;
390
+}
391
+
392
+.round-btn {
393
+ width: 60px;
394
+ height: 60px;
395
+ border: none;
396
+ border-radius: 50%;
397
+ background: var(--accent);
398
+ color: #fff;
399
+ font-size: 2rem;
400
+ line-height: 1;
401
+ cursor: pointer;
402
+ touch-action: manipulation;
403
+}
404
+
405
+.round-btn:active:not(:disabled) {
406
+ transform: scale(0.94);
407
+}
408
+
409
+.modes {
410
+ display: flex;
411
+ flex-direction: column;
412
+ gap: 14px;
413
+}
414
+
415
+.field select {
416
+ width: 100%;
417
+ padding: 12px 14px;
418
+ border: 1px solid var(--border);
419
+ border-radius: 12px;
420
+ background: var(--surface);
421
+ font: inherit;
422
+ font-size: 1.05rem;
423
+}
424
+
425
+.summary {
426
+ display: grid;
427
+ gap: 10px;
428
+ margin: 0;
429
+}
430
+
431
+.summary div {
432
+ display: flex;
433
+ justify-content: space-between;
434
+ gap: 12px;
435
+ padding-bottom: 8px;
436
+ border-bottom: 1px dashed var(--border);
437
+}
438
+
439
+.summary dt {
440
+ color: var(--muted);
441
+}
442
+
443
+.summary dd {
444
+ margin: 0;
445
+ font-weight: 600;
446
+ text-align: right;
447
+}
448
+
449
+/* ---------- buttons ---------- */
450
+
451
+.btn {
452
+ display: inline-flex;
453
+ align-items: center;
454
+ justify-content: center;
455
+ padding: 12px 22px;
456
+ border: none;
457
+ border-radius: 12px;
458
+ font: inherit;
459
+ font-weight: 700;
460
+ cursor: pointer;
461
+}
462
+
463
+.btn-primary {
464
+ background: var(--accent);
465
+ color: #fff;
466
+}
467
+
468
+.btn-secondary {
469
+ background: #f1f5f9;
470
+ color: var(--text);
471
+}
472
+
473
+.btn-small {
474
+ padding: 6px 12px;
475
+ background: #991b1b;
476
+ color: #fff;
477
+ font-size: 0.875rem;
478
+}
479
+
480
+.big-button {
481
+ width: 100%;
482
+ min-height: 110px;
483
+ border: none;
484
+ border-radius: var(--radius);
485
+ box-shadow: var(--shadow);
486
+ color: #fff;
487
+ font: inherit;
488
+ font-size: 1.75rem;
489
+ font-weight: 800;
490
+ cursor: pointer;
491
+}
492
+
493
+.big-button-backflush {
494
+ background: linear-gradient(135deg, #0ea5e9, #0369a1);
495
+}
496
+
497
+.big-button-setup {
498
+ background: linear-gradient(135deg, #14b8a6, #0f766e);
499
+}
500
+
501
+.big-button:active:not(:disabled) {
502
+ transform: translateY(1px);
503
+}
504
+
505
+.round-btn:disabled,
506
+.btn:disabled,
507
+.big-button:disabled {
508
+ opacity: 0.4;
509
+ cursor: not-allowed;
510
+}
511
+
512
+/* ---------- diagnostic ---------- */
513
+
514
+.backflush-slot {
515
+ display: grid;
516
+ place-items: center;
517
+ min-height: 190px;
518
+ margin-top: 16px;
519
+}
520
+
521
+.hint {
522
+ max-width: 320px;
523
+ color: var(--muted);
524
+ text-align: center;
525
+}
526
+
527
+.backflush-anim {
528
+ color: var(--accent-strong);
529
+ font-weight: 600;
530
+ text-align: center;
531
+}
532
+
533
+.backflush-anim svg {
534
+ width: 140px;
535
+ height: 140px;
536
+}
537
+
538
+.backflush-anim p {
539
+ margin: 6px 0 0;
540
+}
541
+
542
+.backflush-arrows {
543
+ fill: none;
544
+ stroke: var(--filter);
545
+ stroke-width: 6;
546
+ stroke-linecap: round;
547
+ transform-box: view-box;
548
+ transform-origin: 60px 60px;
549
+ animation: spin 2s linear infinite;
550
+}
551
+
552
+.backflush-arrows .arrowhead {
553
+ fill: var(--filter);
554
+ stroke: none;
555
+}
556
+
557
+.droplet {
558
+ fill: #38bdf8;
559
+}
560
+
561
+.droplet-eye {
562
+ fill: var(--text);
563
+}
564
+
565
+.droplet-smile {
566
+ fill: none;
567
+ stroke: var(--text);
568
+ stroke-width: 2.5;
569
+ stroke-linecap: round;
570
+}
571
+
572
+.bubble {
573
+ fill: #7dd3fc;
574
+ transform-box: fill-box;
575
+ animation: bubble 1.8s ease-in infinite;
576
+}
577
+
578
+.bubble.b2 {
579
+ animation-delay: 0.6s;
580
+}
581
+
582
+.bubble.b3 {
583
+ animation-delay: 1.2s;
584
+}
585
+
586
+.readings {
587
+ margin-top: 20px;
588
+}
589
+
590
+.readings-header {
591
+ display: flex;
592
+ justify-content: space-between;
593
+ align-items: baseline;
594
+}
595
+
596
+.setup-state {
597
+ color: var(--muted);
598
+ font-size: 0.85rem;
599
+ text-transform: capitalize;
600
+}
601
+
602
+.probe-list {
603
+ display: grid;
604
+ gap: 8px;
605
+ margin: 8px 0 0;
606
+ padding: 0;
607
+ list-style: none;
608
+}
609
+
610
+.probe-row {
611
+ display: flex;
612
+ align-items: center;
613
+ justify-content: space-between;
614
+ padding: 10px 12px;
615
+ border-radius: 10px;
616
+ background: var(--surface);
617
+}
618
+
619
+.probe-row .status-light {
620
+ color: var(--text);
621
+}
622
+
623
+.probe-value {
624
+ font-weight: 600;
625
+ font-variant-numeric: tabular-nums;
626
+}
627
+
628
+/* ---------- sensor setup ---------- */
629
+
630
+.setup-card {
631
+ max-width: 640px;
632
+ margin: 0 auto;
633
+}
634
+
635
+.setup-card h1 {
636
+ margin: 0 0 16px;
637
+ font-size: 1.5rem;
638
+}
639
+
640
+.stepper {
641
+ display: flex;
642
+ gap: 8px;
643
+ margin: 0;
644
+ padding: 0;
645
+ list-style: none;
646
+}
647
+
648
+.stepper li {
649
+ flex: 1;
650
+ padding-top: 10px;
651
+ border-top: 4px solid var(--border);
652
+ color: var(--muted);
653
+ font-size: 0.8rem;
654
+ text-align: center;
655
+}
656
+
657
+.stepper li.done,
658
+.stepper li.current {
659
+ border-color: var(--accent);
660
+ color: var(--accent-strong);
661
+ font-weight: 700;
662
+}
663
+
664
+.setup-step h2 {
665
+ margin: 24px 0 8px;
666
+ font-size: 1.25rem;
667
+}
668
+
669
+.setup-step p {
670
+ color: var(--muted);
671
+ line-height: 1.5;
672
+}
673
+
674
+.setup-status {
675
+ margin: 20px 0;
676
+ padding: 16px;
677
+ border-radius: 12px;
678
+ background: var(--surface);
679
+}
680
+
681
+.setup-status .status-light {
682
+ color: var(--text);
683
+ font-size: 1.1rem;
684
+}
685
+
686
+.setup-status .light {
687
+ width: 22px;
688
+ height: 22px;
689
+}
690
+
691
+.actions {
692
+ display: flex;
693
+ justify-content: space-between;
694
+ gap: 12px;
695
+ margin-top: 24px;
696
+}
697
+
698
+/* ---------- mock backend panel (not part of the app) ---------- */
699
+
700
+.mock-panel {
701
+ position: fixed;
702
+ right: 12px;
703
+ bottom: 12px;
704
+ z-index: 50;
705
+ max-width: 250px;
706
+ border: 2px dashed #f59e0b;
707
+ border-radius: 12px;
708
+ background: #fffbeb;
709
+ box-shadow: var(--shadow);
710
+ font-size: 0.85rem;
711
+}
712
+
713
+.mock-panel summary {
714
+ padding: 8px 12px;
715
+ color: #92400e;
716
+ font-weight: 700;
717
+ cursor: pointer;
718
+}
719
+
720
+.mock-panel-body {
721
+ display: grid;
722
+ gap: 8px;
723
+ padding: 0 12px 12px;
724
+}
725
+
726
+.mock-panel label {
727
+ display: flex;
728
+ align-items: center;
729
+ gap: 6px;
730
+}
731
+
732
+.mock-panel button {
733
+ padding: 6px 8px;
734
+ border: 1px solid #fcd34d;
735
+ border-radius: 8px;
736
+ background: #fff;
737
+ font: inherit;
738
+ text-align: left;
739
+ cursor: pointer;
740
+}
741
+
742
+/* ---------- animation ---------- */
743
+
744
+@keyframes spin {
745
+ to {
746
+ transform: rotate(360deg);
747
+ }
748
+}
749
+
750
+@keyframes wave {
751
+ to {
752
+ transform: translateX(-100px);
753
+ }
754
+}
755
+
756
+@keyframes bob {
757
+ 50% {
758
+ transform: translateY(4px) rotate(-4deg);
759
+ }
760
+}
761
+
762
+@keyframes rise {
763
+ 0% {
764
+ opacity: 0;
765
+ transform: translateY(8px);
766
+ }
767
+ 40% {
768
+ opacity: 0.9;
769
+ }
770
+ 100% {
771
+ opacity: 0;
772
+ transform: translateY(-14px);
773
+ }
774
+}
775
+
776
+@keyframes bubble {
777
+ 0% {
778
+ opacity: 0;
779
+ transform: translateY(0);
780
+ }
781
+ 20% {
782
+ opacity: 1;
783
+ }
784
+ 100% {
785
+ opacity: 0;
786
+ transform: translateY(-40px);
787
+ }
788
+}
789
+
790
+@keyframes pulse {
791
+ 50% {
792
+ opacity: 0.35;
793
+ }
794
+}
795
+
796
+@media (prefers-reduced-motion: reduce) {
797
+ *,
798
+ *::before,
799
+ *::after {
800
+ animation: none !important;
801
+ transition: none !important;
802
+ }
803
+}
projects/hot-tub/src/web/frontend/src/types.ts
... ...
@@ -0,0 +1,25 @@
1
+export type ConnectionState = 'connecting' | 'open' | 'reconnecting'
2
+
3
+export interface ControlState {
4
+ ts: string
5
+ heat_mode: 'sleep' | 'maintain' | 'backflush'
6
+ filter_mode: 'on' | 'auto' | 'off'
7
+ target_f: number
8
+ tub_avg_f: number | null
9
+ hot_in_f: number | null
10
+ heat_pump: 'on' | 'off'
11
+ filter_pump: 'on' | 'off'
12
+ heat_phase: string
13
+ error?: { code: string; detail?: string } | null
14
+ filter_next_start?: string | null
15
+}
16
+
17
+export interface TempsState {
18
+ ts: string
19
+ setup_state: string
20
+ probes: Record<string, number | null>
21
+ roles?: Record<string, number | null>
22
+ tub_avg_f?: number | null
23
+}
24
+
25
+export type FirstBundle = 'hot' | 'water' | null
projects/hot-tub/src/web/frontend/tsconfig.app.json
... ...
@@ -0,0 +1,26 @@
1
+{
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "es2023",
5
+ "lib": ["ES2023", "DOM"],
6
+ "module": "esnext",
7
+ "types": ["vite/client"],
8
+ "allowArbitraryExtensions": true,
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "erasableSyntaxOnly": true,
23
+ "noFallthroughCasesInSwitch": true
24
+ },
25
+ "include": ["src"]
26
+}
projects/hot-tub/src/web/frontend/tsconfig.json
... ...
@@ -0,0 +1,7 @@
1
+{
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+}
projects/hot-tub/src/web/frontend/tsconfig.node.json
... ...
@@ -0,0 +1,23 @@
1
+{
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "es2023",
5
+ "lib": ["ES2023"],
6
+ "types": ["node"],
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "module": "nodenext",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "erasableSyntaxOnly": true,
20
+ "noFallthroughCasesInSwitch": true
21
+ },
22
+ "include": ["vite.config.ts"]
23
+}
projects/hot-tub/src/web/frontend/vite.config.ts
... ...
@@ -0,0 +1,11 @@
1
+import react from '@vitejs/plugin-react'
2
+import { defineConfig } from 'vite'
3
+
4
+export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ proxy: {
8
+ '/api': 'http://127.0.0.1:8080',
9
+ },
10
+ },
11
+})
projects/hot-tub/src/web/health.py
... ...
@@ -0,0 +1,79 @@
1
+"""Web service health heartbeat."""
2
+
3
+from __future__ import annotations
4
+
5
+import logging
6
+import os
7
+import threading
8
+import time
9
+from typing import TYPE_CHECKING
10
+
11
+from .redis_io import (
12
+ KEY_CONTROL_LATEST,
13
+ KEY_HEALTH_CONTROL,
14
+ KEY_HEALTH_TEMPS,
15
+ KEY_TEMPS_LATEST,
16
+ WebRedis,
17
+ payload_age_s,
18
+)
19
+
20
+if TYPE_CHECKING:
21
+ from .config import WebConfig
22
+
23
+logger = logging.getLogger("hot_tub.web")
24
+
25
+
26
+def compute_health(redis_io: WebRedis, cfg: WebConfig) -> tuple[str, str]:
27
+ client_ok = redis_io._ensure() is not None # noqa: SLF001
28
+ if not client_ok:
29
+ return "error", "redis down"
30
+
31
+ control = redis_io.get_json(KEY_CONTROL_LATEST)
32
+ temps = redis_io.get_json(KEY_TEMPS_LATEST)
33
+ stale = cfg.stale_s
34
+ control_age = payload_age_s(control)
35
+ temps_age = payload_age_s(temps)
36
+ if control_age is not None and control_age > stale:
37
+ return "degraded", "control:latest stale"
38
+ if temps_age is not None and temps_age > stale:
39
+ return "degraded", "temps:latest stale"
40
+ if control is None or temps is None:
41
+ return "degraded", "missing upstream snapshots"
42
+ return "ok", "serving; redis ok"
43
+
44
+
45
+def health_snapshot(redis_io: WebRedis, cfg: WebConfig) -> dict:
46
+ status, detail = compute_health(redis_io, cfg)
47
+ from datetime import datetime, timezone
48
+
49
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
50
+ return {
51
+ "service": "web",
52
+ "ts": ts,
53
+ "status": status,
54
+ "detail": detail,
55
+ "pid": os.getpid(),
56
+ }
57
+
58
+
59
+def start_health_thread(redis_io: WebRedis, cfg: WebConfig, stop: threading.Event) -> threading.Thread:
60
+ def _loop() -> None:
61
+ while not stop.is_set():
62
+ payload = health_snapshot(redis_io, cfg)
63
+ redis_io.publish_health(payload)
64
+ stop.wait(cfg.heartbeat_s)
65
+
66
+ thread = threading.Thread(target=_loop, daemon=True)
67
+ thread.start()
68
+ return thread
69
+
70
+
71
+def api_health_payload(redis_io: WebRedis, cfg: WebConfig) -> dict:
72
+ web = health_snapshot(redis_io, cfg)
73
+ return {
74
+ "web": web,
75
+ "control_age_s": payload_age_s(redis_io.get_json(KEY_CONTROL_LATEST)),
76
+ "temps_age_s": payload_age_s(redis_io.get_json(KEY_TEMPS_LATEST)),
77
+ "health_control": redis_io.get_json(KEY_HEALTH_CONTROL),
78
+ "health_temps": redis_io.get_json(KEY_HEALTH_TEMPS),
79
+ }
projects/hot-tub/src/web/interface-requirements.md
... ...
@@ -0,0 +1,23 @@
1
+# Hot Tub Control Web Interface
2
+
3
+- Should be responsive. Should look good on large, medium and phone-sized screens
4
+- should respond to events from the backend using something like SSE or websockets
5
+- Should have 2 main nav items:
6
+ - Main Control:
7
+ - Should include a simple, fun graphic, prominently featured in the center of a horse-trough shaped hot tub. In the water of the hot tub, the current temperature in fahrenheit should be dispayed
8
+ - two simple pump / fan graphics should be shown below the tub, one for heat and one for the filter. When one of the pumps is running, a green light should be displayed below it, and the fan / pump should show an animation. If a fan is used, the blades could rotate, for instance
9
+ - a number showing the target temp in fahrenheit
10
+ - Plus and minus buttons to increment / decrement the target temp
11
+ - a dropdown menu allowing the user to set the mode
12
+ - Diagnostic:
13
+ - A big button labeled "Backflush".
14
+ - When pressed
15
+ - it should initiate a backflush
16
+ - it the backflush button should be disabled
17
+ - a cute graphic should indicate that a backflush is in progress
18
+ - when idle or backflush is finished, the button should re-enable
19
+ - A big button labeled "Sensor Setup"
20
+ - when clicked, user navigates to sensor_setup_1:
21
+ - User is prompted to connect disconnect all sensors, if they aren't disconnected arleady. Continue button navigates to sensor_setup_2
22
+ - User is prompted to plug in one set of sensors. Disabled "Continue" button becomes enabled when temperature readings start coming in from either hot input or water temp sensors. Perhaps a yellow or red light that turns green. Upon clicking "Continue", user navigates to sensor_setup_3
23
+ - User is prompted to plug in the other set of sensors. Disabled "Finish" button becomes enabled when temperature readings start coming in from all temp sensors. Perhaps a yellow or red light that turns green. Upon clicking "Finish", user navigates to main Diagnostic screen
projects/hot-tub/src/web/logging_setup.py
... ...
@@ -0,0 +1,20 @@
1
+"""File logging for the web service."""
2
+
3
+from __future__ import annotations
4
+
5
+import logging
6
+from pathlib import Path
7
+
8
+
9
+def setup_logging(log_path: Path, *, verbose: bool = False) -> logging.Logger:
10
+ log_path.parent.mkdir(parents=True, exist_ok=True)
11
+ logger = logging.getLogger("hot_tub.web")
12
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
13
+ logger.handlers.clear()
14
+
15
+ handler = logging.FileHandler(log_path, encoding="utf-8")
16
+ handler.setFormatter(
17
+ logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
18
+ )
19
+ logger.addHandler(handler)
20
+ return logger
projects/hot-tub/src/web/mockup/app.js
... ...
@@ -0,0 +1,523 @@
1
+/*
2
+ * Hot tub web UI mockup, structured like the planned React app:
3
+ * - `state` + `setState` stand in for a top-level useReducer/useState
4
+ * - each PascalCase function takes props and returns markup -> one React component
5
+ * - `data-action` attributes stand in for onClick / onChange props
6
+ * - hash routes map to react-router paths
7
+ * - `window.HotTubApi` is the API client (mocked in mock-backend.js)
8
+ */
9
+(function () {
10
+ const api = window.HotTubApi;
11
+ const root = document.getElementById("root");
12
+
13
+ const TARGET_MIN = 50;
14
+ const TARGET_MAX = 110;
15
+ const PROBES = [
16
+ { key: "hot_in", label: "Hot input" },
17
+ { key: "tub1", label: "Tub 1" },
18
+ { key: "tub2", label: "Tub 2" },
19
+ { key: "tub3", label: "Tub 3" },
20
+ { key: "tub4", label: "Tub 4" },
21
+ ];
22
+ const WATER_KEYS = ["tub1", "tub2", "tub3", "tub4"];
23
+ const TUB_INSIDE = "M40 64 L360 64 L338 204 Q335 214 324 214 L76 214 Q65 214 62 204 Z";
24
+
25
+ /* ---------- state + routing ---------- */
26
+
27
+ if (!location.hash) history.replaceState(null, "", "#/control");
28
+
29
+ let state = {
30
+ route: parseRoute(location.hash),
31
+ connection: "connecting",
32
+ control: null,
33
+ temps: null,
34
+ backflushRequested: false,
35
+ firstBundle: null,
36
+ };
37
+
38
+ function setState(patchObj) {
39
+ state = { ...state, ...patchObj };
40
+ render();
41
+ }
42
+
43
+ function parseRoute(hash) {
44
+ const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
45
+ if (parts[0] === "diagnostic" && parts[1] === "sensor-setup") {
46
+ const step = Number(parts[2]);
47
+ return { page: "sensor-setup", step: [1, 2, 3].includes(step) ? step : 1 };
48
+ }
49
+ if (parts[0] === "diagnostic") return { page: "diagnostic" };
50
+ return { page: "control" };
51
+ }
52
+
53
+ const navigate = (path) => {
54
+ location.hash = path;
55
+ };
56
+
57
+ window.addEventListener("hashchange", () => setState({ route: parseRoute(location.hash) }));
58
+
59
+ /* ---------- helpers ---------- */
60
+
61
+ const esc = (v) =>
62
+ String(v).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
63
+ const has = (v) => v !== null && v !== undefined;
64
+ const fmtF = (v) => (has(v) ? `${Number(v).toFixed(1)}&deg;F` : "--");
65
+ const fmtTarget = (v) => `${Number.isInteger(v) ? v : Number(v).toFixed(1)}&deg;F`;
66
+ const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
67
+ const attr = (cond, name) => (cond ? ` ${name}` : "");
68
+ const isBackflushing = (c) => c.heat_mode === "backflush" || c.heat_phase === "backflush";
69
+
70
+ function phaseLabel({ heat_phase, heat_mode }) {
71
+ if (heat_phase.startsWith("startup_")) return `Starting (attempt ${heat_phase.slice(-1)} of 3)`;
72
+ const labels = {
73
+ idle: heat_mode === "maintain" ? "Waiting (in range)" : "Off",
74
+ heating: "Heating",
75
+ backflush: "Backflushing",
76
+ error_startup: "Failed to start",
77
+ error_safety: "Blocked by safety check",
78
+ };
79
+ return labels[heat_phase] || heat_phase;
80
+ }
81
+
82
+ function filterLabel(c) {
83
+ const running = c.filter_pump === "on";
84
+ if (c.filter_mode !== "auto") return running ? "On" : "Off";
85
+ if (running) return "Auto &middot; running";
86
+ return c.filter_next_start ? `Auto &middot; next ${fmtTime(c.filter_next_start)}` : "Auto &middot; idle";
87
+ }
88
+
89
+ /* ---------- components ---------- */
90
+
91
+ function App(s) {
92
+ return `<div class="app">
93
+ ${Header(s)}
94
+ <main class="page${s.connection === "open" ? "" : " stale"}">${Page(s)}</main>
95
+ </div>`;
96
+ }
97
+
98
+ function Page(s) {
99
+ if (!s.control || !s.temps) return Loading();
100
+ if (s.route.page === "diagnostic") return DiagnosticPage(s);
101
+ if (s.route.page === "sensor-setup") return SensorSetupPage(s);
102
+ return ControlPage(s);
103
+ }
104
+
105
+ function Loading() {
106
+ return `<div class="card loading">Connecting to the hot tub&hellip;</div>`;
107
+ }
108
+
109
+ function Header({ route, connection }) {
110
+ const onControl = route.page === "control";
111
+ const tab = (href, label, active) =>
112
+ `<a class="nav-tab${active ? " active" : ""}" href="${href}">${label}</a>`;
113
+ return `<header class="app-header">
114
+ <div class="brand"><span class="brand-icon" aria-hidden="true">&#9832;</span> Hot Tub</div>
115
+ <nav class="nav">
116
+ ${tab("#/control", "Main Control", onControl)}
117
+ ${tab("#/diagnostic", "Diagnostic", !onControl)}
118
+ </nav>
119
+ ${ConnectionBadge({ connection })}
120
+ </header>`;
121
+ }
122
+
123
+ function ConnectionBadge({ connection }) {
124
+ const map = {
125
+ open: ["on", "Live"],
126
+ connecting: ["waiting", "Connecting&hellip;"],
127
+ reconnecting: ["error", "Reconnecting&hellip;"],
128
+ };
129
+ const [status, label] = map[connection];
130
+ return `<div class="connection">${StatusLight({ status, label })}</div>`;
131
+ }
132
+
133
+ function StatusLight({ status, label }) {
134
+ return `<span class="status-light"><span class="light light-${status}" aria-hidden="true"></span><span>${label}</span></span>`;
135
+ }
136
+
137
+ /* ----- Main Control ----- */
138
+
139
+ function ControlPage({ control }) {
140
+ const tub = control.tub_avg_f;
141
+ return `<div class="control-layout">
142
+ <section class="card tub-card">
143
+ <div class="banner-slot">${control.error ? ErrorBanner({ error: control.error }) : ""}</div>
144
+ ${TubGraphic({ tempF: tub, steaming: control.heat_pump === "on" || (has(tub) && tub >= 95) })}
145
+ <div class="pumps">
146
+ ${Pump({ kind: "heat", label: "Heat", running: control.heat_pump === "on" })}
147
+ ${Pump({ kind: "filter", label: "Filter", running: control.filter_pump === "on" })}
148
+ </div>
149
+ </section>
150
+ <section class="card settings-card">
151
+ ${TargetTemp({ targetF: control.target_f })}
152
+ ${ModeSelects({ control })}
153
+ ${StatusSummary({ control })}
154
+ </section>
155
+ </div>`;
156
+ }
157
+
158
+ function ErrorBanner({ error }) {
159
+ return `<div class="banner banner-error" role="alert">
160
+ <span><strong>Heater problem.</strong> ${esc(error.detail || error.code)}</span>
161
+ <button class="btn btn-small" data-action="reset-error">Reset (set Sleep)</button>
162
+ </div>`;
163
+ }
164
+
165
+ function wavePath(y) {
166
+ let d = `M-100 ${y}`;
167
+ for (let i = 0; i < 6; i++) d += " q25 -7 50 0 q25 7 50 0";
168
+ return `${d} L500 240 L-100 240 Z`;
169
+ }
170
+
171
+ function TubGraphic({ tempF, steaming }) {
172
+ const label = has(tempF) ? `${tempF.toFixed(1)}&deg;F` : "--.-&deg;F";
173
+ const rivets = [60, 110, 160, 210, 260, 310, 340]
174
+ .map((x) => `<circle class="rivet" cx="${x}" cy="65" r="2.2"/>`)
175
+ .join("");
176
+ return `<svg class="tub" viewBox="0 0 400 240" role="img" aria-label="Tub water temperature">
177
+ <defs>
178
+ <clipPath id="tub-inside"><path d="${TUB_INSIDE}"/></clipPath>
179
+ <linearGradient id="water-grad" x1="0" y1="0" x2="0" y2="1">
180
+ <stop offset="0" stop-color="#38bdf8"/>
181
+ <stop offset="1" stop-color="#0369a1"/>
182
+ </linearGradient>
183
+ <linearGradient id="steel-grad" x1="0" y1="0" x2="1" y2="0">
184
+ <stop offset="0" stop-color="#94a3b8"/>
185
+ <stop offset="0.5" stop-color="#f1f5f9"/>
186
+ <stop offset="1" stop-color="#94a3b8"/>
187
+ </linearGradient>
188
+ </defs>
189
+ <ellipse class="ground" cx="200" cy="222" rx="170" ry="10"/>
190
+ <g class="steam${steaming ? " on" : ""}" aria-hidden="true">
191
+ <path class="steam-puff" d="M140 52 q-8 -10 0 -20 t0 -20"/>
192
+ <path class="steam-puff" d="M200 52 q-8 -10 0 -20 t0 -20"/>
193
+ <path class="steam-puff" d="M260 52 q-8 -10 0 -20 t0 -20"/>
194
+ </g>
195
+ <path class="tub-shell" d="${TUB_INSIDE}"/>
196
+ <g clip-path="url(#tub-inside)">
197
+ <g class="wave wave-back"><path d="${wavePath(90)}" fill="#7dd3fc"/></g>
198
+ <g class="wave wave-front"><path d="${wavePath(96)}" fill="url(#water-grad)"/></g>
199
+ </g>
200
+ <path class="tub-wall" d="${TUB_INSIDE}"/>
201
+ <rect class="tub-rim" x="22" y="58" width="356" height="14" rx="7" fill="url(#steel-grad)"/>
202
+ ${rivets}
203
+ <g transform="translate(286 72)">${Duck()}</g>
204
+ <text class="tub-temp" x="200" y="165" text-anchor="middle">${label}</text>
205
+ <text class="tub-temp-caption" x="200" y="190" text-anchor="middle">water</text>
206
+ </svg>`;
207
+ }
208
+
209
+ function Duck() {
210
+ return `<g class="duck-bob" aria-hidden="true">
211
+ <ellipse cx="14" cy="18" rx="16" ry="10" fill="#facc15"/>
212
+ <path d="M4 16 q8 -6 14 2" fill="none" stroke="#eab308" stroke-width="2" stroke-linecap="round"/>
213
+ <circle cx="25" cy="6" r="8" fill="#facc15"/>
214
+ <path d="M32 5 l8 2 l-8 3 z" fill="#f97316"/>
215
+ <circle cx="27" cy="4" r="1.6" fill="#1e293b"/>
216
+ </g>`;
217
+ }
218
+
219
+ function Pump({ kind, label, running }) {
220
+ const blades = [0, 90, 180, 270]
221
+ .map((a) => `<ellipse cx="60" cy="36" rx="11" ry="21" transform="rotate(${a} 60 60)"/>`)
222
+ .join("");
223
+ return `<div class="pump pump-${kind}${running ? " running" : ""}">
224
+ <svg class="pump-svg" viewBox="0 0 120 120" aria-hidden="true">
225
+ <rect class="pump-frame" x="4" y="4" width="112" height="112" rx="18"/>
226
+ <circle class="pump-housing" cx="60" cy="60" r="48"/>
227
+ <g class="fan-blades">${blades}</g>
228
+ <circle class="pump-hub" cx="60" cy="60" r="8"/>
229
+ </svg>
230
+ <div class="pump-label">${label}</div>
231
+ ${StatusLight({ status: running ? "on" : "off", label: running ? "Running" : "Off" })}
232
+ </div>`;
233
+ }
234
+
235
+ function TargetTemp({ targetF }) {
236
+ return `<div class="target">
237
+ <span class="field-label">Target temperature</span>
238
+ <div class="target-row">
239
+ <button class="round-btn" data-action="target-dec" aria-label="Decrease target"${attr(targetF <= TARGET_MIN, "disabled")}>&minus;</button>
240
+ <div class="target-value">${fmtTarget(targetF)}</div>
241
+ <button class="round-btn" data-action="target-inc" aria-label="Increase target"${attr(targetF >= TARGET_MAX, "disabled")}>+</button>
242
+ </div>
243
+ </div>`;
244
+ }
245
+
246
+ function ModeSelects({ control }) {
247
+ const opt = (value, label, current, disabled = false) =>
248
+ `<option value="${value}"${attr(value === current, "selected")}${attr(disabled, "disabled")}>${label}</option>`;
249
+ const heatOptions = [
250
+ opt("sleep", "Sleep", control.heat_mode),
251
+ opt("maintain", "Maintain temperature", control.heat_mode),
252
+ ];
253
+ if (control.heat_mode === "backflush") {
254
+ heatOptions.push(opt("backflush", "Backflush (running)", control.heat_mode, true));
255
+ }
256
+ return `<div class="modes">
257
+ <label class="field">
258
+ <span class="field-label">Heat mode</span>
259
+ <select data-action="set-heat-mode">${heatOptions.join("")}</select>
260
+ </label>
261
+ <label class="field">
262
+ <span class="field-label">Filter mode</span>
263
+ <select data-action="set-filter-mode">
264
+ ${opt("auto", "Auto (daily schedule)", control.filter_mode)}
265
+ ${opt("on", "On", control.filter_mode)}
266
+ ${opt("off", "Off", control.filter_mode)}
267
+ </select>
268
+ </label>
269
+ </div>`;
270
+ }
271
+
272
+ function StatusSummary({ control }) {
273
+ return `<dl class="summary">
274
+ <div><dt>Heater</dt><dd>${esc(phaseLabel(control))}</dd></div>
275
+ <div><dt>Hot input</dt><dd>${fmtF(control.hot_in_f)}</dd></div>
276
+ <div><dt>Filter</dt><dd>${filterLabel(control)}</dd></div>
277
+ </dl>`;
278
+ }
279
+
280
+ /* ----- Diagnostic ----- */
281
+
282
+ function DiagnosticPage({ control, temps, backflushRequested }) {
283
+ const running = isBackflushing(control);
284
+ return `<div class="diagnostic-layout">
285
+ <section class="card">
286
+ <button class="big-button big-button-backflush" data-action="backflush"${attr(running || backflushRequested, "disabled")}>
287
+ ${running ? "Backflushing&hellip;" : "Backflush"}
288
+ </button>
289
+ <div class="backflush-slot">${running ? BackflushAnimation() : `<p class="hint">Runs the heat pump briefly to purge the heater line, then returns to Sleep.</p>`}</div>
290
+ </section>
291
+ <section class="card">
292
+ <button class="big-button big-button-setup" data-action="start-sensor-setup">Sensor Setup</button>
293
+ ${SensorReadings({ temps })}
294
+ </section>
295
+ </div>`;
296
+ }
297
+
298
+ function BackflushAnimation() {
299
+ return `<div class="backflush-anim" role="status">
300
+ <svg viewBox="0 0 120 120" aria-hidden="true">
301
+ <g class="backflush-arrows">
302
+ <path d="M60 10 A50 50 0 0 1 110 60"/>
303
+ <path d="M60 110 A50 50 0 0 1 10 60"/>
304
+ <path class="arrowhead" d="M102 54 L118 54 L110 66 Z"/>
305
+ <path class="arrowhead" d="M2 66 L18 66 L10 54 Z"/>
306
+ </g>
307
+ <path class="droplet" d="M60 28 C60 28 36 56 36 72 A24 24 0 0 0 84 72 C84 56 60 28 60 28 Z"/>
308
+ <circle class="droplet-eye" cx="52" cy="70" r="3"/>
309
+ <circle class="droplet-eye" cx="68" cy="70" r="3"/>
310
+ <path class="droplet-smile" d="M52 80 q8 7 16 0"/>
311
+ <circle class="bubble b1" cx="44" cy="102" r="4"/>
312
+ <circle class="bubble b2" cx="60" cy="104" r="3"/>
313
+ <circle class="bubble b3" cx="76" cy="102" r="4"/>
314
+ </svg>
315
+ <p>Backflush in progress&hellip;</p>
316
+ </div>`;
317
+ }
318
+
319
+ function SensorReadings({ temps }) {
320
+ return `<div class="readings">
321
+ <div class="readings-header">
322
+ <span class="field-label">Sensors</span>
323
+ <span class="setup-state">${esc(temps.setup_state.replace(/_/g, " "))}</span>
324
+ </div>
325
+ <ul class="probe-list">
326
+ ${PROBES.map((p) => ProbeRow({ label: p.label, value: temps.probes[p.key], missingStatus: "error" })).join("")}
327
+ </ul>
328
+ </div>`;
329
+ }
330
+
331
+ function ProbeRow({ label, value, missingStatus }) {
332
+ return `<li class="probe-row">
333
+ ${StatusLight({ status: has(value) ? "on" : missingStatus, label })}
334
+ <span class="probe-value">${fmtF(value)}</span>
335
+ </li>`;
336
+ }
337
+
338
+ /* ----- Sensor setup wizard ----- */
339
+
340
+ function SensorSetupPage(s) {
341
+ const steps = { 1: SetupStep1, 2: SetupStep2, 3: SetupStep3 };
342
+ return `<section class="card setup-card">
343
+ <h1>Sensor setup</h1>
344
+ ${Stepper({ step: s.route.step })}
345
+ ${steps[s.route.step](s)}
346
+ </section>`;
347
+ }
348
+
349
+ function Stepper({ step }) {
350
+ const labels = ["Disconnect", "First set", "Second set"];
351
+ return `<ol class="stepper">
352
+ ${labels.map((l, i) => `<li class="${i + 1 < step ? "done" : i + 1 === step ? "current" : ""}">${l}</li>`).join("")}
353
+ </ol>`;
354
+ }
355
+
356
+ function SetupStep1({ temps }) {
357
+ const connected = PROBES.filter((p) => has(temps.probes[p.key])).length;
358
+ const status = connected
359
+ ? StatusLight({ status: "waiting", label: `${connected} sensor${connected === 1 ? "" : "s"} still connected` })
360
+ : StatusLight({ status: "on", label: "All sensors disconnected" });
361
+ return `<div class="setup-step">
362
+ <h2>Disconnect all sensors</h2>
363
+ <p>Unplug both sensor bundles from the enclosure: the hot-input probe and the four water probes.</p>
364
+ <div class="setup-status">${status}</div>
365
+ <div class="actions">
366
+ <button class="btn btn-secondary" data-action="setup-cancel">Cancel</button>
367
+ <button class="btn btn-primary" data-action="setup-continue-1">Continue</button>
368
+ </div>
369
+ </div>`;
370
+ }
371
+
372
+ function SetupStep2({ temps }) {
373
+ const hot = has(temps.probes.hot_in);
374
+ const water = WATER_KEYS.some((k) => has(temps.probes[k]));
375
+ let label = "Waiting for readings&hellip;";
376
+ if (hot && water) label = "Both sets detected";
377
+ else if (hot) label = "Hot-input sensor detected";
378
+ else if (water) label = "Water sensors detected";
379
+ return `<div class="setup-step">
380
+ <h2>Plug in one set of sensors</h2>
381
+ <p>Connect either the hot-input bundle or the water bundle. Continue unlocks once readings arrive.</p>
382
+ <div class="setup-status">${StatusLight({ status: hot || water ? "on" : "waiting", label })}</div>
383
+ <div class="actions">
384
+ <button class="btn btn-secondary" data-action="setup-cancel">Cancel</button>
385
+ <button class="btn btn-primary" data-action="setup-continue-2"${attr(!(hot || water), "disabled")}>Continue</button>
386
+ </div>
387
+ </div>`;
388
+ }
389
+
390
+ function SetupStep3({ temps, firstBundle }) {
391
+ const remaining = {
392
+ hot: "the water sensor bundle (4 probes)",
393
+ water: "the hot-input sensor",
394
+ }[firstBundle] || "any remaining sensors";
395
+ const all = PROBES.every((p) => has(temps.probes[p.key]));
396
+ return `<div class="setup-step">
397
+ <h2>Plug in the other set</h2>
398
+ <p>Now connect ${remaining}. Finish unlocks once every sensor is reporting.</p>
399
+ <ul class="probe-list">
400
+ ${PROBES.map((p) => ProbeRow({ label: p.label, value: temps.probes[p.key], missingStatus: "waiting" })).join("")}
401
+ </ul>
402
+ <div class="actions">
403
+ <button class="btn btn-secondary" data-action="setup-cancel">Cancel</button>
404
+ <button class="btn btn-primary" data-action="setup-finish"${attr(!all, "disabled")}>Finish</button>
405
+ </div>
406
+ </div>`;
407
+ }
408
+
409
+ /* ---------- events ---------- */
410
+
411
+ function command(op, args) {
412
+ return api.sendCommand(op, args).then(
413
+ () => true,
414
+ (err) => {
415
+ console.warn(`command ${op} failed`, err);
416
+ return false;
417
+ },
418
+ );
419
+ }
420
+
421
+ root.addEventListener("click", (e) => {
422
+ const el = e.target.closest("[data-action]");
423
+ if (!el || el.disabled || el.tagName === "SELECT") return;
424
+ switch (el.dataset.action) {
425
+ case "target-inc":
426
+ command("target_temp_increment");
427
+ break;
428
+ case "target-dec":
429
+ command("target_temp_decrement");
430
+ break;
431
+ case "reset-error":
432
+ command("set_heat_mode", { mode: "sleep" });
433
+ break;
434
+ case "backflush":
435
+ setState({ backflushRequested: true });
436
+ command("set_heat_mode", { mode: "backflush" }).then((ok) => {
437
+ if (!ok) setState({ backflushRequested: false });
438
+ });
439
+ break;
440
+ case "start-sensor-setup":
441
+ navigate("#/diagnostic/sensor-setup/1");
442
+ break;
443
+ case "setup-continue-1":
444
+ api.startSensorSetup().then(() => navigate("#/diagnostic/sensor-setup/2"));
445
+ break;
446
+ case "setup-continue-2": {
447
+ const { probes } = state.temps;
448
+ const hot = has(probes.hot_in);
449
+ const water = WATER_KEYS.some((k) => has(probes[k]));
450
+ setState({ firstBundle: hot && water ? null : hot ? "hot" : "water" });
451
+ navigate("#/diagnostic/sensor-setup/3");
452
+ break;
453
+ }
454
+ case "setup-finish":
455
+ api.finishSensorSetup().then(() => navigate("#/diagnostic"));
456
+ break;
457
+ case "setup-cancel":
458
+ api.cancelSensorSetup().finally(() => navigate("#/diagnostic"));
459
+ break;
460
+ }
461
+ });
462
+
463
+ root.addEventListener("change", (e) => {
464
+ const { action } = e.target.dataset;
465
+ if (action === "set-heat-mode") command("set_heat_mode", { mode: e.target.value });
466
+ if (action === "set-filter-mode") command("set_filter_mode", { mode: e.target.value });
467
+ });
468
+
469
+ api.connect({
470
+ onOpen: () => setState({ connection: "open" }),
471
+ onError: () => setState({ connection: "reconnecting" }),
472
+ onMessage: (event, data) => {
473
+ if (event === "temps.reading") setState({ temps: data });
474
+ if (event === "control.state") {
475
+ setState({
476
+ control: data,
477
+ backflushRequested: state.backflushRequested && !isBackflushing(data),
478
+ });
479
+ }
480
+ },
481
+ });
482
+
483
+ /* ---------- rendering ---------- */
484
+
485
+ // Stand-in for React reconciliation: unchanged nodes stay in place so CSS
486
+ // animations (fans, waves, duck) don't restart on every SSE update.
487
+ function patch(oldNode, newNode) {
488
+ if (oldNode.nodeType !== newNode.nodeType || oldNode.nodeName !== newNode.nodeName) {
489
+ oldNode.replaceWith(newNode);
490
+ return;
491
+ }
492
+ if (oldNode.nodeType !== Node.ELEMENT_NODE) {
493
+ if (oldNode.nodeValue !== newNode.nodeValue) oldNode.nodeValue = newNode.nodeValue;
494
+ return;
495
+ }
496
+ const selectedValue =
497
+ oldNode.tagName === "SELECT" ? newNode.querySelector("option[selected]")?.value : undefined;
498
+
499
+ for (const { name } of [...oldNode.attributes]) {
500
+ if (!newNode.hasAttribute(name)) oldNode.removeAttribute(name);
501
+ }
502
+ for (const { name, value } of [...newNode.attributes]) {
503
+ if (oldNode.getAttribute(name) !== value) oldNode.setAttribute(name, value);
504
+ }
505
+
506
+ const oldKids = [...oldNode.childNodes];
507
+ const newKids = [...newNode.childNodes];
508
+ if (oldKids.length !== newKids.length) oldNode.replaceChildren(...newKids);
509
+ else oldKids.forEach((child, i) => patch(child, newKids[i]));
510
+
511
+ if (selectedValue !== undefined && oldNode.value !== selectedValue) oldNode.value = selectedValue;
512
+ }
513
+
514
+ function render() {
515
+ const tpl = document.createElement("template");
516
+ tpl.innerHTML = App(state).trim();
517
+ const next = tpl.content.firstElementChild;
518
+ if (root.firstElementChild) patch(root.firstElementChild, next);
519
+ else root.appendChild(next);
520
+ }
521
+
522
+ render();
523
+})();
projects/hot-tub/src/web/mockup/index.html
... ...
@@ -0,0 +1,14 @@
1
+<!doctype html>
2
+<html lang="en">
3
+<head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Hot Tub</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+</head>
9
+<body>
10
+ <div id="root"></div>
11
+ <script src="mock-backend.js"></script>
12
+ <script src="app.js"></script>
13
+</body>
14
+</html>
projects/hot-tub/src/web/mockup/mock-backend.js
... ...
@@ -0,0 +1,294 @@
1
+/*
2
+ * Fake Web/API backend for the mockup. Exposes the surface the React app will
3
+ * use against the real service:
4
+ * HotTubApi.connect({ onOpen, onError, onMessage }) -> SSE GET /api/events
5
+ * HotTubApi.sendCommand(op, args) -> POST /api/command
6
+ * HotTubApi.startSensorSetup() / finishSensorSetup() / cancelSensorSetup()
7
+ * -> POST /api/sensor-setup/*
8
+ * Event payloads match Redis control:latest ("control.state") and
9
+ * temps:latest ("temps.reading").
10
+ */
11
+(function () {
12
+ const TARGET_MIN = 50;
13
+ const TARGET_MAX = 110;
14
+ const BAND_F = 5;
15
+ const BACKFLUSH_S = 10;
16
+ const STARTUP_S = 3;
17
+ const STARTUP_ATTEMPTS = 3;
18
+ const FILTER_RUN_H = 2;
19
+ const TUB_OFFSETS = [-0.4, 0.1, 0.3, -0.1];
20
+
21
+ const world = {
22
+ hotBundle: true,
23
+ waterBundle: true,
24
+ tubF: 97.0,
25
+ hotF: 97.4,
26
+ noPropane: false,
27
+ online: true,
28
+ };
29
+
30
+ const control = {
31
+ heat_mode: "maintain",
32
+ filter_mode: "auto",
33
+ target_f: 102,
34
+ tub_avg_f: null,
35
+ hot_in_f: null,
36
+ heat_pump: "off",
37
+ filter_pump: "off",
38
+ heat_phase: "idle",
39
+ startup_attempt: 0,
40
+ error: null,
41
+ last_command_id: null,
42
+ filter_window_active: false,
43
+ filter_next_start: null,
44
+ };
45
+
46
+ let phaseTicks = 0;
47
+ let commandSeq = 0;
48
+ const subscribers = new Set();
49
+
50
+ const round1 = (n) => Math.round(n * 10) / 10;
51
+ const nowIso = () => new Date().toISOString().replace(/\.\d+Z$/, "Z");
52
+ const clone = (o) => JSON.parse(JSON.stringify(o));
53
+ const clampTarget = (t) => Math.max(TARGET_MIN, Math.min(TARGET_MAX, t));
54
+
55
+ function tempsReading() {
56
+ const tubs = TUB_OFFSETS.map((o) => (world.waterBundle ? round1(world.tubF + o) : null));
57
+ const hotIn = world.hotBundle ? round1(world.hotF) : null;
58
+ const valid = tubs.filter((t) => t !== null);
59
+ let setupState = "ready";
60
+ if (!world.hotBundle) setupState = "need_hot_in";
61
+ else if (!world.waterBundle) setupState = "need_water";
62
+ return {
63
+ ts: nowIso(),
64
+ setup_state: setupState,
65
+ probes: { hot_in: hotIn, tub1: tubs[0], tub2: tubs[1], tub3: tubs[2], tub4: tubs[3] },
66
+ tub_avg_f: valid.length ? round1(valid.reduce((a, b) => a + b, 0) / valid.length) : null,
67
+ hot_in_f: hotIn,
68
+ ok_count: valid.length,
69
+ fail_count: TUB_OFFSETS.length - valid.length,
70
+ };
71
+ }
72
+
73
+ function setPhase(phase) {
74
+ control.heat_phase = phase;
75
+ phaseTicks = 0;
76
+ }
77
+
78
+ function goIdle() {
79
+ control.heat_pump = "off";
80
+ if (control.heat_phase !== "idle") setPhase("idle");
81
+ }
82
+
83
+ function stepHeat(temps) {
84
+ const tub = temps.tub_avg_f;
85
+
86
+ if (control.heat_mode === "sleep") {
87
+ goIdle();
88
+ control.startup_attempt = 0;
89
+ return;
90
+ }
91
+
92
+ if (control.heat_mode === "backflush") {
93
+ if (control.heat_phase !== "backflush") {
94
+ setPhase("backflush");
95
+ control.heat_pump = "on";
96
+ return;
97
+ }
98
+ phaseTicks += 1;
99
+ if (phaseTicks >= BACKFLUSH_S) {
100
+ goIdle();
101
+ control.heat_mode = "sleep";
102
+ }
103
+ return;
104
+ }
105
+
106
+ if (control.heat_phase.startsWith("error_")) {
107
+ control.heat_pump = "off";
108
+ return;
109
+ }
110
+ if (tub === null || tub >= control.target_f) {
111
+ goIdle();
112
+ control.startup_attempt = 0;
113
+ return;
114
+ }
115
+ if (control.heat_phase === "idle") {
116
+ if (tub <= control.target_f - BAND_F) {
117
+ control.startup_attempt = 1;
118
+ setPhase("startup_1");
119
+ control.heat_pump = "on";
120
+ }
121
+ return;
122
+ }
123
+ if (control.heat_phase.startsWith("startup_")) {
124
+ phaseTicks += 1;
125
+ if (phaseTicks < STARTUP_S) return;
126
+ if (!world.noPropane && temps.hot_in_f !== null) {
127
+ setPhase("heating");
128
+ control.startup_attempt = 0;
129
+ return;
130
+ }
131
+ if (control.startup_attempt >= STARTUP_ATTEMPTS) {
132
+ control.heat_pump = "off";
133
+ setPhase("error_startup");
134
+ control.error = {
135
+ code: "startup_failed",
136
+ detail: "Heater did not warm up after 3 tries. Propane may be empty, or a backflush may be needed.",
137
+ };
138
+ return;
139
+ }
140
+ control.startup_attempt += 1;
141
+ setPhase(`startup_${control.startup_attempt}`);
142
+ }
143
+ }
144
+
145
+ function stepFilter() {
146
+ if (control.filter_mode !== "auto") {
147
+ control.filter_pump = control.filter_mode === "on" ? "on" : "off";
148
+ control.filter_window_active = false;
149
+ control.filter_next_start = null;
150
+ return;
151
+ }
152
+ const now = new Date();
153
+ const start = new Date(now);
154
+ start.setHours(0, 0, 0, 0);
155
+ const end = new Date(start.getTime() + FILTER_RUN_H * 3600e3);
156
+ const active = now >= start && now < end;
157
+ const next = new Date(start);
158
+ if (now >= start) next.setDate(next.getDate() + 1);
159
+ control.filter_pump = active ? "on" : "off";
160
+ control.filter_window_active = active;
161
+ control.filter_next_start = active ? null : next.toISOString();
162
+ }
163
+
164
+ function publish(temps = tempsReading()) {
165
+ if (!world.online) return;
166
+ control.tub_avg_f = temps.tub_avg_f;
167
+ control.hot_in_f = temps.hot_in_f;
168
+ const snapshot = { ts: nowIso(), ...control };
169
+ subscribers.forEach((s) => {
170
+ s.onMessage("temps.reading", clone(temps));
171
+ s.onMessage("control.state", clone(snapshot));
172
+ });
173
+ }
174
+
175
+ function tick() {
176
+ const flame =
177
+ control.heat_pump === "on" && !world.noPropane && control.heat_phase !== "backflush";
178
+ world.tubF += flame ? 0.15 : -0.02;
179
+ const hotTarget = flame ? world.tubF + 14 : world.tubF;
180
+ world.hotF += (hotTarget - world.hotF) * 0.35;
181
+
182
+ const temps = tempsReading();
183
+ stepHeat(temps);
184
+ stepFilter();
185
+ publish(temps);
186
+ }
187
+
188
+ function delay(fn) {
189
+ return new Promise((resolve, reject) => {
190
+ setTimeout(() => {
191
+ if (!world.online) {
192
+ reject(new Error("offline"));
193
+ return;
194
+ }
195
+ try {
196
+ resolve(fn());
197
+ } catch (err) {
198
+ reject(err);
199
+ }
200
+ }, 150);
201
+ });
202
+ }
203
+
204
+ function sendCommand(op, args = {}) {
205
+ return delay(() => {
206
+ control.last_command_id = `mock-${++commandSeq}`;
207
+ switch (op) {
208
+ case "set_heat_mode":
209
+ control.heat_mode = args.mode;
210
+ if (args.mode === "sleep") {
211
+ control.error = null;
212
+ if (control.heat_phase.startsWith("error_")) setPhase("idle");
213
+ }
214
+ stepHeat(tempsReading());
215
+ break;
216
+ case "set_target_temp":
217
+ control.target_f = clampTarget(Number(args.target_f));
218
+ break;
219
+ case "target_temp_increment":
220
+ control.target_f = clampTarget(control.target_f + (args.delta_f ?? 1));
221
+ break;
222
+ case "target_temp_decrement":
223
+ control.target_f = clampTarget(control.target_f - (args.delta_f ?? 1));
224
+ break;
225
+ case "set_filter_mode":
226
+ control.filter_mode = args.mode;
227
+ stepFilter();
228
+ break;
229
+ default:
230
+ throw new Error(`unknown op ${op}`);
231
+ }
232
+ publish();
233
+ return { id: control.last_command_id };
234
+ });
235
+ }
236
+
237
+ function connect(handlers) {
238
+ subscribers.add(handlers);
239
+ if (world.online) {
240
+ handlers.onOpen?.();
241
+ publish();
242
+ }
243
+ return () => subscribers.delete(handlers);
244
+ }
245
+
246
+ function setOnline(online) {
247
+ world.online = online;
248
+ subscribers.forEach((s) => (online ? s.onOpen?.() : s.onError?.()));
249
+ if (online) publish();
250
+ }
251
+
252
+ function mountPanel() {
253
+ const panel = document.createElement("details");
254
+ panel.className = "mock-panel";
255
+ panel.innerHTML = `
256
+ <summary>Mock backend</summary>
257
+ <div class="mock-panel-body">
258
+ <label><input type="checkbox" data-mock="hot" checked> Hot-input bundle plugged in</label>
259
+ <label><input type="checkbox" data-mock="water" checked> Water bundle plugged in</label>
260
+ <label><input type="checkbox" data-mock="no-propane"> Empty propane</label>
261
+ <label><input type="checkbox" data-mock="offline"> Drop connection</label>
262
+ <button type="button" data-mock="cool">Cool tub 5&deg;F</button>
263
+ </div>`;
264
+ panel.addEventListener("change", (e) => {
265
+ const { mock } = e.target.dataset;
266
+ const on = e.target.checked;
267
+ if (mock === "hot") world.hotBundle = on;
268
+ if (mock === "water") world.waterBundle = on;
269
+ if (mock === "no-propane") world.noPropane = on;
270
+ if (mock === "offline") setOnline(!on);
271
+ else publish();
272
+ });
273
+ panel.addEventListener("click", (e) => {
274
+ if (e.target.dataset.mock === "cool") {
275
+ world.tubF -= 5;
276
+ world.hotF -= 5;
277
+ publish();
278
+ }
279
+ });
280
+ document.body.appendChild(panel);
281
+ }
282
+
283
+ stepFilter();
284
+ setInterval(tick, 1000);
285
+ mountPanel();
286
+
287
+ window.HotTubApi = {
288
+ connect,
289
+ sendCommand,
290
+ startSensorSetup: () => delay(() => ({ ok: true })),
291
+ finishSensorSetup: () => delay(() => ({ ok: true })),
292
+ cancelSensorSetup: () => delay(() => ({ ok: true })),
293
+ };
294
+})();
projects/hot-tub/src/web/mockup/styles.css
... ...
@@ -0,0 +1,803 @@
1
+:root {
2
+ --bg-top: #e0f2fe;
3
+ --bg-bottom: #f8fafc;
4
+ --card: #ffffff;
5
+ --text: #0f172a;
6
+ --muted: #64748b;
7
+ --border: #e2e8f0;
8
+ --surface: #f8fafc;
9
+ --accent: #0e7490;
10
+ --accent-strong: #155e75;
11
+ --heat: #f97316;
12
+ --filter: #0ea5e9;
13
+ --green: #22c55e;
14
+ --yellow: #eab308;
15
+ --red: #ef4444;
16
+ --off: #cbd5e1;
17
+ --radius: 18px;
18
+ --shadow: 0 6px 24px rgba(15, 23, 42, 0.08);
19
+}
20
+
21
+* {
22
+ box-sizing: border-box;
23
+}
24
+
25
+body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
29
+ color: var(--text);
30
+ background: linear-gradient(var(--bg-top), var(--bg-bottom)) fixed;
31
+}
32
+
33
+/* ---------- header / nav ---------- */
34
+
35
+.app-header {
36
+ position: sticky;
37
+ top: 0;
38
+ z-index: 10;
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: center;
42
+ gap: 12px 20px;
43
+ padding: 12px 20px;
44
+ background: rgba(255, 255, 255, 0.85);
45
+ backdrop-filter: blur(8px);
46
+ border-bottom: 1px solid var(--border);
47
+}
48
+
49
+.brand {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 8px;
53
+ font-size: 1.25rem;
54
+ font-weight: 700;
55
+}
56
+
57
+.brand-icon {
58
+ color: var(--heat);
59
+ font-size: 1.4rem;
60
+}
61
+
62
+.nav {
63
+ display: flex;
64
+ gap: 4px;
65
+ padding: 4px;
66
+ border-radius: 999px;
67
+ background: #f1f5f9;
68
+}
69
+
70
+.nav-tab {
71
+ padding: 8px 16px;
72
+ border-radius: 999px;
73
+ color: var(--muted);
74
+ font-weight: 600;
75
+ text-decoration: none;
76
+}
77
+
78
+.nav-tab.active {
79
+ background: var(--card);
80
+ color: var(--accent-strong);
81
+ box-shadow: 0 1px 4px rgba(15, 23, 42, 0.12);
82
+}
83
+
84
+.connection {
85
+ margin-left: auto;
86
+ font-size: 0.85rem;
87
+}
88
+
89
+@media (max-width: 599px) {
90
+ .app-header {
91
+ padding: 10px 12px;
92
+ }
93
+ .nav {
94
+ order: 3;
95
+ width: 100%;
96
+ }
97
+ .nav-tab {
98
+ flex: 1;
99
+ text-align: center;
100
+ }
101
+}
102
+
103
+/* ---------- layout ---------- */
104
+
105
+.page {
106
+ max-width: 1100px;
107
+ margin: 0 auto;
108
+ padding: 20px;
109
+ transition: opacity 0.3s;
110
+}
111
+
112
+.page.stale {
113
+ opacity: 0.55;
114
+}
115
+
116
+@media (max-width: 599px) {
117
+ .page {
118
+ padding: 12px;
119
+ }
120
+}
121
+
122
+.card {
123
+ padding: 20px;
124
+ border-radius: var(--radius);
125
+ background: var(--card);
126
+ box-shadow: var(--shadow);
127
+}
128
+
129
+.loading {
130
+ padding: 60px 20px;
131
+ color: var(--muted);
132
+ text-align: center;
133
+}
134
+
135
+.control-layout,
136
+.diagnostic-layout {
137
+ display: grid;
138
+ gap: 20px;
139
+}
140
+
141
+@media (min-width: 900px) {
142
+ .control-layout {
143
+ grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
144
+ align-items: start;
145
+ }
146
+}
147
+
148
+@media (min-width: 760px) {
149
+ .diagnostic-layout {
150
+ grid-template-columns: 1fr 1fr;
151
+ align-items: start;
152
+ }
153
+}
154
+
155
+/* ---------- status lights ---------- */
156
+
157
+.status-light {
158
+ display: inline-flex;
159
+ align-items: center;
160
+ gap: 8px;
161
+ color: var(--muted);
162
+}
163
+
164
+.light {
165
+ flex-shrink: 0;
166
+ width: 14px;
167
+ height: 14px;
168
+ border-radius: 50%;
169
+ background: var(--off);
170
+}
171
+
172
+.light-on {
173
+ background: var(--green);
174
+ box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2), 0 0 12px var(--green);
175
+}
176
+
177
+.light-waiting {
178
+ background: var(--yellow);
179
+ animation: pulse 1.2s ease-in-out infinite;
180
+}
181
+
182
+.light-error {
183
+ background: var(--red);
184
+}
185
+
186
+/* ---------- tub ---------- */
187
+
188
+.banner {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ align-items: center;
192
+ justify-content: space-between;
193
+ gap: 8px 12px;
194
+ margin-bottom: 12px;
195
+ padding: 12px 14px;
196
+ border-radius: 12px;
197
+}
198
+
199
+.banner-error {
200
+ border: 1px solid #fecaca;
201
+ background: #fef2f2;
202
+ color: #991b1b;
203
+}
204
+
205
+.tub {
206
+ display: block;
207
+ width: 100%;
208
+ max-width: 560px;
209
+ margin: 0 auto;
210
+}
211
+
212
+.ground {
213
+ fill: rgba(15, 23, 42, 0.08);
214
+}
215
+
216
+.tub-shell {
217
+ fill: #475569;
218
+}
219
+
220
+.tub-wall {
221
+ fill: none;
222
+ stroke: #94a3b8;
223
+ stroke-width: 10;
224
+ stroke-linejoin: round;
225
+}
226
+
227
+.tub-rim {
228
+ stroke: #64748b;
229
+ stroke-width: 1.5;
230
+}
231
+
232
+.rivet {
233
+ fill: #64748b;
234
+}
235
+
236
+.tub-temp {
237
+ font-size: 52px;
238
+ font-weight: 800;
239
+ fill: #fff;
240
+ paint-order: stroke;
241
+ stroke: rgba(3, 105, 161, 0.6);
242
+ stroke-width: 4px;
243
+}
244
+
245
+.tub-temp-caption {
246
+ font-size: 15px;
247
+ letter-spacing: 0.2em;
248
+ text-transform: uppercase;
249
+ fill: #e0f2fe;
250
+}
251
+
252
+.wave {
253
+ animation: wave 4s linear infinite;
254
+}
255
+
256
+.wave-back {
257
+ opacity: 0.8;
258
+ animation-duration: 6s;
259
+ animation-direction: reverse;
260
+}
261
+
262
+.duck-bob {
263
+ transform-box: fill-box;
264
+ transform-origin: center;
265
+ animation: bob 2.4s ease-in-out infinite;
266
+}
267
+
268
+.steam {
269
+ opacity: 0;
270
+ transition: opacity 1s;
271
+}
272
+
273
+.steam.on {
274
+ opacity: 1;
275
+}
276
+
277
+.steam-puff {
278
+ fill: none;
279
+ stroke: #cbd5e1;
280
+ stroke-width: 5;
281
+ stroke-linecap: round;
282
+ animation: rise 3s ease-in-out infinite;
283
+}
284
+
285
+.steam-puff:nth-child(2) {
286
+ animation-delay: 1s;
287
+}
288
+
289
+.steam-puff:nth-child(3) {
290
+ animation-delay: 2s;
291
+}
292
+
293
+/* ---------- pumps ---------- */
294
+
295
+.pumps {
296
+ display: grid;
297
+ grid-template-columns: repeat(2, minmax(0, 1fr));
298
+ gap: 16px;
299
+ max-width: 420px;
300
+ margin: 8px auto 0;
301
+}
302
+
303
+.pump {
304
+ display: flex;
305
+ flex-direction: column;
306
+ align-items: center;
307
+ gap: 6px;
308
+}
309
+
310
+.pump-svg {
311
+ width: 96px;
312
+ height: 96px;
313
+}
314
+
315
+.pump-frame {
316
+ fill: #f1f5f9;
317
+ stroke: var(--border);
318
+ stroke-width: 2;
319
+}
320
+
321
+.pump-housing {
322
+ fill: #1e293b;
323
+}
324
+
325
+.pump-hub {
326
+ fill: #e2e8f0;
327
+}
328
+
329
+.fan-blades {
330
+ opacity: 0.5;
331
+ transform-box: view-box;
332
+ transform-origin: 60px 60px;
333
+ transition: opacity 0.3s;
334
+}
335
+
336
+.pump-heat .fan-blades {
337
+ fill: var(--heat);
338
+}
339
+
340
+.pump-filter .fan-blades {
341
+ fill: var(--filter);
342
+}
343
+
344
+.pump.running .fan-blades {
345
+ opacity: 1;
346
+ animation: spin 0.9s linear infinite;
347
+}
348
+
349
+.pump-label {
350
+ font-weight: 700;
351
+}
352
+
353
+@media (max-width: 400px) {
354
+ .pump-svg {
355
+ width: 80px;
356
+ height: 80px;
357
+ }
358
+}
359
+
360
+/* ---------- settings ---------- */
361
+
362
+.settings-card {
363
+ display: flex;
364
+ flex-direction: column;
365
+ gap: 22px;
366
+}
367
+
368
+.field-label {
369
+ display: block;
370
+ margin-bottom: 6px;
371
+ color: var(--muted);
372
+ font-size: 0.8rem;
373
+ font-weight: 700;
374
+ letter-spacing: 0.06em;
375
+ text-transform: uppercase;
376
+}
377
+
378
+.target-row {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: space-between;
382
+ gap: 12px;
383
+}
384
+
385
+.target-value {
386
+ color: var(--accent-strong);
387
+ font-size: 3.25rem;
388
+ font-weight: 800;
389
+ font-variant-numeric: tabular-nums;
390
+}
391
+
392
+.round-btn {
393
+ width: 60px;
394
+ height: 60px;
395
+ border: none;
396
+ border-radius: 50%;
397
+ background: var(--accent);
398
+ color: #fff;
399
+ font-size: 2rem;
400
+ line-height: 1;
401
+ cursor: pointer;
402
+ touch-action: manipulation;
403
+}
404
+
405
+.round-btn:active:not(:disabled) {
406
+ transform: scale(0.94);
407
+}
408
+
409
+.modes {
410
+ display: flex;
411
+ flex-direction: column;
412
+ gap: 14px;
413
+}
414
+
415
+.field select {
416
+ width: 100%;
417
+ padding: 12px 14px;
418
+ border: 1px solid var(--border);
419
+ border-radius: 12px;
420
+ background: var(--surface);
421
+ font: inherit;
422
+ font-size: 1.05rem;
423
+}
424
+
425
+.summary {
426
+ display: grid;
427
+ gap: 10px;
428
+ margin: 0;
429
+}
430
+
431
+.summary div {
432
+ display: flex;
433
+ justify-content: space-between;
434
+ gap: 12px;
435
+ padding-bottom: 8px;
436
+ border-bottom: 1px dashed var(--border);
437
+}
438
+
439
+.summary dt {
440
+ color: var(--muted);
441
+}
442
+
443
+.summary dd {
444
+ margin: 0;
445
+ font-weight: 600;
446
+ text-align: right;
447
+}
448
+
449
+/* ---------- buttons ---------- */
450
+
451
+.btn {
452
+ display: inline-flex;
453
+ align-items: center;
454
+ justify-content: center;
455
+ padding: 12px 22px;
456
+ border: none;
457
+ border-radius: 12px;
458
+ font: inherit;
459
+ font-weight: 700;
460
+ cursor: pointer;
461
+}
462
+
463
+.btn-primary {
464
+ background: var(--accent);
465
+ color: #fff;
466
+}
467
+
468
+.btn-secondary {
469
+ background: #f1f5f9;
470
+ color: var(--text);
471
+}
472
+
473
+.btn-small {
474
+ padding: 6px 12px;
475
+ background: #991b1b;
476
+ color: #fff;
477
+ font-size: 0.875rem;
478
+}
479
+
480
+.big-button {
481
+ width: 100%;
482
+ min-height: 110px;
483
+ border: none;
484
+ border-radius: var(--radius);
485
+ box-shadow: var(--shadow);
486
+ color: #fff;
487
+ font: inherit;
488
+ font-size: 1.75rem;
489
+ font-weight: 800;
490
+ cursor: pointer;
491
+}
492
+
493
+.big-button-backflush {
494
+ background: linear-gradient(135deg, #0ea5e9, #0369a1);
495
+}
496
+
497
+.big-button-setup {
498
+ background: linear-gradient(135deg, #14b8a6, #0f766e);
499
+}
500
+
501
+.big-button:active:not(:disabled) {
502
+ transform: translateY(1px);
503
+}
504
+
505
+.round-btn:disabled,
506
+.btn:disabled,
507
+.big-button:disabled {
508
+ opacity: 0.4;
509
+ cursor: not-allowed;
510
+}
511
+
512
+/* ---------- diagnostic ---------- */
513
+
514
+.backflush-slot {
515
+ display: grid;
516
+ place-items: center;
517
+ min-height: 190px;
518
+ margin-top: 16px;
519
+}
520
+
521
+.hint {
522
+ max-width: 320px;
523
+ color: var(--muted);
524
+ text-align: center;
525
+}
526
+
527
+.backflush-anim {
528
+ color: var(--accent-strong);
529
+ font-weight: 600;
530
+ text-align: center;
531
+}
532
+
533
+.backflush-anim svg {
534
+ width: 140px;
535
+ height: 140px;
536
+}
537
+
538
+.backflush-anim p {
539
+ margin: 6px 0 0;
540
+}
541
+
542
+.backflush-arrows {
543
+ fill: none;
544
+ stroke: var(--filter);
545
+ stroke-width: 6;
546
+ stroke-linecap: round;
547
+ transform-box: view-box;
548
+ transform-origin: 60px 60px;
549
+ animation: spin 2s linear infinite;
550
+}
551
+
552
+.backflush-arrows .arrowhead {
553
+ fill: var(--filter);
554
+ stroke: none;
555
+}
556
+
557
+.droplet {
558
+ fill: #38bdf8;
559
+}
560
+
561
+.droplet-eye {
562
+ fill: var(--text);
563
+}
564
+
565
+.droplet-smile {
566
+ fill: none;
567
+ stroke: var(--text);
568
+ stroke-width: 2.5;
569
+ stroke-linecap: round;
570
+}
571
+
572
+.bubble {
573
+ fill: #7dd3fc;
574
+ transform-box: fill-box;
575
+ animation: bubble 1.8s ease-in infinite;
576
+}
577
+
578
+.bubble.b2 {
579
+ animation-delay: 0.6s;
580
+}
581
+
582
+.bubble.b3 {
583
+ animation-delay: 1.2s;
584
+}
585
+
586
+.readings {
587
+ margin-top: 20px;
588
+}
589
+
590
+.readings-header {
591
+ display: flex;
592
+ justify-content: space-between;
593
+ align-items: baseline;
594
+}
595
+
596
+.setup-state {
597
+ color: var(--muted);
598
+ font-size: 0.85rem;
599
+ text-transform: capitalize;
600
+}
601
+
602
+.probe-list {
603
+ display: grid;
604
+ gap: 8px;
605
+ margin: 8px 0 0;
606
+ padding: 0;
607
+ list-style: none;
608
+}
609
+
610
+.probe-row {
611
+ display: flex;
612
+ align-items: center;
613
+ justify-content: space-between;
614
+ padding: 10px 12px;
615
+ border-radius: 10px;
616
+ background: var(--surface);
617
+}
618
+
619
+.probe-row .status-light {
620
+ color: var(--text);
621
+}
622
+
623
+.probe-value {
624
+ font-weight: 600;
625
+ font-variant-numeric: tabular-nums;
626
+}
627
+
628
+/* ---------- sensor setup ---------- */
629
+
630
+.setup-card {
631
+ max-width: 640px;
632
+ margin: 0 auto;
633
+}
634
+
635
+.setup-card h1 {
636
+ margin: 0 0 16px;
637
+ font-size: 1.5rem;
638
+}
639
+
640
+.stepper {
641
+ display: flex;
642
+ gap: 8px;
643
+ margin: 0;
644
+ padding: 0;
645
+ list-style: none;
646
+}
647
+
648
+.stepper li {
649
+ flex: 1;
650
+ padding-top: 10px;
651
+ border-top: 4px solid var(--border);
652
+ color: var(--muted);
653
+ font-size: 0.8rem;
654
+ text-align: center;
655
+}
656
+
657
+.stepper li.done,
658
+.stepper li.current {
659
+ border-color: var(--accent);
660
+ color: var(--accent-strong);
661
+ font-weight: 700;
662
+}
663
+
664
+.setup-step h2 {
665
+ margin: 24px 0 8px;
666
+ font-size: 1.25rem;
667
+}
668
+
669
+.setup-step p {
670
+ color: var(--muted);
671
+ line-height: 1.5;
672
+}
673
+
674
+.setup-status {
675
+ margin: 20px 0;
676
+ padding: 16px;
677
+ border-radius: 12px;
678
+ background: var(--surface);
679
+}
680
+
681
+.setup-status .status-light {
682
+ color: var(--text);
683
+ font-size: 1.1rem;
684
+}
685
+
686
+.setup-status .light {
687
+ width: 22px;
688
+ height: 22px;
689
+}
690
+
691
+.actions {
692
+ display: flex;
693
+ justify-content: space-between;
694
+ gap: 12px;
695
+ margin-top: 24px;
696
+}
697
+
698
+/* ---------- mock backend panel (not part of the app) ---------- */
699
+
700
+.mock-panel {
701
+ position: fixed;
702
+ right: 12px;
703
+ bottom: 12px;
704
+ z-index: 50;
705
+ max-width: 250px;
706
+ border: 2px dashed #f59e0b;
707
+ border-radius: 12px;
708
+ background: #fffbeb;
709
+ box-shadow: var(--shadow);
710
+ font-size: 0.85rem;
711
+}
712
+
713
+.mock-panel summary {
714
+ padding: 8px 12px;
715
+ color: #92400e;
716
+ font-weight: 700;
717
+ cursor: pointer;
718
+}
719
+
720
+.mock-panel-body {
721
+ display: grid;
722
+ gap: 8px;
723
+ padding: 0 12px 12px;
724
+}
725
+
726
+.mock-panel label {
727
+ display: flex;
728
+ align-items: center;
729
+ gap: 6px;
730
+}
731
+
732
+.mock-panel button {
733
+ padding: 6px 8px;
734
+ border: 1px solid #fcd34d;
735
+ border-radius: 8px;
736
+ background: #fff;
737
+ font: inherit;
738
+ text-align: left;
739
+ cursor: pointer;
740
+}
741
+
742
+/* ---------- animation ---------- */
743
+
744
+@keyframes spin {
745
+ to {
746
+ transform: rotate(360deg);
747
+ }
748
+}
749
+
750
+@keyframes wave {
751
+ to {
752
+ transform: translateX(-100px);
753
+ }
754
+}
755
+
756
+@keyframes bob {
757
+ 50% {
758
+ transform: translateY(4px) rotate(-4deg);
759
+ }
760
+}
761
+
762
+@keyframes rise {
763
+ 0% {
764
+ opacity: 0;
765
+ transform: translateY(8px);
766
+ }
767
+ 40% {
768
+ opacity: 0.9;
769
+ }
770
+ 100% {
771
+ opacity: 0;
772
+ transform: translateY(-14px);
773
+ }
774
+}
775
+
776
+@keyframes bubble {
777
+ 0% {
778
+ opacity: 0;
779
+ transform: translateY(0);
780
+ }
781
+ 20% {
782
+ opacity: 1;
783
+ }
784
+ 100% {
785
+ opacity: 0;
786
+ transform: translateY(-40px);
787
+ }
788
+}
789
+
790
+@keyframes pulse {
791
+ 50% {
792
+ opacity: 0.35;
793
+ }
794
+}
795
+
796
+@media (prefers-reduced-motion: reduce) {
797
+ *,
798
+ *::before,
799
+ *::after {
800
+ animation: none !important;
801
+ transition: none !important;
802
+ }
803
+}
projects/hot-tub/src/web/redis_io.py
... ...
@@ -0,0 +1,217 @@
1
+"""Redis access for the web service."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+import logging
7
+import queue
8
+import threading
9
+import time
10
+import uuid
11
+from datetime import datetime, timezone
12
+from typing import Any, Callable
13
+
14
+try:
15
+ import redis
16
+except ImportError:
17
+ redis = None # type: ignore[assignment]
18
+
19
+logger = logging.getLogger("hot_tub.web")
20
+
21
+KEY_CONTROL_LATEST = "control:latest"
22
+CHAN_CONTROL_STATE = "control.state"
23
+KEY_TEMPS_LATEST = "temps:latest"
24
+CHAN_TEMPS_READING = "temps.reading"
25
+KEY_TEMPS_SETUP = "temps:setup"
26
+CHAN_TEMPS_SETUP = "temps.setup"
27
+KEY_HEALTH_WEB = "health:web"
28
+CHAN_HEALTH_WEB = "health.web"
29
+KEY_HEALTH_CONTROL = "health:control"
30
+KEY_HEALTH_TEMPS = "health:temps"
31
+CHAN_COMMAND = "control.command"
32
+CHAN_TEMPS_COMMAND = "temps.command"
33
+
34
+SSE_CHANNELS = (CHAN_CONTROL_STATE, CHAN_TEMPS_READING, CHAN_TEMPS_SETUP)
35
+
36
+
37
+def _utc_now() -> str:
38
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
39
+
40
+
41
+def _parse_ts(ts: str | None) -> datetime | None:
42
+ if not ts:
43
+ return None
44
+ try:
45
+ if ts.endswith("Z"):
46
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
47
+ return datetime.fromisoformat(ts)
48
+ except ValueError:
49
+ return None
50
+
51
+
52
+def payload_age_s(payload: dict[str, Any] | None, now: datetime | None = None) -> float | None:
53
+ if payload is None:
54
+ return None
55
+ ts = _parse_ts(payload.get("ts"))
56
+ if ts is None:
57
+ return None
58
+ if ts.tzinfo is None:
59
+ ts = ts.replace(tzinfo=timezone.utc)
60
+ now = now or datetime.now(timezone.utc)
61
+ return (now - ts.astimezone(timezone.utc)).total_seconds()
62
+
63
+
64
+class WebRedis:
65
+ def __init__(self, redis_url: str) -> None:
66
+ if redis is None:
67
+ raise RuntimeError("redis package not installed (apt: python3-redis)")
68
+ self._url = redis_url
69
+ self._client: redis.Redis | None = None
70
+ self._pubsub: redis.client.PubSub | None = None
71
+ self._backoff_s = 1.0
72
+ self._fanout_lock = threading.Lock()
73
+ self._subscribers: list[queue.Queue[tuple[str, str]]] = []
74
+ self._fanout_thread: threading.Thread | None = None
75
+ self._fanout_stop = threading.Event()
76
+
77
+ def _connect(self) -> redis.Redis:
78
+ if self._client is None:
79
+ self._client = redis.from_url(self._url, decode_responses=True)
80
+ return self._client
81
+
82
+ def _ensure(self) -> redis.Redis | None:
83
+ try:
84
+ client = self._connect()
85
+ client.ping()
86
+ self._backoff_s = 1.0
87
+ return client
88
+ except Exception as exc:
89
+ logger.warning("Redis unavailable: %s (retry in %.0fs)", exc, self._backoff_s)
90
+ self._client = None
91
+ self._pubsub = None
92
+ time.sleep(min(self._backoff_s, 30.0))
93
+ self._backoff_s = min(self._backoff_s * 2, 30.0)
94
+ return None
95
+
96
+ def get_json(self, key: str) -> dict[str, Any] | None:
97
+ client = self._ensure()
98
+ if client is None:
99
+ return None
100
+ try:
101
+ raw = client.get(key)
102
+ if not raw:
103
+ return None
104
+ data = json.loads(raw)
105
+ return data if isinstance(data, dict) else None
106
+ except Exception as exc:
107
+ logger.warning("GET %s failed: %s", key, exc)
108
+ return None
109
+
110
+ def publish_control_command(self, op: str, args: dict[str, Any]) -> str:
111
+ cmd_id = str(uuid.uuid4())
112
+ envelope = {
113
+ "ts": _utc_now(),
114
+ "source": "web",
115
+ "id": cmd_id,
116
+ "op": op,
117
+ "args": args,
118
+ }
119
+ client = self._ensure()
120
+ if client is None:
121
+ raise RuntimeError("redis unavailable")
122
+ body = json.dumps(envelope)
123
+ client.publish(CHAN_COMMAND, body)
124
+ return cmd_id
125
+
126
+ def publish_temp_setup_command(self, op: str) -> str:
127
+ cmd_id = str(uuid.uuid4())
128
+ envelope = {"ts": _utc_now(), "source": "web", "id": cmd_id, "op": op}
129
+ client = self._ensure()
130
+ if client is None:
131
+ raise RuntimeError("redis unavailable")
132
+ body = json.dumps(envelope)
133
+ client.publish(CHAN_TEMPS_COMMAND, body)
134
+ return cmd_id
135
+
136
+ def publish_health(self, payload: dict[str, Any]) -> bool:
137
+ client = self._ensure()
138
+ if client is None:
139
+ return False
140
+ body = json.dumps(payload)
141
+ client.set(KEY_HEALTH_WEB, body)
142
+ client.publish(CHAN_HEALTH_WEB, body)
143
+ return True
144
+
145
+ def _ensure_fanout(self) -> None:
146
+ with self._fanout_lock:
147
+ if self._fanout_thread is not None and self._fanout_thread.is_alive():
148
+ return
149
+ self._fanout_stop.clear()
150
+ self._fanout_thread = threading.Thread(target=self._fanout_loop, daemon=True)
151
+ self._fanout_thread.start()
152
+
153
+ def _fanout_loop(self) -> None:
154
+ while not self._fanout_stop.is_set():
155
+ client = self._ensure()
156
+ if client is None:
157
+ time.sleep(1.0)
158
+ continue
159
+ try:
160
+ if self._pubsub is None:
161
+ self._pubsub = client.pubsub(ignore_subscribe_messages=True)
162
+ self._pubsub.subscribe(*SSE_CHANNELS)
163
+ msg = self._pubsub.get_message(timeout=1.0)
164
+ if msg is None or msg.get("type") != "message":
165
+ continue
166
+ channel = msg.get("channel")
167
+ data = msg.get("data")
168
+ if not isinstance(channel, str) or not isinstance(data, str):
169
+ continue
170
+ with self._fanout_lock:
171
+ dead: list[queue.Queue[tuple[str, str]]] = []
172
+ for q in self._subscribers:
173
+ try:
174
+ q.put_nowait((channel, data))
175
+ except queue.Full:
176
+ dead.append(q)
177
+ for q in dead:
178
+ self._subscribers.remove(q)
179
+ except Exception as exc:
180
+ logger.warning("SSE fanout error: %s", exc)
181
+ if self._pubsub is not None:
182
+ try:
183
+ self._pubsub.close()
184
+ except Exception:
185
+ pass
186
+ self._pubsub = None
187
+ time.sleep(0.5)
188
+
189
+ def subscribe_sse(self) -> queue.Queue[tuple[str, str]]:
190
+ q: queue.Queue[tuple[str, str]] = queue.Queue(maxsize=64)
191
+ with self._fanout_lock:
192
+ self._subscribers.append(q)
193
+ self._ensure_fanout()
194
+ return q
195
+
196
+ def unsubscribe_sse(self, q: queue.Queue[tuple[str, str]]) -> None:
197
+ with self._fanout_lock:
198
+ if q in self._subscribers:
199
+ self._subscribers.remove(q)
200
+
201
+ def close(self) -> None:
202
+ self._fanout_stop.set()
203
+ if self._fanout_thread is not None:
204
+ self._fanout_thread.join(timeout=2.0)
205
+ if self._pubsub is not None:
206
+ try:
207
+ self._pubsub.unsubscribe()
208
+ self._pubsub.close()
209
+ except Exception:
210
+ pass
211
+ self._pubsub = None
212
+ if self._client is not None:
213
+ try:
214
+ self._client.close()
215
+ except Exception:
216
+ pass
217
+ self._client = None
projects/hot-tub/src/web/server.py
... ...
@@ -0,0 +1,150 @@
1
+"""Flask HTTP server: static UI, REST API, SSE."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+import logging
7
+import queue
8
+import time
9
+from pathlib import Path
10
+from typing import Any
11
+
12
+from flask import Flask, Response, jsonify, request, send_from_directory
13
+
14
+from .commands import CommandValidationError, validate_control_command, validate_temp_setup_op
15
+from .config import WebConfig
16
+from .health import api_health_payload
17
+from .redis_io import (
18
+ CHAN_CONTROL_STATE,
19
+ CHAN_TEMPS_READING,
20
+ CHAN_TEMPS_SETUP,
21
+ KEY_CONTROL_LATEST,
22
+ KEY_HEALTH_CONTROL,
23
+ KEY_HEALTH_TEMPS,
24
+ KEY_HEALTH_WEB,
25
+ KEY_TEMPS_LATEST,
26
+ KEY_TEMPS_SETUP,
27
+ WebRedis,
28
+)
29
+
30
+logger = logging.getLogger("hot_tub.web")
31
+
32
+
33
+def create_app(cfg: WebConfig, redis_io: WebRedis) -> Flask:
34
+ app = Flask(__name__, static_folder=None)
35
+ static_dir = cfg.static_dir.resolve()
36
+
37
+ def _sse_event(name: str, data: str) -> str:
38
+ return f"event: {name}\ndata: {data}\n\n"
39
+
40
+ @app.get("/api/events")
41
+ def api_events() -> Response:
42
+ def generate():
43
+ for key, channel in (
44
+ (KEY_CONTROL_LATEST, CHAN_CONTROL_STATE),
45
+ (KEY_TEMPS_LATEST, CHAN_TEMPS_READING),
46
+ (KEY_TEMPS_SETUP, CHAN_TEMPS_SETUP),
47
+ ):
48
+ snap = redis_io.get_json(key)
49
+ if snap is not None:
50
+ yield _sse_event(channel, json.dumps(snap))
51
+
52
+ q = redis_io.subscribe_sse()
53
+ last_keepalive = time.monotonic()
54
+ try:
55
+ while True:
56
+ try:
57
+ channel, data = q.get(timeout=1.0)
58
+ yield _sse_event(channel, data)
59
+ except queue.Empty:
60
+ pass
61
+ if time.monotonic() - last_keepalive >= cfg.sse_keepalive_s:
62
+ yield ": keepalive\n\n"
63
+ last_keepalive = time.monotonic()
64
+ finally:
65
+ redis_io.unsubscribe_sse(q)
66
+
67
+ return Response(
68
+ generate(),
69
+ mimetype="text/event-stream",
70
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
71
+ )
72
+
73
+ @app.post("/api/command")
74
+ def api_command() -> tuple[Any, int]:
75
+ body = request.get_json(silent=True)
76
+ if not isinstance(body, dict):
77
+ return jsonify({"error": "JSON body required"}), 400
78
+ try:
79
+ op, args = validate_control_command(body)
80
+ cmd_id = redis_io.publish_control_command(op, args)
81
+ except CommandValidationError as exc:
82
+ return jsonify({"error": str(exc)}), 400
83
+ except RuntimeError as exc:
84
+ return jsonify({"error": str(exc)}), 503
85
+ return jsonify({"id": cmd_id}), 202
86
+
87
+ def _setup_op(op: str) -> tuple[Any, int]:
88
+ try:
89
+ validate_temp_setup_op(op)
90
+ cmd_id = redis_io.publish_temp_setup_command(op)
91
+ except CommandValidationError as exc:
92
+ return jsonify({"error": str(exc)}), 400
93
+ except RuntimeError as exc:
94
+ return jsonify({"error": str(exc)}), 503
95
+ return jsonify({"id": cmd_id}), 202
96
+
97
+ @app.post("/api/sensor-setup/start")
98
+ def sensor_setup_start() -> tuple[Any, int]:
99
+ return _setup_op("setup_start")
100
+
101
+ @app.post("/api/sensor-setup/finish")
102
+ def sensor_setup_finish() -> tuple[Any, int]:
103
+ return _setup_op("setup_finish")
104
+
105
+ @app.post("/api/sensor-setup/cancel")
106
+ def sensor_setup_cancel() -> tuple[Any, int]:
107
+ return _setup_op("setup_cancel")
108
+
109
+ @app.get("/api/status")
110
+ def api_status() -> Any:
111
+ return jsonify(
112
+ {
113
+ "control": redis_io.get_json(KEY_CONTROL_LATEST),
114
+ "temps": redis_io.get_json(KEY_TEMPS_LATEST),
115
+ "health": {
116
+ "control": redis_io.get_json(KEY_HEALTH_CONTROL),
117
+ "temps": redis_io.get_json(KEY_HEALTH_TEMPS),
118
+ "web": redis_io.get_json(KEY_HEALTH_WEB),
119
+ },
120
+ }
121
+ )
122
+
123
+ @app.get("/api/health")
124
+ def api_health() -> Any:
125
+ return jsonify(api_health_payload(redis_io, cfg))
126
+
127
+ @app.route("/", defaults={"path": ""})
128
+ @app.route("/<path:path>")
129
+ def static_files(path: str) -> Any:
130
+ if path.startswith("api/"):
131
+ return jsonify({"error": "not found"}), 404
132
+ target = static_dir / path
133
+ if path and target.is_file():
134
+ return send_from_directory(static_dir, path)
135
+ index = static_dir / "index.html"
136
+ if index.is_file():
137
+ return send_from_directory(static_dir, "index.html")
138
+ return jsonify({"error": "frontend not built; see src/web/frontend"}), 503
139
+
140
+ return app
141
+
142
+
143
+def run_server(cfg: WebConfig, redis_io: WebRedis) -> None:
144
+ app = create_app(cfg, redis_io)
145
+ app.run(
146
+ host=cfg.bind_host,
147
+ port=cfg.bind_port,
148
+ threaded=True,
149
+ use_reloader=False,
150
+ )
projects/hot-tub/src/web/tests/__init__.py
... ...
@@ -0,0 +1 @@
1
+
projects/hot-tub/src/web/tests/test_commands.py
... ...
@@ -0,0 +1,29 @@
1
+"""Command validation tests."""
2
+
3
+from __future__ import annotations
4
+
5
+import unittest
6
+
7
+from web.commands import CommandValidationError, validate_control_command, validate_temp_setup_op
8
+
9
+
10
+class TestWebCommands(unittest.TestCase):
11
+ def test_set_heat_mode(self) -> None:
12
+ op, args = validate_control_command({"op": "set_heat_mode", "args": {"mode": "maintain"}})
13
+ self.assertEqual(op, "set_heat_mode")
14
+ self.assertEqual(args["mode"], "maintain")
15
+
16
+ def test_invalid_mode(self) -> None:
17
+ with self.assertRaises(CommandValidationError):
18
+ validate_control_command({"op": "set_heat_mode", "args": {"mode": "bogus"}})
19
+
20
+ def test_target_clamp(self) -> None:
21
+ _, args = validate_control_command({"op": "set_target_temp", "args": {"target_f": 200}})
22
+ self.assertEqual(args["target_f"], 110.0)
23
+
24
+ def test_setup_ops(self) -> None:
25
+ self.assertEqual(validate_temp_setup_op("setup_start"), "setup_start")
26
+
27
+
28
+if __name__ == "__main__":
29
+ unittest.main()
projects/hot-tub/src/web/tests/test_server.py
... ...
@@ -0,0 +1,32 @@
1
+"""Flask route smoke tests (no Redis)."""
2
+
3
+from __future__ import annotations
4
+
5
+import unittest
6
+from unittest.mock import MagicMock
7
+
8
+from web.config import WebConfig
9
+from web.server import create_app
10
+
11
+
12
+class TestServer(unittest.TestCase):
13
+ def test_status_route(self) -> None:
14
+ redis_io = MagicMock()
15
+ redis_io.get_json.return_value = None
16
+ cfg = WebConfig()
17
+ app = create_app(cfg, redis_io)
18
+ client = app.test_client()
19
+ res = client.get("/api/status")
20
+ self.assertEqual(res.status_code, 200)
21
+
22
+ def test_command_validation_400(self) -> None:
23
+ redis_io = MagicMock()
24
+ cfg = WebConfig()
25
+ app = create_app(cfg, redis_io)
26
+ client = app.test_client()
27
+ res = client.post("/api/command", json={"op": "bad_op"})
28
+ self.assertEqual(res.status_code, 400)
29
+
30
+
31
+if __name__ == "__main__":
32
+ unittest.main()