ReviewOS

also looking at this

stacks/wildloop

feat: native trail maps and complete native mobile screens

#1017
Draft chris wants to merge codex/trail-experience into main
60 files +2505 -823
.github/labeler.ymlmodified+251-91
Changes to .github/labeler.yml
@@ -1,250 +1,410 @@
11api:
2 - 'storage/framework/api/**'
2 - changed-files:
3 - any-glob-to-any-file:
4 - "storage/framework/api/**"
35
46deps:
5 - ./package.json
7 - changed-files:
8 - any-glob-to-any-file:
9 - "package.json"
610
711core:
8 - 'storage/framework/core/**'
12 - changed-files:
13 - any-glob-to-any-file:
14 - "storage/framework/core/**"
915
1016ide:
11 - 'storage/framework/defaults/ide/**'
17 - changed-files:
18 - any-glob-to-any-file:
19 - "storage/framework/defaults/ide/**"
1220
1321jetbrains:
14 - 'storage/framework/defaults/ide/jetbrains/**'
22 - changed-files:
23 - any-glob-to-any-file:
24 - "storage/framework/defaults/ide/jetbrains/**"
1525
1626vscode:
17 - 'storage/framework/defaults/ide/vscode/**'
27 - changed-files:
28 - any-glob-to-any-file:
29 - "storage/framework/defaults/ide/vscode/**"
1830
1931libs:
20 - 'storage/framework/libs/**'
32 - changed-files:
33 - any-glob-to-any-file:
34 - "storage/framework/libs/**"
2135
2236components:
23 - 'storage/framework/libs/components/**'
24 - resources/components/**
37 - changed-files:
38 - any-glob-to-any-file:
39 - "storage/framework/libs/components/**"
40 - "resources/components/**"
2541
2642entries:
27 - storage/framework/libs/entries/**
43 - changed-files:
44 - any-glob-to-any-file:
45 - "storage/framework/libs/entries/**"
2846
2947examples:
30 - storage/framework/libs/examples/**
48 - changed-files:
49 - any-glob-to-any-file:
50 - "storage/framework/libs/examples/**"
3151
3252functions:
33 - 'storage/framework/libs/functions/**'
34 - resources/functions/**
53 - changed-files:
54 - any-glob-to-any-file:
55 - "storage/framework/libs/functions/**"
56 - "resources/functions/**"
3557
3658scripts:
37 - ./storage/framework/scripts/**
59 - changed-files:
60 - any-glob-to-any-file:
61 - "storage/framework/scripts/**"
3862
3963stack:
40 - 'storage/framework/stacks/**'
64 - changed-files:
65 - any-glob-to-any-file:
66 - "storage/framework/stacks/**"
4167
4268dashboard:
43 - 'storage/framework/stacks/dashboard/**'
69 - changed-files:
70 - any-glob-to-any-file:
71 - "storage/framework/stacks/dashboard/**"
4472
4573vcs:
46 - ./storage/framework/core/vcs/**
74 - changed-files:
75 - any-glob-to-any-file:
76 - "storage/framework/core/vcs/**"
4777
4878github:
49 - ./storage/framework/core/vcs/github/**
79 - changed-files:
80 - any-glob-to-any-file:
81 - "storage/framework/core/vcs/github/**"
5082
5183views:
52 - 'storage/framework/core/views/**'
53 - resources/views/**
84 - changed-files:
85 - any-glob-to-any-file:
86 - "storage/framework/core/views/**"
87 - "resources/views/**"
5488
5589web:
56 - ./storage/framework/core/views/web/**
90 - changed-files:
91 - any-glob-to-any-file:
92 - "storage/framework/core/views/web/**"
5793
5894actions:
59 - 'storage/framework/core/actions/**'
95 - changed-files:
96 - any-glob-to-any-file:
97 - "storage/framework/core/actions/**"
6098
6199ai:
62 - 'storage/framework/core/ai/**'
100 - changed-files:
101 - any-glob-to-any-file:
102 - "storage/framework/core/ai/**"
63103
64104alias:
65 - 'storage/framework/core/alias/**'
105 - changed-files:
106 - any-glob-to-any-file:
107 - "storage/framework/core/alias/**"
66108
67109analytics:
68 - 'storage/framework/core/analytics/**'
110 - changed-files:
111 - any-glob-to-any-file:
112 - "storage/framework/core/analytics/**"
69113
70114arrays:
71 - 'storage/framework/core/utils/arrays/**'
115 - changed-files:
116 - any-glob-to-any-file:
117 - "storage/framework/core/utils/arrays/**"
72118
73119auth:
74 - 'storage/framework/core/auth/**'
120 - changed-files:
121 - any-glob-to-any-file:
122 - "storage/framework/core/auth/**"
75123
76124buddy:
77 - 'storage/framework/core/buddy/**'
125 - changed-files:
126 - any-glob-to-any-file:
127 - "storage/framework/core/buddy/**"
78128
79129build:
80 - 'storage/framework/core/build/**'
130 - changed-files:
131 - any-glob-to-any-file:
132 - "storage/framework/core/build/**"
81133
82134cache:
83 - 'storage/framework/core/cache/**'
135 - changed-files:
136 - any-glob-to-any-file:
137 - "storage/framework/core/cache/**"
84138
85139chat:
86 - 'storage/framework/core/notifications/chat/**'
140 - changed-files:
141 - any-glob-to-any-file:
142 - "storage/framework/core/notifications/chat/**"
87143
88144cli:
89 - 'storage/framework/core/cli/**'
90 - app/Commands/**
145 - changed-files:
146 - any-glob-to-any-file:
147 - "storage/framework/core/cli/**"
148 - "app/Commands/**"
91149
92150cloud:
93 - 'storage/framework/core/cloud/**'
151 - changed-files:
152 - any-glob-to-any-file:
153 - "storage/framework/core/cloud/**"
94154
95155collections:
96 - 'storage/framework/core/utils/collections/**'
156 - changed-files:
157 - any-glob-to-any-file:
158 - "storage/framework/core/utils/collections/**"
97159
98160config:
99 - 'storage/framework/core/config/**'
100 - config/**
161 - changed-files:
162 - any-glob-to-any-file:
163 - "storage/framework/core/config/**"
164 - "config/**"
101165
102166database:
103 - 'storage/framework/core/database/**'
167 - changed-files:
168 - any-glob-to-any-file:
169 - "storage/framework/core/database/**"
104170
105171datetime:
106 - 'storage/framework/core/utils/datetime/**'
172 - changed-files:
173 - any-glob-to-any-file:
174 - "storage/framework/core/utils/datetime/**"
107175
108176desktop:
109 - 'storage/framework/core/desktop/**'
110 - ./storage/framework/core/views/desktop/**
177 - changed-files:
178 - any-glob-to-any-file:
179 - "storage/framework/core/desktop/**"
180 - "storage/framework/core/views/desktop/**"
111181
112182development:
113 - 'storage/framework/core/development/**'
183 - changed-files:
184 - any-glob-to-any-file:
185 - "storage/framework/core/development/**"
114186
115187dns:
116 - 'storage/framework/core/dns/**'
188 - changed-files:
189 - any-glob-to-any-file:
190 - "storage/framework/core/dns/**"
117191
118192docs:
119 - 'storage/framework/core/docs/**'
120 - 'storage/framework/docs/**'
121 - docs/**
122 - config/docs.ts
193 - changed-files:
194 - any-glob-to-any-file:
195 - "storage/framework/core/docs/**"
196 - "storage/framework/docs/**"
197 - "docs/**"
198 - "config/docs.ts"
123199
124200email:
125 - 'storage/framework/core/notifications/email/**'
201 - changed-files:
202 - any-glob-to-any-file:
203 - "storage/framework/core/notifications/email/**"
126204
127205error-handling:
128 - 'storage/framework/core/error-handling/**'
206 - changed-files:
207 - any-glob-to-any-file:
208 - "storage/framework/core/error-handling/**"
129209
130210events:
131 - 'storage/framework/core/events/**'
132 - app/Events/**
211 - changed-files:
212 - any-glob-to-any-file:
213 - "storage/framework/core/events/**"
214 - "app/Events/**"
133215
134216faker:
135 - 'storage/framework/core/faker/**'
217 - changed-files:
218 - any-glob-to-any-file:
219 - "storage/framework/core/faker/**"
136220
137221git:
138 - 'storage/framework/core/utils/git/**'
222 - changed-files:
223 - any-glob-to-any-file:
224 - "storage/framework/core/utils/git/**"
139225
140226health:
141 - 'storage/framework/core/health/**'
227 - changed-files:
228 - any-glob-to-any-file:
229 - "storage/framework/core/health/**"
142230
143231lint:
144 - 'storage/framework/core/lint/**'
232 - changed-files:
233 - any-glob-to-any-file:
234 - "storage/framework/core/lint/**"
145235
146236logging:
147 - 'storage/framework/core/logging/**'
237 - changed-files:
238 - any-glob-to-any-file:
239 - "storage/framework/core/logging/**"
148240
149241modules:
150 - 'storage/framework/core/modules/**'
242 - changed-files:
243 - any-glob-to-any-file:
244 - "storage/framework/core/modules/**"
151245
152246notifications:
153 - 'storage/framework/core/notifications/**'
154 - app/Notifications/**
247 - changed-files:
248 - any-glob-to-any-file:
249 - "storage/framework/core/notifications/**"
250 - "app/Notifications/**"
155251
156252objects:
157 - 'storage/framework/core/utils/objects/**'
253 - changed-files:
254 - any-glob-to-any-file:
255 - "storage/framework/core/utils/objects/**"
158256
159257orm:
160 - 'storage/framework/core/orm/**'
258 - changed-files:
259 - any-glob-to-any-file:
260 - "storage/framework/core/orm/**"
161261
162262path:
163 - 'storage/framework/core/path/**'
263 - changed-files:
264 - any-glob-to-any-file:
265 - "storage/framework/core/path/**"
164266
165267payments:
166 - 'storage/framework/core/payments/**'
268 - changed-files:
269 - any-glob-to-any-file:
270 - "storage/framework/core/payments/**"
167271
168272push:
169 - 'storage/framework/core/notifications/push/**'
273 - changed-files:
274 - any-glob-to-any-file:
275 - "storage/framework/core/notifications/push/**"
170276
171277query-builder:
172 - 'storage/framework/core/query-builder/**'
278 - changed-files:
279 - any-glob-to-any-file:
280 - "storage/framework/core/query-builder/**"
173281
174282queue:
175 - 'storage/framework/core/queue/**'
283 - changed-files:
284 - any-glob-to-any-file:
285 - "storage/framework/core/queue/**"
176286
177287real-time:
178 - 'storage/framework/core/real-time/**'
288 - changed-files:
289 - any-glob-to-any-file:
290 - "storage/framework/core/real-time/**"
179291
180292repl:
181 - 'storage/framework/core/repl/**'
293 - changed-files:
294 - any-glob-to-any-file:
295 - "storage/framework/core/repl/**"
182296
183297router:
184 - 'storage/framework/core/router/**'
298 - changed-files:
299 - any-glob-to-any-file:
300 - "storage/framework/core/router/**"
185301
186302scheduler:
187 - 'storage/framework/core/scheduler/**'
303 - changed-files:
304 - any-glob-to-any-file:
305 - "storage/framework/core/scheduler/**"
188306
189307search-engine:
190 - 'storage/framework/core/search-engine/**'
308 - changed-files:
309 - any-glob-to-any-file:
310 - "storage/framework/core/search-engine/**"
191311
192312security:
193 - 'storage/framework/core/security/**'
313 - changed-files:
314 - any-glob-to-any-file:
315 - "storage/framework/core/security/**"
194316
195317signals:
196 - 'storage/framework/core/signals/**'
318 - changed-files:
319 - any-glob-to-any-file:
320 - "storage/framework/core/signals/**"
197321
198322slug:
199 - 'storage/framework/core/slug/**'
323 - changed-files:
324 - any-glob-to-any-file:
325 - "storage/framework/core/slug/**"
200326
201327sms:
202 - 'storage/framework/core/notifications/sms/**'
328 - changed-files:
329 - any-glob-to-any-file:
330 - "storage/framework/core/notifications/sms/**"
203331
204332storage:
205 - storage/**
333 - changed-files:
334 - any-glob-to-any-file:
335 - "storage/**"
206336
207337strings:
208 - 'storage/framework/core/utils/strings/**'
338 - changed-files:
339 - any-glob-to-any-file:
340 - "storage/framework/core/utils/strings/**"
209341
210342testing:
211 - 'storage/framework/core/testing/**'
343 - changed-files:
344 - any-glob-to-any-file:
345 - "storage/framework/core/testing/**"
212346
213347tinker:
214 - 'storage/framework/core/tinker/**'
348 - changed-files:
349 - any-glob-to-any-file:
350 - "storage/framework/core/tinker/**"
215351
216352types:
217 - 'storage/framework/core/types/**'
353 - changed-files:
354 - any-glob-to-any-file:
355 - "storage/framework/core/types/**"
218356
219357ui:
220 - 'storage/framework/core/ui/**'
358 - changed-files:
359 - any-glob-to-any-file:
360 - "storage/framework/core/ui/**"
221361
222362utils:
223 - 'storage/framework/core/utils/**'
363 - changed-files:
364 - any-glob-to-any-file:
365 - "storage/framework/core/utils/**"
224366
225367validation:
226 - 'storage/framework/core/validation/**'
368 - changed-files:
369 - any-glob-to-any-file:
370 - "storage/framework/core/validation/**"
227371
228372app:
229 - app/**
373 - changed-files:
374 - any-glob-to-any-file:
375 - "app/**"
230376
231377jobs:
232 - app/Jobs/**
378 - changed-files:
379 - any-glob-to-any-file:
380 - "app/Jobs/**"
233381
234382models:
235 - app/Models/**
383 - changed-files:
384 - any-glob-to-any-file:
385 - "app/Models/**"
236386
237387resources:
238 - resources/**
388 - changed-files:
389 - any-glob-to-any-file:
390 - "resources/**"
239391
240392lang:
241 - resources/lang/**
393 - changed-files:
394 - any-glob-to-any-file:
395 - "resources/lang/**"
242396
243397stores:
244 - resources/stores/**
398 - changed-files:
399 - any-glob-to-any-file:
400 - "resources/stores/**"
245401
246402routes:
247 - routes/**
403 - changed-files:
404 - any-glob-to-any-file:
405 - "routes/**"
248406
249407tests:
250 - tests/**
408 - changed-files:
409 - any-glob-to-any-file:
410 - "tests/**"
.github/workflows/ci.ymlmodified+10-19
Changes to .github/workflows/ci.yml
@@ -48,7 +48,7 @@ jobs:
4848 node-modules-
4949
5050 - name: Install dependencies
51 run: bun install
51 run: bun install --frozen-lockfile
5252
5353 - name: Lint
5454 run: bunx --bun pickier .
@@ -84,7 +84,7 @@ jobs:
8484 node-modules-
8585
8686 - name: Install dependencies
87 run: bun install
87 run: bun install --frozen-lockfile
8888
8989 - name: Typecheck
9090 run: bun buddy test:types
@@ -146,12 +146,12 @@ jobs:
146146 node-modules-
147147
148148 - name: Install Dependencies
149 run: bun install
149 run: bun install --frozen-lockfile
150150
151151 - name: Test Suite
152152 env:
153153 AWS_DYNAMODB_ENDPOINT: http://localhost:8000
154 run: bun test
154 run: ./buddy test
155155
156156 recording-qa:
157157 runs-on: ubuntu-latest
@@ -165,7 +165,7 @@ jobs:
165165 # Production's Bun, as in the jobs above.
166166 bun-version: '1.4.2'
167167 - name: Install app dependencies
168 run: bun install
168 run: bun install --frozen-lockfile
169169 # Isolated test dependencies: no changes to the app's hoisted ORM tree.
170170 # Server-side regressions that need the QA app but no browser.
171171 #
@@ -243,7 +243,7 @@ jobs:
243243 node-modules-
244244
245245 - name: Install Dependencies
246 run: bun install
246 run: bun install --frozen-lockfile
247247
248248 - name: Build
249249 run: bun run build
@@ -255,20 +255,11 @@ jobs:
255255 # `"private": true` because it is an application rather than a library,
256256 # so it answered `400 {"message":"No packages"}` on every run.
257257 #
258 # The installer action lives inside the pantry monorepo (packages/action)
259 # and is not published as `pantry-pm/pantry-installer` yet, so the
260 # release binary is fetched directly. Swap this step for the action once
261 # that repo exists.
262258 - name: Install Pantry
263 run: |
264 tmp=$(mktemp -d)
265 curl -fsSL -o "$tmp/pantry.zip" \
266 https://github.com/pantry-pm/pantry/releases/latest/download/pantry-linux-x64.zip
267 # The archive holds a bare `pantry` binary, no wrapping directory.
268 unzip -oq "$tmp/pantry.zip" -d "$tmp"
269 sudo install -m 755 "$tmp/pantry" /usr/local/bin/pantry
270 rm -rf "$tmp"
271 pantry --version
259 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
260 with:
261 version: '0.11.94'
262 install: 'false'
272263
273264 # `publish:commit` derives the version from the commit SHA itself, so
274265 # each build is separately installable without anyone stamping
.github/workflows/deploy.ymlmodified+4-1
Changes to .github/workflows/deploy.yml
@@ -96,7 +96,10 @@ jobs:
9696 bun-version: '1.4.2'
9797
9898 - name: Setup Pantry
99 uses: home-lang/pantry/packages/action@main
99 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
100 with:
101 version: '0.11.94'
102 install: 'false'
100103
101104 - name: Install dependencies
102105 run: bun install --frozen-lockfile
.github/workflows/mobile-e2e.ymlmodified+72-14
Changes to .github/workflows/mobile-e2e.yml
@@ -8,6 +8,8 @@ on:
88 - '.maestro/**'
99 - 'tests/native/**'
1010 - 'scripts/run-native-trail-e2e.ts'
11 - 'scripts/run-native-trail-android-e2e.ts'
12 - 'scripts/run-owned-android-e2e.ts'
1113 - 'app/**'
1214 - 'config/mobile.ts'
1315 # The app icon config/mobile.ts points at; a bad one fails the iOS build.
@@ -21,6 +23,8 @@ on:
2123 - '.maestro/**'
2224 - 'tests/native/**'
2325 - 'scripts/run-native-trail-e2e.ts'
26 - 'scripts/run-native-trail-android-e2e.ts'
27 - 'scripts/run-owned-android-e2e.ts'
2428 - 'app/**'
2529 - 'config/mobile.ts'
2630 # The app icon config/mobile.ts points at; a bad one fails the iOS build.
@@ -53,7 +57,10 @@ jobs:
5357 distribution: temurin
5458 java-version: '17'
5559 - name: Setup Pantry
56 uses: home-lang/pantry/packages/action@main
60 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
61 with:
62 version: '0.11.94'
63 install: 'false'
5764 - name: Install Maestro
5865 run: |
5966 curl -fsSL "https://github.com/mobile-dev-inc/Maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o "$RUNNER_TEMP/maestro.zip"
@@ -75,8 +82,6 @@ jobs:
7582 if-no-files-found: warn
7683
7784 android:
78 env:
79 ANDROID_MAPS_API_KEY: ${{ secrets.ANDROID_MAPS_API_KEY }}
8085 runs-on: ubuntu-latest
8186 timeout-minutes: 30
8287 steps:
@@ -91,6 +96,11 @@ jobs:
9196 - uses: gradle/actions/setup-gradle@v4
9297 with:
9398 gradle-version: '8.11.1'
99 - uses: android-actions/setup-android@v4
100 with:
101 packages: platform-tools
102 - name: Install the published SDK compile platform
103 run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
94104 - name: Enable KVM
95105 run: |
96106 echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
@@ -107,15 +117,7 @@ jobs:
107117 - name: Build Android E2E app
108118 run: bun scripts/run-mobile-e2e.ts android --build-only
109119 - name: Run Android E2E flows
110 uses: reactivecircus/android-emulator-runner@v2
111 with:
112 api-level: 34
113 target: google_apis
114 arch: x86_64
115 profile: pixel_7
116 ram-size: 4096M
117 disable-animations: true
118 script: bun scripts/run-mobile-e2e.ts android --skip-build
120 run: bun scripts/run-owned-android-e2e.ts baseline
119121 - name: Upload Android diagnostics
120122 if: always()
121123 uses: actions/upload-artifact@v5
@@ -125,17 +127,25 @@ jobs:
125127 if-no-files-found: warn
126128
127129 native-trail-ios:
130 strategy:
131 fail-fast: false
132 matrix:
133 group: [maps, reviews, utilities, navigation]
128134 runs-on: macos-latest
129135 timeout-minutes: 35
130136 env:
131137 QA_PORT_BASE: '6320'
138 NATIVE_TRAIL_GROUP: ${{ matrix.group }}
132139 steps:
133140 - uses: actions/checkout@v5
134141 - uses: oven-sh/setup-bun@v2
135142 with:
136143 bun-version: '1.4.2'
137144 - name: Setup Pantry
138 uses: home-lang/pantry/packages/action@main
145 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
146 with:
147 version: '0.11.94'
148 install: 'false'
139149 - name: Install dependencies
140150 run: bun install --frozen-lockfile
141151 - name: Install iOS toolchain
@@ -168,10 +178,58 @@ jobs:
168178 if: always()
169179 uses: actions/upload-artifact@v5
170180 with:
171 name: native-trail-ios
181 name: native-trail-ios-${{ matrix.group }}
172182 path: |
173183 storage/framework/runtime/e2e/native-trail/*.log
174184 storage/framework/runtime/e2e/native-trail/*.txt
185 storage/framework/runtime/e2e/native-trail/*-persistence.json
175186 storage/framework/runtime/e2e/native-trail/*.png
176187 storage/framework/runtime/e2e/native-trail/results-*.xcresult
177188 if-no-files-found: warn
189
190 native-trail-android:
191 runs-on: ubuntu-latest
192 timeout-minutes: 35
193 env:
194 QA_PORT_BASE: '7320'
195 steps:
196 - uses: actions/checkout@v5
197 - uses: oven-sh/setup-bun@v2
198 with:
199 bun-version: '1.4.2'
200 - uses: actions/setup-java@v5
201 with:
202 distribution: temurin
203 java-version: '17'
204 - uses: gradle/actions/setup-gradle@v4
205 with:
206 gradle-version: '8.11.1'
207 - uses: android-actions/setup-android@v4
208 with:
209 packages: platform-tools
210 - name: Install the published SDK compile platform
211 run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
212 - name: Install Maestro
213 run: |
214 curl -fsSL "https://github.com/mobile-dev-inc/Maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o "$RUNNER_TEMP/maestro.zip"
215 echo "$MAESTRO_SHA256 $RUNNER_TEMP/maestro.zip" | sha256sum --check
216 unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP/maestro-cli"
217 echo "$RUNNER_TEMP/maestro-cli/maestro/bin" >> "$GITHUB_PATH"
218 - name: Install published dependencies
219 run: bun install --frozen-lockfile
220 - name: Build native MapLibre trail app
221 run: bun scripts/run-native-trail-android-e2e.ts --build-only
222 - name: Enable KVM
223 run: |
224 echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
225 sudo udevadm control --reload-rules
226 sudo udevadm trigger --name-match=kvm
227 - name: Test native maps, sticky actions and persisted reviews
228 run: bun scripts/run-owned-android-e2e.ts native-trail
229 - name: Upload Android native trail evidence
230 if: always()
231 uses: actions/upload-artifact@v5
232 with:
233 name: native-trail-android
234 path: storage/framework/runtime/e2e/native-trail-android
235 if-no-files-found: warn
.github/workflows/mobile.ymlmodified+11-2
Changes to .github/workflows/mobile.yml
@@ -32,7 +32,6 @@ jobs:
3232 source-projects:
3333 env:
3434 ANDROID_GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}
35 ANDROID_MAPS_API_KEY: ${{ secrets.ANDROID_MAPS_API_KEY }}
3635 strategy:
3736 fail-fast: false
3837 matrix:
@@ -56,9 +55,19 @@ jobs:
5655 if: matrix.platform == 'android'
5756 with:
5857 gradle-version: '8.11.1'
58 - uses: android-actions/setup-android@v4
59 if: matrix.platform == 'android'
60 with:
61 packages: platform-tools
62 - name: Install the published SDK compile platform
63 if: matrix.platform == 'android'
64 run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
5965 - name: Setup Pantry
6066 if: matrix.platform == 'ios'
61 uses: home-lang/pantry/packages/action@main
67 uses: pantry-pm/pantry/packages/action@da17bb9317e8f8c247618fb176fe97269f15bfe4 # v0.11.94
68 with:
69 version: '0.11.94'
70 install: 'false'
6271 - name: Install dependencies
6372 run: bun install --frozen-lockfile
6473 - name: Install iOS toolchain
.maestro/native-trail/appearance.yamladded+43-0
Changes to .maestro/native-trail/appearance.yaml
@@ -0,0 +1,43 @@
1appId: ${APP_ID}
2name: Native trail maps and sticky controls in system dark appearance
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}
6- extendedWaitUntil:
7 visible: ${TRAIL_NAME}
8 timeout: 30000
9- assertVisible:
10 id: trail-sticky-actions
11- takeScreenshot: android-native-dark-trail-overview
12- scrollUntilVisible:
13 element:
14 id: trail-profile-toggle
15 direction: DOWN
16- takeScreenshot: android-native-dark-inline-profile
17- scrollUntilVisible:
18 element:
19 id: trail-map-fullscreen
20 direction: UP
21- tapOn:
22 id: trail-map-fullscreen
23- extendedWaitUntil:
24 visible:
25 id: trail-full-map
26 timeout: 15000
27- takeScreenshot: android-native-dark-full-map
28- tapOn:
29 id: map-profile-toggle
30- takeScreenshot: android-native-dark-collapsed-profile
31- back
32- scrollUntilVisible:
33 element:
34 id: trail-weather-heading
35 direction: DOWN
36- takeScreenshot: android-native-dark-weather
37- scrollUntilVisible:
38 element:
39 id: trail-surfaces-heading
40 direction: DOWN
41- takeScreenshot: android-native-dark-surfaces
42- assertVisible:
43 id: trail-sticky-actions
.maestro/native-trail/directions-map.yamladded+16-0
Changes to .maestro/native-trail/directions-map.yaml
@@ -0,0 +1,16 @@
1appId: ${APP_ID}
2name: Open trailhead directions from the full native map
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}/map
6- extendedWaitUntil:
7 visible:
8 id: map-directions
9 timeout: 30000
10- tapOn:
11 id: map-directions
12- extendedWaitUntil:
13 notVisible:
14 id: trail-full-map
15 timeout: 15000
16- takeScreenshot: android-native-full-map-directions-handoff
.maestro/native-trail/directions.yamladded+16-0
Changes to .maestro/native-trail/directions.yaml
@@ -0,0 +1,16 @@
1appId: ${APP_ID}
2name: Open trailhead directions from the native sticky actions
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}
6- extendedWaitUntil:
7 visible:
8 id: trail-directions
9 timeout: 30000
10- tapOn:
11 id: trail-directions
12- extendedWaitUntil:
13 notVisible:
14 id: trail-sticky-actions
15 timeout: 15000
16- takeScreenshot: android-native-directions-handoff
.maestro/native-trail/maps.yamladded+192-0
Changes to .maestro/native-trail/maps.yaml
@@ -0,0 +1,192 @@
1appId: ${APP_ID}
2name: Native Android trail maps and sticky save
3---
4- runFlow: sign-in.yaml
5- tapOn:
6 id: trail-save
7- extendedWaitUntil:
8 visible: '^Saved$'
9 timeout: 15000
10- openLink: wildloop://feed
11- extendedWaitUntil:
12 visible:
13 id: native-feed-scroll
14 timeout: 30000
15- openLink: wildloop://trail/${TRAIL_ID}
16- extendedWaitUntil:
17 visible: '^Saved$'
18 timeout: 30000
19- tapOn:
20 id: trail-save
21- extendedWaitUntil:
22 visible: '^Save$'
23 timeout: 15000
24- openLink: wildloop://feed
25- extendedWaitUntil:
26 visible:
27 id: native-feed-scroll
28 timeout: 30000
29- openLink: wildloop://trail/${TRAIL_ID}
30- extendedWaitUntil:
31 visible: '^Save$'
32 timeout: 30000
33- scrollUntilVisible:
34 element:
35 id: trail-elevation-slider
36 direction: DOWN
37- tapOn:
38 id: trail-elevation-slider
39 point: '99.9%,50%'
40- extendedWaitUntil:
41 visible: ${INLINE_PROFILE_END_TEXT}
42 timeout: 15000
43- takeScreenshot: android-native-inline-elevation-selection
44- tapOn:
45 id: trail-profile-toggle
46- assertVisible: 'Toggle elevation profile, Elevation profile collapsed'
47- takeScreenshot: android-native-inline-profile-collapsed
48- tapOn:
49 id: trail-profile-toggle
50- assertVisible: 'Toggle elevation profile, Elevation profile expanded'
51- extendedWaitUntil:
52 visible: ${INLINE_PROFILE_END_TEXT}
53 timeout: 15000
54- takeScreenshot: android-native-inline-profile-restored
55- scrollUntilVisible:
56 element:
57 id: trail-zoom-in
58 direction: DOWN
59- tapOn:
60 id: trail-zoom-in
61- takeScreenshot: android-native-inline-zoom-in
62- tapOn:
63 id: trail-zoom-out
64- takeScreenshot: android-native-inline-zoom-out
65- tapOn:
66 id: trail-fit-route
67- takeScreenshot: android-native-inline-fit-route
68- tapOn:
69 id: trail-map-3d
70- assertVisible: '^2D$'
71- takeScreenshot: android-native-inline-map-tilted
72- tapOn: '^North$'
73- takeScreenshot: android-native-inline-map-north
74- tapOn:
75 id: trail-map-3d
76- assertVisible: '^3D$'
77- takeScreenshot: android-native-inline-map-flat
78- scrollUntilVisible:
79 element:
80 id: trail-map-layer
81 direction: DOWN
82- tapOn:
83 id: trail-map-layer
84- tapOn: '^Satellite$'
85- assertVisible: 'Map layer, Satellite'
86- takeScreenshot: android-native-inline-map-satellite
87- tapOn:
88 id: trail-map-layer
89- tapOn: '^Hybrid$'
90- assertVisible: 'Map layer, Hybrid'
91- takeScreenshot: android-native-inline-map-hybrid
92- tapOn:
93 id: trail-map-layer
94- tapOn: '^Street$'
95- assertVisible: 'Map layer, Street'
96- takeScreenshot: android-native-inline-map-street
97- tapOn:
98 id: trail-map-layer
99- tapOn: '^Outdoors$'
100- assertVisible: 'Map layer, Outdoors'
101- takeScreenshot: android-native-inline-map-outdoors
102- scrollUntilVisible:
103 element:
104 id: trail-map-fullscreen
105 direction: UP
106- assertVisible:
107 id: trail-sticky-actions
108- tapOn:
109 id: trail-map-fullscreen
110- extendedWaitUntil:
111 visible:
112 id: trail-full-map
113 timeout: 30000
114- takeScreenshot: android-native-map-outdoors
115- tapOn:
116 id: map-zoom-in
117- takeScreenshot: android-native-fullscreen-zoom-in
118- tapOn:
119 id: map-zoom-out
120- takeScreenshot: android-native-fullscreen-zoom-out
121- tapOn:
122 id: map-fit
123- takeScreenshot: android-native-fullscreen-fit-route
124- tapOn:
125 id: map-3d
126- assertVisible: '^2D$'
127- takeScreenshot: android-native-fullscreen-map-tilted
128- tapOn:
129 id: map-north
130- takeScreenshot: android-native-fullscreen-map-north
131- tapOn:
132 id: map-3d
133- assertVisible: '^3D$'
134- takeScreenshot: android-native-fullscreen-map-flat
135- tapOn:
136 id: map-profile-toggle
137- assertVisible: 'Toggle elevation profile, Elevation profile collapsed'
138- takeScreenshot: android-native-map-profile-collapsed
139- tapOn:
140 id: map-profile-toggle
141- assertVisible: 'Toggle elevation profile, Elevation profile expanded'
142- tapOn:
143 id: map-elevation-slider
144 point: '99.9%,50%'
145- extendedWaitUntil:
146 visible: ${PROFILE_END_TEXT}
147 timeout: 15000
148- takeScreenshot: android-native-fullscreen-elevation-selection
149- tapOn:
150 id: map-fit
151- tapOn:
152 id: map-layer
153- tapOn: '^Satellite$'
154- assertVisible: 'Map layer, Satellite'
155- takeScreenshot: android-native-map-satellite
156- tapOn:
157 id: map-layer
158- tapOn: '^Hybrid$'
159- assertVisible: 'Map layer, Hybrid'
160- takeScreenshot: android-native-map-hybrid
161- tapOn:
162 id: map-layer
163- tapOn: '^Street$'
164- assertVisible: 'Map layer, Street'
165- takeScreenshot: android-native-map-street
166- tapOn:
167 id: map-layer
168- tapOn: '^Outdoors$'
169- assertVisible: 'Map layer, Outdoors'
170- tapOn:
171 id: map-fit
172- takeScreenshot: android-native-map-outdoors-restored
173- back
174- assertVisible:
175 id: trail-sticky-actions
176- takeScreenshot: android-native-sticky-actions
177- scrollUntilVisible:
178 element:
179 id: trail-weather-heading
180 direction: DOWN
181- assertVisible:
182 id: trail-weather-heading
183- takeScreenshot: android-native-trail-weather
184- scrollUntilVisible:
185 element:
186 id: trail-surfaces-heading
187 direction: DOWN
188- assertVisible:
189 id: trail-surfaces-heading
190- takeScreenshot: android-native-trail-surfaces
191- assertVisible:
192 id: trail-sticky-actions
.maestro/native-trail/review-create.yamladded+37-0
Changes to .maestro/native-trail/review-create.yaml
@@ -0,0 +1,37 @@
1appId: ${APP_ID}
2name: Create a review with native controls
3---
4- runFlow: sign-in.yaml
5- scrollUntilVisible:
6 element:
7 id: trail-write-review
8 direction: DOWN
9- assertVisible:
10 id: trail-sticky-actions
11- tapOn:
12 id: trail-write-review
13- extendedWaitUntil:
14 visible:
15 id: trail-review-content
16 timeout: 15000
17- tapOn:
18 id: trail-review-star-5
19- tapOn:
20 id: trail-review-content
21- inputText: ${REVIEW_TEXT}
22- hideKeyboard
23- scrollUntilVisible:
24 element:
25 id: trail-review-submit
26 direction: DOWN
27- tapOn:
28 id: trail-review-submit
29- extendedWaitUntil:
30 visible: 'Edit your review'
31 timeout: 15000
32- scrollUntilVisible:
33 element:
34 text: ${REVIEW_TEXT}
35 direction: DOWN
36- assertVisible: ${REVIEW_TEXT}
37- takeScreenshot: android-native-review-created
.maestro/native-trail/review-delete.yamladded+25-0
Changes to .maestro/native-trail/review-delete.yaml
@@ -0,0 +1,25 @@
1appId: ${APP_ID}
2name: Delete the persisted review through native confirmation
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}
6- extendedWaitUntil:
7 visible: ${TRAIL_NAME}
8 timeout: 30000
9- scrollUntilVisible:
10 element:
11 id: trail-delete-review
12 direction: DOWN
13- tapOn:
14 id: trail-delete-review
15- tapOn: '^Delete review$'
16- extendedWaitUntil:
17 visible: 'Leave a review'
18 timeout: 15000
19- assertNotVisible: ${REVIEW_TEXT}
20- assertNotVisible: ${EDITED_REVIEW_TEXT}
21- assertNotVisible:
22 id: trail-delete-review
23- assertVisible:
24 id: trail-sticky-actions
25- takeScreenshot: android-native-review-deleted
.maestro/native-trail/review-edit.yamladded+48-0
Changes to .maestro/native-trail/review-edit.yaml
@@ -0,0 +1,48 @@
1appId: ${APP_ID}
2name: Reopen and edit the persisted native review
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}
6- extendedWaitUntil:
7 visible: ${TRAIL_NAME}
8 timeout: 30000
9- scrollUntilVisible:
10 element:
11 id: trail-write-review
12 direction: DOWN
13- tapOn:
14 id: trail-write-review
15- extendedWaitUntil:
16 visible:
17 id: trail-review-content
18 timeout: 15000
19- tapOn:
20 id: trail-review-content
21- longPressOn:
22 id: trail-review-content
23- takeScreenshot: android-native-review-edit-menu
24- tapOn: '(?i)^Select all$'
25- eraseText: 1
26- inputText: ${EDITED_REVIEW_TEXT}
27- assertVisible:
28 id: trail-review-content
29 text: ${EDITED_REVIEW_TEXT}
30- hideKeyboard
31- tapOn:
32 id: trail-review-star-4
33- scrollUntilVisible:
34 element:
35 id: trail-review-submit
36 direction: DOWN
37- tapOn:
38 id: trail-review-submit
39- extendedWaitUntil:
40 visible: 'Edit your review'
41 timeout: 15000
42- scrollUntilVisible:
43 element:
44 text: ${EDITED_REVIEW_TEXT}
45 direction: DOWN
46- assertVisible: ${EDITED_REVIEW_TEXT}
47- assertNotVisible: ${REVIEW_TEXT}
48- takeScreenshot: android-native-review-edited
.maestro/native-trail/sharing.yamladded+30-0
Changes to .maestro/native-trail/sharing.yaml
@@ -0,0 +1,30 @@
1appId: ${APP_ID}
2name: Copy the trail link and paste it into a real native field
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}
6- extendedWaitUntil:
7 visible:
8 id: trail-share
9 timeout: 30000
10- tapOn:
11 id: trail-share
12- extendedWaitUntil:
13 visible: '^Copy$'
14 timeout: 15000
15- takeScreenshot: android-native-share-sheet
16- tapOn: '^Copy$'
17- openLink: wildloop://search
18- extendedWaitUntil:
19 visible:
20 id: search-input
21 timeout: 15000
22- tapOn:
23 id: search-input
24- longPressOn:
25 id: search-input
26- tapOn: '^Paste$'
27- assertVisible:
28 id: search-input
29 text: 'https://wildloop.org/trail/${TRAIL_ID}'
30- takeScreenshot: android-native-share-link-pasted
.maestro/native-trail/sign-in.yamladded+27-0
Changes to .maestro/native-trail/sign-in.yaml
@@ -0,0 +1,27 @@
1appId: ${APP_ID}
2---
3- launchApp:
4 clearState: true
5- extendedWaitUntil:
6 visible:
7 id: native-feed-scroll
8 timeout: 30000
9- openLink: wildloop://login?next=/trail/${TRAIL_ID}
10- extendedWaitUntil:
11 visible:
12 id: login-email
13 timeout: 30000
14- tapOn:
15 id: login-email
16- inputText: ${E2E_EMAIL}
17- tapOn:
18 id: login-password
19- inputText: ${E2E_PASSWORD}
20- hideKeyboard
21- tapOn:
22 id: login-submit
23- extendedWaitUntil:
24 visible: ${TRAIL_NAME}
25 timeout: 30000
26- assertVisible:
27 id: trail-sticky-actions
.maestro/native-trail/utilities.yamladded+99-0
Changes to .maestro/native-trail/utilities.yaml
@@ -0,0 +1,99 @@
1appId: ${APP_ID}
2name: Native location, route customization and GPX sharing
3---
4- launchApp
5- openLink: wildloop://trail/${TRAIL_ID}/map
6- extendedWaitUntil:
7 visible:
8 id: trail-full-map
9 timeout: 30000
10- assertNotVisible:
11 id: '.*permission_allow_foreground_only_button'
12- tapOn:
13 id: map-zoom-in
14- tapOn:
15 id: map-3d
16- assertVisible:
17 id: map-3d
18 text: '^2D$'
19- takeScreenshot: android-native-camera-before-location
20- tapOn:
21 id: map-locate
22- extendedWaitUntil:
23 visible:
24 id: '.*permission_allow_foreground_only_button'
25 timeout: 15000
26- tapOn:
27 id: '.*permission_allow_foreground_only_button'
28- extendedWaitUntil:
29 visible: 'My location, Your location is shown'
30 timeout: 30000
31- assertVisible:
32 id: map-3d
33 text: '^2D$'
34- takeScreenshot: android-native-map-location
35- tapOn:
36 id: map-customize
37- extendedWaitUntil:
38 visible:
39 id: route-builder-map
40 timeout: 30000
41- assertVisible: ${TRAIL_NAME}
42- takeScreenshot: android-native-customize-route
43- back
44- assertVisible:
45 id: trail-full-map
46- tapOn:
47 id: map-export
48- extendedWaitUntil:
49 visible: ${GPX_NAME}
50 timeout: 15000
51- takeScreenshot: android-native-gpx-share-sheet
52- back
53- assertVisible:
54 id: trail-full-map
55- openLink: wildloop://trail/${TRAIL_ID}
56- extendedWaitUntil:
57 visible:
58 id: trail-sticky-actions
59 timeout: 30000
60- scrollUntilVisible:
61 element:
62 id: trail-zoom-in
63 direction: DOWN
64- tapOn:
65 id: trail-zoom-in
66- scrollUntilVisible:
67 element:
68 id: trail-map-3d
69 direction: DOWN
70- tapOn:
71 id: trail-map-3d
72- assertVisible:
73 id: trail-map-3d
74 text: '^2D$'
75- takeScreenshot: android-native-inline-camera-before-location
76- tapOn:
77 id: trail-map-locate
78- extendedWaitUntil:
79 visible: 'My location, Your location is shown'
80 timeout: 30000
81- assertNotVisible:
82 id: '.*permission_allow_foreground_only_button'
83- assertVisible:
84 id: trail-map-3d
85 text: '^2D$'
86- takeScreenshot: android-native-inline-map-location
87- scrollUntilVisible:
88 element:
89 id: trail-map-export
90 direction: DOWN
91- tapOn:
92 id: trail-map-export
93- extendedWaitUntil:
94 visible: ${GPX_NAME}
95 timeout: 15000
96- takeScreenshot: android-native-inline-gpx-share-sheet
97- back
98- assertVisible:
99 id: trail-sticky-actions
bun.lockmodified+149-115
Changes to bun.lock
@@ -8,12 +8,12 @@
88 "@bughq/stacks": "^0.2.2",
99 "@iconify-json/lucide": "^1.2.137",
1010 "@loghq/stacks": "^0.2.6",
11 "@stacksjs/composables": "^0.75.219",
12 "@stacksjs/mobile": "^0.75.219",
13 "@stacksjs/stx": "^0.2.563",
11 "@stacksjs/composables": "^0.75.221",
12 "@stacksjs/mobile": "^0.75.221",
13 "@stacksjs/stx": "^0.2.573",
1414 "@ts-analytics/tracking": "^0.1.17",
15 "craft-native": "^0.0.237",
16 "stacks": "^0.75.219",
15 "craft-native": "^0.0.244",
16 "stacks": "^0.75.221",
1717 "ts-health": "^0.1.0",
1818 "ts-maps": "^0.5.5",
1919 "ts-qr-codes": "^0.1.8",
@@ -41,31 +41,31 @@
4141
4242 "@loghq/stacks": ["@loghq/stacks@0.2.6", "", {}, "sha512-7E4v4nWUDgKuuyFZwa5VZxuRGewMGwAcclCAcm+fm36rGdWnLx6TTdRwMhADlzAYw3nCRyljpnVrViiExQ0UDg=="],
4343
44 "@stacksjs/action-runner": ["@stacksjs/action-runner@0.75.219", "", { "dependencies": { "@stacksjs/enums": "0.75.219", "@stacksjs/types": "0.75.219" } }, "sha512-pnFyK+Hl1FvqSYsNX+i78v6fJqdSm93qctaeNIlxlcCozMc/r9lfyOidIwRp4UlCYU5imU6GAF+oio6g0IZQVQ=="],
44 "@stacksjs/action-runner": ["@stacksjs/action-runner@0.75.221", "", { "dependencies": { "@stacksjs/enums": "0.75.221", "@stacksjs/types": "0.75.221" } }, "sha512-BAVajHkw5iTFTt2yGU5Zb+G3HtsdQOk7ksKQmqPqFbUo4HDxtsNTrj5kJlBO98FAGkiBhTMvaSBxoDNGkZjurw=="],
4545
46 "@stacksjs/actions": ["@stacksjs/actions@0.75.219", "", { "dependencies": { "@stacksjs/action-runner": "0.75.219", "@stacksjs/ai": "0.75.219", "@stacksjs/analytics": "0.75.219", "@stacksjs/api": "0.75.219", "@stacksjs/auth": "0.75.219", "@stacksjs/build": "0.75.219", "@stacksjs/bumpx": "^0.2.21", "@stacksjs/bunpress": "^0.2.15", "@stacksjs/cli": "0.75.219", "@stacksjs/cloud": "0.75.219", "@stacksjs/cms": "0.75.219", "@stacksjs/components": "^0.2.534", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/desktop-build": "0.75.219", "@stacksjs/dns": "0.75.219", "@stacksjs/enums": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/github": "0.75.219", "@stacksjs/gitit": "^0.2.5", "@stacksjs/i18n": "0.75.219", "@stacksjs/image": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/logsmith": "^0.2.11", "@stacksjs/mobile": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/payments": "0.75.219", "@stacksjs/queue": "0.75.219", "@stacksjs/registry": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/rpx": "^0.11.54", "@stacksjs/scheduler": "0.75.219", "@stacksjs/search-engine": "0.75.219", "@stacksjs/security": "0.75.219", "@stacksjs/server": "0.75.219", "@stacksjs/sites": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/stx": "^0.2.534", "@stacksjs/tlsx": "^0.13.19", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/ts-md": "^0.1.1", "@stacksjs/tunnel": "0.75.219", "@stacksjs/types": "0.75.219", "@stacksjs/utils": "0.75.219", "@stacksjs/whois": "0.75.219", "bun-plugin-stx": "^0.2.534" }, "peerDependencies": { "craft-native": ">=0.0.163", "pickier": "^0.1.70" }, "optionalPeers": ["craft-native", "pickier"] }, "sha512-anDtPH4ODwHYl1iDZLhD2C9GD4JRNnKAvtTs2UuBOt6f7sn++VhxWE49Fl4vjWIbsyjI47cwXUcF/MYNqVI5rw=="],
46 "@stacksjs/actions": ["@stacksjs/actions@0.75.221", "", { "dependencies": { "@stacksjs/action-runner": "0.75.221", "@stacksjs/ai": "0.75.221", "@stacksjs/analytics": "0.75.221", "@stacksjs/api": "0.75.221", "@stacksjs/auth": "0.75.221", "@stacksjs/build": "0.75.221", "@stacksjs/bumpx": "^0.2.21", "@stacksjs/bunpress": "^0.2.15", "@stacksjs/cli": "0.75.221", "@stacksjs/cloud": "0.75.221", "@stacksjs/cms": "0.75.221", "@stacksjs/components": "^0.2.534", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/desktop-build": "0.75.221", "@stacksjs/dns": "0.75.221", "@stacksjs/enums": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/github": "0.75.221", "@stacksjs/gitit": "^0.2.5", "@stacksjs/i18n": "0.75.221", "@stacksjs/image": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/logsmith": "^0.2.11", "@stacksjs/mobile": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/payments": "0.75.221", "@stacksjs/queue": "0.75.221", "@stacksjs/registry": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/rpx": "^0.11.54", "@stacksjs/scheduler": "0.75.221", "@stacksjs/search-engine": "0.75.221", "@stacksjs/security": "0.75.221", "@stacksjs/server": "0.75.221", "@stacksjs/sites": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/stx": "^0.2.534", "@stacksjs/tlsx": "^0.13.19", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/ts-md": "^0.1.1", "@stacksjs/tunnel": "0.75.221", "@stacksjs/types": "0.75.221", "@stacksjs/utils": "0.75.221", "@stacksjs/whois": "0.75.221", "bun-plugin-stx": "^0.2.534" }, "peerDependencies": { "craft-native": ">=0.0.163", "pickier": "^0.1.70" }, "optionalPeers": ["craft-native", "pickier"] }, "sha512-ySyi/LzeN7ySByOpUQ6DMSi1Orm0Bjxh+BQ3TrK1rsoSgfuBRam+/DzPp++xJOtWACyCsU8AEHptPG3ReYR7kg=="],
4747
48 "@stacksjs/ai": ["@stacksjs/ai@0.75.219", "", { "dependencies": { "@stacksjs/cli": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/ts-cloud": "^0.16.58" }, "peerDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.79" }, "optionalPeers": ["@anthropic-ai/claude-agent-sdk"] }, "sha512-A252eE6w4eA/toEOpprcrEUkTtSx3NfSaaBScybS/eX0qAaNModXxb+jnFoEqzbtW7iZxVMaw/gfWUiSYq+kRA=="],
48 "@stacksjs/ai": ["@stacksjs/ai@0.75.221", "", { "dependencies": { "@stacksjs/cli": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/ts-cloud": "^0.16.58" }, "peerDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.79" }, "optionalPeers": ["@anthropic-ai/claude-agent-sdk"] }, "sha512-TzwT/7cWVHh5P1vJTE5z2tf1Ad9AGwlApokblpB8EKpQlRFCTX2onnCrVJzwM/wHVCLh/46viTnebesfWMP8YA=="],
4949
50 "@stacksjs/alias": ["@stacksjs/alias@0.75.219", "", { "dependencies": { "@stacksjs/path": "0.75.219" } }, "sha512-nufSHMvF3Mhako3m31nierIQnj2KPE4GK2xOPmUi55O9soWEiHhFw/2xSvyb0aDy73OUE2/cX1eEJ5gwN9TgmQ=="],
50 "@stacksjs/alias": ["@stacksjs/alias@0.75.221", "", { "dependencies": { "@stacksjs/path": "0.75.221" } }, "sha512-LbTx3LBkSr9zGC7LReyhZrx3nHezcgHcdj1+Q2qIu59yYNev/frRnj568e+FzIf138pM2MjCvLAro+pWZBKVPg=="],
5151
52 "@stacksjs/analytics": ["@stacksjs/analytics@0.75.219", "", { "dependencies": { "@stacksjs/database": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/types": "0.75.219" } }, "sha512-L5wgVHuROHQnATVVbBqPQwYT5wJoXTXDgL1NdaZ1KPCrclK1T+WY+2fBElEl8SJA2rKEnOgsC2Xgzw+l+BW/dw=="],
52 "@stacksjs/analytics": ["@stacksjs/analytics@0.75.221", "", { "dependencies": { "@stacksjs/database": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/types": "0.75.221" } }, "sha512-zkTqsIUqTq8YSjMO44h7sN3YZZiuAdKVe3tGbEQQ+kagzp9OXBwBLfzw8al/hIuKXtugDBitc3eywCzPH9PF0g=="],
5353
54 "@stacksjs/api": ["@stacksjs/api@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/path": "0.75.219" }, "peerDependencies": { "@stacksjs/router": "0.75.219" }, "optionalPeers": ["@stacksjs/router"] }, "sha512-XHLgopMyvCxKDRMLjdFo53f9iE+fEIWdjXzPV0gLFTEOJHF7aBEKpB8kM8535Ohq9heYIT/X0qaa/AwEoOPVcA=="],
54 "@stacksjs/api": ["@stacksjs/api@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/path": "0.75.221" }, "peerDependencies": { "@stacksjs/router": "0.75.221" }, "optionalPeers": ["@stacksjs/router"] }, "sha512-CIBkH9BfQKgaKs+fOY0bfkJvAY+nxZkuWuSEdKK+OyiA/Zx5X9w/Lh/t/hYG9hkLIj6hOQF+wHKTqK4mGBUH9w=="],
5555
56 "@stacksjs/arrays": ["@stacksjs/arrays@0.75.219", "", {}, "sha512-oAppaMlKWOzl/++iCcMrV9L1ReU5sIKsa3be2iPtHAmNTkDDmvU8SndefbW5PKecExWnb9zwMwxK+E1vMCT6Pw=="],
56 "@stacksjs/arrays": ["@stacksjs/arrays@0.75.221", "", {}, "sha512-+q1xYA7xllZgsenahvVerp9zDWJHDc76TtwhfaynvMN50eZvS639iT2uz5vaQIQe2eSYwrGHb50tlF6zmKztgg=="],
5757
58 "@stacksjs/audio": ["@stacksjs/audio@0.75.219", "", { "dependencies": { "@ts-audio/core": "^0.1.2", "ts-video-player": "^0.1.6" } }, "sha512-tlrtVQwOjZbYadqw9WggKuFvY062Dp7qgkjbcDhj/CcLFjlF8Mv0aXXgYWNefIr4q7FLH0RyQ6tf+VIrm/wcRg=="],
58 "@stacksjs/audio": ["@stacksjs/audio@0.75.221", "", { "dependencies": { "@ts-audio/core": "^0.1.2", "ts-video-player": "^0.1.6" } }, "sha512-bVtpJ1aeaBp6hi3qBcT9Vt+G6LAha/Ws9NXzwmevkWpsmi5ajN58ZbOx3vNqMLjqlNficPZOHcAppGg00l4ofQ=="],
5959
60 "@stacksjs/auth": ["@stacksjs/auth@0.75.219", "", { "dependencies": { "@stacksjs/bun-router": "^0.1.24", "@stacksjs/cache": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/email": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/security": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/ts-auth": "^0.4.7", "ts-qr-codes": "^0.1.8" } }, "sha512-/bpA8jrbItusUdqzTbKUZe6/E6VDiEy9YcWBnkZHz3Xc0wjlaTs30W0TOxUGvSoFUpmLhfyCpe+TiE3VpwVecg=="],
60 "@stacksjs/auth": ["@stacksjs/auth@0.75.221", "", { "dependencies": { "@stacksjs/bun-router": "^0.1.24", "@stacksjs/cache": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/email": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/security": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/ts-auth": "^0.4.7", "ts-qr-codes": "^0.1.8" } }, "sha512-yXzr2XeTUqQbjAitj1ZPrO6R7X0yHqVbWUibfsjlqrMTictbDNWJZG/ZqGFe5ea2iKBwwa9LoU9yi+lN6LwCjw=="],
6161
62 "@stacksjs/browser": ["@stacksjs/browser@0.75.219", "", { "dependencies": { "@stacksjs/composables": "0.75.219", "@stacksjs/datetime": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/stx": "^0.2.534", "bun-query-builder": "^0.3.8" }, "peerDependencies": { "@stripe/stripe-js": "^10.0.0" }, "optionalPeers": ["@stripe/stripe-js"] }, "sha512-+JS80mVOhUNJRdQkbCEVYvU7kEso0LvQ8/FnET/TNDmdqdyIW6k47QBNJAiCOxblkbcpuFJKUb0mkSMaM1iDYA=="],
62 "@stacksjs/browser": ["@stacksjs/browser@0.75.221", "", { "dependencies": { "@stacksjs/composables": "0.75.221", "@stacksjs/datetime": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/stx": "^0.2.534", "bun-query-builder": "^0.3.8" }, "peerDependencies": { "@stripe/stripe-js": "^10.0.0" }, "optionalPeers": ["@stripe/stripe-js"] }, "sha512-MIW+qyTzUq6CqodgdLzrWUNUIOvoNmj5zek7ZpzfwQoc582VezLN+3eRegF0tIsHF8XUzbway1WUhKAMbG/8Mw=="],
6363
64 "@stacksjs/browser-extension": ["@stacksjs/browser-extension@0.75.219", "", { "dependencies": { "bun-plugin-stx": "^0.2.534", "ts-pantry": "^0.11.69" }, "peerDependencies": { "web-ext": ">=10.7.0" }, "optionalPeers": ["web-ext"] }, "sha512-ZEQlAKUx8VEefU7eUrYiTpon95O20t3/bCWOjZ4Iiq73jv6O2muK8iki4E1bCLF3iplLfYo323oHzHcJDpt6gw=="],
64 "@stacksjs/browser-extension": ["@stacksjs/browser-extension@0.75.221", "", { "dependencies": { "bun-plugin-stx": "^0.2.534", "ts-pantry": "^0.11.69" }, "peerDependencies": { "web-ext": ">=10.7.0" }, "optionalPeers": ["web-ext"] }, "sha512-2vrM6o4hisE45JY/9qldvSMnsppEbdo8yaMpmbHKkzR2DhYWESiyYtlUnarCK3ClaHNCS9boTcWRad2hHtPcpQ=="],
6565
66 "@stacksjs/buddy": ["@stacksjs/buddy@0.75.219", "", { "dependencies": { "@stacksjs/actions": "0.75.219", "@stacksjs/ai": "0.75.219", "@stacksjs/alias": "0.75.219", "@stacksjs/analytics": "0.75.219", "@stacksjs/api": "0.75.219", "@stacksjs/arrays": "0.75.219", "@stacksjs/auth": "0.75.219", "@stacksjs/browser-extension": "0.75.219", "@stacksjs/build": "0.75.219", "@stacksjs/bumpx": "^0.2.21", "@stacksjs/cache": "0.75.219", "@stacksjs/chat": "0.75.219", "@stacksjs/clapp": "^0.2.16", "@stacksjs/cli": "0.75.219", "@stacksjs/cloud": "0.75.219", "@stacksjs/cms": "0.75.219", "@stacksjs/collections": "0.75.219", "@stacksjs/commerce": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/desktop-build": "0.75.219", "@stacksjs/dns": "0.75.219", "@stacksjs/dnsx": "^0.2.3", "@stacksjs/email": "0.75.219", "@stacksjs/enums": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/events": "0.75.219", "@stacksjs/features": "0.75.219", "@stacksjs/git": "0.75.219", "@stacksjs/gitit": "^0.2.5", "@stacksjs/health": "0.75.219", "@stacksjs/httx": "^0.1.10", "@stacksjs/image": "0.75.219", "@stacksjs/lint": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/notifications": "0.75.219", "@stacksjs/objects": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/payments": "0.75.219", "@stacksjs/realtime": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/rpx": "^0.11.54", "@stacksjs/scheduler": "0.75.219", "@stacksjs/search-engine": "0.75.219", "@stacksjs/security": "0.75.219", "@stacksjs/server": "0.75.219", "@stacksjs/sites": "0.75.219", "@stacksjs/skills": "0.75.219", "@stacksjs/socials": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/stx": "^0.2.534", "@stacksjs/testing": "0.75.219", "@stacksjs/tinker": "0.75.219", "@stacksjs/tlsx": "^0.13.19", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/tunnel": "0.75.219", "@stacksjs/types": "0.75.219", "@stacksjs/ui": "0.75.219", "@stacksjs/utils": "0.75.219", "@stacksjs/validation": "0.75.219", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "bun-plugin-stx": "^0.2.534", "ts-pantry": "^0.11.69" }, "bin": { "bud": "dist/cli.js", "buddy": "dist/cli.js", "stacks": "dist/cli.js" } }, "sha512-zRPDS1/ejvM6CMWLlUmlasoRXbyJgVPqazZpqb/EW6z/kzFdN314FKF2h+UQbBvifkkDhPNPg+Y1b7HyisrAzA=="],
66 "@stacksjs/buddy": ["@stacksjs/buddy@0.75.221", "", { "dependencies": { "@stacksjs/actions": "0.75.221", "@stacksjs/ai": "0.75.221", "@stacksjs/alias": "0.75.221", "@stacksjs/analytics": "0.75.221", "@stacksjs/api": "0.75.221", "@stacksjs/arrays": "0.75.221", "@stacksjs/auth": "0.75.221", "@stacksjs/browser-extension": "0.75.221", "@stacksjs/build": "0.75.221", "@stacksjs/bumpx": "^0.2.21", "@stacksjs/cache": "0.75.221", "@stacksjs/chat": "0.75.221", "@stacksjs/clapp": "^0.2.16", "@stacksjs/cli": "0.75.221", "@stacksjs/cloud": "0.75.221", "@stacksjs/cms": "0.75.221", "@stacksjs/collections": "0.75.221", "@stacksjs/commerce": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/desktop-build": "0.75.221", "@stacksjs/dns": "0.75.221", "@stacksjs/dnsx": "^0.2.3", "@stacksjs/email": "0.75.221", "@stacksjs/enums": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/events": "0.75.221", "@stacksjs/features": "0.75.221", "@stacksjs/git": "0.75.221", "@stacksjs/gitit": "^0.2.5", "@stacksjs/health": "0.75.221", "@stacksjs/httx": "^0.1.10", "@stacksjs/image": "0.75.221", "@stacksjs/lint": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/notifications": "0.75.221", "@stacksjs/objects": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/payments": "0.75.221", "@stacksjs/realtime": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/rpx": "^0.11.54", "@stacksjs/scheduler": "0.75.221", "@stacksjs/search-engine": "0.75.221", "@stacksjs/security": "0.75.221", "@stacksjs/server": "0.75.221", "@stacksjs/sites": "0.75.221", "@stacksjs/skills": "0.75.221", "@stacksjs/socials": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/stx": "^0.2.534", "@stacksjs/testing": "0.75.221", "@stacksjs/tinker": "0.75.221", "@stacksjs/tlsx": "^0.13.19", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/tunnel": "0.75.221", "@stacksjs/types": "0.75.221", "@stacksjs/ui": "0.75.221", "@stacksjs/utils": "0.75.221", "@stacksjs/validation": "0.75.221", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "bun-plugin-stx": "^0.2.534", "ts-pantry": "^0.11.69" }, "bin": { "bud": "dist/cli.js", "buddy": "dist/cli.js", "stacks": "dist/cli.js" } }, "sha512-JC1wKFiaL0juh+dPi38WhiHuuz2DMdRtHs/Od3bPrxr5SuMpfrK3QbCHkis3lSa/ZoeZBm7MympRA32LJJb1jA=="],
6767
68 "@stacksjs/build": ["@stacksjs/build@0.75.219", "", { "dependencies": { "@stacksjs/cli": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/storage": "0.75.219", "bun-plugin-dtsx": "^0.11.19" } }, "sha512-XiUrdrPUqr/hqCpm3RtCmB92LcW6fMils7j/9Vp0EKKxlClOa7zD+sNT1NdoMQ7kVSFIlRhrHfJRskESNpxaxw=="],
68 "@stacksjs/build": ["@stacksjs/build@0.75.221", "", { "dependencies": { "@stacksjs/cli": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/storage": "0.75.221", "bun-plugin-dtsx": "^0.11.19" } }, "sha512-yVCTP7WZsaCM4MeooFqsU9eqGhF4j2C3fXyvWNKmisfxED5NZtNcVMbI3gNXkCDbqDk1PWNjwxl12HU0nKwmqg=="],
6969
7070 "@stacksjs/bumpx": ["@stacksjs/bumpx@0.2.21", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/logsmith": "^0.2.6", "bunfig": "^0.15.6" }, "bin": { "bumpx": "./dist/bin/cli.js" } }, "sha512-OFbqVX35ksAMlRQ7cEUltEqmA0zoBQdK4Zv0Me5FBlFQDt1JIo7K+uE9zCLIry4dyNa7MoKxCeGdGhH4iJbtQA=="],
7171
@@ -75,161 +75,161 @@
7575
7676 "@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.16", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.375", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-aHXXkoYvbTirOFqGqUcQOmlv07mAds4bR/oDKbLB1GqvR8tmjdopHugyir6IWzz9JX+3PpGzCG41HNM7l1UZFw=="],
7777
78 "@stacksjs/cache": ["@stacksjs/cache@0.75.219", "", { "dependencies": { "@stacksjs/ts-cache": "^0.1.7" } }, "sha512-JV7Q0VKztLS7CcZ1o9PGcgBwlkXACvIeZZ+nJIwem8uLpWC1HtoQXeM3FJ3EIqz+wnb3e9XliDEir90akZjx8Q=="],
78 "@stacksjs/cache": ["@stacksjs/cache@0.75.221", "", { "dependencies": { "@stacksjs/ts-cache": "^0.1.7" } }, "sha512-D4B2tkif9FISL+5xaMff1Ceu2A1iSlMPLyLbgHGzWuqp2mR8KoB/8Ilpk5uq0Bd/xTf63U9opxkQvKlOr1jkTA=="],
7979
80 "@stacksjs/calendar-api": ["@stacksjs/calendar-api@0.75.219", "", { "dependencies": { "@stacksjs/datetime": "0.75.219", "@stacksjs/stx": "^0.2.534" } }, "sha512-dLOXgtxomxJ1jG6mgm+fBloYcK9OQoTcQ0IS77J+mSMdJgCW5Vf2nVvrhCAie8PYj8Mh2R+buqCNV7yebMXQuw=="],
80 "@stacksjs/calendar-api": ["@stacksjs/calendar-api@0.75.221", "", { "dependencies": { "@stacksjs/datetime": "0.75.221", "@stacksjs/stx": "^0.2.534" } }, "sha512-jGOPEJRsPhWMs1ZYFINZSph0nzAihY5ImZOOcngnRpeJY773j0kHLBDqRB4EXIeUrZvj9hAuxCKq/sxqh/85xQ=="],
8181
82 "@stacksjs/charts": ["@stacksjs/charts@0.75.219", "", { "dependencies": { "@ts-charts/format": "^0.2.7", "@ts-charts/scale": "^0.2.7", "@ts-charts/shape": "^0.2.7" } }, "sha512-JPrAWA9Jgkx+Li3WHjO+GFrmU7TACWBH7Jtn4xCQ+HWdIf5lfBojCr/FVe2G7NWONukU09o27VHJz6H7xbdzaA=="],
82 "@stacksjs/charts": ["@stacksjs/charts@0.75.221", "", { "dependencies": { "@ts-charts/format": "^0.2.7", "@ts-charts/scale": "^0.2.7", "@ts-charts/shape": "^0.2.7" } }, "sha512-CGsArz4tiivcGwfX4Ts8yHde0Ow3ujbImnYa1o9634ASl7di25FUwlWFsoCpSV2kJrLgyTO71iXTnTmAlVrP6w=="],
8383
84 "@stacksjs/chat": ["@stacksjs/chat@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/orm": "0.75.219" } }, "sha512-oDGB0dPgsiQhfrUjISDoCs5BVCs+BqdT6GxVHMNo25iuf6xpkyxkYj1a01CINeOlm22tq/BJPlSi/hE0ca5l6Q=="],
84 "@stacksjs/chat": ["@stacksjs/chat@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/orm": "0.75.221" } }, "sha512-VXYmgWxNwwBKTVmhFvb3DgeNAwfxD/+7JVhi9NE7h7b8fA7by6LpcmW+kni2o7aX7Y0Rfeenbhj7rJMkffxoxQ=="],
8585
8686 "@stacksjs/clapp": ["@stacksjs/clapp@0.2.16", "", {}, "sha512-g/5HUXGxD9QffyPKGdywZODte0DJYwzNhSmrPime1z7TLxWPodJ9IYfIwr2G6Wco+fAQISgaVQUtVf0pUNGV0w=="],
8787
8888 "@stacksjs/clarity": ["@stacksjs/clarity@0.3.33", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "clarity": "./dist/bin/cli.js" } }, "sha512-+5GLKnAI/M/z64UfNLLfQqx/kp+frmRY+NZXYwwGwbrkud/xlsEGDWEExdhqOhqEDMWdb1oafw/ubGAPg1PaFw=="],
8989
90 "@stacksjs/cli": ["@stacksjs/cli@0.75.219", "", { "dependencies": { "@stacksjs/clapp": "^0.2.12", "@stacksjs/collections": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/types": "0.75.219" } }, "sha512-dXbndNyMWmlEa0mCZ1xWypb/ot0lhbz8rdBEqvsKYdwNr0RD2dVQRgZyBIn8tPVfoXrSl6GSiJx0HuLmLCidrQ=="],
90 "@stacksjs/cli": ["@stacksjs/cli@0.75.221", "", { "dependencies": { "@stacksjs/clapp": "^0.2.12", "@stacksjs/collections": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/types": "0.75.221" } }, "sha512-YoEkcYY13VLa+HjfVaLVoEYeCdEw7NFloypLH68NWS/j88hPLWhRu0mkoZhT0fhJiTKGRxPpfJbbT9wDG0B1YA=="],
9191
92 "@stacksjs/cloud": ["@stacksjs/cloud@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/ts-xml": "^0.1.2" } }, "sha512-uDJnIilDp+0AmcQ/wAaIYCsuLLeELJDuW9cIWmHkmZigH56P1+JrXr8lueAZNhx6LgaKLviTXSsdDEMRcQf7+Q=="],
92 "@stacksjs/cloud": ["@stacksjs/cloud@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/ts-xml": "^0.1.2" } }, "sha512-TxaAv1FOMLIjsXW9oEA31RRyOgjmogkAy18Qce1AG9cVEYX1YqOkLVWRJmcOGw85SgzhfSQJCvdragncW/T7NQ=="],
9393
94 "@stacksjs/cms": ["@stacksjs/cms@0.75.219", "", { "dependencies": { "@stacksjs/database": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/sites": "0.75.219", "@stacksjs/slug": "0.75.219", "@stacksjs/stx": "^0.2.534", "@stacksjs/validation": "0.75.219", "ts-medium-editor": "^0.2.0", "ts-slug": "^0.1.2", "ts-spreadsheets": "^0.2.5" } }, "sha512-BY0fkN8Y4jO/VJiet4KyLDLF1cvz/9ZSTCkOZvPOWU52BQi03NT+MhnaQ3UmvR4lVwvozzChTGXpKUE8l4WElw=="],
94 "@stacksjs/cms": ["@stacksjs/cms@0.75.221", "", { "dependencies": { "@stacksjs/database": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/sites": "0.75.221", "@stacksjs/slug": "0.75.221", "@stacksjs/stx": "^0.2.534", "@stacksjs/validation": "0.75.221", "ts-medium-editor": "^0.2.0", "ts-slug": "^0.1.2", "ts-spreadsheets": "^0.2.5" } }, "sha512-FDFu7A3ufyBTzBFJtBQgqmbySId4LFUWnrwGn/K4ys6jU3tNL3eO/OrvZ9eyxtw7KtfYZ63IrMJ7xRVyJqIkOw=="],
9595
96 "@stacksjs/collections": ["@stacksjs/collections@0.75.219", "", { "dependencies": { "ts-collect": "^0.4.3" } }, "sha512-vv+lK82ZvbkvMokY8MTrbspUj+ftHzaK3lBKsX3173i8GWBTJ155zaj+AK1da7Gp65pW1dyHbuI5kJsk1SnctA=="],
96 "@stacksjs/collections": ["@stacksjs/collections@0.75.221", "", { "dependencies": { "ts-collect": "^0.4.3" } }, "sha512-PIM59l69FPaWEufFE/wmQi2GszPrgraDSLdfSzaQn71KkfbNesPklAbsUQH5Ia8WuYlpagtW6R/kmUR3xMVnBQ=="],
9797
98 "@stacksjs/commerce": ["@stacksjs/commerce@0.75.219", "", { "dependencies": { "@stacksjs/cache": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/events": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/payments": "0.75.219", "@stacksjs/realtime": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/validation": "0.75.219", "ts-spreadsheets": "^0.2.0" } }, "sha512-qEKbEdd9ZLG7ZDs4pA1LQ3yeBv0I2Vi3KMqx5Xx5eFMXzfw2t+vCDaf/3lMUbFUxJz8Umf54e6AvPzdeT8X9hw=="],
98 "@stacksjs/commerce": ["@stacksjs/commerce@0.75.221", "", { "dependencies": { "@stacksjs/cache": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/events": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/payments": "0.75.221", "@stacksjs/realtime": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/validation": "0.75.221", "ts-spreadsheets": "^0.2.0" } }, "sha512-EXsaTWQorx4vEQAbjBDfO6nRy/VKC1GYxoR1E+rBaXA4x4B7NVi73P4e3VdFPFJXJJ6f3PMXlEoDp46YFZ0sqw=="],
9999
100100 "@stacksjs/components": ["@stacksjs/components@0.2.556", "", { "dependencies": { "@iconify-json/f7": "^1.2.2", "@stacksjs/stx": "0.2.556", "@stacksjs/ts-css": "^0.3.11", "bun-plugin-stx": "0.2.556", "ts-syntax-highlighter": "^0.2.17", "ts-video-player": "^0.1.9" } }, "sha512-2InQNlRHa62ke6yaDQI1NKME+ayVwwNqx6ZkITyKQ0ldfHOyjjvTobTr3uxhPmHqdZ326Y+ikAI4C/Loywj/WQ=="],
101101
102 "@stacksjs/composables": ["@stacksjs/composables@0.75.219", "", { "dependencies": { "@stacksjs/datetime": "0.75.219", "@stacksjs/stx": "^0.2.534" } }, "sha512-8fXtgEFRf4Y5AqKL56W4MEntq/zX16rLXPdy4tA1+nJa0yuaTr1uHOp6V3pfG5Oy/w2AZsAbFdp3yRjGZx31Kw=="],
102 "@stacksjs/composables": ["@stacksjs/composables@0.75.221", "", { "dependencies": { "@stacksjs/datetime": "0.75.221", "@stacksjs/stx": "^0.2.534" } }, "sha512-YtOzZWrirlwdT3c8uidMu8UKqLvD2h3ZGCI6fekYx8aJC8PGkciDBJAlt8t5Uo1lgADvoH9J8hwu1kBJalSmFw=="],
103103
104 "@stacksjs/config": ["@stacksjs/config@0.75.219", "", { "dependencies": { "@stacksjs/events": "0.75.219", "@stacksjs/features": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/tunnel": "0.75.219", "ts-pantry": "^0.11.69" } }, "sha512-Xgbn45jteS6/kMexeiNQb8D6FKAf9/AjDxKiWn/eKf4KjnMy/s+ZMsg6uvMDrgKcA3WmkJEWs4QJh6456tyWwg=="],
104 "@stacksjs/config": ["@stacksjs/config@0.75.221", "", { "dependencies": { "@stacksjs/events": "0.75.221", "@stacksjs/features": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/tunnel": "0.75.221", "ts-pantry": "^0.11.69" } }, "sha512-c3h0+/VvqseJQjOM+OBW8paXgxeocAgUKptVs99wSUQzG4z+YhhT0uBL+Ixk0ZORXdFELefq7icZgiFwv689cQ=="],
105105
106 "@stacksjs/cron": ["@stacksjs/cron@0.75.219", "", {}, "sha512-2MHBbTF0lVlvSsl1kvyUiBFc7w+EClzJntG4YKE/JWiIQPAbk67FRUGbP4Fg+0aP206fDi+XCsroBIn4/AYmVQ=="],
106 "@stacksjs/cron": ["@stacksjs/cron@0.75.221", "", {}, "sha512-+zjYLMzEVi2KPOi2KHVaxzTG5h3m/rRxKt8q2LwuE0T2lNxHlWUojesukMoCUMQf+wP1NrflkpsnZ/rYyrXZHw=="],
107107
108 "@stacksjs/database": ["@stacksjs/database@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/faker": "0.75.219", "@stacksjs/features": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/model-meta": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/query-builder": "0.75.219", "@stacksjs/security": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/ts-validation": "^0.5.6", "bun-query-builder": "^0.3.9", "dynamodb-tooling": "^0.3.6" } }, "sha512-6Q5WAwIgbHLwLoBstUCvxrk4X49TbLLJyeDC/TmkyCkRPCgLdcymOi39Jvs1wpik54lna18rGSVaJ1ByPOJYKg=="],
108 "@stacksjs/database": ["@stacksjs/database@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/faker": "0.75.221", "@stacksjs/features": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/model-meta": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/query-builder": "0.75.221", "@stacksjs/security": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/ts-validation": "^0.5.6", "bun-query-builder": "^0.3.9", "dynamodb-tooling": "^0.3.6" } }, "sha512-3gqYnz5qHVeI07oeiiDSAjuaMqu/iYzMZQ/HNyOGiCGPF+Kp93xULYYYddXt2R2XW3NCZ1PXihNJZBrLxrEtcQ=="],
109109
110 "@stacksjs/datetime": ["@stacksjs/datetime@0.75.219", "", {}, "sha512-WFpz4OP6w6UbjhpfV0hvElhuHSUWO9rJL1xuoyW3CpdBRLu7RsWvWudMsBklwjAiuZ0QiWV4Lpw1v/oIW4UJnQ=="],
110 "@stacksjs/datetime": ["@stacksjs/datetime@0.75.221", "", {}, "sha512-qL9t6S9ykomv1tSplSQ/3SVPw7GYBB+pR0QLeu+0F8dwCPEeldq8/revXigsL4hn1k8syffVQ9zLERrNUy+lDw=="],
111111
112 "@stacksjs/defaults": ["@stacksjs/defaults@0.75.219", "", { "dependencies": { "@iconify-json/f7": "^1.2.2", "@iconify-json/hugeicons": "^1.2.35", "@stacksjs/mobile": "0.75.219", "@stacksjs/sanitizer": "^0.2.534", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "ts-qr-codes": "^0.1.8" } }, "sha512-wrw5pbvoB5ZMRrQWE2kdZ6lNhYABkoYN856oRzuvpYYNMPiDGzXRbfx8F8xGBFZLBxsJUvEBGBsEhn7X+ihKSw=="],
112 "@stacksjs/defaults": ["@stacksjs/defaults@0.75.221", "", { "dependencies": { "@iconify-json/f7": "^1.2.2", "@iconify-json/hugeicons": "^1.2.35", "@stacksjs/mobile": "0.75.221", "@stacksjs/sanitizer": "^0.2.534", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "ts-qr-codes": "^0.1.8" } }, "sha512-/q67bbK8Dwdo6/1kqt+VLBZz8OsZQCTLGVTatVwghCfR2tMTJXHp8RvwN+vYKrDu0O6MXgkj2x9580BJXSZ84g=="],
113113
114 "@stacksjs/desktop": ["@stacksjs/desktop@0.2.563", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-kNN1D1CZVF+luzgXhk12iRcQM51zrZy4siHhQ7y3WTDQBKY4O8sW+j/KrXPgMYaf1dDc9O3moxBSFPvPvqmuOg=="],
114 "@stacksjs/desktop": ["@stacksjs/desktop@0.2.573", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-2QIG4rm8AJ8ORCUnYjcJ6hNA/p6iXIFhjrjv3aM2hmB/7bs1juDVIheVuSD6+HaWrVsRLWlmRqL9AkdsQA5tLg=="],
115115
116 "@stacksjs/desktop-build": ["@stacksjs/desktop-build@0.75.219", "", { "dependencies": { "craft-native": ">=0.0.135" } }, "sha512-MI/gpXP/vY2/X2lNW0Yr5W6HLrf52K8TGAGUY7Y7PBcWS3ixDyAkpKBLJBsAIM8bIYg6N3sqxLNKLs9PJCWEHg=="],
116 "@stacksjs/desktop-build": ["@stacksjs/desktop-build@0.75.221", "", { "dependencies": { "craft-native": ">=0.0.135" } }, "sha512-a6gKMYiR0sdICDKhp81Vl2n9AHQXXPhAebYlg8gwcP7t6+9u6cG+ht9IUC1fL5nK2BNH9P9PyLfRA+NjXdlzZA=="],
117117
118 "@stacksjs/dns": ["@stacksjs/dns@0.75.219", "", { "dependencies": { "@stacksjs/action-runner": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/dnsx": "^0.2.3", "@stacksjs/enums": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/ts-cloud": "^0.16.58" } }, "sha512-4C8OWXGaN4332aOXfKMyOnLvy56isHT+Opdt6r+EQ49W7gsneot1ONw3G6RVGGo3T6/08prHaFbiHvVGozujRQ=="],
118 "@stacksjs/dns": ["@stacksjs/dns@0.75.221", "", { "dependencies": { "@stacksjs/action-runner": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/dnsx": "^0.2.3", "@stacksjs/enums": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/ts-cloud": "^0.16.58" } }, "sha512-CvTSkGiqY654cqNGRD0ab0Fo70tLzhV5QoXjEvQydksc2kXZo+KwXmsQrlqx033yHkjzD2HtqW4x1T7f07FG1Q=="],
119119
120120 "@stacksjs/dnsx": ["@stacksjs/dnsx@0.2.3", "", { "bin": { "dnsx": "./dist/bin/cli.js" } }, "sha512-KoubPLapgNSEOoTJNEL3BV1WIhSHz70Kzyf2i2EM9ZViaNOeHrUOIwG8qRX9D+L/5Njh45LmAG+vwkaJalD1ng=="],
121121
122 "@stacksjs/docs": ["@stacksjs/docs@0.75.219", "", { "dependencies": { "@stacksjs/bunpress": "^0.2.15", "@stacksjs/config": "0.75.219", "@stacksjs/path": "0.75.219" } }, "sha512-jmbX4nTZ35Z0ICHImZxr50lUvbblxhJtuWMVRMKHgvaFWXOOxGMhV7Tr+Y1Vcgz1N7iLIoPEuQbwy69DA+0s4w=="],
122 "@stacksjs/docs": ["@stacksjs/docs@0.75.221", "", { "dependencies": { "@stacksjs/bunpress": "^0.2.15", "@stacksjs/config": "0.75.221", "@stacksjs/path": "0.75.221" } }, "sha512-dpq/bMwwvI3LdCAybolApHRQ3A0pXt5Fv9tTa6ingAPHfpdzEv89aQhGxNSh5pEB/U35o6j10dL1bZ9/3Fes4Q=="],
123123
124124 "@stacksjs/dtsx": ["@stacksjs/dtsx@0.11.19", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16" }, "bin": { "dtsx": "./dist/bin/cli.js" } }, "sha512-QPf4h2gGRW9kyLXpIAMkYEZkmVkoFjlWK0jwWKpc5ItrqpvyMx2gqYBqa0qVij43oMBo2QiQTrqW4+iYEho6Tg=="],
125125
126 "@stacksjs/email": ["@stacksjs/email@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/events": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/mail": "^0.3.11", "@stacksjs/path": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/stx": "^0.2.534", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/utils": "0.75.219" }, "peerDependencies": { "@stacksjs/queue": "0.75.219" }, "optionalPeers": ["@stacksjs/queue"] }, "sha512-SmcSU/AAXyqal0mZUCAhVWY4Omw47irJZEzYNuu3Onp1l7IJl8uSNgt/C8DyKAtZNi1fhut3GcwIaNGNaLBoWA=="],
126 "@stacksjs/email": ["@stacksjs/email@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/events": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/mail": "^0.3.11", "@stacksjs/path": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/stx": "^0.2.534", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/utils": "0.75.221" }, "peerDependencies": { "@stacksjs/queue": "0.75.221" }, "optionalPeers": ["@stacksjs/queue"] }, "sha512-Fiw/cp+RVwmSV3sJ6UYeJY6MQKyLqoqofV2EokOlgtMoRHEPMtFdCUcOZTv+bFVa1ze1XS09oPLSZpmHtJ5qng=="],
127127
128 "@stacksjs/enums": ["@stacksjs/enums@0.75.219", "", {}, "sha512-Sy2K+I5mcV44vya9EyD7LA5BxvwcUCsiMoobGK6owyGnYPn0b4flwmmJbbpCX9rQcdjSvNTHYvLNZMXB8PFjVw=="],
128 "@stacksjs/enums": ["@stacksjs/enums@0.75.221", "", {}, "sha512-4UOaJ0I6C9I0eY8QvVqj8Zva+VosPiuyAr2g+5o9MjOazGCgmM8jamiwOLDQ7gTn3YS11mxQXIQE1xff3/ilEg=="],
129129
130 "@stacksjs/env": ["@stacksjs/env@0.75.219", "", { "dependencies": { "@stacksjs/path": "0.75.219" } }, "sha512-xwKILq4muUxBjvjkL+L/3q0V4A0tR9mUZDWyKte4Nwo8RK0wqmZUH2kwGFrpm9YY1eUAZKibFgBA9S5X6niVVg=="],
130 "@stacksjs/env": ["@stacksjs/env@0.75.221", "", { "dependencies": { "@stacksjs/path": "0.75.221" } }, "sha512-vS4JObosctiPkXtom87BjXtWZ01zn4+tUyfT02Is5Gl+tOn6DqzeBgOfZwnsyb068J/cfxhFwpPkbSO/MLXqQg=="],
131131
132 "@stacksjs/error-handling": ["@stacksjs/error-handling@0.75.219", "", { "dependencies": { "@bughq/sdk": "^0.3.0", "@stacksjs/path": "0.75.219", "@stacksjs/stx": "^0.2.534", "@stacksjs/types": "0.75.219", "ts-error-handling": "^0.1.3", "ts-syntax-highlighter": "^0.2.17" } }, "sha512-nLooxnAR39Drf2NtNqxTkur7TpOIwthbkeEIRW3RM4+iViZJcocueT48ZAzzSacfA/9HA86te2LRUXpaObXmbg=="],
132 "@stacksjs/error-handling": ["@stacksjs/error-handling@0.75.221", "", { "dependencies": { "@bughq/sdk": "^0.3.0", "@stacksjs/path": "0.75.221", "@stacksjs/stx": "^0.2.534", "@stacksjs/types": "0.75.221", "ts-error-handling": "^0.1.3", "ts-syntax-highlighter": "^0.2.17" } }, "sha512-PrAD9DhMuaZm0wvmags2N7fktjEfhmcaWWj6D9cSnvdLajp9ypJGqtE1Y7asbDnMl/9ClcX9/OGDfFvu8wDSJQ=="],
133133
134 "@stacksjs/events": ["@stacksjs/events@0.75.219", "", { "dependencies": { "@stacksjs/path": "0.75.219" } }, "sha512-0xOXhSeDSJkgsogNv/ALP+k+NH3H/tW8owfakESqzp6aKIqQIt4MPCWPhijs/Pu1d9IjrF4vWTNxIfg5RqZW1g=="],
134 "@stacksjs/events": ["@stacksjs/events@0.75.221", "", { "dependencies": { "@stacksjs/path": "0.75.221" } }, "sha512-pZ9SV3k52Zoj+Kc3h/IpvOgjbsOmRSYKelKBXup2bWkInYzygEKJcX/qc3R6X++abS2Ka26Lrr3hHNgeiVZisA=="],
135135
136 "@stacksjs/faker": ["@stacksjs/faker@0.75.219", "", { "dependencies": { "@stacksjs/ts-faker": "^0.1.8" } }, "sha512-5JdZv4sL4RxkgqgMtf8vEEWAA4sdb+Sgb6sNlx+yz0fnTdBJ1aVDObdM674m7B2bLOFysh9cNSnZF6ROsLci3A=="],
136 "@stacksjs/faker": ["@stacksjs/faker@0.75.221", "", { "dependencies": { "@stacksjs/ts-faker": "^0.1.8" } }, "sha512-raS69LvnuaX7cXonw3O/MkrML9EbIF//xm/mtO9NFtiv2jjRL3HggnRP/yWjNEWxT3VOZ57caqRlGFrkaSGn4Q=="],
137137
138 "@stacksjs/feature-flags": ["@stacksjs/feature-flags@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219" } }, "sha512-brajSdfZg5/L4UbEI8LD+JIDzsIr37F1aftOioiz8FstoRm2fg6eXz0ys+3PpV0JkmtZzA2Gb7ZqxduqMSn+Wg=="],
138 "@stacksjs/feature-flags": ["@stacksjs/feature-flags@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221" } }, "sha512-UFLPwSCQOiOS7K+1FzKyqfg4nX6nqsQJb6iqmzH62aQUMuqusQUtHWNdGQczgPnS24ywJoTwgKqJwv+JiT0uOQ=="],
139139
140 "@stacksjs/features": ["@stacksjs/features@0.75.219", "", { "dependencies": { "@stacksjs/path": "0.75.219" } }, "sha512-zZpgEmYf6E8aJlcvXxgxn0ZlyLg5EWw7ZFmuUHj85rLEgGhU90jKVrFHaitdR6KWz9eO9+fsAqDEhhTmyanIvA=="],
140 "@stacksjs/features": ["@stacksjs/features@0.75.221", "", { "dependencies": { "@stacksjs/path": "0.75.221" } }, "sha512-KAVZjBkYnFvO2TlnlE+a9ygvgf9BSccLK4zspHaDz3ESwwd7bT1VUPLPUEEbo/sSQNza8/fGfB+rdQziGT/yZA=="],
141141
142 "@stacksjs/forms": ["@stacksjs/forms@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/notifications": "0.75.219" } }, "sha512-5MqO5rwvPbHClTfst7Ah3Lk/G9PL2HqqA1mYdt3ioIIRDqUSCLUwZPgsqvsAOGo1+S3RK1fsxedTDq39zgpsvA=="],
142 "@stacksjs/forms": ["@stacksjs/forms@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/notifications": "0.75.221" } }, "sha512-rjwiAqqAyM4NPwm4himRUYQVbIZgzYuopvGlezjjNFEYC/PHai7T8az8ZQsvCZTi74JzdMJnp3R/xoM18VCP8A=="],
143143
144 "@stacksjs/git": ["@stacksjs/git@0.75.219", "", { "dependencies": { "cz-git": "^1.14.0" } }, "sha512-8881M2+HhAM5rLgxO9ONPtnfIFH8BpU53/i0VBTiJIQO2uCnes0VYKyjytuyObwkOlJ0F1JjT9m9DelFYol8WA=="],
144 "@stacksjs/git": ["@stacksjs/git@0.75.221", "", { "dependencies": { "cz-git": "^1.14.0" } }, "sha512-xMdsVyQwMQNXoGqNd5AQmJgjlGrV7znYH9yaJa7JeLYNePHO7iiqlLpS6sF/3K9Ae6pVVrYGm7e4IMBNGXrn0Q=="],
145145
146 "@stacksjs/github": ["@stacksjs/github@0.75.219", "", {}, "sha512-LSPc1QbPsgzdahTREmZ4HVIllxenEP2BK02hmmilQ0FOPUhIChIOQcCo8jruzNmO+jv4ycN9tgXb4y59GP/DBQ=="],
146 "@stacksjs/github": ["@stacksjs/github@0.75.221", "", {}, "sha512-jqxl74x6i+Wpp7CA16gIQcoSdjkznW9iZCQjxEOGJ0x8joVmcvrH6APxQAoZXklclzuYHqZNyNDhzJsKfLm0aA=="],
147147
148148 "@stacksjs/gitit": ["@stacksjs/gitit@0.2.5", "", { "bin": { "gitit": "dist/bin/cli.js" } }, "sha512-HI9L1EsWTXG4JxqMlEjujDWovH5UnYqP6Uv9Pll74+vLGEH2OK6FRfarNQgRHBkMyzBx2fjutAHOXrPSCTN8eQ=="],
149149
150 "@stacksjs/health": ["@stacksjs/health@0.75.219", "", {}, "sha512-nMMTPZkRFkUbEpOUW1KjDUYsqo6MGkdWkboExkjg30NGpN1OP1OicP7tKC7HlQc5GG0C+QXXhazaFnY+HqYGzg=="],
150 "@stacksjs/health": ["@stacksjs/health@0.75.221", "", {}, "sha512-CApjb2B/2aPcGFpYsaUH1MDZiWcHAw7xPrC7EukGBxBV51aRO5mxGv4wSLXyoWw31GTx2QEeYLzKS2LJB843ZQ=="],
151151
152 "@stacksjs/http": ["@stacksjs/http@0.75.219", "", { "dependencies": { "@stacksjs/httx": "^0.1.11" } }, "sha512-nqNWbXTqkWugDW4/Tq44hXxXtO4oyEMoyVaXINtRhGw5rSNiliYe38dcAAGz3X45rm68mlrVc/Pif70sfGTJfA=="],
152 "@stacksjs/http": ["@stacksjs/http@0.75.221", "", { "dependencies": { "@stacksjs/httx": "^0.1.11" } }, "sha512-bbwX6S/13XvBs6uKcELR6iQeFanIVWFJR77CVmq3g6fpryvS/S4sF78L2DPGRWkO8mPR+krR/4ErcWvmTNVQFw=="],
153153
154154 "@stacksjs/httx": ["@stacksjs/httx@0.1.11", "", { "dependencies": { "ts-error-handling": "^0.1.0" }, "bin": { "httx": "./dist/bin/cli.js", "http": "./dist/bin/cli.js", "https": "./dist/bin/cli.js" } }, "sha512-7Ml5D1NpTl4qadeNNFh0CFXjNnv4rb1+9d2MC159FElVo3SbAGWGpobSnwrjg/SZK10rc9mpeOU3l2LPqvJ9uQ=="],
155155
156 "@stacksjs/i18n": ["@stacksjs/i18n@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/ts-i18n": "^0.1.11" } }, "sha512-AOl559zpXtEHxlxDX3RPjhEoaAT0Y8mC1Rv8la5LlXZd1346+G7E69i4Agk9/7BG5s6Vn+1IGYtLTDHOjleUdQ=="],
156 "@stacksjs/i18n": ["@stacksjs/i18n@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/ts-i18n": "^0.1.11" } }, "sha512-hhtvSuU9JxtjJr9OfILl9reB5TNmWkdW9jvctCXshY0S5c+08/y8B/6Fk7rdQ366ysivh+zUQo+dSVix1FpHCA=="],
157157
158 "@stacksjs/image": ["@stacksjs/image@0.75.219", "", { "dependencies": { "@stacksjs/ts-svg": "^0.1.5", "@stacksjs/types": "0.75.219", "ts-images": "^0.2.23" } }, "sha512-h8xKthKs49CH8pvZmXbEBq0r8O66rP+Iy9EsyTebeAn59VBnk7EFnQ1fZ3fnezW387zNjAjael2SiIrp+yo7fA=="],
158 "@stacksjs/image": ["@stacksjs/image@0.75.221", "", { "dependencies": { "@stacksjs/ts-svg": "^0.1.5", "@stacksjs/types": "0.75.221", "ts-images": "^0.2.23" } }, "sha512-k9jPEl/437y35Eeko9bqcnq5IHXWEe+WDfDewjX5HlX37fTxhFpJuBzfYYmIjCwwWrxicgVu3mqSw/dEe+UYaA=="],
159159
160 "@stacksjs/lint": ["@stacksjs/lint@0.75.219", "", { "dependencies": { "pickier": "^0.1.70" } }, "sha512-Q+9GMPrA9Xd63TtD5UwqyNjhNxCiyPbg+I4Mo+6KetWD2A/xqReetuAAkSnIdJxTn8WLyOGGW+8P/39s7gc/Tw=="],
160 "@stacksjs/lint": ["@stacksjs/lint@0.75.221", "", { "dependencies": { "pickier": "^0.1.70" } }, "sha512-KCBi1FkxAJBtC8l5vlQAENsNgZvp5fV0oLozoR6wRc9Oh71PdbJMjdm+CqEJS/7X/R8dJ+hugy7jYaBdV2Zmzw=="],
161161
162 "@stacksjs/logging": ["@stacksjs/logging@0.75.219", "", { "dependencies": { "@loghq/stacks": "^0.2.6", "@stacksjs/clarity": "^0.3.33", "@stacksjs/config": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/types": "0.75.219" } }, "sha512-txlY3x7VEQHZGGn8OY9fWs3V3U4SeXpi562qjwLHsd3VQEQsIhPsg7KX5MSHdFZC1VBs6dC0UuGftNnuh06AlQ=="],
162 "@stacksjs/logging": ["@stacksjs/logging@0.75.221", "", { "dependencies": { "@loghq/stacks": "^0.2.6", "@stacksjs/clarity": "^0.3.33", "@stacksjs/config": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/types": "0.75.221" } }, "sha512-b3ygs4yTijiZxb/PFVjPj2xwu3KsLz3ioCt6PUjbtufLr4MpSOxiEuEcyzB4ys8HAbnPL4MiR04ssDrdWjYUkg=="],
163163
164164 "@stacksjs/logsmith": ["@stacksjs/logsmith@0.2.11", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "bunfig": "^0.15.6", "pickier": "^0.1.9" }, "bin": { "@stacksjs/logsmith": "./dist/bin/cli.js", "logsmith": "./dist/bin/cli.js" } }, "sha512-wqqAxewpsHVDzpC+IRy+yYyGXGlv1j5GCqYTh8fL7d3LLhnRPN72PEQywM357burtORvdkFHfOzk3Ih1SMvuiw=="],
165165
166166 "@stacksjs/mail": ["@stacksjs/mail@0.3.12", "", {}, "sha512-RLoGTaS4Hi8Jy5LRrtNsy5038mTOTqALvAUxm2hrqKOOZLWj091/wms0hnBoRQ1bfk0vuQMSYQNlmzoVUeIicA=="],
167167
168 "@stacksjs/mobile": ["@stacksjs/mobile@0.75.219", "", { "dependencies": { "@bughq/sdk": "^0.3.0", "@stacksjs/types": "0.75.219", "craft-native": ">=0.0.179" } }, "sha512-2IYgWSf93DKcIqOx6ieRT45vXWMB7Mt8U4dcq0t/Kmtt7e/GQkhnloG6uU9zP/ZbOzllh91Dz2PnmKSfJ5DK1Q=="],
168 "@stacksjs/mobile": ["@stacksjs/mobile@0.75.221", "", { "dependencies": { "@bughq/sdk": "^0.3.0", "@stacksjs/types": "0.75.221", "craft-native": ">=0.0.179" } }, "sha512-PmODrSk5la9korZmKjm4p0jyy05+cswJ4BMN7PDUYT2x19VSha/d2tzwCveW8yfsZx6YTCGJ/zkqR9C1+5LNgA=="],
169169
170 "@stacksjs/model-meta": ["@stacksjs/model-meta@0.75.219", "", { "dependencies": { "@stacksjs/path": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/ts-validation": "^0.5.6", "@stacksjs/types": "0.75.219", "@stacksjs/validation": "0.75.219" } }, "sha512-2NHb81xOtUUFx1YokFJ8bsbaLvNcKVdkwGzHrY/yCcH4N+Aeb+bLHy3R2sfihsMko3BEm1V37528PBR+S/bXvw=="],
170 "@stacksjs/model-meta": ["@stacksjs/model-meta@0.75.221", "", { "dependencies": { "@stacksjs/path": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/ts-validation": "^0.5.6", "@stacksjs/types": "0.75.221", "@stacksjs/validation": "0.75.221" } }, "sha512-Wl1luIJ9JcjMBZJlry3xeQIaWLX/0ceM9ifUVw7Z5EF1sOLxcvIKwYOMg5YO9n9F4HIjBmkp9tcfHrNWzAEA8w=="],
171171
172 "@stacksjs/newsletter": ["@stacksjs/newsletter@0.75.219", "", { "dependencies": { "@stacksjs/database": "0.75.219", "@stacksjs/email": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/queue": "0.75.219" } }, "sha512-EYyaBR/rQPFPb0hlfdlU/3DWMLWHu2BVTPcbevQsP0M/O5hzZOxL4CoXLWqBIYQmJ4Nql9lD6V6czMh/BdlWBQ=="],
172 "@stacksjs/newsletter": ["@stacksjs/newsletter@0.75.221", "", { "dependencies": { "@stacksjs/database": "0.75.221", "@stacksjs/email": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/queue": "0.75.221" } }, "sha512-yK6x86CeAKqnefkSRddaziCfxKV4jnG4BXLhZdL47ObzMo4WN6RwDeNdNmnBQ4JJgWWcDSf4Sb9EYQJw12XBSw=="],
173173
174 "@stacksjs/notifications": ["@stacksjs/notifications@0.75.219", "", { "dependencies": { "@stacksjs/chat": "0.75.219", "@stacksjs/cli": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/datetime": "0.75.219", "@stacksjs/email": "0.75.219", "@stacksjs/push": "0.75.219", "@stacksjs/realtime": "0.75.219", "@stacksjs/sms": "0.75.219" } }, "sha512-t7IXZJxETJ5zsNt1iHIJn072el8cGGL7CkoN1pKkngIzJOORCBRDCgx32+7vCX1f9ST9Y8cA/ThcWQJETQ4nuQ=="],
174 "@stacksjs/notifications": ["@stacksjs/notifications@0.75.221", "", { "dependencies": { "@stacksjs/chat": "0.75.221", "@stacksjs/cli": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/datetime": "0.75.221", "@stacksjs/email": "0.75.221", "@stacksjs/push": "0.75.221", "@stacksjs/realtime": "0.75.221", "@stacksjs/sms": "0.75.221" } }, "sha512-oio5XfXY4WLCHpMCcfP5r4Pei2TFODKTD40KWS1wijs0I0jkPzzBfiaxA9sZtGlDWq9yAH0rrfMh6eZs/NteCw=="],
175175
176 "@stacksjs/objects": ["@stacksjs/objects@0.75.219", "", { "dependencies": { "@stacksjs/utils": "0.75.219", "@stacksjs/validation": "0.75.219" } }, "sha512-e2UUJhBlxCepClOngb3O4fgF8J8MzhGPdqHs9JDZLTcRQd93H0psrfRyqXyQfxvsGnKgcMR8BOfjJvSzavGpMg=="],
176 "@stacksjs/objects": ["@stacksjs/objects@0.75.221", "", { "dependencies": { "@stacksjs/utils": "0.75.221", "@stacksjs/validation": "0.75.221" } }, "sha512-O3brmL1MJLNAX+pUNWu0RpXTAYs2DEZKHIE7/+GLa+b9v4edUOTxY4m/bw8jvbWyebgrD2EEcLY+sVVToVOiIw=="],
177177
178 "@stacksjs/orm": ["@stacksjs/orm@0.75.219", "", { "dependencies": { "@stacksjs/cache": "0.75.219", "@stacksjs/cli": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/events": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/model-meta": "0.75.219", "@stacksjs/pagination": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/query-builder": "0.75.219", "@stacksjs/realtime": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/ts-validation": "^0.5.6", "@stacksjs/validation": "0.75.219", "bun-query-builder": "^0.3.8" }, "peerDependencies": { "@stacksjs/auth": "0.75.219", "@stacksjs/payments": "0.75.219", "@stacksjs/queue": "0.75.219", "@stacksjs/search-engine": "0.75.219", "stripe": "23.0.0" }, "optionalPeers": ["@stacksjs/auth", "@stacksjs/payments", "@stacksjs/queue", "@stacksjs/search-engine", "stripe"] }, "sha512-C3NGIiKqGXGfdKSqgTcrCH7ipf23tx3gMyLYoO5SDW6060qrdlSrRNwsmHB8rLNcMulwXb3yKULvqQ48GS+vng=="],
178 "@stacksjs/orm": ["@stacksjs/orm@0.75.221", "", { "dependencies": { "@stacksjs/cache": "0.75.221", "@stacksjs/cli": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/events": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/model-meta": "0.75.221", "@stacksjs/pagination": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/query-builder": "0.75.221", "@stacksjs/realtime": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/ts-validation": "^0.5.6", "@stacksjs/validation": "0.75.221", "bun-query-builder": "^0.3.8" }, "peerDependencies": { "@stacksjs/auth": "0.75.221", "@stacksjs/payments": "0.75.221", "@stacksjs/queue": "0.75.221", "@stacksjs/search-engine": "0.75.221", "stripe": "23.0.0" }, "optionalPeers": ["@stacksjs/auth", "@stacksjs/payments", "@stacksjs/queue", "@stacksjs/search-engine", "stripe"] }, "sha512-CpIMYShnDKRzO0HOzb05ehIN3c6dInbByOEwZRoRjpoZZrh9+Q9dl6cD2jFVIi9WZi7OEYak5ZcEMRh4a6nScA=="],
179179
180 "@stacksjs/pagination": ["@stacksjs/pagination@0.75.219", "", {}, "sha512-lPcNKULqF1h2/zPjgh9AivwxbNsasZnwS4L4oXy7w9zWJAUpDqmw212gUqlZrIWcS/mDYP/mOpEuZEY2sOf1QQ=="],
180 "@stacksjs/pagination": ["@stacksjs/pagination@0.75.221", "", {}, "sha512-y1UkhVhyp2rqpW/hxvFho7zGCHgj1BBEKIb7LRUCYE8p/EwzuHPXgn08iQ+7DX79LLDDLIX/vihfOY7NnJ3PMw=="],
181181
182 "@stacksjs/path": ["@stacksjs/path@0.75.219", "", {}, "sha512-5GX6flfE+eSlmEb9arrlRFibVySE8R4wOCvhaqGiSbmYoP7VkGTz4bxvZLkqR+AdKLvVDcDdGa2z4dITFBewxA=="],
182 "@stacksjs/path": ["@stacksjs/path@0.75.221", "", {}, "sha512-E7QpuOf3yOzPyInvcCNqwTm/WYww2xSGqjY6jQu23OwWxOQM/NMeChseXmr4Lm2QVbfN+g0QGq2+xbh7B7oq2Q=="],
183183
184 "@stacksjs/payments": ["@stacksjs/payments@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/types": "0.75.219" }, "peerDependencies": { "@stripe/stripe-js": "^10.0.0", "stripe": "23.0.0" }, "optionalPeers": ["@stripe/stripe-js", "stripe"] }, "sha512-shSMvujyPYuUleDPkoGynobCJINW/yZDz5XKO++5MMvOKoM6I99Ca7RRjcwAxUkmqKydOUvm5YGFgpETdBk6AQ=="],
184 "@stacksjs/payments": ["@stacksjs/payments@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/types": "0.75.221" }, "peerDependencies": { "@stripe/stripe-js": "^10.0.0", "stripe": "23.0.0" }, "optionalPeers": ["@stripe/stripe-js", "stripe"] }, "sha512-LiTJRssFY5RPIGwsa/LkCcX+m6DPv876D1O3hZYn8TCecIIGkjHgeBuW2a9vX6MPT99Kiev1dwVbI0bhtDyuHA=="],
185185
186 "@stacksjs/push": ["@stacksjs/push@0.75.219", "", { "dependencies": { "@stacksjs/cli": "0.75.219", "@stacksjs/config": "0.75.219" } }, "sha512-uTSdndHnR7Rkc5d5hZmU9bXT0ReDgJ4K9FEZ1VY8mB3qHifTsxXoDYxQNbYsvY3DRksXtTh3DzsoXn+bb7VYOg=="],
186 "@stacksjs/push": ["@stacksjs/push@0.75.221", "", { "dependencies": { "@stacksjs/cli": "0.75.221", "@stacksjs/config": "0.75.221" } }, "sha512-wPRfmENu1X0FGlV0CH+stZHE8Fq+sbVs9tJnm7MkENlALmfCNAXVxmJmhtZhqoWjjlBLzFoF/DNKgWqoOkx/fw=="],
187187
188 "@stacksjs/query-builder": ["@stacksjs/query-builder@0.75.219", "", { "dependencies": { "bun-query-builder": "^0.3.13" } }, "sha512-Pf/fropB6rURr2+FCmx3/zMSJc35ata+OmETLjEiJqjkJgh9ib4yJkR4bsgM3JWxb7R50b1WqFwHNDHY4rGeKA=="],
188 "@stacksjs/query-builder": ["@stacksjs/query-builder@0.75.221", "", { "dependencies": { "bun-query-builder": "^0.3.13" } }, "sha512-DLOwoG8psEtTTleYe7DMVCcqvq20rDUVSR8RAFz3D/Ksclvfi8NytOV2idlPRAtqcBzW/GDRpasNAkztoh3Cog=="],
189189
190 "@stacksjs/queue": ["@stacksjs/queue@0.75.219", "", { "dependencies": { "@stacksjs/action-runner": "0.75.219", "@stacksjs/bun-queue": "^0.1.8", "@stacksjs/cache": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/cron": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/path": "0.75.219" }, "peerDependencies": { "@stacksjs/email": "0.75.219", "@stacksjs/router": "0.75.219" }, "optionalPeers": ["@stacksjs/email", "@stacksjs/router"] }, "sha512-+2E+G/IzOajV61bP29Sfohz5xlYOzBazsJdQoMs4xObpksX2UjJEDPepsqH7X9IQAhaLsskH8VNEDKnLbFvdLw=="],
190 "@stacksjs/queue": ["@stacksjs/queue@0.75.221", "", { "dependencies": { "@stacksjs/action-runner": "0.75.221", "@stacksjs/bun-queue": "^0.1.8", "@stacksjs/cache": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/cron": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/path": "0.75.221" }, "peerDependencies": { "@stacksjs/email": "0.75.221", "@stacksjs/router": "0.75.221" }, "optionalPeers": ["@stacksjs/email", "@stacksjs/router"] }, "sha512-Si73rtRX92vgCHL5WeBclehKxMOtoCtJm8GJU1RhhyJLZe9nEmdmwmWnXNxjePRHp5vUdqxszEdviVFKa/teVA=="],
191191
192 "@stacksjs/realtime": ["@stacksjs/realtime@0.75.219", "", { "dependencies": { "@stacksjs/logging": "0.75.219", "@stacksjs/path": "0.75.219", "ts-broadcasting": "^0.0.11" } }, "sha512-QD4wZNU9kteFMqT4X1a6a3mP7js7kpsnhw/gnQIBmkiXzf0mrMkqkudTVzAdGrqQmGidv+uJzkqp4geUm6wUQQ=="],
192 "@stacksjs/realtime": ["@stacksjs/realtime@0.75.221", "", { "dependencies": { "@stacksjs/logging": "0.75.221", "@stacksjs/path": "0.75.221", "ts-broadcasting": "^0.0.11" } }, "sha512-S50ERQuZuPh68TYmFS0FR8t0a0MtuQjMB9Dq/Z7IpJg6sBy5ocxHwYI7Bfq5dqGwkOT2oiHaAHXjbuCA+fItww=="],
193193
194 "@stacksjs/registry": ["@stacksjs/registry@0.75.219", "", { "dependencies": { "@stacksjs/types": "0.75.219" } }, "sha512-rCODm8mYfk6C7Af3QmsJ/TXlNCq7/mwNhSemlLU97kTex6Mojhhhl1IZGh3HWCnlcsxFLjqiRnRtGmsaVkmzYQ=="],
194 "@stacksjs/registry": ["@stacksjs/registry@0.75.221", "", { "dependencies": { "@stacksjs/types": "0.75.221" } }, "sha512-b8DEKPiqcuJMhWKjev1VXU43ansUV9pWjTMh/1yM3UtAJ+GBqn+IUGicYcevbzbLIFoYY0IvJD/10UxGjcWnUQ=="],
195195
196 "@stacksjs/repl": ["@stacksjs/repl@0.75.219", "", { "dependencies": { "@stacksjs/tinker": "0.75.219" } }, "sha512-KWUETh2wQoa0MlEe3LS7NFMtFGFMb09gGR2xqVNWlMou/qVXkRfE77864D6vq2occKRFisDxOCdavMaxJxqdzg=="],
196 "@stacksjs/repl": ["@stacksjs/repl@0.75.221", "", { "dependencies": { "@stacksjs/tinker": "0.75.221" } }, "sha512-SYf24pkA2zYsGvr21zqkI5BUUMK8NsywZdCL60U2cI68LsSYTxgBnevSl2znqAiW6aN9kPL+uSEzz3OSyH7qeQ=="],
197197
198 "@stacksjs/router": ["@stacksjs/router@0.75.219", "", { "dependencies": { "@stacksjs/bun-router": "^0.1.24", "@stacksjs/cache": "0.75.219", "@stacksjs/collections": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/pagination": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/security": "0.75.219", "@stacksjs/storage": "0.75.219", "ts-rate-limiter": "^0.4.10" }, "peerDependencies": { "@stacksjs/api": "0.75.219", "@stacksjs/auth": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/validation": "0.75.219" }, "optionalPeers": ["@stacksjs/api", "@stacksjs/auth", "@stacksjs/database", "@stacksjs/validation"] }, "sha512-9r0V7S6WgFUnOpFdDGKDhtIHtRC6qqaOKF8ZnaBMFDG92OInzXMe2jrbHp6W5opC7Zi4/ezPpMCD+tROvEVC6g=="],
198 "@stacksjs/router": ["@stacksjs/router@0.75.221", "", { "dependencies": { "@stacksjs/bun-router": "^0.1.24", "@stacksjs/cache": "0.75.221", "@stacksjs/collections": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/pagination": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/security": "0.75.221", "@stacksjs/storage": "0.75.221", "ts-rate-limiter": "^0.4.10" }, "peerDependencies": { "@stacksjs/api": "0.75.221", "@stacksjs/auth": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/validation": "0.75.221" }, "optionalPeers": ["@stacksjs/api", "@stacksjs/auth", "@stacksjs/database", "@stacksjs/validation"] }, "sha512-q+twwIaPsDmr+QdqgNwuQkD/0SC0tL73yGKjA0l/wRcOM61O7IyZAkBNrp69khicIrv66/CnoRhUmpVjHyv48g=="],
199199
200200 "@stacksjs/rpx": ["@stacksjs/rpx@0.11.54", "", { "dependencies": { "@stacksjs/clapp": "^0.2.10", "@stacksjs/tlsx": "^0.13.19", "ts-images": "^0.2.17" }, "bin": { "rpx": "./dist/bin/cli.js", "reverse-proxy": "./dist/bin/cli.js" } }, "sha512-3njfy0BYCpof1rkuY4slGmHRggLWlJdrnUJMNoou6kfBqXNvm8ANqXgiL/bIlQeIWwidqoo1IOjgPcB/EozNog=="],
201201
202 "@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.563", "", {}, "sha512-JkhUgIuiyTmjegQLKe+LEvdbgY+ACrnO0uurj3DNU95MhTtLLB6L/XmJCVHw68PUMbeZVVUw/hBXx4/SrUNg9g=="],
202 "@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.573", "", {}, "sha512-kcDa3Anv1MwJk4DkUeUromb1H2aPFAD02ELBXSlACjHVSvgQFj0gzmgl2f6B3f2/kB0hcEbfNHj7LiCe9xY+hA=="],
203203
204 "@stacksjs/scheduler": ["@stacksjs/scheduler@0.75.219", "", { "dependencies": { "@stacksjs/action-runner": "0.75.219", "@stacksjs/cli": "0.75.219", "@stacksjs/cron": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/notifications": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/queue": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/types": "0.75.219" } }, "sha512-ourjJ5S/zcW/5QWvbLUuS7rw0KBbp6bpMM4sJBxeZ+ad6Cvw50v/y3iLn35WCebQAlNgwPnS3PRg1KPDtN6qLw=="],
204 "@stacksjs/scheduler": ["@stacksjs/scheduler@0.75.221", "", { "dependencies": { "@stacksjs/action-runner": "0.75.221", "@stacksjs/cli": "0.75.221", "@stacksjs/cron": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/notifications": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/queue": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/types": "0.75.221" } }, "sha512-j5wRcyeN9bKBd70mP1c6W+8Xh2IS8n2TMbTl85Z1iPt2jrlrHUuY+EwcJwJYB0IJBiKnnp8j30bJm2FHlbJtBA=="],
205205
206 "@stacksjs/search-engine": ["@stacksjs/search-engine@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219" }, "peerDependencies": { "meilisearch": "^0.62.0" }, "optionalPeers": ["meilisearch"] }, "sha512-97nIsTppDccdSK4JXN6MukSCaYs3OhSc36gzTTLrNO+yMdBi8RoK8S/7oM6y0CpqcgLdy6NLbjACuaIqktX/9Q=="],
206 "@stacksjs/search-engine": ["@stacksjs/search-engine@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221" }, "peerDependencies": { "meilisearch": "^0.62.0" }, "optionalPeers": ["meilisearch"] }, "sha512-yK2/jnOuVq/I8lCgaP7psJNHxpp4gE85GrR3sT5z4Boe+Yg1z34mRG87IhTEncDuYLp/ta5QMwgAsBBE8W0s3Q=="],
207207
208 "@stacksjs/security": ["@stacksjs/security@0.75.219", "", { "dependencies": { "@stacksjs/config": "0.75.219", "ts-security-crypto": "0.0.2" } }, "sha512-V/bw7eFLVBKJij4Ue69+NEH8kDbhCXbUCNbyaKCvRBRlGnuv/HDecM9zwNwycqvR1tSohvwSuEO8sZX20d1Khw=="],
208 "@stacksjs/security": ["@stacksjs/security@0.75.221", "", { "dependencies": { "@stacksjs/config": "0.75.221", "ts-security-crypto": "0.0.2" } }, "sha512-v0KElp70zQj8b401jHIaqOkFZ3bNH7FcNOTFwOIOkwYK3AEUh69R29xMptna6tGhQ1rmLpSPo+2LVECo+KTkiQ=="],
209209
210 "@stacksjs/server": ["@stacksjs/server@0.75.219", "", { "dependencies": { "@stacksjs/auth": "0.75.219", "@stacksjs/bun-router": "^0.1.24", "@stacksjs/config": "0.75.219", "@stacksjs/events": "0.75.219", "@stacksjs/i18n": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/payments": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/storage": "0.75.219", "bun-plugin-auto-imports": "^0.4.4" } }, "sha512-EMPtc9uQtnSX+sZX1Rdl82p+yzIdi0I+VHXYdKemaDIz85JVS+11Th9h/v2iy8EwKS14KoGsgzesy9gFuvidQQ=="],
210 "@stacksjs/server": ["@stacksjs/server@0.75.221", "", { "dependencies": { "@stacksjs/auth": "0.75.221", "@stacksjs/bun-router": "^0.1.24", "@stacksjs/config": "0.75.221", "@stacksjs/events": "0.75.221", "@stacksjs/i18n": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/payments": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/storage": "0.75.221", "bun-plugin-auto-imports": "^0.4.4" } }, "sha512-UZY4n7/yVNOphIzIRqcnufiSIdGka2aSfAD5+JKhFmRju2JNXySZczhAhq07ZAxu/VLb4bQ9ZtA0nQxoKC+cqw=="],
211211
212 "@stacksjs/shell": ["@stacksjs/shell@0.75.219", "", {}, "sha512-g9YdInB+fz89zz8sKuaEP9/2NlrpbLc2D6DLphw2wp70qWDL9PnUDyOe9p+TYnUeO6mfr+wNIVHqOhderLv7qg=="],
212 "@stacksjs/shell": ["@stacksjs/shell@0.75.221", "", {}, "sha512-10np3JLzeDP2ioC8SryL0zmZ6TvZCh1Gb+fu8sW4XFle++NpOZ7ex1GlPbN1Ob1mEyLTsj1Zfl5IxacNdRH9rw=="],
213213
214 "@stacksjs/sites": ["@stacksjs/sites@0.75.219", "", { "dependencies": { "@stacksjs/auth": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/router": "0.75.219" }, "peerDependencies": { "@stacksjs/cms": "0.75.219" }, "optionalPeers": ["@stacksjs/cms"] }, "sha512-9uxfougtwuMYCiD1CItbDbPvcI1hrUo0NOHByBD7yR5kudMvJjHCo/SrWus7iK8k92rU+T0w4cP8CYE5imYeew=="],
214 "@stacksjs/sites": ["@stacksjs/sites@0.75.221", "", { "dependencies": { "@stacksjs/auth": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/router": "0.75.221" }, "peerDependencies": { "@stacksjs/cms": "0.75.221" }, "optionalPeers": ["@stacksjs/cms"] }, "sha512-Al4ueNVSE3LEeFjP+dpVJ99+8bXhbK7/lrDoEgSEO/x5MTjBBotTB4DcrVPJNE6sqpuxUO9CaTEBDx9zKhKOaw=="],
215215
216 "@stacksjs/skills": ["@stacksjs/skills@0.75.219", "", { "dependencies": { "@stacksjs/path": "0.75.219", "@stacksjs/storage": "0.75.219" } }, "sha512-zrSw4QoBiiLm08l8dFhZES498j9dDPMrblRvdSOsmpvyygVzBU6/SUX26JgP+mh8pkxtkMpORngv/w3NJwNIIQ=="],
216 "@stacksjs/skills": ["@stacksjs/skills@0.75.221", "", { "dependencies": { "@stacksjs/path": "0.75.221", "@stacksjs/storage": "0.75.221" } }, "sha512-jcPWbY45OMnUqKXNaOMl5TKJnx/nKrXluSrVbaWjSn90qhLOdQdD4BfRWW1niMQnvy9szPMTVZqSy+i9gxTmOw=="],
217217
218 "@stacksjs/slug": ["@stacksjs/slug@0.75.219", "", { "dependencies": { "@stacksjs/database": "0.75.219", "ts-slug": "^0.1.0" } }, "sha512-AHBN0M4Cgs2CFNAA01eZiUMpdEHGqm5UqIbqsO5xAhpXtzSkmpx2P0T+5eywZC04C4fnpYl2GJ4yTziQU3kkNQ=="],
218 "@stacksjs/slug": ["@stacksjs/slug@0.75.221", "", { "dependencies": { "@stacksjs/database": "0.75.221", "ts-slug": "^0.1.0" } }, "sha512-oedPoXEUOHakcPhtGYP8vOR4oFqUAxu5PFSftt7vPhjZyU3bb27N04E58tpiONFsPVhxtJnB+KOk25sE9MJqJQ=="],
219219
220 "@stacksjs/sms": ["@stacksjs/sms@0.75.219", "", { "dependencies": { "@stacksjs/database": "0.75.219" } }, "sha512-hCaOxzUy06DK7TFb417g2EPrEC3p2J0KKNnavJxUyfNy04nItTexW5IvnucIMtilz+xE/GGNkNSo87BfWATX3A=="],
220 "@stacksjs/sms": ["@stacksjs/sms@0.75.221", "", { "dependencies": { "@stacksjs/database": "0.75.221" } }, "sha512-qWkuasYoQmE/EUMk9OriZs9kLkbqZKlOKz6wYf/OxbLsJOX/Qqf4XM2wes1QqwW1A4KYl5GJr0tj5SY3Lpzm5w=="],
221221
222 "@stacksjs/socials": ["@stacksjs/socials@0.75.219", "", { "dependencies": { "@stacksjs/api": "0.75.219", "@stacksjs/composables": "0.75.219", "@stacksjs/config": "0.75.219" } }, "sha512-EWM17eGeHlE3c/vJsfGPFWLf6IoZal5Nu651QK6vdVg4unXKlY3dNvAW6Cc2PqfBtU55XplxyQ14ZWH6S6qvdQ=="],
222 "@stacksjs/socials": ["@stacksjs/socials@0.75.221", "", { "dependencies": { "@stacksjs/api": "0.75.221", "@stacksjs/composables": "0.75.221", "@stacksjs/config": "0.75.221" } }, "sha512-6SVr7QOa0XchLmaQ2KvBBKXSwl+P6Oxo4sAfhJRdRJI4ibj5a5rXi8IiYSMdTlHR5h99jiQ9H//5GOisA5tl8w=="],
223223
224 "@stacksjs/storage": ["@stacksjs/storage@0.75.219", "", { "dependencies": { "@stacksjs/arrays": "0.75.219", "@stacksjs/cli": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/ts-xml": "^0.1.0", "ts-images": "^0.2.23" } }, "sha512-o+Asg6+YElTa1UlcCWycWv/j98Enp8TWvJUZ5OKUI+Id3PlsmmCOgzLnjFdt8ImOE6tMtqKhG4dq0mcIArkTBA=="],
224 "@stacksjs/storage": ["@stacksjs/storage@0.75.221", "", { "dependencies": { "@stacksjs/arrays": "0.75.221", "@stacksjs/cli": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/ts-cloud": "^0.16.58", "@stacksjs/ts-xml": "^0.1.0", "ts-images": "^0.2.23" } }, "sha512-d7AAC2QqHCIVAikmrDvjYY0bDQPw5zgQO3F8X90Mml4h9x/1ccRB67tNSYYyMWcA82sfkixxBRRYiItpwElibg=="],
225225
226 "@stacksjs/strings": ["@stacksjs/strings@0.75.219", "", {}, "sha512-88rZr9v1bONXcmfcteX1L/pK98LeN8IDVVaIJA5rjp4sd4xhKmB7rnfY4Bbz+GDcF9i0Q6FC289iDiLPUjaUWQ=="],
226 "@stacksjs/strings": ["@stacksjs/strings@0.75.221", "", {}, "sha512-61MfF3GTIYS8+wa9sMH85Ko6AaAFXlf6muwi1Koe1NnHlzBY2ZyHlUxwULPqHM2tmYZOk1/hzh9Ep7FXTVfDiQ=="],
227227
228 "@stacksjs/stx": ["@stacksjs/stx@0.2.563", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.563", "@stacksjs/sanitizer": "0.2.563", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.563", "bunfig": "^0.15.19", "stx-router": "0.2.563", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-WTNOmbdnZ8ZfJp37fGGKc9C4R6xpDFAwauPeEdY/JX9s8A0BZX5cPBsB+4iNeLeOyQ7iFP5fz8Po/4Qmm3cNng=="],
228 "@stacksjs/stx": ["@stacksjs/stx@0.2.573", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.573", "@stacksjs/sanitizer": "0.2.573", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.573", "bunfig": "^0.15.21", "stx-router": "0.2.573", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-G7gjDITc2lzGNnvXnPmecPOJARUFKQWLmzMQtN3bjteF+uX7gvPdVE3Ns791WoketdM2xMrGSwoUS7epNklY7A=="],
229229
230 "@stacksjs/testing": ["@stacksjs/testing@0.75.219", "", { "dependencies": { "@stacksjs/auth": "0.75.219", "@stacksjs/cache": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/email": "0.75.219", "@stacksjs/events": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/ts-cloud": "^0.16.58", "very-happy-dom": "^0.3.5" } }, "sha512-OclrQD3AAOTbU5NHbM1Ls3COOMYAqLLg8+e2jS63gQaXmYfqbjBhLSMt83F+6Xeihmup3puin9XsGGA+fe6Gzw=="],
230 "@stacksjs/testing": ["@stacksjs/testing@0.75.221", "", { "dependencies": { "@stacksjs/auth": "0.75.221", "@stacksjs/cache": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/email": "0.75.221", "@stacksjs/events": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/ts-cloud": "^0.16.58", "very-happy-dom": "^0.3.5" } }, "sha512-ijtsuuxywbEZDlyIzr5MR1WXkTNHyl2TLnzWy+xK3iPCYe8tIGr6B6fZAPjsLfqp8Y61OgSnQG02Qmet6FUFcg=="],
231231
232 "@stacksjs/tinker": ["@stacksjs/tinker@0.75.219", "", {}, "sha512-z4KZKQMpMSmyKtJvEnf4Cbp8Jr+bYWYxCSe+6q74BGlyrj8e0FSZf2FIO90HssW5hzNj52wz8Ox7QH2OxrW0CQ=="],
232 "@stacksjs/tinker": ["@stacksjs/tinker@0.75.221", "", {}, "sha512-4W0CL5LTfw+/OE0yiymXFUv4UO6cqjtysGRzzc4OeX0Y/F4iDxfQFviSporoNyFsSP3GpDIbaVQz7MWc9IlnrQ=="],
233233
234234 "@stacksjs/tlsx": ["@stacksjs/tlsx@0.13.19", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0" }, "bin": { "tlsx": "./dist/bin/cli.js" } }, "sha512-bgVYWBolcbbTSDk/quZiYWfz3qBZiHl1fp9pqTYaeDHXIHxylRfxDFB3bz7mnEmIYSFq9AXilRNkV401uUSGSw=="],
235235
@@ -265,19 +265,19 @@
265265
266266 "@stacksjs/ts-xml": ["@stacksjs/ts-xml@0.1.2", "", {}, "sha512-G20EuKCmpwWwnXfFlX0kxCXnJ6ocTgBl8lcHkOzmomC8L/Ld+UKeNLlvOQf9ArnS7/SHUQ2+fZpM0t6lhwoyPw=="],
267267
268 "@stacksjs/tunnel": ["@stacksjs/tunnel@0.75.219", "", { "dependencies": { "localtunnels": "^0.2.13" } }, "sha512-7y/AU7ozW16xskrFbncO8ZX94lln0wQgZiuomVj4/MwiaErUBEEX8A0fbfxgx+FoECN8WAFoiinMb7FaUZeqwQ=="],
268 "@stacksjs/tunnel": ["@stacksjs/tunnel@0.75.221", "", { "dependencies": { "localtunnels": "^0.2.13" } }, "sha512-fNSq23ZcTqkn4N7R5NMrhYsRqFq9tNbJxMYiCrw4vsNwpjsMeuxTaBYDfyUuqan/rnf3dwSnCKvbrUr6u0po6w=="],
269269
270 "@stacksjs/types": ["@stacksjs/types@0.75.219", "", { "dependencies": { "@stacksjs/bunpress": "^0.2.15", "@stacksjs/clapp": "^0.2.12", "@stacksjs/events": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/stx": "^0.2.534", "@stacksjs/ts-validation": "^0.5.6", "bun-query-builder": "^0.3.8" }, "peerDependencies": { "meilisearch": "^0.62.0" }, "optionalPeers": ["meilisearch"] }, "sha512-VEP7zK92AsHpZKsCUmQ7XQCgbVX2Gsa3LMr/6+KCt0WY/XUxg3bC00r9yP+08eJafcR5GigMg2DXpVI1GNF1ww=="],
270 "@stacksjs/types": ["@stacksjs/types@0.75.221", "", { "dependencies": { "@stacksjs/bunpress": "^0.2.15", "@stacksjs/clapp": "^0.2.12", "@stacksjs/events": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/stx": "^0.2.534", "@stacksjs/ts-validation": "^0.5.6", "bun-query-builder": "^0.3.8" }, "peerDependencies": { "meilisearch": "^0.62.0" }, "optionalPeers": ["meilisearch"] }, "sha512-hqa/x50slPiu+KEP5XXer/j73QwAMeln6ApVmeEiMZo036air8ZxiKDH2wW+UCNNILoabTIBdLt/IogxiU1aYQ=="],
271271
272 "@stacksjs/ui": ["@stacksjs/ui@0.75.219", "", { "dependencies": { "@stacksjs/components": "^0.2.534", "@stacksjs/config": "0.75.219", "@stacksjs/ts-css": "^0.3.6" } }, "sha512-nw8z3QzxffAIkYtTKoABlS2MdkDkmZ5oKQlF3EshN8w9GarfaGXNO88deBE2h/EyuWRsCVcVoxRv3HnxzlAIjQ=="],
272 "@stacksjs/ui": ["@stacksjs/ui@0.75.221", "", { "dependencies": { "@stacksjs/components": "^0.2.534", "@stacksjs/config": "0.75.221", "@stacksjs/ts-css": "^0.3.6" } }, "sha512-jHlD6QrLtAMkPje0Lb1jF9M9CqzX3lerMLzSgXMkDIb+6Lz1YrrkY/Dabck3zlFA20WFe0ya/u8Vv31hlmidyA=="],
273273
274 "@stacksjs/utils": ["@stacksjs/utils@0.75.219", "", { "dependencies": { "@stacksjs/browser": "0.75.219", "@stacksjs/cli": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/enums": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/types": "0.75.219" } }, "sha512-5ltSZ4sdzBBwZg9z7OmpkdyAlHspkCuj4FmoaSYECEqvvLs8G5x4/Q+k/+vyIheEDYb/3AMqilm7MqNRJskq/A=="],
274 "@stacksjs/utils": ["@stacksjs/utils@0.75.221", "", { "dependencies": { "@stacksjs/browser": "0.75.221", "@stacksjs/cli": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/enums": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/types": "0.75.221" } }, "sha512-15YFKedRWkhLcExnM3nF8fDM//ZGrQwXhho3hBUoznUTg6RZeklCrICe/ZiXZu4xi/r/g1/T8BLphLiTTviY9A=="],
275275
276 "@stacksjs/validation": ["@stacksjs/validation@0.75.219", "", { "dependencies": { "@stacksjs/error-handling": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/ts-validation": "^0.5.6", "@stacksjs/types": "0.75.219", "@stacksjs/utils": "0.75.219" }, "peerDependencies": { "@stacksjs/database": "0.75.219" }, "optionalPeers": ["@stacksjs/database"] }, "sha512-XHbbj82dzPAKD2AVLTT+VkPgx2a3wbvVqLehVzlTZO9+WW0nG1hh8wwrZAV970xDfXlw1A2G1GnnexJIy8iYcA=="],
276 "@stacksjs/validation": ["@stacksjs/validation@0.75.221", "", { "dependencies": { "@stacksjs/error-handling": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/ts-validation": "^0.5.6", "@stacksjs/types": "0.75.221", "@stacksjs/utils": "0.75.221" }, "peerDependencies": { "@stacksjs/database": "0.75.221" }, "optionalPeers": ["@stacksjs/database"] }, "sha512-fFjecg396NtQXqRf6HmQx82uDEY/PfIrJvRXJSu4b1HvyFLTg+1XRsm7kOMS6hf6iDHYEhU2P8xIfOAh1Ez6yw=="],
277277
278 "@stacksjs/video": ["@stacksjs/video@0.75.219", "", { "dependencies": { "@ts-videos/mp4": "^0.1.2", "@ts-videos/webm": "^0.1.2", "ts-video-player": "^0.1.3", "ts-videos": "^0.1.2" } }, "sha512-HTrykY1uBvWcLoqG5Pz32Nww6k5EPJi3IhzT9RPMWa1q1FVjsuG+pNN9RnDsD9k2HAT/pMtzrFAo6/0upz/jNA=="],
278 "@stacksjs/video": ["@stacksjs/video@0.75.221", "", { "dependencies": { "@ts-videos/mp4": "^0.1.2", "@ts-videos/webm": "^0.1.2", "ts-video-player": "^0.1.3", "ts-videos": "^0.1.2" } }, "sha512-f+GdcUHNtBc7kYOU3ecVJ2CEpFnSymCEr6tCOJYm33DDSrfUhOdP8t4nUIpgu4LtgLXy/YcZE8+QPs37Se6Udw=="],
279279
280 "@stacksjs/whois": ["@stacksjs/whois@0.75.219", "", { "dependencies": { "@stacksjs/logging": "0.75.219" } }, "sha512-WqLG5P/QZa78YKyhghiv8vc7GGkIHyNr+PWEuRnQz3pCVELIcf5swahlOQoZtyaqC2P5xuKC9c5vm4VYOJ4MXw=="],
280 "@stacksjs/whois": ["@stacksjs/whois@0.75.221", "", { "dependencies": { "@stacksjs/logging": "0.75.221" } }, "sha512-S7bIJZ5/IOnntL1s2zUkVRdsbF/1tK5tKdWjfCH+0e4kauYwsaDwW/k513VWocQQnMZMCvchNHUhbZxzyBWVkA=="],
281281
282282 "@ts-analytics/tracking": ["@ts-analytics/tracking@0.1.17", "", { "dependencies": { "bun-query-builder": "^0.3.0", "dynamodb-tooling": "^0.3.2", "ts-countries": "^0.1.2" }, "peerDependencies": { "@nuxt/kit": "^3.0.0", "@nuxt/schema": "^3.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit", "@nuxt/schema", "vue"], "bin": { "analytics": "./bin/cli.ts" } }, "sha512-E6okdowDJM+mS0pAEaAC1R1xfX7jUIdZ7GoWyKSelZRPRky2w18ukOu2ixoF+g6h15kYF5pBURkz1TbadCtYvg=="],
283283
@@ -337,17 +337,17 @@
337337
338338 "bun-plugin-dtsx": ["bun-plugin-dtsx@0.11.19", "", { "dependencies": { "@stacksjs/dtsx": "0.11.19" } }, "sha512-9itJqTxzONupXIS1Bwp/AqgXWrgSeHe/j0QyFewJ+//pL2Id+RoWFbMLpl42v2ucKgSFknsmAbLjn7wJkVx/vw=="],
339339
340 "bun-plugin-stx": ["bun-plugin-stx@0.2.563", "", { "dependencies": { "@stacksjs/stx": "0.2.563", "stx-router": "0.2.563" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-o1lCCkNY/tgjf4esi0IyyWw8BlGNEtI0TyOhtbUxRZi5CjQOBEfDCe9kl/kcm+9TSJacLZUW92HDa3cA9uhZqg=="],
340 "bun-plugin-stx": ["bun-plugin-stx@0.2.573", "", { "dependencies": { "@stacksjs/stx": "0.2.573", "stx-router": "0.2.573" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-9FHWtkN+b9awtnBnMR6EIL0StIeQoOloBIu0izbrp7zn71BSgzYvTwJYcT1Z2X3jc0x2ecJEnfNevxqv4b7ulQ=="],
341341
342342 "bun-query-builder": ["bun-query-builder@0.3.3", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/ts-faker": "^0.1.8", "@stacksjs/ts-validation": "^0.5.6", "dynamodb-tooling": "^0.3.6" }, "bin": { "qb": "./dist/bin/cli.js", "qbx": "./dist/bin/cli.js", "query-builder": "./dist/bin/cli.js" } }, "sha512-MzCmuRel0l9gmnHZBXqBera93jNthVQAnTHjZ5IGOqNyRSAB3Wm/9cGLu9zFPQK4zzkNXFsmP+maNwbenzkXzA=="],
343343
344344 "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
345345
346 "bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
346 "bunfig": ["bunfig@0.15.21", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-CuOLpPGC+ESF+Y0V8g4B04Y5JR49sZpYSye34n+vfsk2wGJu0wyBYVk9dICLtJZq6yfaaqoPc7MkLSkFdJ9akw=="],
347347
348348 "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
349349
350 "craft-native": ["craft-native@0.0.237", "", { "bin": { "craft-sdk": "dist/cli.js" } }, "sha512-TALWq4Zv0FWGgO9rk71ASKHjVoodKFAKeV+FEOPP0qvQ9+PxPK9hikwY2XeTY018O1bVlIbE6AnW5YxevisBog=="],
350 "craft-native": ["craft-native@0.0.244", "", { "bin": { "craft-sdk": "dist/cli.js" } }, "sha512-sdlzNDKrszlzW1UyIQOgb5cy3Kd7O1DGVo9pj8dO49MRu0ndznFOG8gLAyZ4OSdlKI2Gl+zj0KSm0xyzyvJV9g=="],
351351
352352 "cz-git": ["cz-git@1.14.0", "", {}, "sha512-QjwlXZrGWsXQkyregBHhA2qNNGB2TCuf0ovkW1Q5OLgdJDs9Tq1yahfKpSMLyqOBjVRuv1iHBTDP83zJDBlJdg=="],
353353
@@ -369,9 +369,9 @@
369369
370370 "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
371371
372 "stacks": ["stacks@0.75.219", "", { "dependencies": { "@stacksjs/actions": "0.75.219", "@stacksjs/ai": "0.75.219", "@stacksjs/alias": "0.75.219", "@stacksjs/analytics": "0.75.219", "@stacksjs/api": "0.75.219", "@stacksjs/arrays": "0.75.219", "@stacksjs/audio": "0.75.219", "@stacksjs/auth": "0.75.219", "@stacksjs/browser": "0.75.219", "@stacksjs/browser-extension": "0.75.219", "@stacksjs/buddy": "0.75.219", "@stacksjs/build": "0.75.219", "@stacksjs/cache": "0.75.219", "@stacksjs/calendar-api": "0.75.219", "@stacksjs/charts": "0.75.219", "@stacksjs/chat": "0.75.219", "@stacksjs/cli": "0.75.219", "@stacksjs/cloud": "0.75.219", "@stacksjs/cms": "0.75.219", "@stacksjs/collections": "0.75.219", "@stacksjs/commerce": "0.75.219", "@stacksjs/composables": "0.75.219", "@stacksjs/config": "0.75.219", "@stacksjs/cron": "0.75.219", "@stacksjs/database": "0.75.219", "@stacksjs/datetime": "0.75.219", "@stacksjs/defaults": "0.75.219", "@stacksjs/desktop": "^0.2.534", "@stacksjs/dns": "0.75.219", "@stacksjs/docs": "0.75.219", "@stacksjs/email": "0.75.219", "@stacksjs/enums": "0.75.219", "@stacksjs/env": "0.75.219", "@stacksjs/error-handling": "0.75.219", "@stacksjs/events": "0.75.219", "@stacksjs/faker": "0.75.219", "@stacksjs/feature-flags": "0.75.219", "@stacksjs/forms": "0.75.219", "@stacksjs/git": "0.75.219", "@stacksjs/github": "0.75.219", "@stacksjs/health": "0.75.219", "@stacksjs/http": "0.75.219", "@stacksjs/i18n": "0.75.219", "@stacksjs/image": "0.75.219", "@stacksjs/lint": "0.75.219", "@stacksjs/logging": "0.75.219", "@stacksjs/mobile": "0.75.219", "@stacksjs/newsletter": "0.75.219", "@stacksjs/notifications": "0.75.219", "@stacksjs/objects": "0.75.219", "@stacksjs/orm": "0.75.219", "@stacksjs/path": "0.75.219", "@stacksjs/payments": "0.75.219", "@stacksjs/push": "0.75.219", "@stacksjs/query-builder": "0.75.219", "@stacksjs/queue": "0.75.219", "@stacksjs/realtime": "0.75.219", "@stacksjs/registry": "0.75.219", "@stacksjs/repl": "0.75.219", "@stacksjs/router": "0.75.219", "@stacksjs/scheduler": "0.75.219", "@stacksjs/search-engine": "0.75.219", "@stacksjs/security": "0.75.219", "@stacksjs/server": "0.75.219", "@stacksjs/shell": "0.75.219", "@stacksjs/sites": "0.75.219", "@stacksjs/skills": "0.75.219", "@stacksjs/slug": "0.75.219", "@stacksjs/sms": "0.75.219", "@stacksjs/socials": "0.75.219", "@stacksjs/storage": "0.75.219", "@stacksjs/strings": "0.75.219", "@stacksjs/testing": "0.75.219", "@stacksjs/tinker": "0.75.219", "@stacksjs/tlsx": "^0.13.19", "@stacksjs/ts-cloud": "^0.16.52", "@stacksjs/tunnel": "0.75.219", "@stacksjs/types": "0.75.219", "@stacksjs/ui": "0.75.219", "@stacksjs/utils": "0.75.219", "@stacksjs/validation": "0.75.219", "@stacksjs/video": "0.75.219", "@stacksjs/whois": "0.75.219", "bun-query-builder": "^0.3.8" } }, "sha512-3NdYXURJ14BDEFiyJEq9UxOXLQYfmMe4zK5cWB8+6XuAurFBPnZyn4xwYpX51ta+v/g+9upfk2pmhRJSAEGX0A=="],
372 "stacks": ["stacks@0.75.221", "", { "dependencies": { "@stacksjs/actions": "0.75.221", "@stacksjs/ai": "0.75.221", "@stacksjs/alias": "0.75.221", "@stacksjs/analytics": "0.75.221", "@stacksjs/api": "0.75.221", "@stacksjs/arrays": "0.75.221", "@stacksjs/audio": "0.75.221", "@stacksjs/auth": "0.75.221", "@stacksjs/browser": "0.75.221", "@stacksjs/browser-extension": "0.75.221", "@stacksjs/buddy": "0.75.221", "@stacksjs/build": "0.75.221", "@stacksjs/cache": "0.75.221", "@stacksjs/calendar-api": "0.75.221", "@stacksjs/charts": "0.75.221", "@stacksjs/chat": "0.75.221", "@stacksjs/cli": "0.75.221", "@stacksjs/cloud": "0.75.221", "@stacksjs/cms": "0.75.221", "@stacksjs/collections": "0.75.221", "@stacksjs/commerce": "0.75.221", "@stacksjs/composables": "0.75.221", "@stacksjs/config": "0.75.221", "@stacksjs/cron": "0.75.221", "@stacksjs/database": "0.75.221", "@stacksjs/datetime": "0.75.221", "@stacksjs/defaults": "0.75.221", "@stacksjs/desktop": "^0.2.534", "@stacksjs/dns": "0.75.221", "@stacksjs/docs": "0.75.221", "@stacksjs/email": "0.75.221", "@stacksjs/enums": "0.75.221", "@stacksjs/env": "0.75.221", "@stacksjs/error-handling": "0.75.221", "@stacksjs/events": "0.75.221", "@stacksjs/faker": "0.75.221", "@stacksjs/feature-flags": "0.75.221", "@stacksjs/forms": "0.75.221", "@stacksjs/git": "0.75.221", "@stacksjs/github": "0.75.221", "@stacksjs/health": "0.75.221", "@stacksjs/http": "0.75.221", "@stacksjs/i18n": "0.75.221", "@stacksjs/image": "0.75.221", "@stacksjs/lint": "0.75.221", "@stacksjs/logging": "0.75.221", "@stacksjs/mobile": "0.75.221", "@stacksjs/newsletter": "0.75.221", "@stacksjs/notifications": "0.75.221", "@stacksjs/objects": "0.75.221", "@stacksjs/orm": "0.75.221", "@stacksjs/path": "0.75.221", "@stacksjs/payments": "0.75.221", "@stacksjs/push": "0.75.221", "@stacksjs/query-builder": "0.75.221", "@stacksjs/queue": "0.75.221", "@stacksjs/realtime": "0.75.221", "@stacksjs/registry": "0.75.221", "@stacksjs/repl": "0.75.221", "@stacksjs/router": "0.75.221", "@stacksjs/scheduler": "0.75.221", "@stacksjs/search-engine": "0.75.221", "@stacksjs/security": "0.75.221", "@stacksjs/server": "0.75.221", "@stacksjs/shell": "0.75.221", "@stacksjs/sites": "0.75.221", "@stacksjs/skills": "0.75.221", "@stacksjs/slug": "0.75.221", "@stacksjs/sms": "0.75.221", "@stacksjs/socials": "0.75.221", "@stacksjs/storage": "0.75.221", "@stacksjs/strings": "0.75.221", "@stacksjs/testing": "0.75.221", "@stacksjs/tinker": "0.75.221", "@stacksjs/tlsx": "^0.13.19", "@stacksjs/ts-cloud": "^0.16.52", "@stacksjs/tunnel": "0.75.221", "@stacksjs/types": "0.75.221", "@stacksjs/ui": "0.75.221", "@stacksjs/utils": "0.75.221", "@stacksjs/validation": "0.75.221", "@stacksjs/video": "0.75.221", "@stacksjs/whois": "0.75.221", "bun-query-builder": "^0.3.8" } }, "sha512-O7T4OI0TbLNt8NkAlvfIDIyMBbXdlnryUc53xUVwY7YW5ecEk+eIz5X8xNdjro4GsfRd8M1xTjjTEICOxLBO0Q=="],
373373
374 "stx-router": ["stx-router@0.2.563", "", {}, "sha512-po9NOFH13f0sTTrYr8yssQQmuCecH19UZR5hju3U/PR4bGYWfooN3qm/WAkTeRrbp/JfPbcFFEar1dMfNTzqDw=="],
374 "stx-router": ["stx-router@0.2.573", "", {}, "sha512-hkS4VPVZSmFAVSCtw9r/eSVC+rSLfdvLeK9Ru/gIIEwXRJUZ/KCnFOg4Sl97kXFWT7msaU+q3hDW1WsoLRKTVw=="],
375375
376376 "ts-broadcasting": ["ts-broadcasting@0.0.11", "", { "bin": { "broadcast": "./dist/bin/cli.js" } }, "sha512-lqn/VGfW/JiXY8TMtzq4RLu2QlK6Q8oLQg/G9zLBtp5gFoO4dfxihyXPSr/elmGCvV9keC7RbLva6cMSBkhnQg=="],
377377
@@ -423,28 +423,38 @@
423423
424424 "very-happy-dom": ["very-happy-dom@0.3.5", "", {}, "sha512-uuyxaij6RPM15Znv22NwAK/rBJTHsWi7+EP4tmf7XetvpwSLtX7EYFkQRwYFlbiswHV07MiFnyfU2HK1xGdlQg=="],
425425
426 "@stacksjs/actions/bun-plugin-stx": ["bun-plugin-stx@0.2.559", "", { "dependencies": { "@stacksjs/stx": "0.2.559", "stx-router": "0.2.559" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-Vc115VL5P+pHgyKrutJ50qPGej+pBNyxcyWQJrBL6NLQfcvTbDZI6OOk/uEhg/yq+aXs2OlOW3zlyjLi+RJY+Q=="],
426 "@cwcss/crosswind/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
427
428 "@stacksjs/actions/bun-plugin-stx": ["bun-plugin-stx@0.2.567", "", { "dependencies": { "@stacksjs/stx": "0.2.567", "stx-router": "0.2.567" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-F2cDsc1SApgs4W80jgXtWm0l6YAKNPnGiC0SQ27jPQYg/nfpNssDmO5tCGwUiZ+xqTVOZJsDDT7GzeMDbagriQ=="],
427429
428430 "@stacksjs/browser/bun-query-builder": ["bun-query-builder@0.3.13", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/ts-faker": "^0.1.8", "@stacksjs/ts-validation": "^0.5.6", "dynamodb-tooling": "^0.3.6" }, "bin": { "qb": "./dist/bin/cli.js", "qbx": "./dist/bin/cli.js", "query-builder": "./dist/bin/cli.js" } }, "sha512-9c4IJMILOQ9TYyk6PWWoFgD3tpGA+1+oBIOcj6hIH4duSYRC1qgI5gV6pHg2B9pyJik9fwRuM40TjQZ8GiDIZw=="],
429431
430 "@stacksjs/browser-extension/bun-plugin-stx": ["bun-plugin-stx@0.2.559", "", { "dependencies": { "@stacksjs/stx": "0.2.559", "stx-router": "0.2.559" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-Vc115VL5P+pHgyKrutJ50qPGej+pBNyxcyWQJrBL6NLQfcvTbDZI6OOk/uEhg/yq+aXs2OlOW3zlyjLi+RJY+Q=="],
432 "@stacksjs/browser-extension/bun-plugin-stx": ["bun-plugin-stx@0.2.567", "", { "dependencies": { "@stacksjs/stx": "0.2.567", "stx-router": "0.2.567" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-F2cDsc1SApgs4W80jgXtWm0l6YAKNPnGiC0SQ27jPQYg/nfpNssDmO5tCGwUiZ+xqTVOZJsDDT7GzeMDbagriQ=="],
433
434 "@stacksjs/buddy/bun-plugin-stx": ["bun-plugin-stx@0.2.567", "", { "dependencies": { "@stacksjs/stx": "0.2.567", "stx-router": "0.2.567" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-F2cDsc1SApgs4W80jgXtWm0l6YAKNPnGiC0SQ27jPQYg/nfpNssDmO5tCGwUiZ+xqTVOZJsDDT7GzeMDbagriQ=="],
431435
432 "@stacksjs/buddy/bun-plugin-stx": ["bun-plugin-stx@0.2.559", "", { "dependencies": { "@stacksjs/stx": "0.2.559", "stx-router": "0.2.559" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-Vc115VL5P+pHgyKrutJ50qPGej+pBNyxcyWQJrBL6NLQfcvTbDZI6OOk/uEhg/yq+aXs2OlOW3zlyjLi+RJY+Q=="],
436 "@stacksjs/bumpx/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
433437
434438 "@stacksjs/bunpress/@stacksjs/stx": ["@stacksjs/stx@0.2.520", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.520", "@stacksjs/sanitizer": "0.2.520", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.520", "bunfig": "^0.15.19", "stx-router": "0.2.520", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-XDatWtXZVid/RGL8YX9oOzUl0h1jB+2fPXH9O8VJ4G2+Mp+D5T1JwzHu+Nxu8RdSggCFq4/5PZ5xfgsU1/IIYA=="],
435439
440 "@stacksjs/bunpress/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
441
442 "@stacksjs/clarity/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
443
436444 "@stacksjs/components/@stacksjs/stx": ["@stacksjs/stx@0.2.556", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.556", "@stacksjs/sanitizer": "0.2.556", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.556", "bunfig": "^0.15.19", "stx-router": "0.2.556", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-SpOB2NIiIS33i7XuFT029JtmopdkJb69Y1uQfH1usPQJkwrbMa4UROjekLlpKhx2dMocYqEu9n1qs2fda3DMww=="],
437445
438446 "@stacksjs/components/bun-plugin-stx": ["bun-plugin-stx@0.2.556", "", { "dependencies": { "@stacksjs/stx": "0.2.556", "stx-router": "0.2.556" }, "bin": { "serve": "./dist/serve.js", "stx-serve": "./dist/serve.js" } }, "sha512-hWyPA3v0DKZZtwaZ6FHlThDTMuG1PtQmQeeKuj6J/xd1Gqbd9OUO3PXieLU5Pwu9YYHPpzGEZIG7kQYp0qKdSQ=="],
439447
440448 "@stacksjs/database/bun-query-builder": ["bun-query-builder@0.3.13", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/ts-faker": "^0.1.8", "@stacksjs/ts-validation": "^0.5.6", "dynamodb-tooling": "^0.3.6" }, "bin": { "qb": "./dist/bin/cli.js", "qbx": "./dist/bin/cli.js", "query-builder": "./dist/bin/cli.js" } }, "sha512-9c4IJMILOQ9TYyk6PWWoFgD3tpGA+1+oBIOcj6hIH4duSYRC1qgI5gV6pHg2B9pyJik9fwRuM40TjQZ8GiDIZw=="],
441449
442 "@stacksjs/defaults/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.559", "", {}, "sha512-9Gg3Qc1eGkI3kmAgPk5IwGI1rIyvXLlGvX7SxwitLbdXu8mScmPPbVWY3KV+1fhCS7xvqZJ1vnRZT4Tk++EoZw=="],
450 "@stacksjs/defaults/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.567", "", {}, "sha512-gMq1MqsmLTqZ/y173wAi5/7mrfByPFBJH18bDFzoHV/GkcDuy5GwRbpkmNWiuLEBlUArLutautYHRZBiKeyYPg=="],
443451
444452 "@stacksjs/error-handling/@bughq/sdk": ["@bughq/sdk@0.3.0", "", {}, "sha512-7hHjXJNUrMK/G5w+arw2b8IM2dlV1pU6i7KOScvO9+K/QbHDUjsz/CousavHEh7CE0Puo1TavbG7hl3Rkt1LOA=="],
445453
446454 "@stacksjs/image/@stacksjs/ts-svg": ["@stacksjs/ts-svg@0.1.5", "", { "dependencies": { "@stacksjs/ts-css": "^0.3.8", "@stacksjs/ts-png": "^0.1.2" }, "bin": { "svg": "./dist/cli.js" } }, "sha512-XH6N3+l6NPerascxFO3yDCH6EctIgVaKHGF6UWlFe1mh4IMGFGn+k82+i1bNPtqnNO6JVOIOPG4E4zufFPVW6w=="],
447455
456 "@stacksjs/logsmith/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
457
448458 "@stacksjs/logsmith/pickier": ["pickier@0.1.65", "", { "dependencies": { "@stacksjs/clapp": "^0.2.3", "@stacksjs/clarity": "^0.3.24" }, "optionalDependencies": { "@stacksjs/ts-spell-check": "^0.1.0" }, "bin": { "pickier": "./dist/bin/cli.js" } }, "sha512-hpGKvrWwzzHeV3hMErtnZoGRD/5Wmvy9sKxxHAWvMMdmdzr2DMtuk2T8Vkr8ZoUCNeTKRRPgHTMHg0oCNxDnDA=="],
449459
450460 "@stacksjs/mobile/@bughq/sdk": ["@bughq/sdk@0.3.0", "", {}, "sha512-7hHjXJNUrMK/G5w+arw2b8IM2dlV1pU6i7KOScvO9+K/QbHDUjsz/CousavHEh7CE0Puo1TavbG7hl3Rkt1LOA=="],
@@ -455,25 +465,39 @@
455465
456466 "@stacksjs/rpx/ts-images": ["ts-images@0.2.20", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.4", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.2", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-yKjBq9To98gH+XhH3E/s9vdaMYUTicwBeujcQbODKFFrHjw4mflwn4jJH5tyx0o0KokPCv6K6fXEIfRwWrxAiw=="],
457467
468 "@stacksjs/ts-auth/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
469
470 "@stacksjs/ts-cache/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
471
472 "@stacksjs/ts-css/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
473
474 "@stacksjs/ts-i18n/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
475
458476 "@stacksjs/ts-svg/@stacksjs/ts-css": ["@stacksjs/ts-css@0.1.4", "", { "dependencies": { "bunfig": "^0.15.6" }, "bin": { "ts-css": "./dist/bin/cli.js" } }, "sha512-QvHvgagJ6kQY3+zY37andUEJFKq/v4v2JpDCrvVdbX3TYCLuRQExZl8Qf3ai+Fl9BN2oWikH+DfKSs5HuEZrGQ=="],
459477
460478 "@stacksjs/types/bun-query-builder": ["bun-query-builder@0.3.13", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/ts-faker": "^0.1.8", "@stacksjs/ts-validation": "^0.5.6", "dynamodb-tooling": "^0.3.6" }, "bin": { "qb": "./dist/bin/cli.js", "qbx": "./dist/bin/cli.js", "query-builder": "./dist/bin/cli.js" } }, "sha512-9c4IJMILOQ9TYyk6PWWoFgD3tpGA+1+oBIOcj6hIH4duSYRC1qgI5gV6pHg2B9pyJik9fwRuM40TjQZ8GiDIZw=="],
461479
462 "stacks/@stacksjs/desktop": ["@stacksjs/desktop@0.2.559", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-0Mt21QCbWiV3EwPGlnCniO03+J3ysY64F8fDf4t/eQilH34B4l75fDCd2SGILHedjphkEd43pyxY4FBj9OQxCQ=="],
480 "localtunnels/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
481
482 "stacks/@stacksjs/desktop": ["@stacksjs/desktop@0.2.567", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-g6X8P8l4A8GW3gNENBl9x7fuN0ZUZZqflRlqQXQ3SGyIWl0rCE/mWxqr7wg5sWT8R4Y30OXMBgz6AIQyILxlIg=="],
463483
464484 "stacks/bun-query-builder": ["bun-query-builder@0.3.13", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/ts-faker": "^0.1.8", "@stacksjs/ts-validation": "^0.5.6", "dynamodb-tooling": "^0.3.6" }, "bin": { "qb": "./dist/bin/cli.js", "qbx": "./dist/bin/cli.js", "query-builder": "./dist/bin/cli.js" } }, "sha512-9c4IJMILOQ9TYyk6PWWoFgD3tpGA+1+oBIOcj6hIH4duSYRC1qgI5gV6pHg2B9pyJik9fwRuM40TjQZ8GiDIZw=="],
465485
466 "@stacksjs/actions/bun-plugin-stx/@stacksjs/stx": ["@stacksjs/stx@0.2.559", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.559", "@stacksjs/sanitizer": "0.2.559", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.559", "bunfig": "^0.15.19", "stx-router": "0.2.559", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-JLcCChDUa56FxauagTmm3V2drLBC8KejGznFGCBT2fM8r5OOqUg5qqlTp8lbCM0Wl4ClAR9rd+Hu58BrcR0gRA=="],
486 "ts-videos/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
487
488 "ts-web-scraper/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
467489
468 "@stacksjs/actions/bun-plugin-stx/stx-router": ["stx-router@0.2.559", "", {}, "sha512-uhnWMGZL5E1IDbiUzRlbvOurP5Mn5mJi52/R13ssyE0PV5rKyUXeY4YED7YXuFkuDZwPudxjOe1UdpKgIpOceg=="],
490 "@stacksjs/actions/bun-plugin-stx/@stacksjs/stx": ["@stacksjs/stx@0.2.567", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.567", "@stacksjs/sanitizer": "0.2.567", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.567", "bunfig": "^0.15.19", "stx-router": "0.2.567", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-qH5sXYgf8Bwc4LsA6zkA37sz8jU36xeb1L/Nxrb9VD4yfI9vYLUDWAQRq2qophwjEan1q9zim3goWFJ8vK05sw=="],
469491
470 "@stacksjs/browser-extension/bun-plugin-stx/@stacksjs/stx": ["@stacksjs/stx@0.2.559", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.559", "@stacksjs/sanitizer": "0.2.559", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.559", "bunfig": "^0.15.19", "stx-router": "0.2.559", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-JLcCChDUa56FxauagTmm3V2drLBC8KejGznFGCBT2fM8r5OOqUg5qqlTp8lbCM0Wl4ClAR9rd+Hu58BrcR0gRA=="],
492 "@stacksjs/actions/bun-plugin-stx/stx-router": ["stx-router@0.2.567", "", {}, "sha512-EwpUt/VwzPDNRWrjf6M/Rzxc81asNDNUZFw8PmHODzFGdmL1gi7oJtZZok8zQLvJpDO881Wk2rDm6C+e0WNTQw=="],
471493
472 "@stacksjs/browser-extension/bun-plugin-stx/stx-router": ["stx-router@0.2.559", "", {}, "sha512-uhnWMGZL5E1IDbiUzRlbvOurP5Mn5mJi52/R13ssyE0PV5rKyUXeY4YED7YXuFkuDZwPudxjOe1UdpKgIpOceg=="],
494 "@stacksjs/browser-extension/bun-plugin-stx/@stacksjs/stx": ["@stacksjs/stx@0.2.567", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.567", "@stacksjs/sanitizer": "0.2.567", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.567", "bunfig": "^0.15.19", "stx-router": "0.2.567", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-qH5sXYgf8Bwc4LsA6zkA37sz8jU36xeb1L/Nxrb9VD4yfI9vYLUDWAQRq2qophwjEan1q9zim3goWFJ8vK05sw=="],
473495
474 "@stacksjs/buddy/bun-plugin-stx/@stacksjs/stx": ["@stacksjs/stx@0.2.559", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.559", "@stacksjs/sanitizer": "0.2.559", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.559", "bunfig": "^0.15.19", "stx-router": "0.2.559", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-JLcCChDUa56FxauagTmm3V2drLBC8KejGznFGCBT2fM8r5OOqUg5qqlTp8lbCM0Wl4ClAR9rd+Hu58BrcR0gRA=="],
496 "@stacksjs/browser-extension/bun-plugin-stx/stx-router": ["stx-router@0.2.567", "", {}, "sha512-EwpUt/VwzPDNRWrjf6M/Rzxc81asNDNUZFw8PmHODzFGdmL1gi7oJtZZok8zQLvJpDO881Wk2rDm6C+e0WNTQw=="],
475497
476 "@stacksjs/buddy/bun-plugin-stx/stx-router": ["stx-router@0.2.559", "", {}, "sha512-uhnWMGZL5E1IDbiUzRlbvOurP5Mn5mJi52/R13ssyE0PV5rKyUXeY4YED7YXuFkuDZwPudxjOe1UdpKgIpOceg=="],
498 "@stacksjs/buddy/bun-plugin-stx/@stacksjs/stx": ["@stacksjs/stx@0.2.567", "", { "dependencies": { "@stacksjs/clapp": "^0.2.16", "@stacksjs/desktop": "0.2.567", "@stacksjs/sanitizer": "0.2.567", "@stacksjs/ts-css": "^0.3.11", "@stacksjs/ts-i18n": "^0.1.11", "bun-plugin-stx": "0.2.567", "bunfig": "^0.15.19", "stx-router": "0.2.567", "ts-broadcasting": "^0.0.11", "ts-images": "^0.2.22", "ts-syntax-highlighter": "^0.2.1" }, "bin": { "stx": "./dist/cli.js" } }, "sha512-qH5sXYgf8Bwc4LsA6zkA37sz8jU36xeb1L/Nxrb9VD4yfI9vYLUDWAQRq2qophwjEan1q9zim3goWFJ8vK05sw=="],
499
500 "@stacksjs/buddy/bun-plugin-stx/stx-router": ["stx-router@0.2.567", "", {}, "sha512-EwpUt/VwzPDNRWrjf6M/Rzxc81asNDNUZFw8PmHODzFGdmL1gi7oJtZZok8zQLvJpDO881Wk2rDm6C+e0WNTQw=="],
477501
478502 "@stacksjs/bunpress/@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.520", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-0LwPYuRqWP4yNgb7txzW5jikOTA3PVLje6TDOS4BLDiT3PojBg+aRt+YCEEZet4Rlu2yNHp4QzK1fALGfI137A=="],
479503
@@ -487,23 +511,33 @@
487511
488512 "@stacksjs/components/@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.556", "", {}, "sha512-xm74lJChDP1NKUsL4aNDNqF4RhX5QWTogChphY7b2EbQdzPx3QwLdRbKt3bes2UDrP2Zw5cg4OoI0W+Jo3suVA=="],
489513
514 "@stacksjs/components/@stacksjs/stx/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
515
490516 "@stacksjs/components/@stacksjs/stx/stx-router": ["stx-router@0.2.556", "", {}, "sha512-gLx2kFDINgVx8emxGJ6f+vShLgAK2XTop/D0xMfdy6dK3Qal5tBeGMT9Vdti7KfL2N+oYTDLs4mVHby6pf12sg=="],
491517
492518 "@stacksjs/components/bun-plugin-stx/stx-router": ["stx-router@0.2.556", "", {}, "sha512-gLx2kFDINgVx8emxGJ6f+vShLgAK2XTop/D0xMfdy6dK3Qal5tBeGMT9Vdti7KfL2N+oYTDLs4mVHby6pf12sg=="],
493519
494520 "@stacksjs/rpx/ts-images/@stacksjs/ts-webp": ["@stacksjs/ts-webp@0.1.3", "", {}, "sha512-0PFmmBLKCxIc+8pNEwfvoXfS5uH47y7rXpVaGEfj6VQGPoQCAWUmheppCeJ66YCdSVR1dezR/4MGTZCnT+uZow=="],
495521
496 "@stacksjs/actions/bun-plugin-stx/@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.559", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-0Mt21QCbWiV3EwPGlnCniO03+J3ysY64F8fDf4t/eQilH34B4l75fDCd2SGILHedjphkEd43pyxY4FBj9OQxCQ=="],
522 "@stacksjs/ts-svg/@stacksjs/ts-css/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
523
524 "@stacksjs/actions/bun-plugin-stx/@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.567", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-g6X8P8l4A8GW3gNENBl9x7fuN0ZUZZqflRlqQXQ3SGyIWl0rCE/mWxqr7wg5sWT8R4Y30OXMBgz6AIQyILxlIg=="],
525
526 "@stacksjs/actions/bun-plugin-stx/@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.567", "", {}, "sha512-gMq1MqsmLTqZ/y173wAi5/7mrfByPFBJH18bDFzoHV/GkcDuy5GwRbpkmNWiuLEBlUArLutautYHRZBiKeyYPg=="],
527
528 "@stacksjs/actions/bun-plugin-stx/@stacksjs/stx/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
529
530 "@stacksjs/browser-extension/bun-plugin-stx/@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.567", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-g6X8P8l4A8GW3gNENBl9x7fuN0ZUZZqflRlqQXQ3SGyIWl0rCE/mWxqr7wg5sWT8R4Y30OXMBgz6AIQyILxlIg=="],
497531
498 "@stacksjs/actions/bun-plugin-stx/@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.559", "", {}, "sha512-9Gg3Qc1eGkI3kmAgPk5IwGI1rIyvXLlGvX7SxwitLbdXu8mScmPPbVWY3KV+1fhCS7xvqZJ1vnRZT4Tk++EoZw=="],
532 "@stacksjs/browser-extension/bun-plugin-stx/@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.567", "", {}, "sha512-gMq1MqsmLTqZ/y173wAi5/7mrfByPFBJH18bDFzoHV/GkcDuy5GwRbpkmNWiuLEBlUArLutautYHRZBiKeyYPg=="],
499533
500 "@stacksjs/browser-extension/bun-plugin-stx/@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.559", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-0Mt21QCbWiV3EwPGlnCniO03+J3ysY64F8fDf4t/eQilH34B4l75fDCd2SGILHedjphkEd43pyxY4FBj9OQxCQ=="],
534 "@stacksjs/browser-extension/bun-plugin-stx/@stacksjs/stx/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
501535
502 "@stacksjs/browser-extension/bun-plugin-stx/@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.559", "", {}, "sha512-9Gg3Qc1eGkI3kmAgPk5IwGI1rIyvXLlGvX7SxwitLbdXu8mScmPPbVWY3KV+1fhCS7xvqZJ1vnRZT4Tk++EoZw=="],
536 "@stacksjs/buddy/bun-plugin-stx/@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.567", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-g6X8P8l4A8GW3gNENBl9x7fuN0ZUZZqflRlqQXQ3SGyIWl0rCE/mWxqr7wg5sWT8R4Y30OXMBgz6AIQyILxlIg=="],
503537
504 "@stacksjs/buddy/bun-plugin-stx/@stacksjs/stx/@stacksjs/desktop": ["@stacksjs/desktop@0.2.559", "", { "dependencies": { "craft-native": ">=0.0.111" } }, "sha512-0Mt21QCbWiV3EwPGlnCniO03+J3ysY64F8fDf4t/eQilH34B4l75fDCd2SGILHedjphkEd43pyxY4FBj9OQxCQ=="],
538 "@stacksjs/buddy/bun-plugin-stx/@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.567", "", {}, "sha512-gMq1MqsmLTqZ/y173wAi5/7mrfByPFBJH18bDFzoHV/GkcDuy5GwRbpkmNWiuLEBlUArLutautYHRZBiKeyYPg=="],
505539
506 "@stacksjs/buddy/bun-plugin-stx/@stacksjs/stx/@stacksjs/sanitizer": ["@stacksjs/sanitizer@0.2.559", "", {}, "sha512-9Gg3Qc1eGkI3kmAgPk5IwGI1rIyvXLlGvX7SxwitLbdXu8mScmPPbVWY3KV+1fhCS7xvqZJ1vnRZT4Tk++EoZw=="],
540 "@stacksjs/buddy/bun-plugin-stx/@stacksjs/stx/bunfig": ["bunfig@0.15.19", "", { "dependencies": { "@stacksjs/clarity": "^0.3.24" }, "bin": { "bunfig": "./dist/cli.js" } }, "sha512-Ig302A74YUmagywXX1Dl8hXNdwjq/MIlB1TBVRduO6kGTMjkFQsxS3LimTFBNEThWypc/JS6cv4+TLeRAl2RGw=="],
507541
508542 "@stacksjs/components/@stacksjs/stx/@stacksjs/desktop/craft-native": ["craft-native@0.0.173", "", { "bin": { "craft-sdk": "dist/cli.js" } }, "sha512-JzMSiBqX+lSxcpk70flk6gH96Q2Xl4QsG1nisLLvVU/AqqFlYfkY/+MtkdwTfp8t1v1BG4Bt0L/e/O2Rk4APAQ=="],
509543 }
config/mobile.tsmodified+1-1
Changes to config/mobile.ts
@@ -159,7 +159,7 @@ export default {
159159 urlSchemes: ['wildloop'],
160160 appIcon: 'public/images/app/wildloop-app-icon.png',
161161 googleServicesFile: envVars.ANDROID_GOOGLE_SERVICES_FILE,
162 mapsApiKey: envVars.ANDROID_MAPS_API_KEY,
162 mapProvider: 'maplibre',
163163 nativeScreens,
164164 capabilities: {
165165 backgroundLocation: true,
package.jsonmodified+5-5
Changes to package.json
@@ -10,12 +10,12 @@
1010 "@bughq/stacks": "^0.2.2",
1111 "@iconify-json/lucide": "^1.2.137",
1212 "@loghq/stacks": "^0.2.6",
13 "@stacksjs/composables": "^0.75.219",
14 "@stacksjs/mobile": "^0.75.219",
15 "@stacksjs/stx": "^0.2.563",
13 "@stacksjs/composables": "^0.75.221",
14 "@stacksjs/mobile": "^0.75.221",
15 "@stacksjs/stx": "^0.2.573",
1616 "@ts-analytics/tracking": "^0.1.17",
17 "craft-native": "^0.0.237",
18 "stacks": "^0.75.219",
17 "craft-native": "^0.0.244",
18 "stacks": "^0.75.221",
1919 "ts-health": "^0.1.0",
2020 "ts-maps": "^0.5.5",
2121 "ts-qr-codes": "^0.1.8",
resources/functions/native-api.tsmodified+39-5
Changes to resources/functions/native-api.ts
@@ -73,8 +73,12 @@ export interface NativeApi {
7373 send: (path: string, method?: string, body?: unknown) => Promise<NativeResponse>
7474 /** Native FormData carries on-device files through Craft's multipart transport. */
7575 upload: (path: string, form: FormData) => Promise<NativeResponse>
76 /** Keep a protected operation with the screen's loaded account; never retarget it. */
77 bindSession: () => Promise<NativeApi>
7678}
7779
80interface NativeSessionBinding { account: string, bearer: string }
81
7882/**
7983 * A token reader and sender bound to one screen.
8084 *
@@ -89,6 +93,15 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho
8993 let reading: Promise<string> | null = null
9094 let session = ''
9195 const sessionKey = (): string => host.snapshots ? JSON.stringify(host.snapshots.get('wildloop-session')) ?? 'null' : ''
96 const accountChanged = (): Error => new Error('Your account changed. Please try again.')
97 const assertSession = async (binding: NativeSessionBinding): Promise<string> => {
98 if (binding.account !== sessionKey()) throw accountChanged()
99 const current = await host.secureStorage.get('auth_token')
100 // The keychain can rotate before the shared account snapshot is published,
101 // and cold starts can have a bearer without any snapshot yet.
102 if (binding.account !== sessionKey() || current !== binding.bearer) throw accountChanged()
103 return binding.bearer
104 }
92105 const readToken = async (): Promise<string> => {
93106 const current = sessionKey()
94107 if (!host.snapshots || session !== current) {
@@ -120,11 +133,11 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho
120133 return { 'X-CSRF-Token': token, Cookie: `X-CSRF-Token=${token}` }
121134 }
122135
123 const request = async (path: string, method: string, body: string | FormData | undefined, json: boolean): Promise<NativeResponse> => {
124 const account = sessionKey()
125 const bearer = await readToken()
136 const request = async (path: string, method: string, body: string | FormData | undefined, json: boolean, binding?: NativeSessionBinding): Promise<NativeResponse> => {
137 const account = binding?.account ?? sessionKey()
138 const bearer = binding ? await assertSession(binding) : await readToken()
126139 const csrf = !bearer && !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase()) ? await guestMutationHeaders() : {}
127 if (account !== sessionKey()) throw new Error('Your account changed. Please try again.')
140 if (account !== sessionKey()) throw accountChanged()
128141 const response = await fetch(`${origin}${path}`, {
129142 method,
130143 headers: { ...nativeHeaders(bearer, json), ...csrf },
@@ -133,14 +146,35 @@ export function createNativeApi(host: NativeApiHost, origin = nativeApiOrigin(ho
133146 // A body that is not JSON is not an exception — an error page is text, and
134147 // the status is what the screen reads anyway.
135148 const payload = await response.json().catch(() => null)
136 if (account !== sessionKey()) throw new Error('Your account changed. Please try again.')
149 if (binding) await assertSession(binding)
150 else if (account !== sessionKey()) throw accountChanged()
137151 return { ok: response.ok, status: response.status, body: payload }
138152 }
139153
154 const bindSession = async (): Promise<NativeApi> => {
155 const account = sessionKey()
156 if (reading && session !== account) throw accountChanged()
157 // Anchor an already-loaded screen to its last bearer, even on a host
158 // without snapshots. Reading a fresh bearer here would retarget its draft.
159 const bearer = await (reading ?? readToken())
160 if (account !== sessionKey()) throw accountChanged()
161 if (!bearer) throw new Error('Sign in before saving changes.')
162 const binding = { account, bearer }
163 await assertSession(binding)
164 const bound: NativeApi = {
165 readToken: () => assertSession(binding),
166 send: (path, method = 'GET', body) => request(path, method, body === undefined ? undefined : JSON.stringify(body), body !== undefined, binding),
167 upload: (path, form) => request(path, 'POST', form, false, binding),
168 bindSession: async () => { await assertSession(binding); return bound },
169 }
170 return bound
171 }
172
140173 return {
141174 readToken,
142175 send: (path, method = 'GET', body) => request(path, method, body === undefined ? undefined : JSON.stringify(body), body !== undefined),
143176 upload: (path, form) => request(path, 'POST', form, false),
177 bindSession,
144178 }
145179}
146180
resources/functions/native-recording-checkpoint.tsadded+48-0
Changes to resources/functions/native-recording-checkpoint.ts
@@ -0,0 +1,48 @@
1import type { NativeStorage } from '@stacksjs/stx/native'
2import type { NativeRecording } from './native-recording'
3
4const KEY = 'wildloop-native-recording-v1'
5const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value))
6const conflict = () => new Error('The saved recording changed on this device. Pull down to recover it before trying again. Your track has been retained.')
7
8/** Serialize exact native CAS writes; a stale screen never replaces another track. */
9export function createNativeRecordingCheckpoint(storage: Pick<NativeStorage, 'get' | 'setIfAbsent' | 'compareAndSet'>) {
10 let saved: NativeRecording | null = null
11 let writes = Promise.resolve()
12 function serialize<T>(work: () => Promise<T>): Promise<T> {
13 const pending = writes.then(work)
14 writes = pending.then(() => {}, () => {})
15 return pending
16 }
17 function sameRecording(value: NativeRecording): void {
18 if (!saved || saved.id !== value.id || saved.userId !== value.userId) throw conflict()
19 }
20 return {
21 read: () => serialize(async () => {
22 saved = copy(await storage.get<NativeRecording>(KEY))
23 return copy(saved)
24 }),
25 register: (value: NativeRecording) => {
26 const snapshot = copy(value)
27 return serialize(async () => {
28 // CAS null updates an existing tombstone; setIfAbsent creates a key.
29 // Neither operation may replace an existing account's checkpoint.
30 if (!await storage.setIfAbsent(KEY, snapshot) && !await storage.compareAndSet(KEY, null, snapshot)) throw conflict()
31 saved = snapshot
32 })
33 },
34 save: (value: NativeRecording) => {
35 const snapshot = copy(value)
36 return serialize(async () => {
37 sameRecording(snapshot)
38 if (!await storage.compareAndSet(KEY, saved, snapshot)) throw conflict()
39 saved = snapshot
40 })
41 },
42 clear: (value: NativeRecording) => serialize(async () => {
43 sameRecording(value)
44 if (!await storage.compareAndSet(KEY, saved, null)) throw conflict()
45 saved = null
46 }),
47 }
48}
resources/functions/native-recording.tsmodified+2-0
Changes to resources/functions/native-recording.ts
@@ -16,6 +16,8 @@ export interface NativeRecording {
1616 targetTerritoryId: number | null
1717 pauses: Array<{ start: number, end: number | null }>
1818 samples: RecorderSample[]
19 phase?: 'starting' | 'recording' | 'stopped'
20 endedAt?: number
1921 pending?: Record<string, unknown>
2022}
2123
resources/native/Events.stxmodified+1-1
Changes to resources/native/Events.stx
@@ -161,7 +161,7 @@ onMount(() => onNativeAccountChange(craft, () => { rows.set([]); void load() }))
161161 </View>
162162
163163 <View :show="past().length > 0" class="mt-5 gap-2">
164 <Text testID="past-heading" class="text-xs font-semibold uppercase text-slate-600">Finished</Text>
164 <Text testID="past-heading" class="text-xs font-semibold uppercase text-[#64745f] dark:text-[#91a28b]">Finished</Text>
165165 <Pressable :for="row in past()" :key="row.id" testID="past-row" class="rounded-xl bg-white dark:bg-[#1b2c22]/60 p-3" @click="openEvent(row)">
166166 <Text class="text-sm font-semibold text-[#253321] dark:text-[#e3ecdf]" :text="row.name" />
167167 <Text class="text-xs text-[#64745f] dark:text-[#91a28b]" :text="kind(row) + ' · ' + when(row)" />
resources/native/Feed.stxmodified+2-2
Changes to resources/native/Feed.stx
@@ -88,8 +88,8 @@ onMount(() => onNativeAccountChange(craft, () => { rows.set([]); kudosed.set({})
8888 </Pressable>
8989 <Button :show="activity.trailId" :class="ui.secondary" @click="craft.navigation.open('/trail/' + activity.trailId)" :text="activity.trailName || 'View trail'" />
9090 <View class="mt-3 flex-row gap-3">
91 <Button testID="feed-kudos" :disabled="pending()[activity.id]" @click="kudos(activity)" :text="(kudosed()[activity.id] ? 'Kudos given' : 'Give kudos') + ' · ' + activity.kudosCount" />
92 <Button @click="craft.navigation.open('/activity/' + activity.id)">Comments</Button>
91 <Button testID="feed-kudos" :class="ui.secondary" :disabled="pending()[activity.id]" @click="kudos(activity)" :text="(kudosed()[activity.id] ? 'Kudos given' : 'Give kudos') + ' · ' + activity.kudosCount" />
92 <Button :class="ui.secondary" @click="craft.navigation.open('/activity/' + activity.id)">Comments</Button>
9393 </View>
9494 </View>
9595 <Button :show="hasMore" :disabled="loading" class="m-4" :class="ui.secondary" @click="load(true)" testID="feed-load-more">Load more</Button>
resources/native/ManualActivity.stxmodified+73-61
Changes to resources/native/ManualActivity.stx
@@ -1,7 +1,8 @@
11<script client lang="ts">
2import { nativeApiOrigin } from '../functions/native-api'
2import { createNativeApi, onNativeAccountChange, type NativeApi } from '../functions/native-api'
33import type { NativeCraft } from '@stacksjs/stx/native'
4import { ACTIVITY_VISIBILITIES, buildManualActivity, MANUAL_ACTIVITY_TYPES, todayLocal } from '../functions/manual-activity'
4import { ACTIVITY_VISIBILITIES, buildManualActivity, MANUAL_ACTIVITY_TYPES, todayLocal, type ManualActivityPayload } from '../functions/manual-activity'
5import { nativeUi as ui } from '../functions/native-ui'
56
67const craft = (globalThis as typeof globalThis & { craft: NativeCraft }).craft
78const types = [...MANUAL_ACTIVITY_TYPES]
@@ -18,6 +19,9 @@ const notes = state('')
1819const saving = state(false)
1920const error = state('')
2021let active = true
22let accountGeneration = 0
23let api = createNativeApi(craft)
24let intent: { fields: string, payload: ManualActivityPayload, api: NativeApi } | null = null
2125
2226function eventText(event: { text?: unknown }): string {
2327 return event?.text == null ? '' : String(event.text)
@@ -35,18 +39,9 @@ function goBack() {
3539 craft.navigation.back()
3640}
3741
38async function messageFrom(response: Response, fallback: string): Promise<{ body: any, message: string }> {
39 const body = await response.json().catch(() => null)
40 const fields = body?.fields ?? body?.errors
41 const message = fields && typeof fields === 'object'
42 ? Object.values(fields).map(String).join(' ')
43 : String(body?.message ?? fallback)
44 return { body, message }
45}
46
4742async function submit() {
4843 if (saving()) return
49 const built = buildManualActivity({
44 const input = {
5045 type: types[typeIndex()],
5146 distance: distance(),
5247 duration: duration(),
@@ -55,58 +50,75 @@ async function submit() {
5550 date: date(),
5651 notes: notes(),
5752 visibility: visibilities[visibilityIndex()],
58 // No id: the token below is the only identity this screen has, and the API
59 // takes the actor from the session regardless. Reading one from
60 // `route.params` refused a save on a cold start, where `initialScreen`
61 // passes no params — with a valid token sitting in secureStorage.
62 }, null)
53 }
54 const built = buildManualActivity(input, null, { uploadId: '' })
6355 if (built.ok === false) {
6456 error.set(built.error)
6557 return
6658 }
6759
60 const generation = accountGeneration
61 const current = () => active && generation === accountGeneration
6862 saving.set(true)
6963 error.set('')
7064 try {
71 const token = await craft.secureStorage.get('auth_token')
72 if (!token) throw new Error('Sign in before saving an activity')
73 const response = await fetch(`${nativeApiOrigin(craft)}/api/activities`, {
74 method: 'POST',
75 headers: {
76 Accept: 'application/json',
77 Authorization: `Bearer ${token}`,
78 'Content-Type': 'application/json',
79 },
80 body: JSON.stringify(built.payload),
81 })
82 const result = await messageFrom(response, 'Could not save the activity')
83 if (!response.ok) throw new Error(result.message)
84 const created = result.body?.activity
65 const fields = JSON.stringify(input)
66 if (!intent || intent.fields !== fields) {
67 const bound = await api.bindSession()
68 if (!current()) return
69 if (!craft.crypto) throw new Error('Secure activity identities are unavailable in this app build.')
70 const id = await craft.crypto.randomUUID()
71 if (!current()) return
72 await bound.readToken()
73 const ready = buildManualActivity(input, null, { uploadId: `manual:${id}` })
74 if (ready.ok === false) throw new Error(ready.error)
75 intent = { fields, payload: ready.payload, api: bound }
76 }
77 const pending = intent
78 const response = await pending.api.send('/api/activities', 'POST', pending.payload)
79 if (!current()) return
80 const result = response.body
81 if (!response.ok) {
82 const fields = result?.fields ?? result?.errors
83 throw new Error(fields && typeof fields === 'object'
84 ? Object.values(fields).map(String).join(' ')
85 : String(result?.message ?? result?.error ?? 'Could not save the activity'))
86 }
87 const created = result?.activity
8588 if (!created?.id) throw new Error('Wildloop did not return the saved activity')
86 if (!active) return
8789 await craft.haptics.notification('success')
88 if (!active) return
90 if (!current()) return
8991 craft.navigation.push('ActivitySaved', {
9092 id: created.id,
9193 // The server's answer, not a guess carried in from the route.
9294 userId: created.userId ?? 0,
93 activityType: built.payload.activity_type,
94 distance: built.payload.distance,
95 duration: built.payload.duration,
96 pace: built.payload.pace,
97 elevation: built.payload.elevation,
98 completedAt: built.payload.completed_at,
95 activityType: pending.payload.activity_type,
96 distance: pending.payload.distance,
97 duration: pending.payload.duration,
98 pace: pending.payload.pace,
99 elevation: pending.payload.elevation,
100 completedAt: pending.payload.completed_at,
99101 })
100102 }
101103 catch (cause) {
102 if (active) error.set(cause instanceof Error ? cause.message : 'Could not save the activity')
104 if (current()) error.set(cause instanceof Error ? cause.message : 'Could not save the activity')
103105 }
104106 finally {
105 if (active) saving.set(false)
107 if (current()) saving.set(false)
106108 }
107109}
108110
111onMount(() => onNativeAccountChange(craft, () => {
112 accountGeneration++
113 intent = null
114 api = createNativeApi(craft)
115 typeIndex.set(0);visibilityIndex.set(1)
116 distance.set('');duration.set('');elevation.set('');trailId.set('');notes.set('');date.set(todayLocal())
117 saving.set(false);error.set('')
118 void api.readToken().catch(() => {})
119}))
109120onMount(() => {
121 void api.readToken().catch(() => {})
110122 craft.navigation.setOptions({ title: 'Log activity', largeTitle: true })
111123})
112124onDestroy(() => {
@@ -114,34 +126,34 @@ onDestroy(() => {
114126})
115127</script>
116128
117<SafeAreaView class="flex-1 bg-[#f2f4ef] dark:bg-[#101c16]">
118 <ScrollView class="p-4" testID="manual-scroll">
119 <Text class="mt-1 text-sm text-[#51604e] dark:text-[#b0bfac]">The same manual entry saved by the Wildloop web feed.</Text>
129<SafeAreaView :class="ui.screen">
130 <ScrollView class="flex-1 p-4" testID="manual-scroll">
131 <Text class="mt-1" :class="ui.caption">Add a run, hike, walk, or ride to your log.</Text>
120132
121133 <View class="mt-5 gap-2">
122 <Text class="text-sm font-semibold text-slate-200">Activity</Text>
134 <Text :class="ui.title">Activity</Text>
123135 <Text testID="activity-type" class="text-lg text-[#24724b] dark:text-[#9be579]" :text="types[typeIndex()]" />
124 <Button testID="cycle-type" @click="cycleType()">Change activity type</Button>
136 <Button testID="cycle-type" :class="ui.secondary" @click="cycleType()">Change activity type</Button>
125137 </View>
126138
127139 <View class="mt-4 gap-2">
128 <Text class="text-sm text-slate-200">Distance (miles)</Text>
129 <TextInput testID="distance-input" placeholder="5.0" :value="distance" @input="distance.set(eventText($event))" />
130 <Text class="text-sm text-slate-200">Duration (MM:SS or H:MM:SS)</Text>
131 <TextInput testID="duration-input" placeholder="45:30" :value="duration" @input="duration.set(eventText($event))" />
132 <Text class="text-sm text-slate-200">Elevation gain (feet)</Text>
133 <TextInput testID="elevation-input" placeholder="800" :value="elevation" @input="elevation.set(eventText($event))" />
134 <Text class="text-sm text-slate-200">Trail id (optional)</Text>
135 <TextInput testID="trail-input" placeholder="42" :value="trailId" @input="trailId.set(eventText($event))" />
136 <Text class="text-sm text-slate-200">Date</Text>
137 <TextInput testID="date-input" placeholder="YYYY-MM-DD" :value="date" @input="date.set(eventText($event))" />
138 <Text class="text-sm text-slate-200">Notes</Text>
139 <TextInput testID="notes-input" placeholder="How did it go?" :value="notes" @input="notes.set(eventText($event))" />
140 <Text :class="ui.caption">Distance (miles)</Text>
141 <TextInput testID="distance-input" :class="ui.input" placeholder="5.0" :value="distance" @input="distance.set(eventText($event))" />
142 <Text :class="ui.caption">Duration (MM:SS or H:MM:SS)</Text>
143 <TextInput testID="duration-input" :class="ui.input" placeholder="45:30" :value="duration" @input="duration.set(eventText($event))" />
144 <Text :class="ui.caption">Elevation gain (feet)</Text>
145 <TextInput testID="elevation-input" :class="ui.input" placeholder="800" :value="elevation" @input="elevation.set(eventText($event))" />
146 <Text :class="ui.caption">Trail id (optional)</Text>
147 <TextInput testID="trail-input" :class="ui.input" placeholder="42" :value="trailId" @input="trailId.set(eventText($event))" />
148 <Text :class="ui.caption">Date</Text>
149 <TextInput testID="date-input" :class="ui.input" placeholder="YYYY-MM-DD" :value="date" @input="date.set(eventText($event))" />
150 <Text :class="ui.caption">Notes</Text>
151 <TextInput testID="notes-input" :class="ui.input" placeholder="How did it go?" :value="notes" @input="notes.set(eventText($event))" />
140152 </View>
141153
142154 <View class="mt-4 gap-2">
143155 <Text testID="visibility" class="text-sm text-[#253321] dark:text-[#e3ecdf]" :text="'Visible to ' + visibilities[visibilityIndex()]" />
144 <Button testID="cycle-visibility" @click="cycleVisibility()">Change visibility</Button>
156 <Button testID="cycle-visibility" :class="ui.secondary" @click="cycleVisibility()">Change visibility</Button>
145157 </View>
146158
147159 <View class="mt-5 gap-2">
@@ -154,7 +166,7 @@ onDestroy(() => {
154166 </View>
155167
156168 <Text :show="error" testID="manual-error" class="mt-4 text-[#b4232a] dark:text-[#ff8487]" :text="error" />
157 <Button testID="save-activity" class="mt-5" @click="submit()">Save activity</Button>
158 <Button testID="manual-back" class="mt-2" @click="goBack()">Back</Button>
169 <Button testID="save-activity" class="mt-5" :class="ui.action" :disabled="saving" :title="saving() ? 'Saving…' : 'Save activity'" @click="submit()" />
170 <Button testID="manual-back" class="mt-2" :class="ui.secondary" @click="goBack()">Back</Button>
159171 </ScrollView>
160172</SafeAreaView>
resources/native/Record.stxmodified+177-76
Changes to resources/native/Record.stx
@@ -5,12 +5,14 @@ import { createHealthWorkout } from '../functions/health-workout'
55import { useIntervalFn } from '@stacksjs/composables'
66import { readNativeAccount } from '../functions/native-session'
77import { onNativeAccountChange, createNativeApi } from '../functions/native-api'
8import { createNativeRecordingCheckpoint } from '../functions/native-recording-checkpoint'
89import { nativeOutdoorTiles } from '../functions/native-basemaps'
910import { chooseNativeChoice, nativeChoiceLabel } from '../functions/native-choice'
1011import { nativeUi as ui } from '../functions/native-ui'
1112import { nativeRecordingDuration, movingSeconds, recordingFigures, recordingPayload, recordingSamples, type NativeRecording } from '../functions/native-recording'
1213const craft = (globalThis as typeof globalThis & {craft:NativeCraft}).craft
13const KEY = 'wildloop-native-recording-v1'
14const checkpoint = createNativeRecordingCheckpoint(craft.storage)
15let api = createNativeApi(craft)
1416const recording = state<NativeRecording|null>(null)
1517const running = state(false)
1618const paused = state(false)
@@ -23,12 +25,13 @@ const visibility = state('followers')
2325const gameMode = state('capture')
2426const now = state(Date.now())
2527const figures = derived(() => recording() ? recordingFigures(recording()!) : {distance:0,elevation:0})
26const movingTime = derived(() => { const value=recording(); return value ? movingSeconds(value,now()) : 0 })
28const movingTime = derived(() => { const value=recording(); return value ? movingSeconds(value,value.endedAt ?? (value.pending ? Date.parse(String(value.pending.completed_at)) : now())) : 0 })
2729const coordinates = derived(() => recording()?.samples.flatMap(sample => [sample.lat,sample.lng]) ?? [])
2830let alive = true
2931let owner = 0
3032let watch: number | null = null
31let writes = Promise.resolve()
33let accountGeneration = 0
34let recoverQueued = false
3235let recovering = false
3336const live=createLiveActivity(craft.liveActivity)
3437let lastLiveUpdate=0
@@ -40,17 +43,24 @@ function updateLive(force=false) {
4043 const moving=movingSeconds(value,at)
4144 void live.update({activityId:'wildloop:'+value.id,title:value.type,symbol:'figure.run',deepLink:'wildloop://record',tint:'#24724b',status:paused()?'Paused':'Recording',paused:paused(),distanceMeters:recordingFigures(value).distance*1609.344,durationSeconds:moving,timerStartedAt:paused()?null:at-moving*1000}).catch(()=>{})
4245}
43function persist(value: NativeRecording): Promise<void> {
44 const snapshot = JSON.parse(JSON.stringify(value))
45 writes = writes.catch(() => {}).then(async () => {await craft.storage.set(KEY,snapshot)})
46 return writes
46function persist(value: NativeRecording): Promise<void> { return checkpoint.save(value) }
47async function ownedSession(value?: NativeRecording) {
48 const bound = await api.bindSession()
49 const cached = await readNativeAccount(craft)
50 const me = cached ? null : await bound.send('/api/me')
51 await bound.readToken()
52 const userId = cached?.id ?? (Number(me?.body?.user?.id) || 0)
53 if (!userId || (value && value.userId !== userId)) throw new Error('Log in as the account that started this recording before changing or uploading it.')
54 return { api: bound, userId }
4755}
48async function perform(work:()=>Promise<void>) {
56async function perform(work:(current:()=>boolean)=>Promise<void>) {
4957 if (busy()) return
58 const generation = accountGeneration
59 const current = () => alive && generation === accountGeneration
5060 busy.set(true);error.set('')
51 try { await work() }
52 catch (cause) { error.set(cause instanceof Error ? cause.message : 'The recording could not be updated. Your track remains on this device.') }
53 finally { busy.set(false) }
61 try { await work(current) }
62 catch (cause) { if (current()) error.set(cause instanceof Error ? cause.message : 'The recording could not be updated. Your track remains on this device.') }
63 finally { busy.set(false);if(recoverQueued && alive) void recover() }
5464}
5565function append(locations:any[]) {
5666 const value = recording()
@@ -61,29 +71,60 @@ function append(locations:any[]) {
6171}
6272function attachWatch() {
6373 if (watch !== null || !craft.location) return
74 const generation=accountGeneration
75 const id=recording()?.id
6476 watch = craft.location.watchPosition(location => {
65 if (!alive || busy() || paused() || !running()) return
77 if (!alive || generation!==accountGeneration || recording()?.id!==id || busy() || paused() || !running()) return
6678 append([location])
67 if (recording()) void persist(recording()!).catch(() => error.set('Device storage could not save a checkpoint. Keep the app open until your activity is saved.'))
79 if (recording()) void persist(recording()!).catch(() => {if(alive && generation===accountGeneration) error.set('Device storage could not save a checkpoint. Keep the app open until your activity is saved.')})
6880 },{enableHighAccuracy:true})
6981}
7082async function recover() {
71 if (recovering || busy()) return
83 if (recovering || busy()) {recoverQueued=true;return}
84 recoverQueued=false
85 const generation=accountGeneration
86 const current=()=>alive && generation===accountGeneration
7287 recovering=true;loading.set(true)
7388 try {
74 const api=createNativeApi(craft)
75 const cached=await readNativeAccount(craft)
76 const me=cached ? null : await api.send('/api/me')
77 if (!alive) return
78 owner=cached?.id ?? (Number(me?.body?.user?.id)||0)
79 const saved=await craft.storage.get<NativeRecording>(KEY)
80 const host=await craft.location?.getRecordingState()
81 if (!alive) return
89 const token=await api.readToken()
90 const session=token ? await ownedSession() : null
91 if (!current()) return
92 owner=session?.userId ?? 0
93 const saved=await checkpoint.read()
94 if (!current()) return
8295 if (saved && saved.userId !== owner) {
8396 recording.set(null);running.set(false)
8497 error.set('Log in as the account that started the saved recording before resuming or uploading it.')
8598 return
8699 }
100 if (saved && !saved.id) {
101 recording.set(saved);running.set(false);paused.set(false)
102 error.set('This saved track has no verifiable native recording identity. Its checkpoint has been retained; it cannot be attached to another recording.')
103 return
104 }
105 // A finished checkpoint is sufficient for an offline retry. Do not read
106 // the shared GPS slot, which may now belong to another recording.
107 if (saved?.pending) {
108 recording.set(saved);running.set(false);paused.set(false)
109 notice.set('Your finished recording is saved on this device. Retry the upload when connected.')
110 return
111 }
112 if (!owner) {recording.set(null);running.set(false);paused.set(false);return}
113 await session!.api.readToken()
114 let host
115 try { host=await craft.location?.getRecordingState(saved?.id ? {expectedId:saved.id} : undefined) }
116 catch (cause) {
117 // A registered intent may not have reached the host. An explicit native
118 // ownership conflict permits retrying that same ID, never a replacement.
119 if (saved?.phase==='starting' && cause instanceof Error && 'code' in cause && cause.code==='RECORDING_CONFLICT') {
120 if (!current()) return
121 recording.set(saved);running.set(false);paused.set(false)
122 notice.set('The recording start was interrupted. Retry starting with its saved identity, or discard the intent.')
123 return
124 }
125 throw cause
126 }
127 if (!current()) return
87128 if (host?.active && (!saved || (saved.id && saved.id !== host.id))) {
88129 error.set('A device recording is already active. Its account checkpoint could not be verified, so it has been retained without starting another recording.')
89130 return
@@ -91,66 +132,91 @@ async function recover() {
91132 recording.set(saved)
92133 running.set(Boolean(host?.active))
93134 paused.set(Boolean(host?.paused))
94 if (saved && host?.active && !saved.pending) {
95 append(await craft.location!.readRecording())
135 if (saved && host?.id===saved.id && !saved.pending) {
136 const fixes=await craft.location!.readRecording({expectedId:saved.id!})
137 if (!current()) return
138 recording.set({...saved,phase:host.active ? 'recording' : 'stopped',startedAt:host.startedAt ?? saved.startedAt,...host.active ? {} : {endedAt:saved.endedAt ?? Date.now()}})
139 append(fixes)
96140 await persist(recording()!)
97 attachWatch()
141 if (!current()) return
142 if(host.active) attachWatch()
98143 notice.set('Your recording has been recovered from this device.');updateLive(true)
99144 }
100145 if (saved?.pending) notice.set('Your finished recording is saved on this device. Retry the upload when connected.')
101146 now.set(Date.now())
102147 }
103 catch { if (alive) error.set('Could not check the device recording. Pull down to retry before starting another activity.') }
104 finally { recovering=false;if(alive) loading.set(false) }
148 catch (cause) { if (current()) error.set(cause instanceof Error ? cause.message : 'Could not check the device recording. Pull down to retry before starting another activity.') }
149 finally { recovering=false;if(current()) loading.set(false);if(recoverQueued && alive) void recover() }
105150}
106function start() { void perform(async () => {
107 if (!craft.location) throw new Error('Location recording is unavailable in this app build.')
108 const api=createNativeApi(craft)
109 const cached=await readNativeAccount(craft)
110 const me=cached ? null : await api.send('/api/me')
111 const userId=cached?.id ?? (Number(me?.body?.user?.id)||0)
112 if (!userId) {craft.navigation.open('/login?next=/record');return}
113 if (recording()) throw new Error('Save your previous recording before starting another.')
114 if ((await craft.location.getRecordingState()).active) throw new Error('A recording is already active on this device. Pull down to recover it.')
115 const value:NativeRecording={userId,id:null,startedAt:Date.now(),type:activityType(),visibility:visibility(),gameMode:gameMode() === 'capture' ? 'capture':'free',targetTerritoryId:Number(craft.route.params.target)||null,pauses:[],samples:[]}
116 // Bind the account before location starts. The host owns background fixes;
117 // this checkpoint owns the account and upload identity across app restarts.
151function start() { void perform(async current => {
152 if (!craft.location || !craft.crypto) throw new Error('Secure location recording is unavailable in this app build.')
153 const previous=recording()
154 const session=await ownedSession(previous ?? undefined)
155 if (!current()) return
156 if (previous && previous.phase!=='starting') throw new Error('Save your previous recording before starting another.')
157 const host=await craft.location.getRecordingState()
158 if (!current()) return
159 let value=previous
160 if (!value) {
161 if (host.active) throw new Error('A recording is already active on this device. Pull down to recover it.')
162 const id=await craft.crypto.randomUUID()
163 if (!current()) return
164 await session.api.readToken()
165 value={userId:session.userId,id,phase:'starting',startedAt:Date.now(),type:activityType(),visibility:visibility(),gameMode:gameMode() === 'capture' ? 'capture':'free',targetTerritoryId:Number(craft.route.params.target)||null,pauses:[],samples:[]}
166 await checkpoint.register(value)
167 if (!current()) return
168 recording.set(value)
169 }
170 // A retry can attach only to the same registered start. A different active
171 // native recording is retained and never stopped or replaced here.
172 if (host.active && host.id!==value.id) throw new Error('Another recording is active on this device. Your saved intent has been retained.')
173 await session.api.readToken()
174 const started=host.active ? await craft.location.getRecordingState({expectedId:value.id!}) : await craft.location.startRecording({enableHighAccuracy:true,recordingId:value.id!})
175 if (!current()) return
176 if (!started.active || started.id!==value.id) throw new Error('Location recording did not start with its saved identity. Your checkpoint has been retained.')
177 value={...value,phase:'recording',startedAt:started.startedAt ?? value.startedAt}
118178 await persist(value)
119 recording.set(value)
120 const host=await craft.location.startRecording({enableHighAccuracy:true})
121 if (!host.active || !host.id) throw new Error('Location recording did not start. Check location permission and try again.')
122 recording.set({...value,id:host.id,startedAt:host.startedAt ?? value.startedAt})
123 owner=value.userId
124 await persist(recording()!)
125 running.set(true);paused.set(false);now.set(Date.now());notice.set('Recording continues with your phone locked.')
126 attachWatch()
127 updateLive(true)
179 if (!current()) return
180 recording.set(value);owner=value.userId
181 running.set(true);paused.set(Boolean(started.paused));now.set(Date.now());notice.set('Recording continues with your phone locked.')
182 attachWatch();updateLive(true)
128183 await craft.setKeepAwake?.(true)
129184}) }
130function togglePause() { void perform(async () => {
185function togglePause() { void perform(async current => {
131186 const value=recording()
132 if (!value || !running() || !craft.location) return
187 if (!value?.id || !running() || !craft.location) return
188 const session=await ownedSession(value)
189 if (!current()) return
190 await session.api.readToken()
133191 const at=Date.now()
192 let next=value
134193 if (paused()) {
135 await craft.location.resumeRecording()
136 const pauses=value.pauses.map((pause,index) => index === value.pauses.length-1 && pause.end === null ? {...pause,end:at}:pause)
137 recording.set({...value,pauses});paused.set(false)
194 await craft.location.resumeRecording({expectedId:value.id})
195 if (!current()) return
196 next={...value,pauses:value.pauses.map((pause,index) => index===value.pauses.length-1 && pause.end===null ? {...pause,end:at}:pause)}
197 paused.set(false)
138198 }
139199 else {
140 await craft.location.pauseRecording()
141 append(await craft.location.readRecording())
142 recording.set({...recording()!,pauses:[...value.pauses,{start:at,end:null}]});paused.set(true)
200 await craft.location.pauseRecording({expectedId:value.id})
201 const fixes=await craft.location.readRecording({expectedId:value.id})
202 if (!current()) return
203 next={...value,samples:recordingSamples(value,fixes),pauses:[...value.pauses,{start:at,end:null}]}
204 paused.set(true)
143205 }
144 await persist(recording()!);now.set(at);updateLive(true)
206 await persist(next)
207 if (!current()) return
208 recording.set(next);now.set(at);updateLive(true)
145209}) }
146async function upload() {
210async function upload(current:()=>boolean) {
147211 const value=recording()
148212 if (!value?.pending) return
149 const api=createNativeApi(craft)
213 const {api}=await ownedSession(value)
214 if (!current()) return
150215 const me=await api.send('/api/me')
151216 if (!me.ok || Number(me.body?.user?.id) !== value.userId) throw new Error('Log in as the account that started this recording before uploading it.')
152217 const response=await api.send('/api/activities','POST',value.pending)
153218 if (!response.ok || !response.body?.activity?.id) throw new Error(response.body?.error || 'Upload failed. Your finished track is saved on this device; retry when connected.')
219 if (!current()) return
154220 const activity=response.body.activity
155221 let result='Activity saved.'
156222 if (value.gameMode === 'capture' && activity.captureEligible) {
@@ -165,34 +231,68 @@ async function upload() {
165231 catch {result+=' Your workout could not be exported to Health; check its permission in Settings.'}
166232 }
167233 await craft.snapshots.set('wildloop-activity-notice',{activityId:activity.id,message:result})
168 await writes.catch(()=>{})
169 await craft.storage.remove(KEY)
234 if (!current()) return
235 if (value.id && value.phase==='stopped') await craft.location!.acknowledgeRecording({expectedId:value.id})
236 await checkpoint.clear(value)
237 if (!current()) return
170238 recording.set(null);notice.set(result)
171239 craft.navigation.open(`/activity/${activity.id}`)
172240}
173function finish() { void perform(async () => {
174 if (!recording() || !craft.location) return
241function finish() { void perform(async current => {
242 let value=recording()
243 if (!value?.id || !craft.location) throw new Error('The native recording identity is unavailable. Your checkpoint has been retained.')
244 const session=await ownedSession(value)
245 if (!current()) return
246 await session.api.readToken()
175247 if (running()) {
176 const ended=await craft.location.stopRecording()
177 append(ended.locations)
178 running.set(false);paused.set(false)
248 const ended=await craft.location.stopRecording({expectedId:value.id})
249 if (!current()) return
250 const endedAt=Date.now()
251 value={...value,samples:recordingSamples(value,ended.locations),phase:'stopped',endedAt}
252 await persist(value)
253 if (!current()) return
254 recording.set(value);running.set(false);paused.set(false)
179255 await live.end().catch(()=>{})
180256 await craft.setKeepAwake?.(false)
257 if (!current()) return
258 }
259 if (value.phase==='starting') throw new Error('Retry starting this recording before finishing it.')
260 if (value.samples.length < 2) throw new Error('Not enough GPS fixes to save an activity. Your available fixes remain on this device.')
261 if (!value.pending) {
262 value={...value,pending:recordingPayload(value,value.endedAt ?? Date.now())}
263 await persist(value)
264 if (!current()) return
265 recording.set(value)
181266 }
182 const value=recording()!
183 if (value.samples.length < 2) {await persist(value);throw new Error('Not enough GPS fixes to save an activity. Your available fixes remain on this device.')}
184 if (!value.id) throw new Error('The native recording identity is unavailable. Your fixes have been retained.')
185 if (!value.pending) recording.set({...value,pending:recordingPayload(value,Date.now())})
186 await persist(recording()!)
187 await upload()
267 await upload(current)
188268}) }
189269function retry() {void perform(upload)}
190function discard() {void perform(async()=>{
270function discard() {void perform(async current=>{
271 const value=recording()
272 if (!value) return
191273 if (running()) throw new Error('Finish your recording before discarding it.')
274 const session=await ownedSession(value)
275 if (!current()) return
192276 if (!await craft.dialog?.confirm({title:'Discard this recording?',message:'This removes the unsaved track from this device.',confirmLabel:'Discard',destructive:true})) return
193 await writes.catch(()=>{});await craft.storage.remove(KEY);recording.set(null);notice.set('Recording discarded.')
277 if (!current()) return
278 await session.api.readToken()
279 // A saved start may have reached native GPS before its acknowledgement was
280 // delivered. Never clear its owner checkpoint while it could still be active.
281 if (value.id && !value.pending && value.phase!=='starting') {
282 const host=await craft.location!.getRecordingState({expectedId:value.id})
283 if (host.active) throw new Error('This recording is still active. Pull down to recover and finish it before discarding.')
284 }
285 if (value.phase==='starting') {
286 const host=await craft.location!.getRecordingState()
287 if (host.active && host.id===value.id) throw new Error('This recording started on the device. Retry starting to recover it before finishing.')
288 }
289 if (!current()) return
290 if (value.id && value.phase==='stopped') await craft.location!.acknowledgeRecording({expectedId:value.id})
291 await checkpoint.clear(value)
292 if (!current()) return
293 recording.set(null);notice.set('Recording discarded.')
194294})}
195onMount(()=>onNativeAccountChange(craft,()=>{recording.set(null);running.set(false);paused.set(false);owner=0;if(watch!==null){craft.location?.clearWatch(watch);watch=null}void recover()}))
295onMount(()=>onNativeAccountChange(craft,()=>{accountGeneration++;api=createNativeApi(craft);recoverQueued=true;loading.set(true);recording.set(null);running.set(false);paused.set(false);notice.set('');error.set('');owner=0;if(watch!==null){craft.location?.clearWatch(watch);watch=null}void recover()}))
196296onMount(()=>{
197297 craft.navigation.setOptions({title:'Record',largeTitle:false})
198298 void recover()
@@ -230,7 +330,8 @@ onMount(()=>{
230330 <Button testID="record-finish" :disabled="busy" :class="ui.action" class="flex-1" @click="finish()" title="Finish and save" />
231331 </View>
232332 <Button :show="recording()?.pending" testID="record-retry" :disabled="busy" :class="ui.action" @click="retry()" title="Retry upload" />
233 <Button :show="recording() && !running() && !recording()?.pending" :disabled="busy" @click="finish()">Save retained recording</Button>
333 <Button :show="recording()?.phase==='starting' && !running()" testID="record-retry-start" :disabled="busy || loading" :class="ui.action" @click="start()">Retry starting</Button>
334 <Button :show="recording() && !running() && !recording()?.pending && recording()?.phase!=='starting'" :disabled="busy" @click="finish()">Save retained recording</Button>
234335 <Button :show="recording() && !running()" :disabled="busy" :class="ui.danger" @click="discard()">Discard recording</Button>
235336 </View>
236337</View>
resources/native/Territories.stxmodified+1-1
Changes to resources/native/Territories.stx
@@ -226,7 +226,7 @@ onMount(() => onNativeAccountChange(craft, () => { rows.set([]); void load() }))
226226 <Text class="text-sm font-semibold text-[#192414] dark:text-[#f0f5ec]" :text="holderName(holder)" />
227227 <Text testID="holder-zones" class="text-xs text-[#64745f] dark:text-[#91a28b]" :text="holderZones(holder)" />
228228 </View>
229 <Text testID="holder-area" class="text-sm font-semibold text-slate-200" :text="holderArea(holder)" />
229 <Text testID="holder-area" class="text-sm font-semibold text-[#192414] dark:text-[#f0f5ec]" :text="holderArea(holder)" />
230230 </View>
231231 </View>
232232 </View>
resources/native/TrailDetail.stxmodified+73-35
Changes to resources/native/TrailDetail.stx
@@ -1,5 +1,6 @@
11<script client lang="ts">
22import type { NativeCraft } from '@stacksjs/stx/native'
3import type { NativeApi } from '../functions/native-api'
34import { onNativeAccountChange, createNativeApi, loadOutcome, nativeApiOrigin } from '../functions/native-api'
45import { nativeBasemap, nativeBasemapOptions } from '../functions/native-basemaps'
56import { chooseNativeChoice, nativeChoiceLabel } from '../functions/native-choice'
@@ -58,6 +59,7 @@ const formError = state('')
5859const photoDrafts = state<Array<{file:any,id?:string}>>([])
5960let alive = true
6061let generation = 0
62let accountGeneration = 0
6163
6264const surfaces = derived(() => trailSurfaceTypes(rawTrail()?.surface))
6365const routeAvailable = derived(() => parts().some(part => part.length >= 4))
@@ -66,12 +68,10 @@ const mapRegion = derived(() => region() ?? nativeRouteRegion(parts(), {lat:trai
6668const photos = derived(() => trailGallery(trail(),reviews()))
6769
6870function textOf(event: any): string { return String(event?.text ?? '') }
71function accountIsCurrent(version:number): boolean { return alive && version === accountGeneration }
6972function profilePath(): string { return trailProfilePath(profile()) }
7073function photoUrls(row: any): string[] { return parsePhotoList(row.photos).map(url=>url.startsWith('/') ? nativeApiOrigin(craft)+url : url) }
7174function photoUrl(url:string):string {return url.startsWith('/') ? nativeApiOrigin(craft)+url : url}
72function weatherLine(day: any, index: number): string {
73 return `${forecastDayLabel(day.date, index)}: ${day.label}, ${Math.round(day.low)} to ${Math.round(day.high)}°F`
74}
7575function fitRoute() {
7676 region.set(null)
7777 inspected.set(null)
@@ -122,15 +122,20 @@ function signedIn(): boolean {
122122}
123123async function toggleSaved() {
124124 if (loading() || !viewerReady() || busy() || !signedIn()) return
125 const current = accountGeneration
126 const method = saved() ? 'DELETE' : 'PUT'
125127 busy.set(true)
126128 try {
127 const response = await api.send(`/api/trails/${wanted}/save`, saved() ? 'DELETE' : 'PUT')
129 const operation = await api.bindSession()
130 if (!accountIsCurrent(current)) return
131 const response = await operation.send(`/api/trails/${wanted}/save`, method)
132 if (!accountIsCurrent(current)) return
128133 if (!response.ok) throw new Error(response.body?.error || 'The trail could not be saved.')
129134 saved.set(Boolean(response.body.saved))
130135 notice.set(saved() ? 'Trail saved.' : 'Trail removed from saved trails.')
131136 }
132 catch (cause) { notice.set(cause instanceof Error ? cause.message : 'The trail could not be saved.') }
133 finally { busy.set(false) }
137 catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'The trail could not be saved.') }
138 finally { if (accountIsCurrent(current)) busy.set(false) }
134139}
135140function editReview() {
136141 if (loading() || !viewerReady() || busy() || !signedIn()) return
@@ -144,73 +149,85 @@ function editReview() {
144149 photoDrafts.set([])
145150 composer.set(true)
146151}
147async function reloadReviews() {
152async function reloadReviews(operation: NativeApi) {
148153 const current = generation
149154 viewerReady.set(false)
150 const [response, personal] = await Promise.all([api.send(`/api/trails/${wanted}/reviews`), api.send(`/api/trails/${wanted}/viewer`)])
155 const [response, personal] = await Promise.all([operation.send(`/api/trails/${wanted}/reviews`), operation.send(`/api/trails/${wanted}/viewer`)])
151156 if (!alive || current !== generation) return
152157 if (personal.ok) { ownReview.set(personal.body.review ?? null); viewerReady.set(true) }
153158 if (response.ok) reviews.set(response.body.reviews ?? [])
154159 if (!response.ok || !personal.ok) throw new Error('Your change was saved, but reviews could not refresh. Pull down to retry.')
155160}
156161async function pickReviewPhoto() {
157 if (busy()) return
162 if (loading() || !viewerReady() || busy() || !signedIn()) return
163 const current = accountGeneration
158164 if (photoDrafts().length + photoUrls(ownReview() ?? {}).length >= 10) { formError.set('A review can have up to 10 photos.'); return }
159165 try {
160166 if (!craft.files) throw new Error('Photo picking is unavailable in this app build.')
161167 const file = await craft.files.pickImage({source:'library',maxPixels:2400,quality:0.88})
168 if (!accountIsCurrent(current)) return
162169 if (file) photoDrafts.set([...photoDrafts(),{file}])
163170 }
164 catch (cause) { formError.set(cause instanceof Error ? cause.message : 'A photo could not be selected.') }
171 catch (cause) { if (accountIsCurrent(current)) formError.set(cause instanceof Error ? cause.message : 'A photo could not be selected.') }
165172}
166173async function submitReview() {
167 if (busy()) return
174 if (loading() || !viewerReady() || busy() || !signedIn()) return
168175 const problem = reviewFormError(rating(), content())
169176 if (problem) { formError.set(problem); return }
177 const current = accountGeneration
170178 busy.set(true)
171179 formError.set('')
172180 try {
181 const operation = await api.bindSession()
182 if (!accountIsCurrent(current)) return
173183 for (let index=0;index<photoDrafts().length;index++) {
174184 const draft=photoDrafts()[index]
175185 if (draft.id) continue
176186 const form=new FormData()
177187 form.append('photo',draft.file)
178 const response=await api.upload(`/api/trails/${wanted}/photos`,form)
188 const response=await operation.upload(`/api/trails/${wanted}/photos`,form)
189 if (!accountIsCurrent(current)) return
179190 const body=response.body
180191 if (!response.ok || !body.photo?.id) throw new Error(body.error || 'A photo could not be uploaded. Try saving again.')
181192 photoDrafts.set(photoDrafts().map((entry,at)=>at===index ? {...entry,id:body.photo.id}:entry))
182193 }
183 const response = await api.send(`/api/trails/${wanted}/reviews`, 'POST', {
194 const response = await operation.send(`/api/trails/${wanted}/reviews`, 'POST', {
184195 rating: rating(), content: content().trim(), conditions: conditions() || null,
185196 difficulty: difficulty() || null, visit_date: visitDate() || null,
186197 ...(photoDrafts().length ? {photo_ids:photoDrafts().map(draft=>draft.id),retain_photos:true}:{}),
187198 })
199 if (!accountIsCurrent(current)) return
188200 if (!response.ok) throw new Error(Object.values(response.body?.fields ?? {}).join('. ') || response.body?.error || 'The review could not be saved.')
189201 if (response.body.trail) trail.set({...trail(),...response.body.trail})
190202 composer.set(false)
191203 notice.set('Your review has been saved.')
192 try { await reloadReviews() } catch (cause) { notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
204 try { await reloadReviews(operation) } catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
193205 }
194 catch (cause) { formError.set(cause instanceof Error ? cause.message : 'The review could not be saved.') }
195 finally { busy.set(false) }
206 catch (cause) { if (accountIsCurrent(current)) formError.set(cause instanceof Error ? cause.message : 'The review could not be saved.') }
207 finally { if (accountIsCurrent(current)) busy.set(false) }
196208}
197209async function deleteReview() {
198210 if (loading() || !viewerReady() || !ownReview() || busy()) return
199 const confirmed = await craft.dialog?.confirm({ title: 'Delete your review?', message: 'Your rating, review, and condition report will be removed from this trail.', confirmLabel: 'Delete review', destructive: true })
200 if (!confirmed) return
211 const current = accountGeneration
201212 busy.set(true)
202213 try {
203 const response = await api.send(`/api/trails/${wanted}/reviews`, 'DELETE')
214 const operation = await api.bindSession()
215 if (!accountIsCurrent(current)) return
216 const confirmed = await craft.dialog?.confirm({ title: 'Delete your review?', message: 'Your rating, review, and condition report will be removed from this trail.', confirmLabel: 'Delete review', destructive: true })
217 if (!confirmed || !accountIsCurrent(current)) return
218 const response = await operation.send(`/api/trails/${wanted}/reviews`, 'DELETE')
219 if (!accountIsCurrent(current)) return
204220 if (!response.ok) throw new Error(response.body?.error || 'The review could not be deleted.')
205221 if (response.body.trail) trail.set({...trail(),...response.body.trail})
206222 ownReview.set(null)
207223 notice.set('Your review has been deleted.')
208 try { await reloadReviews() } catch (cause) { notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
224 try { await reloadReviews(operation) } catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'Pull down to refresh reviews.') }
209225 }
210 catch (cause) { notice.set(cause instanceof Error ? cause.message : 'The review could not be deleted.') }
211 finally { busy.set(false) }
226 catch (cause) { if (accountIsCurrent(current)) notice.set(cause instanceof Error ? cause.message : 'The review could not be deleted.') }
227 finally { if (accountIsCurrent(current)) busy.set(false) }
212228}
213229async function load() {
230 if (busy()) return
214231 const current = ++generation
215232 loading.set(true)
216233 viewerReady.set(false)
@@ -259,7 +276,14 @@ onMount(() => {
259276 const stop = craft.navigation.onAppear(() => { void load() })
260277 return () => { alive = false; generation++; stop() }
261278})
262onMount(() => onNativeAccountChange(craft, () => { viewer.set(0); saved.set(false); ownReview.set(null); viewerReady.set(false); reviews.set([]); composer.set(false); void load() }))
279onMount(() => onNativeAccountChange(craft, () => {
280 accountGeneration++
281 busy.set(false)
282 viewer.set(0); saved.set(false); ownReview.set(null); viewerReady.set(false); reviews.set([])
283 composer.set(false); rating.set(0); content.set(''); conditions.set(''); difficulty.set(''); visitDate.set('')
284 photoDrafts.set([]); formError.set(''); notice.set('')
285 void load()
286}))
263287</script>
264288
265289<View :class="ui.screen">
@@ -290,11 +314,11 @@ onMount(() => onNativeAccountChange(craft, () => { viewer.set(0); saved.set(fals
290314 <Map testID="trail-native-map" class="absolute inset-0 h-full w-full" :region="routeAvailable() ? region() : mapRegion()" :fitToBounds="region() === null && routeAvailable()" :edgePadding="{top:32,right:32,bottom:profileOpen() ? 220 : 88,left:32}" :mapType="basemap().mapType" :tileOverlay="basemap().tileOverlay" :scrollEnabled="false" :zoomEnabled="false" :rotateEnabled="false" :showsCompass="true" :showsScale="true" :showsUserLocation="showLocation" :cameraPitch="pitch" :cameraHeading="heading" @region-change-complete="camera($event)">
291315 <MapPolyline :for="(part, index) in parts()" :key="'route-outline-' + index" :id="'route-outline-' + index" :geometry="part" :strokeColor="trailRouteStyle.casing" :strokeWidth="9" />
292316 <MapPolyline :for="(part, index) in parts()" :key="index" :id="'route-' + index" :geometry="part" :strokeColor="trailRouteStyle.line" :strokeWidth="5" />
293 <MapMarker :for="(sign,index) in directionSigns()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" title="Recorded route direction" />
294 <MapMarker id="trailhead" title="Trailhead" :geometry="[trailhead()?.lat, trailhead()?.lng]" />
295 <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" :geometry="[inspected()?.lat, inspected()?.lng]" />
317 <MapMarker :for="(sign,index) in directionSigns()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" :pinColor="trailRouteStyle.casing" title="Recorded route direction" />
318 <MapMarker id="trailhead" title="Trailhead" pinColor="#24724b" :geometry="[trailhead()?.lat, trailhead()?.lng]" />
319 <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" pinColor="#176bad" :geometry="[inspected()?.lat, inspected()?.lng]" />
296320 </Map>
297 <Button testID="trail-map-preview-expand" accessibilityLabel="Open interactive map" :class="ui.floating" class="absolute top-3 right-3" @click="craft.navigation.open('/trail/' + wanted + '/map')" title="Expand map" />
321 <Button testID="trail-map-fullscreen" accessibilityLabel="Expand map" :class="ui.floating" class="absolute top-3 right-3" @click="craft.navigation.open('/trail/' + wanted + '/map')" title="Expand map" />
298322 <View class="absolute bottom-5 left-3 right-3 rounded-[20px] bg-white p-3 shadow-lg dark:bg-[#1b2c22]" testID="trail-floating-profile">
299323 <View class="flex-row items-center justify-between"><Text :class="ui.caption">Elevation profile</Text><Button testID="trail-profile-toggle" class="h-11 min-w-[44px] text-[15px] font-semibold text-[#253321] dark:text-[#e3ecdf]" accessibilityLabel="Toggle elevation profile" :accessibilityValue="profileOpen() ? 'Elevation profile expanded' : 'Elevation profile collapsed'" @click="profileOpen.set(!profileOpen())" :text="profileOpen() ? 'Hide' : 'Show'" /></View>
300324 <View :show="profileOpen">
@@ -307,7 +331,7 @@ onMount(() => onNativeAccountChange(craft, () => { viewer.set(0); saved.set(fals
307331 </View>
308332 <Text :show="!profile().some(point => point.ft !== null)" :class="ui.caption">Elevation data is unavailable for this route.</Text>
309333 <Text :show="profile().some(point => point.ft !== null)" :text="profileFigures().miles.toFixed(1) + ' mi · ' + Math.round(profileFigures().low) + '–' + Math.round(profileFigures().high) + ' ft'" :class="ui.caption" />
310 <Slider :show="profile().length > 1" testID="trail-elevation-slider" accessibilityLabel="Explore elevation and position along the route" :value="profilePosition" :minimumValue="0" :maximumValue="profile().length - 1" :step="1" @change="inspect($event)" />
334 <Slider :show="profile().length > 1" testID="trail-elevation-slider" accessibilityLabel="Explore elevation and position along the route" :value="profilePosition" :minimumValue="0" :maximumValue="Math.max(0,profile().length - 1)" :step="1" @change="inspect($event)" />
311335 <Text :show="inspected()" :text="inspected()?.mi.toFixed(2) + ' mi, ' + (inspected()?.ft === null ? 'elevation unavailable' : inspected()?.ft + ' ft')" :class="ui.caption" />
312336 </View>
313337 </View>
@@ -321,8 +345,7 @@ onMount(() => onNativeAccountChange(craft, () => { viewer.set(0); saved.set(fals
321345 <View class="mt-2 flex-row flex-wrap gap-2">
322346 <Button testID="trail-map-3d" :class="ui.secondary" @click="pitch.set(pitch() > 0 ? 0 : 45)" :text="pitch() > 0 ? '2D' : '3D'" />
323347 <Button :class="ui.secondary" @click="heading.set(0)">North</Button>
324 <Button testID="trail-map-locate" :class="ui.secondary" @click="locate()">My location</Button>
325 <Button testID="trail-map-fullscreen" :class="ui.secondary" @click="craft.navigation.open('/trail/' + wanted + '/map')">Expand map</Button>
348 <Button testID="trail-map-locate" :class="ui.secondary" accessibilityLabel="My location" :accessibilityValue="showLocation() ? 'Your location is shown' : ''" @click="locate()">My location</Button>
326349 </View>
327350 <Button testID="trail-map-export" :disabled="!routeAvailable()" :class="ui.secondary" class="mt-2" @click="exportRoute()">Export GPX</Button>
328351 <Button testID="trail-map-layer" :class="ui.secondary" :title="'Map layer' + ': ' + nativeChoiceLabel(nativeBasemapOptions,mapType())" accessibilityLabel="Map layer" :accessibilityValue="nativeChoiceLabel(nativeBasemapOptions,mapType())" @click="chooseNativeChoice(craft,'Map layer',nativeBasemapOptions,choice=>{mapType.set(choice)},message=>error.set(message))" />
@@ -330,15 +353,30 @@ onMount(() => onNativeAccountChange(craft, () => { viewer.set(0); saved.set(fals
330353 </View>
331354 <Text :class="ui.header">Conditions</Text>
332355 <View :class="ui.card">
333 <Text :class="ui.title">Weather</Text>
356 <Text testID="trail-weather-heading" :class="ui.title">Weather</Text>
334357 <Text :show="!forecast()" :class="ui.caption">No forecast is available right now. Pull down to retry.</Text>
335358 <View :show="forecast()" class="gap-3 mt-2">
336 <Text :show="forecast()?.current" :text="Math.round(forecast()?.current.temperature) + '°F, ' + forecast()?.current.label" :class="ui.body" />
337 <Text :for="(day, index) in forecast()?.days ?? []" :key="day.date" :text="weatherLine(day, index)" :class="ui.body" />
359 <View :show="forecast()?.current" class="gap-1 py-2">
360 <Text :text="Math.round(forecast()?.current.temperature) + '°F'" class="text-[42px] font-semibold text-[#192414] dark:text-[#f0f5ec]" />
361 <Text :text="forecast()?.current.label" :class="ui.body" />
362 </View>
363 <View :for="(day, index) in forecast()?.days ?? []" :key="day.date" class="gap-1 py-3 border-t border-[#e7edde] dark:border-[#314536]">
364 <View class="flex-row items-center justify-between gap-3">
365 <Text :text="forecastDayLabel(day.date, index)" :class="ui.title" />
366 <Text :text="Math.round(day.low) + '°–' + Math.round(day.high) + '°F'" :class="ui.body" />
367 </View>
368 <Text :text="day.label" :class="ui.caption" />
369 <Text :show="Number.isFinite(day.windMph) && Number.isFinite(day.precipitationMm)" :text="Math.round(day.windMph) + ' mph wind · ' + (day.precipitationMm / 25.4).toFixed(2) + ' in precipitation'" :class="ui.caption" />
370 </View>
338371 <Button @click="craft.linking.openURL(forecast()?.attribution.url)" :class="ui.caption" :text="forecast()?.attribution.label" />
339372 </View>
340 <Text class="mt-5" :class="ui.title">Surface types</Text>
341 <Text :for="surface in surfaces()" :key="surface.label" :text="surface.label" :class="ui.body" />
373 </View>
374 <View :class="ui.card" class="mt-3 gap-2">
375 <Text testID="trail-surfaces-heading" :class="ui.title">Surface types</Text>
376 <View :for="surface in surfaces()" :key="surface.label" :class="ui.row">
377 <View class="h-5 w-5 rounded-[4px]" :style="{backgroundColor:surface.color}" />
378 <Text :text="surface.label" :class="ui.body" />
379 </View>
342380 <Text :show="!surfaces().length" :class="ui.caption">Surface information has not been recorded for this trail.</Text>
343381 </View>
344382 <Text :class="ui.header">Reviews</Text>
resources/native/TrailMap.stxmodified+4-4
Changes to resources/native/TrailMap.stx
@@ -102,9 +102,9 @@ onMount(() => { void load(); return () => { alive = false } })
102102 @region-change-complete="camera($event)">
103103 <MapPolyline :for="(part,index) in parts()" :key="'route-outline-' + index" :id="'route-outline-' + index" :geometry="part" :strokeColor="trailRouteStyle.casing" :strokeWidth="9" />
104104 <MapPolyline :for="(part,index) in parts()" :key="index" :id="'route-' + index" :geometry="part" :strokeColor="trailRouteStyle.line" :strokeWidth="5" />
105 <MapMarker :for="(sign,index) in signs()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" title="Recorded route direction" />
106 <MapMarker id="trailhead" title="Trailhead" :geometry="[trailhead()?.lat, trailhead()?.lng]" />
107 <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" :geometry="[inspected()?.lat, inspected()?.lng]" />
105 <MapMarker :for="(sign,index) in signs()" :key="index" :id="'direction-' + index" :geometry="sign.point" :bearing="sign.bearing" :pinColor="trailRouteStyle.casing" title="Recorded route direction" />
106 <MapMarker id="trailhead" title="Trailhead" pinColor="#24724b" :geometry="[trailhead()?.lat, trailhead()?.lng]" />
107 <MapMarker :show="inspected()" id="elevation-point" title="Elevation position" pinColor="#176bad" :geometry="[inspected()?.lat, inspected()?.lng]" />
108108 </Map>
109109 <View class="absolute top-3 left-3 right-3 flex-row flex-wrap gap-2">
110110 <Button testID="map-layer" class="bg-white dark:bg-[#1b2c22] rounded-[16px]" :class="ui.secondary" :title="'Map layer' + ': ' + nativeChoiceLabel(nativeBasemapOptions,mapType())" accessibilityLabel="Map layer" :accessibilityValue="nativeChoiceLabel(nativeBasemapOptions,mapType())" @click="chooseNativeChoice(craft,'Map layer',nativeBasemapOptions,choice=>{mapType.set(choice)},message=>error.set(message))" />
@@ -138,7 +138,7 @@ onMount(() => { void load(); return () => { alive = false } })
138138 <Button testID="map-fit" class="flex-1" :class="ui.secondary" @click="fit()">Fit route</Button>
139139 <Button testID="map-zoom-in" class="w-11" :class="ui.secondary" @click="region.set(nativeZoomRegion(currentRegion(),0.5))" accessibilityLabel="Zoom in">+</Button>
140140 <Button testID="map-zoom-out" class="w-11" :class="ui.secondary" @click="region.set(nativeZoomRegion(currentRegion(),2))" accessibilityLabel="Zoom out">−</Button>
141 <Button testID="map-locate" class="flex-1" :class="ui.secondary" :accessibilityValue="showLocation() ? 'Your location is shown' : ''" @click="locate()">My location</Button>
141 <Button testID="map-locate" class="flex-1" :class="ui.secondary" accessibilityLabel="My location" :accessibilityValue="showLocation() ? 'Your location is shown' : ''" @click="locate()">My location</Button>
142142 </View>
143143 <View class="flex-row gap-2">
144144 <Button testID="map-export" class="flex-1" :class="ui.secondary" @click="exportRoute()">Export GPX</Button>
resources/native/Trails.stxmodified+3-3
Changes to resources/native/Trails.stx
@@ -175,13 +175,13 @@ onMount(() => onNativeAccountChange(craft, () => { rows.set([]); mine.set([]); v
175175 @input="query.set(eventText($event))"
176176 />
177177 <View class="flex-row gap-2">
178 <Button testID="trails-submit" @click="search()">Search</Button>
179 <Button :show="query() !== ''" testID="trails-clear" @click="clear()">Clear</Button>
178 <Button testID="trails-submit" :class="ui.action + ' flex-1'" @click="search()">Search</Button>
179 <Button :show="query() !== ''" testID="trails-clear" :class="ui.secondary" @click="clear()">Clear</Button>
180180 </View>
181181 </View>
182182
183183 <View :show="listMode() === 'all'" class="mt-3 gap-3">
184 <View class="flex-row gap-3"><Button testID="trails-near-me" @click="locate()">Near me</Button><Button @click="showFilters.set(!showFilters())">Filters</Button><Button :show="nearby()" @click="nearby.set(null);load()">Anywhere</Button></View>
184 <View class="flex-row gap-3"><Button testID="trails-near-me" :class="ui.secondary" @click="locate()">Near me</Button><Button :class="ui.secondary" @click="showFilters.set(!showFilters())">Filters</Button><Button :show="nearby()" :class="ui.secondary" @click="nearby.set(null);load()">Anywhere</Button></View>
185185 <View :show="showFilters" class="gap-3 rounded-xl bg-white dark:bg-[#1b2c22] p-4">
186186 <Text class="text-[#51604e] dark:text-[#b0bfac]">Difficulty</Text><Button testID="trails-difficulty" :class="ui.secondary" :title="'Difficulty' + ': ' + nativeChoiceLabel([{label:'Any difficulty',value:'all'},{label:'Easy',value:'easy'},{label:'Moderate',value:'moderate'},{label:'Hard',value:'hard'}],difficulty())" accessibilityLabel="Difficulty" :accessibilityValue="nativeChoiceLabel([{label:'Any difficulty',value:'all'},{label:'Easy',value:'easy'},{label:'Moderate',value:'moderate'},{label:'Hard',value:'hard'}],difficulty())" @click="chooseNativeChoice(craft,'Difficulty',[{label:'Any difficulty',value:'all'},{label:'Easy',value:'easy'},{label:'Moderate',value:'moderate'},{label:'Hard',value:'hard'}],choice=>{difficulty.set(choice);load()},message=>error.set(message))" />
187187 <Text class="text-[#51604e] dark:text-[#b0bfac]">Route type</Text><Button :class="ui.secondary" :title="'Route type' + ': ' + nativeChoiceLabel([{label:'Any route',value:'all'},{label:'Loop',value:'loop'},{label:'Out and back',value:'out-and-back'},{label:'Point to point',value:'point-to-point'}],routeType())" accessibilityLabel="Route type" :accessibilityValue="nativeChoiceLabel([{label:'Any route',value:'all'},{label:'Loop',value:'loop'},{label:'Out and back',value:'out-and-back'},{label:'Point to point',value:'point-to-point'}],routeType())" @click="chooseNativeChoice(craft,'Route type',[{label:'Any route',value:'all'},{label:'Loop',value:'loop'},{label:'Out and back',value:'out-and-back'},{label:'Point to point',value:'point-to-point'}],choice=>{routeType.set(choice);load()},message=>error.set(message))" />
scripts/build-mobile.tsmodified+0-2
Changes to scripts/build-mobile.ts
@@ -1,8 +1,6 @@
11import process from 'node:process'
2import mobile from '../config/mobile'
32
43const platform=process.argv[2]
54if(platform!=='ios' && platform!=='android')throw new Error('Usage: bun scripts/build-mobile.ts ios|android')
6if(platform==='android' && !mobile.android.mapsApiKey?.trim())throw new Error('Set ANDROID_MAPS_API_KEY to a Google Maps SDK for Android key before building Wildloop. Native trail maps require this configured SDK credential.')
75const build=Bun.spawn(['./buddy',`build:${platform}`,...process.argv.slice(3)],{stdout:'inherit',stderr:'inherit',stdin:'inherit'})
86process.exitCode=await build.exited
scripts/run-mobile-e2e.tsmodified+20-26
Changes to scripts/run-mobile-e2e.ts
@@ -98,10 +98,9 @@ export function selectAndroidDeepLinkActivity(output: string, packageName: strin
9898 return null
9999}
100100
101export function selectAndroidHomePackage(output: string): string | null {
102 const component = output.split(/\s+/).find(value => value.includes('/'))
103 const packageName = component?.split('/')[0]
104 return packageName || null
101export function assertAndroidPlatformHealthy(events: string): void {
102 if (/am_(anr|crash)/.test(events))
103 throw new Error('Android platform crash/ANR invalidates native acceptance. Inspect the emulator diagnostics.')
105104}
106105
107106export function selectIosSimulator(payload: SimctlDevices): IosDevice | null {
@@ -114,10 +113,11 @@ export function selectIosSimulator(payload: SimctlDevices): IosDevice | null {
114113 return candidates.find(device => device.state === 'Booted') ?? candidates[0] ?? null
115114}
116115
117export function maestroReportSummary(xml: string): { failures: number, tests: number } {
116export function maestroReportSummary(xml: string): { failures: number, tests: number, skipped: number } {
118117 return {
119118 failures: xml.match(/<(?:failure|error)\b/g)?.length ?? 0,
120119 tests: xml.match(/<testcase\b/g)?.length ?? 0,
120 skipped: xml.match(/<skipped\b/g)?.length ?? 0,
121121 }
122122}
123123
@@ -288,6 +288,7 @@ function runMaestroFlow(
288288 const summary = maestroReportSummary(readFileSync(report, 'utf8'))
289289 if (summary.tests === 0) throw new Error(`Maestro ran no ${platform} tests`)
290290 if (summary.failures > 0) throw new Error(`Maestro reported ${summary.failures} failed ${platform} test(s)`)
291 if (summary.skipped > 0) throw new Error(`Maestro skipped ${summary.skipped} required ${platform} test(s)`)
291292}
292293
293294function runMaestroJourneys(platform: MobilePlatform, deviceId: string, signedInURL: string | null): void {
@@ -348,19 +349,10 @@ function seedTestLocation(platform: MobilePlatform, deviceId: string): void {
348349 ])
349350}
350351
351/**
352 * Google APIs images occasionally leave Pixel Launcher in an ANR state while
353 * Wildloop is foregrounded. The launcher is not part of this journey, so stop
354 * that background process before Maestro launches the app under test.
355 */
356function stopAndroidHomeLauncher(deviceId: string): void {
357 const home = execute([
358 'adb', '-s', deviceId, 'shell', 'cmd', 'package', 'resolve-activity', '--brief',
359 '-a', 'android.intent.action.MAIN', '-c', 'android.intent.category.HOME',
360 ], { capture: true })
361 const packageName = selectAndroidHomePackage(home)
362 if (packageName)
363 execute(['adb', '-s', deviceId, 'shell', 'am', 'force-stop', packageName])
352function checkAndroidPlatform(deviceId: string): void {
353 assertAndroidPlatformHealthy(execute([
354 'adb', '-s', deviceId, 'logcat', '-b', 'events', '-d', '-v', 'threadtime',
355 ], { capture: true }))
364356}
365357
366358function appId(platform: MobilePlatform): string {
@@ -372,21 +364,23 @@ function appId(platform: MobilePlatform): string {
372364function runAndroid(preview: boolean): void {
373365 requireCommand('adb')
374366 const devices = parseAdbDevices(execute(['adb', 'devices'], { capture: true }))
375 if (devices.length !== 1) throw new Error(`Expected exactly one ready Android device, found ${devices.length}`)
367 const device = process.env.ANDROID_SERIAL ?? (devices.length === 1 ? devices[0] : undefined)
368 if (!device || !devices.includes(device)) throw new Error('Select a ready Android device with ANDROID_SERIAL, or connect exactly one device.')
376369
377370 const apk = process.env.MOBILE_E2E_APP
378371 ?? join(generatedRoot, 'android/app/build/outputs/apk/debug/app-debug.apk')
379372 requirePath(apk, 'Android E2E APK')
380 execute(['adb', '-s', devices[0], 'install', '-r', apk])
373 execute(['adb', '-s', device, 'install', '-r', apk])
381374 if (preview) {
382 execute(['adb', '-s', devices[0], 'shell', 'am', 'force-stop', appId('android')])
383 execute(['adb', '-s', devices[0], 'shell', 'monkey', '-p', appId('android'), '-c', 'android.intent.category.LAUNCHER', '1'])
384 console.log(`Wildloop is open on Android device ${devices[0]}.`)
375 execute(['adb', '-s', device, 'shell', 'am', 'force-stop', appId('android')])
376 execute(['adb', '-s', device, 'shell', 'monkey', '-p', appId('android'), '-c', 'android.intent.category.LAUNCHER', '1'])
377 console.log(`Wildloop is open on Android device ${device}.`)
385378 }
386379 else {
387 stopAndroidHomeLauncher(devices[0])
388 seedTestLocation('android', devices[0])
389 runMaestroJourneys('android', devices[0], null)
380 checkAndroidPlatform(device)
381 seedTestLocation('android', device)
382 runMaestroJourneys('android', device, null)
383 checkAndroidPlatform(device)
390384 }
391385}
392386
scripts/run-native-trail-android-e2e.tsadded+166-0
Changes to scripts/run-native-trail-android-e2e.ts
@@ -0,0 +1,166 @@
1import { mkdir, readFile, writeFile } from 'node:fs/promises'
2import { join, resolve } from 'node:path'
3import process from 'node:process'
4import mobile from '../config/mobile'
5import { QA_PORTS } from '../tests/browser/qa-ports'
6import { QA_SOCIAL_ACCOUNT } from '../tests/browser/qa-social-account'
7import { decodeRouteParts, primaryRoutePart } from '../resources/functions/trail-geometry'
8import { assertAndroidPlatformHealthy, maestroEnvironment, maestroReportSummary, parseAdbDevices, validateGeneratedNativeApp } from './run-mobile-e2e'
9
10interface PersistedReview { id: number, rating: number, content: string }
11
12/** Check server persistence independently of the native screen's local state. */
13export function assertNativeReviewPersistence(
14 reviews: PersistedReview[], phase: 'create' | 'edit' | 'delete', content: string, previousId?: number,
15): number | undefined {
16 if (phase === 'delete') {
17 if (reviews.length) throw new Error('The native delete did not remove the persisted review.')
18 return
19 }
20 const rating = phase === 'create' ? 5 : 4
21 if (reviews.length !== 1 || reviews[0].content !== content || reviews[0].rating !== rating)
22 throw new Error(`The native ${phase} did not persist the expected review and rating.`)
23 if (phase === 'edit' && reviews[0].id !== previousId)
24 throw new Error('Editing created a second review instead of updating the original.')
25 return reviews[0].id
26}
27
28/** Use only an owned emulator, a disposable database and normally published SDKs. */
29export async function runNativeTrailAndroidE2e(): Promise<void> {
30 const root = resolve('storage/framework/runtime/e2e/native-trail-android')
31 const project = resolve('storage/framework/mobile/android')
32 const origin = `http://127.0.0.1:${QA_PORTS.app}`
33 await mkdir(root, { recursive: true })
34 async function command(args: string[], name: string, env: Record<string, string> = {}): Promise<string> {
35 console.log(`Native Android trail QA: ${name}`)
36 const child = Bun.spawn(args, { env: { ...process.env, ...env }, stdout: 'pipe', stderr: 'pipe' })
37 const [stdout, stderr, status] = await Promise.all([
38 new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
39 ])
40 await writeFile(join(root, `${name}.log`), stdout + stderr)
41 if (status) throw new Error(`${args[0]} exited ${status}; see ${name}.log`)
42 return stdout
43 }
44 if (!process.argv.includes('--skip-build')) {
45 await command(['bun', 'run', 'build:android'], 'app-build', { MOBILE_URL: `${origin}/feed` })
46 }
47 validateGeneratedNativeApp(project)
48 const config = JSON.parse(await readFile(join(project, 'craft.config.json'), 'utf8'))
49 if (config.devServerURL !== `${origin}/feed` || config.mapProvider !== 'maplibre')
50 throw new Error('Android trail QA requires its own loopback API and the native MapLibre provider.')
51 const gradle = await readFile(join(project, 'app/build.gradle.kts'), 'utf8')
52 if (!gradle.includes('org.maplibre.gl:android-sdk:') || gradle.includes('play-services-maps'))
53 throw new Error('Android trail QA requires the real key-free native map SDK.')
54 if (process.argv.includes('--build-only')) return
55
56 const devices = parseAdbDevices(await command(['adb', 'devices'], 'devices'))
57 const device = process.env.NATIVE_QA_ANDROID_DEVICE
58 if (!device || !/^emulator-\d+$/.test(device) || !devices.includes(device)) throw new Error('NATIVE_QA_ANDROID_DEVICE must name an owned, ready emulator.')
59 const adb = (args: string[], name: string) => command(['adb', '-s', device, ...args], name)
60 const platformEvents = async (phase: string) => {
61 const events = await adb(['logcat', '-b', 'events', '-d', '-v', 'threadtime'], `platform-${phase}`)
62 assertAndroidPlatformHealthy(events)
63 }
64 await platformEvents('before')
65 for (const port of [QA_PORTS.app, QA_PORTS.api]) {
66 const occupied = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1000) }).then(() => true).catch(() => false)
67 if (occupied) throw new Error(`QA port ${port} is occupied; select a different QA_PORT_BASE.`)
68 }
69 const qa = Bun.spawn(['bun', 'scripts/start-recording-qa.ts'], { stdout: Bun.file(join(root, 'qa-server.log')), stderr: 'inherit' })
70 const forwards: number[] = []
71 try {
72 const deadline = Date.now() + 180000
73 while (!await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(1000) }).then(r => r.ok).catch(() => false)) {
74 if (qa.exitCode !== null || Date.now() > deadline) throw new Error('Isolated native QA server did not become ready.')
75 await Bun.sleep(250)
76 }
77 for (const port of [QA_PORTS.app, QA_PORTS.api]) {
78 await adb(['reverse', `tcp:${port}`, `tcp:${port}`], `reverse-${port}`)
79 forwards.push(port)
80 }
81 const listed = await (await fetch(`${origin}/api/trails?q=Craggy&country=all&limit=10`)).json() as any
82 let trail: any
83 for (const candidate of listed.trails ?? []) {
84 const detail = await (await fetch(`${origin}/api/trails/${candidate.id}`)).json() as any
85 if (detail.trail?.hasGeometry) { trail = detail.trail; break }
86 }
87 if (!trail) throw new Error('Native Android QA requires its seeded recorded route.')
88 const profileResponse = await fetch(`${origin}/api/trails/${trail.id}/profile`)
89 if (!profileResponse.ok) throw new Error('Native Android QA requires the trail profile endpoint to be available.')
90 const profile = (await profileResponse.json()) as { points: Array<{ mi: number, ft: number | null }> }
91 const endpoint = profile.points?.at(-1)
92 if (!endpoint || profile.points.length < 2 || !Number.isFinite(endpoint.mi) || (endpoint.ft !== null && !Number.isFinite(endpoint.ft)))
93 throw new Error('Native Android QA requires a selectable recorded route profile.')
94 const endElevation = endpoint.ft === null ? 'elevation unavailable' : `${endpoint.ft} ft`
95 const first = primaryRoutePart([...decodeRouteParts(trail.geometry), ...(Array.isArray(trail.pieceRoutes) ? trail.pieceRoutes : [])])[0]
96 if (!first) throw new Error('The seeded trail has no recorded route start.')
97 await adb(['emu', 'geo', 'fix', String(first[1]), String(first[0])], 'seed-location')
98 const reviews = async (): Promise<PersistedReview[]> => {
99 const response = await fetch(`${origin}/api/trails/${trail.id}/reviews`, { cache: 'no-store' })
100 if (!response.ok) throw new Error(`Reading persisted reviews failed: ${response.status}`)
101 return ((await response.json()) as any).reviews
102 }
103 if ((await reviews()).length) throw new Error('The isolated trail must start without reviews.')
104 await adb(['install', '-r', join(project, 'app/build/outputs/apk/debug/app-debug.apk')], 'app-install')
105 const marker = crypto.randomUUID().slice(0, 8)
106 const review = `Android QA ${marker}: clear signs and a beautiful ridge.`
107 const edit = `Android QA ${marker}: updated after a return visit.`
108 const variables = {
109 APP_ID: mobile.android.packageName, TRAIL_ID: String(trail.id), TRAIL_NAME: trail.name,
110 E2E_EMAIL: QA_SOCIAL_ACCOUNT.email, E2E_PASSWORD: QA_SOCIAL_ACCOUNT.sessionSecret,
111 REVIEW_TEXT: review, EDITED_REVIEW_TEXT: edit,
112 PROFILE_END_TEXT: `${endpoint.mi.toFixed(2)} mi · ${endElevation}`,
113 INLINE_PROFILE_END_TEXT: `${endpoint.mi.toFixed(2)} mi, ${endElevation}`,
114 GPX_NAME: `${trail.name.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 80)}.gpx`,
115 }
116 let reviewId: number | undefined
117 for (const phase of ['maps', 'utilities', 'sharing', 'directions', 'directions-map', 'review-create', 'review-edit', 'review-delete', 'appearance'] as const) {
118 const report = join(root, `${phase}.xml`)
119 if (phase === 'appearance') await adb(['shell', 'cmd', 'uimode', 'night', 'yes'], 'system-dark-appearance')
120 await command([
121 'maestro', `--device=${device}`, '--no-ansi', 'test',
122 ...Object.entries(variables).flatMap(([key, value]) => ['--env', `${key}=${value}`]),
123 '--format', 'junit', '--output', report, '--debug-output', join(root, 'debug', phase),
124 '--test-output-dir', join(root, 'tests', phase), `.maestro/native-trail/${phase}.yaml`,
125 ], phase, maestroEnvironment())
126 const summary = maestroReportSummary(await readFile(report, 'utf8'))
127 if (!summary.tests || summary.failures || summary.skipped) throw new Error(`Native Android ${phase} did not execute and pass every required test.`)
128 if (phase.startsWith('review-')) {
129 const saved = await reviews()
130 await writeFile(join(root, `${phase}-persistence.json`), JSON.stringify(saved, null, 2))
131 const action = phase === 'review-create' ? 'create' : phase === 'review-edit' ? 'edit' : 'delete'
132 reviewId = assertNativeReviewPersistence(saved, action, action === 'create' ? review : edit, reviewId)
133 }
134 if (phase === 'utilities') {
135 const files = await adb(['shell', 'run-as', mobile.android.packageName, 'find', '.', '-type', 'f'], 'export-files')
136 const paths = files.split(/\r?\n/).filter(path => /^[a-zA-Z0-9_./-]+\.gpx$/.test(path))
137 let exported = false
138 for (const path of paths) {
139 const xml = await adb(['exec-out', 'run-as', mobile.android.packageName, 'cat', path], 'exported-gpx')
140 if (xml.includes('<gpx ') && xml.includes('<trkseg>') && xml.includes(`<trkpt lat="${first[0]}" lon="${first[1]}"`)) exported = true
141 }
142 if (!exported) throw new Error('The native GPX export did not contain the recorded route coordinates.')
143 }
144 if (phase === 'directions' || phase === 'directions-map') {
145 const activities = await adb(['shell', 'dumpsys', 'activity', 'activities'], `${phase}-activities`)
146 const resumed = activities.split('\n').filter(line => /(?:mResumedActivity|topResumedActivity):/.test(line))
147 const expectedHandler = /\b(?:com\.google\.android\.apps\.maps|com\.android\.chrome)\//
148 const unfinishedActivity = /(?:ResolverActivity|ChooserActivity|FirstRunActivity)\b/
149 if (!resumed.length || resumed.some(line => !expectedHandler.test(line) || unfinishedActivity.test(line)))
150 throw new Error('Directions did not reach ready Chrome or Google Maps on the stock Play test device.')
151 }
152 await platformEvents(phase)
153 }
154 console.log('Native Android maps, sticky actions, location, GPX, route customization, actual share-link paste and persisted review create/edit/delete passed.')
155 }
156 finally {
157 await adb(['logcat', '-d', '-s', 'CraftNativeAndroid:V', 'AndroidRuntime:E'], 'app-runtime').catch(() => {})
158 await adb(['shell', 'uiautomator', 'dump', '/sdcard/wildloop-trail-final.xml'], 'final-tree').then(() =>
159 adb(['pull', '/sdcard/wildloop-trail-final.xml', join(root, 'final-tree.xml')], 'pull-final-tree')).catch(() => {})
160 for (const port of forwards) await adb(['reverse', '--remove', `tcp:${port}`], `remove-reverse-${port}`).catch(() => {})
161 qa.kill('SIGTERM')
162 await qa.exited
163 }
164}
165
166if (import.meta.main) await runNativeTrailAndroidE2e()
scripts/run-native-trail-e2e.tsmodified+40-14
Changes to scripts/run-native-trail-e2e.ts
@@ -4,7 +4,7 @@ import process from 'node:process'
44import { generateXcodeProject, resolveXcodegen } from '@stacksjs/actions/build/ios-toolchain'
55import { prepareIosSimulatorDriver, serializeSimulatorSteps } from '@stacksjs/mobile/testing'
66import mobile from '../config/mobile'
7import { nativeTrailJourneys } from '../tests/native/trail-journey'
7import { nativeTrailJourneys, selectNativeTrailJourneys } from '../tests/native/trail-journey'
88import { QA_PORTS } from '../tests/browser/qa-ports'
99import { QA_SOCIAL_ACCOUNT } from '../tests/browser/qa-social-account'
1010import { decodeRouteParts, primaryRoutePart } from '../resources/functions/trail-geometry'
@@ -50,8 +50,23 @@ export async function runNativeTrailE2e(): Promise<void> {
5050 if(detail.trail?.hasGeometry){trail=detail.trail;break}
5151 }
5252 if(!trail)throw new Error('Native trail QA requires its seeded recorded route.')
53 const profileResponse=await fetch(`${origin}/api/trails/${trail.id}/profile`)
54 if(!profileResponse.ok)throw new Error('Native iOS QA requires the trail profile endpoint to be available.')
55 const profile=await profileResponse.json() as {points:Array<{mi:number,ft:number|null}>}
56 const endpoint=profile.points?.at(-1)
57 if(!endpoint || profile.points.length<2 || !Number.isFinite(endpoint.mi) || (endpoint.ft!==null && !Number.isFinite(endpoint.ft)))
58 throw new Error('Native iOS QA requires a selectable recorded route profile.')
59 const endElevation=endpoint.ft===null ? 'elevation unavailable' : `${endpoint.ft} ft`
5360 const review=`Native QA ${crypto.randomUUID().slice(0,8)}: clear signposts and a beautiful ridge.`
54 const journeys=nativeTrailJourneys({trailId:trail.id,trailName:trail.name,email:QA_SOCIAL_ACCOUNT.email,password:QA_SOCIAL_ACCOUNT.sessionSecret,review,edit:' Updated after a return visit.'})
61 const edit='Updated after a return visit.'
62 const readReviews=async()=>{
63 const response=await fetch(`${origin}/api/trails/${trail.id}/reviews`,{cache:'no-store'})
64 if(!response.ok)throw new Error(`Reading persisted reviews failed: ${response.status}`)
65 return ((await response.json()) as any).reviews as Array<{id:number,content:string,rating:number}>
66 }
67 if((await readReviews()).length)throw new Error('The isolated native trail must start without reviews.')
68 let createdReviewId:number|undefined
69 const journeys=selectNativeTrailJourneys(nativeTrailJourneys({trailId:trail.id,trailName:trail.name,email:QA_SOCIAL_ACCOUNT.email,password:QA_SOCIAL_ACCOUNT.sessionSecret,review,edit,profileEndText:`${endpoint.mi.toFixed(2)} mi · ${endElevation}`,inlineProfileEndText:`${endpoint.mi.toFixed(2)} mi, ${endElevation}`}),process.env.NATIVE_TRAIL_GROUP)
5570 const controlPort=QA_PORTS.app+15
5671 const driver=prepareIosSimulatorDriver({...mobile.ios,simulator:{output:join(root,'driver'),projectName:'WildloopTrailDriver',bundleIdPrefix:'org.wildloop.trailqa',controlUrl:`http://127.0.0.1:${controlPort}`}})
5772 control=Bun.serve({hostname:'127.0.0.1',port:controlPort,idleTimeout:120,async fetch(request){
@@ -60,23 +75,34 @@ export async function runNativeTrailE2e(): Promise<void> {
6075 if(url.pathname==='/exec') {
6176 try {
6277 const hook=url.searchParams.get('name')
78 if(hook==='verify-review-created' || hook==='verify-review-edited' || hook==='verify-review-deleted') {
79 const rows=await readReviews()
80 await writeFile(join(root,`${hook}-persistence.json`),JSON.stringify(rows,null,2))
81 if(hook==='verify-review-deleted') {
82 if(rows.length)throw new Error('The native deletion did not remove the persisted review.')
83 return new Response('PASS deleted review is absent from persistence')
84 }
85 if(rows.length!==1)throw new Error('The native review action must persist exactly one review.')
86 if(hook==='verify-review-created') {
87 if(rows[0].content!==review || rows[0].rating!==5)throw new Error('The created review text and rating did not persist.')
88 createdReviewId=rows[0].id
89 return new Response('PASS created review text and rating persisted')
90 }
91 if(rows[0].id!==createdReviewId || rows[0].rating!==4 || rows[0].content!==edit)
92 throw new Error('The native edit did not retain the review identity and persist its new text and rating.')
93 return new Response('PASS edited review retained its identity and persisted')
94 }
95 if(hook==='appearance-dark' || hook==='appearance-light') {
96 const appearance=hook==='appearance-dark' ? 'dark' : 'light'
97 await command(['xcrun','simctl','ui',device,'appearance',appearance],`${hook}.log`)
98 return new Response(`PASS platform appearance ${appearance}`)
99 }
63100 if(hook==='seed-location') {
64101 const first=primaryRoutePart([...decodeRouteParts(trail.geometry),...(Array.isArray(trail.pieceRoutes)?trail.pieceRoutes:[])])[0]
65102 if(!first)throw new Error('The seeded trail has no recorded route start.')
66103 await command(['xcrun','simctl','location',device,'set',`${first[0]},${first[1]}`],'location-seed.log')
67104 return new Response('PASS owned simulator location set to the seeded public trailhead')
68105 }
69 if(hook==='verify-share') {
70 console.log('Native trail QA: inspect the owned simulator share clipboard')
71 const pasted=Bun.spawnSync(['xcrun','simctl','pbpaste',device],{timeout:15000,killSignal:'SIGKILL',maxBuffer:1048576})
72 await writeFile(join(root,'sharing-clipboard.log'),JSON.stringify({exitCode:pasted.exitCode,signalCode:pasted.signalCode,stdout:pasted.stdout.toString(),stderr:pasted.stderr.toString()},null,2))
73 if(pasted.exitCode!==0 || !pasted.stdout.toString().includes(`https://wildloop.org/trail/${trail.id}`)) {
74 const verbose=Bun.spawnSync(['xcrun','simctl','pbpaste','-v',device],{timeout:15000,killSignal:'SIGKILL',maxBuffer:1048576})
75 await writeFile(join(root,'sharing-clipboard-types.log'),JSON.stringify({exitCode:verbose.exitCode,signalCode:verbose.signalCode,stdout:verbose.stdout.toString(),stderr:verbose.stderr.toString()},null,2))
76 throw new Error('The native share action did not copy this trail link. Inspect sharing-clipboard.log and sharing-clipboard-types.log.')
77 }
78 return new Response('PASS native share copied the trail link')
79 }
80106 if(hook==='verify-gpx') {
81107 const located=Bun.spawnSync(['xcrun','simctl','get_app_container',device,mobile.ios.bundleId,'data'])
82108 if(located.exitCode)throw new Error('Could not inspect the owned simulator application container.')
@@ -154,7 +180,7 @@ export async function runNativeTrailE2e(): Promise<void> {
154180 }
155181 }
156182 if(failures.length)throw new Error(`Native trail QA failed: ${failures.join(', ')}. Inspect the separate XCTest results, driver logs and screenshots.`)
157 console.log('Native trail maps, sticky save, review lifecycle, sharing, GPX export, route planner and location journeys passed.')
183 console.log(`Native trail journeys passed: ${journeys.map(journey=>journey.name).join(', ')}.`)
158184 }
159185 finally {
160186 if(nativeRun && nativeRun.exitCode===null)nativeRun.kill()
scripts/run-owned-android-e2e.tsadded+58-0
Changes to scripts/run-owned-android-e2e.ts
@@ -0,0 +1,58 @@
1import { dirname, join, resolve } from 'node:path'
2import { createAndroidEmulator } from 'craft-native/android'
3
4/** Run the installed app on a disposable device owned by the published SDK. */
5export async function runOwnedAndroidE2e(suite: string | undefined): Promise<void> {
6 if (suite !== 'baseline' && suite !== 'native-trail')
7 throw new Error('Select the baseline or native-trail Android journey.')
8 const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
9 const sdkmanager = Bun.which('sdkmanager')
10 const avdmanager = Bun.which('avdmanager')
11 if (!sdkRoot || !sdkmanager || !avdmanager)
12 throw new Error('Install the normal Android SDK command-line tools before native QA.')
13 const root = resolve(`storage/framework/runtime/e2e/${suite === 'native-trail' ? 'native-trail-android' : 'android'}/emulator`)
14 const abi = process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
15 const session = await createAndroidEmulator({
16 sdkRoot, sdkmanager, avdmanager,
17 image: `system-images;android-35;google_apis_playstore;${abi}`,
18 acceleration: 'hardware', acceptLicenses: true, artifactsDirectory: root,
19 })
20 try {
21 const args = suite === 'native-trail'
22 ? ['bun', 'scripts/run-native-trail-android-e2e.ts', '--skip-build']
23 : ['bun', 'scripts/run-mobile-e2e.ts', 'android', '--skip-build']
24 const test = Bun.spawn(args, {
25 env: {
26 ...session.env, PATH: `${dirname(session.adb)}:${session.env.PATH ?? ''}`,
27 NATIVE_QA_ANDROID_DEVICE: session.serial,
28 },
29 stdout: 'inherit', stderr: 'inherit',
30 })
31 const status = await test.exited
32 if (status !== 0) {
33 for (const [name, args] of [
34 ['device-state', ['get-state']],
35 ['platform-events', ['logcat', '-b', 'events', '-d', '-v', 'threadtime']],
36 ['platform-properties', ['shell', 'getprop']],
37 ] as const) {
38 try {
39 const diagnostic = Bun.spawn([session.adb, '-s', session.serial, ...args], {
40 env: session.env, stdout: 'pipe', stderr: 'pipe', signal: AbortSignal.timeout(10_000),
41 })
42 const [code, stdout, stderr] = await Promise.all([
43 diagnostic.exited, new Response(diagnostic.stdout).text(), new Response(diagnostic.stderr).text(),
44 ])
45 await Bun.write(join(root, `failure-${name}.json`), JSON.stringify({ code, stdout, stderr }) + '\n')
46 }
47 catch (cause) { console.warn(`Could not retain Android ${name}: ${cause}`) }
48 }
49 throw new Error(`Android ${suite} acceptance failed with exit ${status}.`)
50 }
51 }
52 finally {
53 await session.dispose()
54 await Bun.write(join(root, 'disposal.json'), JSON.stringify({ serial: session.serial, disposed: true }) + '\n')
55 }
56}
57
58if (import.meta.main) await runOwnedAndroidE2e(process.argv[2])
storage/framework/api/package.jsonmodified+5-5
Changes to storage/framework/api/package.json
@@ -8,11 +8,11 @@
88 "prod": "bun --hot run dist/dev.js"
99 },
1010 "dependencies": {
11 "@stacksjs/logging": "^0.75.219",
12 "@stacksjs/queue": "^0.75.219",
13 "@stacksjs/router": "^0.75.219",
14 "@stacksjs/storage": "^0.75.219",
15 "@stacksjs/utils": "^0.75.219"
11 "@stacksjs/logging": "^0.75.221",
12 "@stacksjs/queue": "^0.75.221",
13 "@stacksjs/router": "^0.75.221",
14 "@stacksjs/storage": "^0.75.221",
15 "@stacksjs/utils": "^0.75.221"
1616 },
1717 "workspaces": [
1818 "../core/*"
storage/framework/auto-imports/functions.tsmodified+2-1
Changes to storage/framework/auto-imports/functions.ts
@@ -73,6 +73,7 @@ export { nativeDestination, nativeShortcutDestination } from '../../../resources
7373export { exportNativeGpx } from '../../../resources/functions/native-export'
7474export { nativeMapParts, nativeRouteRegion, nativeZoomRegion } from '../../../resources/functions/native-map'
7575export { nativeOAuthCallback } from '../../../resources/functions/native-oauth'
76export { createNativeRecordingCheckpoint } from '../../../resources/functions/native-recording-checkpoint'
7677export { nativeRecordingDuration, movingSeconds, recordingSamples, recordingFigures, recordingPayload } from '../../../resources/functions/native-recording'
7778export type { NativeRecording } from '../../../resources/functions/native-recording'
7879export { returnToServerFromBundledCopy, serverPath, serverURLFor, holdOnBundledPage, NATIVE_REMOTE_FILE, NATIVE_REMOTE_RETRY_MS, NATIVE_REMOTE_PROBE_TIMEOUT_MS } from '../../../resources/functions/native-remote'
@@ -136,7 +137,7 @@ export { normalizeTagKey, readTags, featureFromTag, trailFeatures, trailHighligh
136137export type { TrailFeature, TrailNotice, FeatureTrail } from '../../../resources/functions/trail-features'
137138export { routePartsFromSegments, lineLengthMeters, encodeRouteParts, decodeRouteParts, primaryRoutePart, ROUTE_SNAP_METERS } from '../../../resources/functions/trail-geometry'
138139export type { RoutePair, RouteNetwork } from '../../../resources/functions/trail-geometry'
139export { trailSegmentMiles, trailDirectionSigns, trailSurfaceTypes, trailGpx } from '../../../resources/functions/trail-map'
140export { trailheadPosition, trailSegmentMiles, trailDirectionSigns, trailSurfaceTypes, trailGpx, trailRouteStyle } from '../../../resources/functions/trail-map'
140141export { placeOfLocation, whereLocation, wholeTrailFigures, trailFacts, countryLabel, trailBreadcrumbs, trailFaq, exploreNearLinks, ratingSummary, forecastDayLabel, forecastBar, NEAR_PLACE_PREFIX } from '../../../resources/functions/trail-page'
141142export type { PageTrail, TrailFigures, Crumb, FaqEntry, LinkGroup, RatingSummary } from '../../../resources/functions/trail-page'
142143export { parsePhotoList, trailGallery } from '../../../resources/functions/trail-photos'
storage/framework/auto-imports/globals.tsmodified+3-0
Changes to storage/framework/auto-imports/globals.ts
@@ -295,6 +295,7 @@ declare global {
295295 const nativeRouteRegion: typeof autoImports.nativeRouteRegion
296296 const nativeZoomRegion: typeof autoImports.nativeZoomRegion
297297 const nativeOAuthCallback: typeof autoImports.nativeOAuthCallback
298 const createNativeRecordingCheckpoint: typeof autoImports.createNativeRecordingCheckpoint
298299 const nativeRecordingDuration: typeof autoImports.nativeRecordingDuration
299300 const movingSeconds: typeof autoImports.movingSeconds
300301 const recordingSamples: typeof autoImports.recordingSamples
@@ -475,10 +476,12 @@ declare global {
475476 const decodeRouteParts: typeof autoImports.decodeRouteParts
476477 const primaryRoutePart: typeof autoImports.primaryRoutePart
477478 const ROUTE_SNAP_METERS: typeof autoImports.ROUTE_SNAP_METERS
479 const trailheadPosition: typeof autoImports.trailheadPosition
478480 const trailSegmentMiles: typeof autoImports.trailSegmentMiles
479481 const trailDirectionSigns: typeof autoImports.trailDirectionSigns
480482 const trailSurfaceTypes: typeof autoImports.trailSurfaceTypes
481483 const trailGpx: typeof autoImports.trailGpx
484 const trailRouteStyle: typeof autoImports.trailRouteStyle
482485 const placeOfLocation: typeof autoImports.placeOfLocation
483486 const whereLocation: typeof autoImports.whereLocation
484487 const wholeTrailFigures: typeof autoImports.wholeTrailFigures
storage/framework/cloud/package.jsonmodified+11-11
Changes to storage/framework/cloud/package.json
@@ -46,19 +46,19 @@
4646 "dev": "./dev local"
4747 },
4848 "dependencies": {
49 "@stacksjs/config": "^0.75.219",
50 "@stacksjs/dns": "^0.75.219",
51 "@stacksjs/logging": "^0.75.219",
52 "@stacksjs/path": "^0.75.219",
53 "@stacksjs/queue": "^0.75.219",
54 "@stacksjs/router": "^0.75.219",
55 "@stacksjs/storage": "^0.75.219",
56 "@stacksjs/utils": "^0.75.219",
57 "@stacksjs/validation": "^0.75.219"
49 "@stacksjs/config": "^0.75.221",
50 "@stacksjs/dns": "^0.75.221",
51 "@stacksjs/logging": "^0.75.221",
52 "@stacksjs/path": "^0.75.221",
53 "@stacksjs/queue": "^0.75.221",
54 "@stacksjs/router": "^0.75.221",
55 "@stacksjs/storage": "^0.75.221",
56 "@stacksjs/utils": "^0.75.221",
57 "@stacksjs/validation": "^0.75.221"
5858 },
5959 "devDependencies": {
60 "@stacksjs/cli": "^0.75.219",
60 "@stacksjs/cli": "^0.75.221",
6161 "better-dx": "^0.2.25",
62 "@stacksjs/env": "^0.75.219"
62 "@stacksjs/env": "^0.75.221"
6363 }
6464}
storage/framework/defaults/.stacks-sync.jsonmodified+2-2
Changes to storage/framework/defaults/.stacks-sync.json
@@ -1,4 +1,4 @@
11{
2 "version": "0.75.219",
3 "syncedAt": "2026-10-17T20:08:47.667Z"
2 "version": "0.75.221",
3 "syncedAt": "2026-10-18T12:28:01.000Z"
44}
storage/framework/defaults/ai/skills/stacks-env/SKILL.mdmodified+6-0
Changes to storage/framework/defaults/ai/skills/stacks-env/SKILL.md
@@ -233,6 +233,12 @@ buddy env:check # validate env configuration
233233buddy env:check --file .env.production # check a specific environment's file
234234```
235235
236`getEnv()` fails a whole-file read if any encrypted entry cannot be decrypted.
237Restore the environment's matching private key through `.env.keys` or its
238`DOTENV_PRIVATE_KEY_<ENVIRONMENT>` process secret. A request for one plaintext
239key remains readable independently. `buddy deploy` stops on an incomplete or
240unreadable environment instead of deploying a partial map.
241
236242## Tenant isolation on a shared box
237243
238244When several projects share one server - one owns it, the rest attach with
storage/framework/defaults/ai/skills/stacks-git/SKILL.mdmodified+22-0
Changes to storage/framework/defaults/ai/skills/stacks-git/SKILL.md
@@ -83,6 +83,28 @@ Based on `cz-git` UserConfig:
8383
8484## Git Utilities
8585
86`gitCredentialEnvironment({ url, username, password })` from `@stacksjs/git`
87returns environment variables for a child Git process. Pass the caller's base
88environment as its second argument, then merge the result after that base.
89Existing runtime Git configuration is preserved. The HTTP(S) URL must identify one repository without
90userinfo, query or fragment. The helper supplies credentials only for that
91protocol, host and repository path, refuses redirects for that URL, disables
92interactive prompts, and writes no credential or Git config file. Never log
93the returned environment. Use a credential scoped to the repository and the
94operation; this helper does not grant server permissions.
95
96```typescript
97import { gitCredentialEnvironment } from '@stacksjs/git'
98
99const child = Bun.spawn(['git', 'ls-remote', 'origin'], {
100 cwd: workspace,
101 env: { ...process.env, ...gitCredentialEnvironment({ url: repositoryUrl, username: 'x-access-token', password: jobToken }, process.env) },
102})
103```
104
105The child and its descendants receive the credential. Use this for trusted
106job steps, never to transfer a host credential into an isolated guest.
107
86108`buddy release` atomically pushes its commit and version tag to the branch's
87109configured upstream remote and branch. A worktree on `codex/example` tracking
88110`origin/main` releases to `origin/main`; it does not create a remote feature
storage/framework/defaults/ai/skills/stacks-native/CATALOG.mdmodified+10-8
Changes to storage/framework/defaults/ai/skills/stacks-native/CATALOG.md
@@ -21,7 +21,7 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
2121| `@stacksjs/auth` | [`stacks-auth`](../stacks-auth/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/index.ts) | 115 |
2222| `@stacksjs/browser` | [`stacks-browser`](../stacks-browser/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/browser/src/index.ts) | 8 |
2323| `@stacksjs/browser-extension` | [`stacks-browser-extension`](../stacks-browser-extension/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/browser-extension/src/index.ts) | 7 |
24| `@stacksjs/buddy` | [`stacks-buddy`](../stacks-buddy/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/buddy/src/index.ts) | 149 |
24| `@stacksjs/buddy` | [`stacks-buddy`](../stacks-buddy/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/buddy/src/index.ts) | 150 |
2525| `@stacksjs/build` | [`stacks-build`](../stacks-build/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/build/src/index.ts) | 2 |
2626| `@stacksjs/cache` | [`stacks-cache`](../stacks-cache/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/cache/src/index.ts) | 11 |
2727| `@stacksjs/calendar-api` | [`stacks-calendar`](../stacks-calendar/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/calendar-api/src/index.ts) | 2 |
@@ -45,14 +45,14 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
4545| `@stacksjs/docs` | [`stacks-docs`](../stacks-docs/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/docs/src/index.ts) | 2 |
4646| `@stacksjs/email` | [`stacks-email`](../stacks-email/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/email/src/index.ts) | 21 |
4747| `@stacksjs/enums` | [`stacks-enums`](../stacks-enums/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/enums/src/index.ts) | 3 |
48| `@stacksjs/env` | [`stacks-env`](../stacks-env/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/index.ts) | 27 |
48| `@stacksjs/env` | [`stacks-env`](../stacks-env/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/index.ts) | 28 |
4949| `@stacksjs/error-handling` | [`stacks-error-handling`](../stacks-error-handling/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/error-handling/src/index.ts) | 10 |
5050| `@stacksjs/events` | [`stacks-events`](../stacks-events/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/events/src/index.ts) | 12 |
5151| `@stacksjs/faker` | [`stacks-faker`](../stacks-faker/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/faker/src/index.ts) | 1 |
5252| `@stacksjs/feature-flags` | [`stacks-feature-flags`](../stacks-feature-flags/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/feature-flags/src/index.ts) | 7 |
5353| `@stacksjs/features` | [`stacks-features`](../stacks-features/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/features/src/index.ts) | 1 |
5454| `@stacksjs/forms` | [`stacks-forms`](../stacks-forms/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/forms/src/index.ts) | 4 |
55| `@stacksjs/git` | [`stacks-git`](../stacks-git/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/git/src/index.ts) | 1 |
55| `@stacksjs/git` | [`stacks-git`](../stacks-git/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/git/src/index.ts) | 2 |
5656| `@stacksjs/github` | [`stacks-git`](../stacks-git/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/github/src/index.ts) | 7 |
5757| `@stacksjs/health` | [`stacks-health`](../stacks-health/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/health/src/index.ts) | 2 |
5858| `@stacksjs/http` | [`stacks-http`](../stacks-http/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/http/src/index.ts) | 2 |
@@ -92,7 +92,7 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
9292| `@stacksjs/tinker` | [`stacks-repl`](../stacks-repl/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/tinker/src/index.ts) | 1 |
9393| `@stacksjs/tunnel` | [`stacks-tunnel`](../stacks-tunnel/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/tunnel/src/index.ts) | 1 |
9494| `@stacksjs/types` | [`stacks-types`](../stacks-types/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/types/src/index.ts) | 2 |
95| `@stacksjs/ui` | [`stacks-ui`](../stacks-ui/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/ui/src/index.ts) | 5 |
95| `@stacksjs/ui` | [`stacks-ui`](../stacks-ui/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/ui/src/index.ts) | 6 |
9696| `@stacksjs/utils` | [`stacks-utils`](../stacks-utils/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/utils/src/index.ts) | 15 |
9797| `@stacksjs/validation` | [`stacks-validation`](../stacks-validation/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/validation/src/index.ts) | 17 |
9898| `@stacksjs/video` | [`stacks-video`](../stacks-video/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/video/src/index.ts) | 3 |
@@ -215,7 +215,7 @@ Read the entrypoint and package export map for re-exported or type-only surfaces
215215
216216Local source modules: [src/commands/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/buddy/src/commands/index.ts).
217217
218Evidence directory: [149 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/buddy/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
218Evidence directory: [150 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/buddy/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
219219
220220### @stacksjs/build
221221
@@ -443,7 +443,7 @@ Read the entrypoint and package export map for re-exported or type-only surfaces
443443
444444Local source modules: [src/cli.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/cli.ts), [src/crypto.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/crypto.ts), [src/deployment.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/deployment.ts), [src/integrations.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/integrations.ts), [src/numeric-keys.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/numeric-keys.ts), [src/parser.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/parser.ts), [src/plaintext.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/plaintext.ts), [src/plugin.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/plugin.ts), [src/runtime.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/runtime.ts), [src/tenants.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/tenants.ts), [src/types.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/types.ts), [src/utils.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/src/utils.ts).
445445
446Evidence directory: [27 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
446Evidence directory: [28 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/env/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
447447
448448### @stacksjs/error-handling
449449
@@ -509,7 +509,9 @@ Guidance: [`stacks-git`](../stacks-git/SKILL.md). Source: [storage/framework/cor
509509
510510Read the entrypoint and package export map for re-exported or type-only surfaces.
511511
512Evidence directory: [1 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/git/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
512Local source modules: [src/credentials.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/git/src/credentials.ts).
513
514Evidence directory: [2 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/git/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
513515
514516### @stacksjs/github
515517
@@ -887,7 +889,7 @@ Declared runtime exports: `CssEngine`, `buildPageSequence`, `paginatorVariant`,
887889
888890Local source modules: [src/fonts.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/ui/src/fonts.ts), [src/pagination.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/ui/src/pagination.ts).
889891
890Evidence directory: [5 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/ui/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
892Evidence directory: [6 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/ui/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
891893
892894### @stacksjs/utils
893895
storage/framework/defaults/ide/vscode/package.jsonmodified+1-1
Changes to storage/framework/defaults/ide/vscode/package.json
@@ -2,7 +2,7 @@
22 "publisher": "Stacks",
33 "name": "vscode-stacks",
44 "displayName": "Stacks",
5 "version": "0.75.219",
5 "version": "0.75.221",
66 "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
77 "license": "MIT",
88 "funding": "https://github.com/sponsors/chrisbbreuer",
storage/framework/defaults/resources/components/MembershipCard.stxmodified+80-23
Changes to storage/framework/defaults/resources/components/MembershipCard.stx
@@ -12,40 +12,93 @@
1212 const mcReplay = useReactiveProp<number>('replay', 0)
1313 const mcReveals = derived(() => [mcReplay()])
1414 const mcCelebrate = useReactiveProp<boolean>('celebrate', true)
15 const mcSurface = useRef('membershipSurface')
16 const mcCanHover = useMediaQuery('(hover: hover) and (pointer: fine)')
17 const mcReduceMotion = usePreferredReducedMotion()
18 let mcStopHover: (() => void) | undefined
19
20 onMount(() => {
21 const surface = mcSurface.current as HTMLElement | null
22 if (!surface) return
23
24 // Measure the stationary hit area, never the rotating card, to avoid feedback jitter.
25 const reset = () => {
26 surface.style.setProperty('--mc-rotate-x', '0deg')
27 surface.style.setProperty('--mc-rotate-y', '0deg')
28 surface.style.setProperty('--mc-light-x', '50%')
29 surface.style.setProperty('--mc-light-y', '50%')
30 surface.style.setProperty('--mc-hover', '0')
31 }
32 mcStopHover = effect(() => {
33 reset()
34 if (!mcCanHover() || mcReduceMotion()) return
35 const mouse = useMouse({ target: surface, touch: false, resetOnLeave: true })
36 const unsubscribe = mouse.subscribe(({ clientX, clientY, isInside }) => {
37 if (!isInside) return reset()
38 const bounds = surface.getBoundingClientRect()
39 if (!bounds.width || !bounds.height) return reset()
40 const x = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width))
41 const y = Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height))
42 surface.style.setProperty('--mc-rotate-x', `${(0.5 - y) * 12}deg`)
43 surface.style.setProperty('--mc-rotate-y', `${(x - 0.5) * 16}deg`)
44 surface.style.setProperty('--mc-light-x', `${x * 100}%`)
45 surface.style.setProperty('--mc-light-y', `${y * 100}%`)
46 surface.style.setProperty('--mc-hover', '1')
47 })
48 return () => {
49 unsubscribe()
50 mouse.stop()
51 reset()
52 }
53 })
54 })
55 onDestroy(() => mcStopHover?.())
56
1557 const mcPieces = [...Array(32).keys()].map(id => ({
1658 id,
1759 style: `--mc-x:${(id * 37 + 11) % 100}%;--mc-drift:${(id * 29 % 160) - 80}px;--mc-delay:${id % 8 * 65}ms;--mc-turn:${180 + id * 47}deg;--mc-color:var(--membership-card-confetti-${id % 4 + 1},${['#99f6e4', '#e2e8f0', '#5eead4', '#94a3b8'][id % 4]})`,
1860 }))
1961</script>
2062
21<div :for="reveal in mcReveals" :key="reveal" class="relative mx-auto w-full mc-stage" :class="mcCelebrate ? 'mc-celebrate' : ''" data-membership-reveal>
22 <div :if="mcCelebrate" class="absolute inset-0 overflow-hidden pointer-events-none mc-confetti" aria-hidden="true">
23 <span :for="piece in mcPieces" :key="piece.id" class="mc-piece" :style="piece.style"></span>
24 </div>
25 <article class="overflow-hidden relative mx-auto w-full mc-metal" :aria-label="mcLabel" data-membership-card>
26 <div class="absolute inset-0 pointer-events-none mc-grain" aria-hidden="true"></div>
27 <div class="absolute inset-0 pointer-events-none mc-shine" aria-hidden="true"></div>
28 <div class="flex relative gap-4 items-start justify-between">
29 <img :if="mcLogo" class="mc-logo" :src="mcLogo" :alt="mcBrand" width="96" height="38" />
30 <span :if="!mcLogo" class="font-bold mc-wordmark" :text="mcBrand"></span>
31 </div>
32 <p class="relative mt-3 text-xs mc-label" :text="mcLabel"></p>
33 <div class="relative mc-holder">
34 <div class="mc-person">
35 <p class="mb-2 text-xs mc-label" :text="mcNameLabel"></p>
36 <p class="mc-engraving" :style="{ fontFamily: mcNameFont }" :text="mcName" data-membership-name></p>
63<div class="relative mx-auto w-full mc-stage" data-membership-reveal>
64 <div ref="membershipSurface" class="mc-surface">
65 <div :for="reveal in mcReveals" :key="reveal" :class="mcCelebrate ? 'mc-celebrate' : ''">
66 <div :if="mcCelebrate" class="absolute inset-0 overflow-hidden pointer-events-none mc-confetti" aria-hidden="true">
67 <span :for="piece in mcPieces" :key="piece.id" class="mc-piece" :style="piece.style"></span>
3768 </div>
38 <div :if="mcMemberId" class="mc-identity">
39 <p class="text-xs mc-label" :text="mcMemberIdLabel"></p>
40 <p class="font-semibold mc-serial" :text="mcMemberId" data-membership-id></p>
69 <div class="mc-tilt">
70 <article class="overflow-hidden relative mx-auto w-full mc-metal" :aria-label="mcLabel" data-membership-card>
71 <div class="absolute inset-0 pointer-events-none mc-grain" aria-hidden="true"></div>
72 <div class="absolute inset-0 pointer-events-none mc-reflection" aria-hidden="true"></div>
73 <div class="absolute inset-0 pointer-events-none mc-shine" aria-hidden="true"></div>
74 <div class="flex relative gap-4 items-start justify-between">
75 <img :if="mcLogo" class="mc-logo" :src="mcLogo" :alt="mcBrand" width="96" height="38" />
76 <span :if="!mcLogo" class="font-bold mc-wordmark" :text="mcBrand"></span>
77 </div>
78 <p class="relative mt-3 text-xs mc-label" :text="mcLabel"></p>
79 <div class="relative mc-holder">
80 <div class="mc-person">
81 <p class="mb-2 text-xs mc-label" :text="mcNameLabel"></p>
82 <p class="mc-engraving" :style="{ fontFamily: mcNameFont }" :text="mcName" data-membership-name></p>
83 </div>
84 <div :if="mcMemberId" class="mc-identity">
85 <p class="text-xs mc-label" :text="mcMemberIdLabel"></p>
86 <p class="font-semibold mc-serial" :text="mcMemberId" data-membership-id></p>
87 </div>
88 </div>
89 <p :if="mcDetail" class="relative mt-5 text-xs mc-detail" :text="mcDetail"></p>
90 </article>
4191 </div>
4292 </div>
43 <p :if="mcDetail" class="relative mt-5 text-xs mc-detail" :text="mcDetail"></p>
44 </article>
93 </div>
4594</div>
4695
4796<style>
4897 .mc-stage { max-width: 28rem; padding: 2rem 0 2.5rem; perspective: 1000px; }
98 .mc-surface { isolation: isolate; }
99 .mc-tilt { transform: perspective(1000px) rotateX(var(--mc-rotate-x, 0deg)) rotateY(var(--mc-rotate-y, 0deg)) translateY(calc(var(--mc-hover, 0) * -3px)); transition: transform 450ms cubic-bezier(0.2, 0.75, 0.25, 1); }
100 .mc-surface:hover .mc-tilt { transition-duration: 160ms; }
101 .mc-reflection { opacity: calc(var(--mc-hover, 0) * 0.65); background: radial-gradient(ellipse at var(--mc-light-x, 50%) var(--mc-light-y, 50%), #ffffff38, #ffffff0a 42%, transparent 72%); transition: opacity 350ms ease; }
49102 .mc-metal {
50103 box-sizing: border-box; min-height: 15.5rem; padding: clamp(1.25rem, 5vw, 2rem); border-radius: 1.25rem;
51104 color: var(--membership-card-color, #e2e8f0); border: 1px solid var(--membership-card-border, #87939d);
@@ -79,10 +132,14 @@
79132 .mc-celebrate .mc-metal { animation: mc-arrive 1100ms cubic-bezier(0.16, 1, 0.3, 1) both; }
80133 .mc-celebrate .mc-shine { animation: mc-polish 1700ms ease-in-out 650ms both; }
81134 .mc-celebrate .mc-piece { animation: mc-fall 2400ms cubic-bezier(0.2, 0.65, 0.45, 1) var(--mc-delay) both; }
82 .mc-metal:hover .mc-shine { transform: translateX(150%) skewX(-20deg); transition: transform 1400ms ease; }
135 }
136 @media (hover: none), (pointer: coarse) {
137 .mc-tilt { transform: none; transition: none; }
138 .mc-reflection { display: none; }
83139 }
84140 @media (prefers-reduced-motion: reduce) {
85 .mc-confetti, .mc-shine { display: none; }
141 .mc-confetti, .mc-shine, .mc-reflection { display: none; }
142 .mc-tilt { transform: none; transition: none; }
86143 }
87144 @keyframes mc-arrive { from { opacity: 0; transform: translateY(32px) rotateX(16deg) rotateY(-12deg) scale(0.92); } to { opacity: 1; transform: none; } }
88145 @keyframes mc-polish { from { transform: translateX(-150%) skewX(-20deg); } to { transform: translateX(150%) skewX(-20deg); } }
storage/framework/defaults/views/dashboard/.discovered-models.jsonmodified+5-229
Changes to storage/framework/defaults/views/dashboard/.discovered-models.json
@@ -1,22 +1,5 @@
11{
22 "models": [
3 {
4 "id": "achievement",
5 "name": "Achievement",
6 "icon": "tablecells.fill",
7 "hasDedicatedPage": false,
8 "category": "userland",
9 "dashboard": {
10 "highlight": true
11 }
12 },
13 {
14 "id": "activity-comment",
15 "name": "ActivityComment",
16 "icon": "tablecells.fill",
17 "hasDedicatedPage": false,
18 "category": "userland"
19 },
203 {
214 "id": "analytics-event",
225 "name": "AnalyticsEvent",
@@ -132,13 +115,6 @@
132115 "enabled": false
133116 }
134117 },
135 {
136 "id": "card-attachment",
137 "name": "CardAttachment",
138 "icon": "tablecells.fill",
139 "hasDedicatedPage": false,
140 "category": "data"
141 },
142118 {
143119 "id": "card-comment",
144120 "name": "CardComment",
@@ -186,40 +162,6 @@
186162 "highlight": true
187163 }
188164 },
189 {
190 "id": "challenge",
191 "name": "Challenge",
192 "icon": "tablecells.fill",
193 "hasDedicatedPage": false,
194 "category": "userland",
195 "dashboard": {
196 "highlight": true
197 }
198 },
199 {
200 "id": "club",
201 "name": "Club",
202 "icon": "tablecells.fill",
203 "hasDedicatedPage": false,
204 "category": "userland",
205 "dashboard": {
206 "highlight": true
207 }
208 },
209 {
210 "id": "club-invite",
211 "name": "ClubInvite",
212 "icon": "tablecells.fill",
213 "hasDedicatedPage": false,
214 "category": "userland"
215 },
216 {
217 "id": "club-member",
218 "name": "ClubMember",
219 "icon": "tablecells.fill",
220 "hasDedicatedPage": false,
221 "category": "userland"
222 },
223165 {
224166 "id": "comment",
225167 "name": "Comment",
@@ -241,13 +183,6 @@
241183 "hasDedicatedPage": false,
242184 "category": "data"
243185 },
244 {
245 "id": "content-report",
246 "name": "ContentReport",
247 "icon": "tablecells.fill",
248 "hasDedicatedPage": false,
249 "category": "userland"
250 },
251186 {
252187 "id": "coupon",
253188 "name": "Coupon",
@@ -285,13 +220,6 @@
285220 "highlight": true
286221 }
287222 },
288 {
289 "id": "custom-route",
290 "name": "CustomRoute",
291 "icon": "tablecells.fill",
292 "hasDedicatedPage": false,
293 "category": "userland"
294 },
295223 {
296224 "id": "delivery-route",
297225 "name": "DeliveryRoute",
@@ -322,13 +250,6 @@
322250 "highlight": true
323251 }
324252 },
325 {
326 "id": "device-push-token",
327 "name": "DevicePushToken",
328 "icon": "tablecells.fill",
329 "hasDedicatedPage": false,
330 "category": "userland"
331 },
332253 {
333254 "id": "digital-delivery",
334255 "name": "DigitalDelivery",
@@ -383,44 +304,6 @@
383304 "enabled": false
384305 }
385306 },
386 {
387 "id": "event",
388 "name": "Event",
389 "icon": "tablecells.fill",
390 "hasDedicatedPage": false,
391 "category": "userland",
392 "dashboard": {
393 "highlight": true
394 }
395 },
396 {
397 "id": "event-entrant",
398 "name": "EventEntrant",
399 "icon": "tablecells.fill",
400 "hasDedicatedPage": false,
401 "category": "userland"
402 },
403 {
404 "id": "event-lap",
405 "name": "EventLap",
406 "icon": "tablecells.fill",
407 "hasDedicatedPage": false,
408 "category": "userland"
409 },
410 {
411 "id": "feedback-token",
412 "name": "FeedbackToken",
413 "icon": "tablecells.fill",
414 "hasDedicatedPage": false,
415 "category": "data"
416 },
417 {
418 "id": "follow",
419 "name": "Follow",
420 "icon": "tablecells.fill",
421 "hasDedicatedPage": false,
422 "category": "userland"
423 },
424307 {
425308 "id": "form",
426309 "name": "Form",
@@ -466,13 +349,6 @@
466349 "hasDedicatedPage": true,
467350 "category": "system"
468351 },
469 {
470 "id": "kudos",
471 "name": "Kudos",
472 "icon": "tablecells.fill",
473 "hasDedicatedPage": false,
474 "category": "userland"
475 },
476352 {
477353 "id": "label",
478354 "name": "Label",
@@ -772,28 +648,11 @@
772648 "name": "Review",
773649 "icon": "star.fill",
774650 "hasDedicatedPage": true,
775 "category": "userland",
776 "dashboard": {
777 "highlight": true
778 }
779 },
780 {
781 "id": "route-effort",
782 "name": "RouteEffort",
783 "icon": "tablecells.fill",
784 "hasDedicatedPage": false,
785 "category": "userland",
651 "category": "commerce",
786652 "dashboard": {
787653 "highlight": true
788654 }
789655 },
790 {
791 "id": "saved-trail",
792 "name": "SavedTrail",
793 "icon": "tablecells.fill",
794 "hasDedicatedPage": false,
795 "category": "userland"
796 },
797656 {
798657 "id": "sender-domain",
799658 "name": "SenderDomain",
@@ -957,50 +816,6 @@
957816 "enabled": false
958817 }
959818 },
960 {
961 "id": "territory",
962 "name": "Territory",
963 "icon": "tablecells.fill",
964 "hasDedicatedPage": false,
965 "category": "userland",
966 "dashboard": {
967 "highlight": true
968 }
969 },
970 {
971 "id": "territory-history",
972 "name": "TerritoryHistory",
973 "icon": "tablecells.fill",
974 "hasDedicatedPage": false,
975 "category": "userland"
976 },
977 {
978 "id": "territory-stats",
979 "name": "TerritoryStats",
980 "icon": "tablecells.fill",
981 "hasDedicatedPage": false,
982 "category": "userland"
983 },
984 {
985 "id": "trail",
986 "name": "Trail",
987 "icon": "tablecells.fill",
988 "hasDedicatedPage": false,
989 "category": "userland",
990 "dashboard": {
991 "highlight": true
992 }
993 },
994 {
995 "id": "trail-ingest-shard",
996 "name": "TrailIngestShard",
997 "icon": "tablecells.fill",
998 "hasDedicatedPage": false,
999 "category": "userland",
1000 "dashboard": {
1001 "highlight": false
1002 }
1003 },
1004819 {
1005820 "id": "transaction",
1006821 "name": "Transaction",
@@ -1011,13 +826,6 @@
1011826 "highlight": true
1012827 }
1013828 },
1014 {
1015 "id": "trip-plan",
1016 "name": "TripPlan",
1017 "icon": "tablecells.fill",
1018 "hasDedicatedPage": false,
1019 "category": "userland"
1020 },
1021829 {
1022830 "id": "usage-event",
1023831 "name": "UsageEvent",
@@ -1030,42 +838,10 @@
1030838 "name": "User",
1031839 "icon": "person.fill",
1032840 "hasDedicatedPage": true,
1033 "category": "userland"
1034 },
1035 {
1036 "id": "user-achievement",
1037 "name": "UserAchievement",
1038 "icon": "tablecells.fill",
1039 "hasDedicatedPage": false,
1040 "category": "userland"
1041 },
1042 {
1043 "id": "user-block",
1044 "name": "UserBlock",
1045 "icon": "tablecells.fill",
1046 "hasDedicatedPage": false,
1047 "category": "userland"
1048 },
1049 {
1050 "id": "user-notification",
1051 "name": "UserNotification",
1052 "icon": "tablecells.fill",
1053 "hasDedicatedPage": false,
1054 "category": "userland"
1055 },
1056 {
1057 "id": "user-privacy-setting",
1058 "name": "UserPrivacySetting",
1059 "icon": "tablecells.fill",
1060 "hasDedicatedPage": false,
1061 "category": "userland"
1062 },
1063 {
1064 "id": "user-stats",
1065 "name": "UserStats",
1066 "icon": "tablecells.fill",
1067 "hasDedicatedPage": false,
1068 "category": "userland"
841 "category": "data",
842 "dashboard": {
843 "highlight": true
844 }
1069845 },
1070846 {
1071847 "id": "waitlist-product",
storage/framework/docs/package.jsonmodified+7-7
Changes to storage/framework/docs/package.json
@@ -8,12 +8,12 @@
88 "preview": "bunpress preview"
99 },
1010 "dependencies": {
11 "@stacksjs/alias": "^0.75.219",
12 "@stacksjs/cli": "^0.75.219",
13 "@stacksjs/config": "^0.75.219",
14 "@stacksjs/env": "^0.75.219",
15 "@stacksjs/path": "^0.75.219",
16 "@stacksjs/server": "^0.75.219",
17 "@stacksjs/storage": "^0.75.219"
11 "@stacksjs/alias": "^0.75.221",
12 "@stacksjs/cli": "^0.75.221",
13 "@stacksjs/config": "^0.75.221",
14 "@stacksjs/env": "^0.75.221",
15 "@stacksjs/path": "^0.75.221",
16 "@stacksjs/server": "^0.75.221",
17 "@stacksjs/storage": "^0.75.221"
1818 }
1919}
storage/framework/libs/components/stx/package.jsonmodified+1-1
Changes to storage/framework/libs/components/stx/package.json
@@ -42,6 +42,6 @@
4242 "prepublishOnly": "bun run build"
4343 },
4444 "devDependencies": {
45 "stacks": "^0.75.219"
45 "stacks": "^0.75.221"
4646 }
4747}
storage/framework/libs/components/web/package.jsonmodified+1-1
Changes to storage/framework/libs/components/web/package.json
@@ -45,6 +45,6 @@
4545 "prepublishOnly": "bun run build"
4646 },
4747 "devDependencies": {
48 "stacks": "^0.75.219"
48 "stacks": "^0.75.221"
4949 }
5050}
storage/framework/libs/functions/package.jsonmodified+1-1
Changes to storage/framework/libs/functions/package.json
@@ -41,6 +41,6 @@
4141 "prepublishOnly": "bun run build"
4242 },
4343 "devDependencies": {
44 "stacks": "^0.75.219"
44 "stacks": "^0.75.221"
4545 }
4646}
storage/framework/orm/package.jsonmodified+3-3
Changes to storage/framework/orm/package.json
@@ -45,9 +45,9 @@
4545 "prepublishOnly": "bun run build"
4646 },
4747 "dependencies": {
48 "@stacksjs/cache": "^0.75.219",
49 "@stacksjs/database": "^0.75.219",
50 "@stacksjs/error-handling": "^0.75.219"
48 "@stacksjs/cache": "^0.75.221",
49 "@stacksjs/database": "^0.75.221",
50 "@stacksjs/error-handling": "^0.75.221"
5151 },
5252 "devDependencies": {
5353 "better-dx": "^0.2.5"
storage/framework/server/package.jsonmodified+5-5
Changes to storage/framework/server/package.json
@@ -48,10 +48,10 @@
4848 },
4949 "devDependencies": {
5050 "better-dx": "^0.2.5",
51 "@stacksjs/logging": "^0.75.219",
52 "@stacksjs/path": "^0.75.219",
53 "@stacksjs/router": "^0.75.219",
54 "@stacksjs/storage": "^0.75.219",
55 "@stacksjs/utils": "^0.75.219"
51 "@stacksjs/logging": "^0.75.221",
52 "@stacksjs/path": "^0.75.221",
53 "@stacksjs/router": "^0.75.221",
54 "@stacksjs/storage": "^0.75.221",
55 "@stacksjs/utils": "^0.75.221"
5656 }
5757}
tests/fixtures/nativeScreen.tsmodified+13-2
Changes to tests/fixtures/nativeScreen.ts
@@ -49,7 +49,7 @@ export function nodesByTestId(node: any, found = new Map<string, string>()): Map
4949 return found
5050}
5151
52export async function start(route: string, params: Record<string, unknown> = {}, capabilities = ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog']): Promise<NativeSession> {
52export async function start(route: string, params: Record<string, unknown> = {}, capabilities = ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog'], keychain?: Map<string, string>): Promise<NativeSession> {
5353 const sent: any[] = []
5454 let callback: (raw: string) => void = () => {}
5555 /*
@@ -103,7 +103,18 @@ export async function start(route: string, params: Record<string, unknown> = {},
103103 platform: 'ios',
104104 capabilityProtocolVersion: 1,
105105 capabilities,
106 postMessage: (raw: string) => sent.push(JSON.parse(raw)),
106 postMessage: (raw: string) => {
107 const message = JSON.parse(raw)
108 sent.push(message)
109 // Keep real bridge requests observable while modelling repeated native
110 // keychain reads and credential changes between async UI steps.
111 if (keychain && message.type === 'API_REQUEST' && message.payload?.module === 'SecureStorage' && message.payload.method === 'get') {
112 void Promise.resolve().then(() => callback(JSON.stringify({
113 type: 'API_RESPONSE', correlationId: message.id,
114 payload: { version: 1, requestId: message.id, data: keychain.get(message.payload.args[0]) ?? null },
115 })))
116 }
117 },
107118 onMessage: (receiver: typeof callback) => { callback = receiver },
108119 },
109120 }
tests/native/trail-journey.tsmodified+170-7
Changes to tests/native/trail-journey.ts
@@ -1,7 +1,25 @@
11import type { SimulatorStep } from '@stacksjs/mobile/testing'
22
3export const nativeTrailJourneyGroups = {
4 maps: ['maps'],
5 reviews: ['reviews', 'review-delete'],
6 utilities: ['sharing', 'gpx', 'planner', 'location'],
7 navigation: ['directions', 'map-directions', 'appearance'],
8} as const
9
10/** Partition all required journeys while retaining review lifecycle state. */
11export function selectNativeTrailJourneys<T extends {name:string}>(journeys:T[],group?:string):T[] {
12 const grouped=Object.values(nativeTrailJourneyGroups).flat()
13 if(grouped.length!==journeys.length || new Set(grouped).size!==journeys.length || journeys.some(journey=>!grouped.includes(journey.name as typeof grouped[number])))
14 throw new Error('Every native trail journey must belong to exactly one CI group.')
15 if(!group)return journeys
16 if(!Object.hasOwn(nativeTrailJourneyGroups,group))throw new Error(`Unknown native trail journey group: ${group}`)
17 const names=nativeTrailJourneyGroups[group as keyof typeof nativeTrailJourneyGroups]
18 return journeys.filter(journey=>(names as readonly string[]).includes(journey.name))
19}
20
321/** Real native UI interactions against the isolated QA database. */
4export function nativeTrailJourneys(input:{trailId:number,trailName:string,email:string,password:string,review:string,edit:string}):{name:string,steps:SimulatorStep[],completion:string}[] {
22export function nativeTrailJourneys(input:{trailId:number,trailName:string,email:string,password:string,review:string,edit:string,profileEndText:string,inlineProfileEndText:string}):{name:string,steps:SimulatorStep[],completion:string}[] {
523const arrival:SimulatorStep[] = [
624 {command:'launch'},
725 {command:'expectid',text:'native-feed-scroll',seconds:15},
@@ -26,27 +44,80 @@ const maps:SimulatorStep[] = [
2644 {command:'expectexact',text:'Save',seconds:30},
2745 {command:'tapid',text:'trail-save'},
2846 {command:'expect',text:'Saved',seconds:10},
47 {command:'openurl',url:'wildloop://feed'},
48 {command:'expectid',text:'native-feed-scroll',seconds:15},
49 {command:'openurl',url:`wildloop://trail/${input.trailId}`},
50 {command:'expectexact',text:'Saved',seconds:30},
2951 {command:'tapid',text:'trail-save'},
3052 {command:'expectexact',text:'Save',seconds:10},
53 {command:'openurl',url:'wildloop://feed'},
54 {command:'expectid',text:'native-feed-scroll',seconds:15},
55 {command:'openurl',url:`wildloop://trail/${input.trailId}`},
56 {command:'expectexact',text:'Save',seconds:30},
3157 {command:'scrollto',text:'Toggle elevation profile'},
3258 {command:'shot',name:'native-floating-profile-inline'},
59 {command:'scrollto',text:'Explore elevation and position along the route'},
60 {command:'sliderid',id:'trail-elevation-slider',position:1},
61 {command:'expectexact',text:input.inlineProfileEndText,seconds:10},
62 {command:'shot',name:'native-inline-elevation-endpoint'},
63 {command:'tree',name:'native-inline-elevation-endpoint'},
3364 {command:'tapid',text:'trail-profile-toggle'},
3465 {command:'expect',text:'Elevation profile collapsed',seconds:10},
3566 {command:'scrollto',text:'Toggle elevation profile'},
3667 {command:'shot',name:'native-profile-collapsed-inline'},
3768 {command:'tapid',text:'trail-profile-toggle'},
3869 {command:'expect',text:'Elevation profile expanded',seconds:10},
70 {command:'expectexact',text:input.inlineProfileEndText,seconds:10},
71 {command:'shot',name:'native-profile-expanded-inline'},
72 {command:'scrollto',text:'Zoom in'},
73 {command:'tapid',text:'trail-zoom-in'},
74 {command:'shot',name:'native-inline-zoom-in'},
75 {command:'tapid',text:'trail-zoom-out'},
76 {command:'shot',name:'native-inline-zoom-out'},
77 {command:'tapid',text:'trail-fit-route'},
78 {command:'shot',name:'native-inline-fit-route'},
79 {command:'tapid',text:'trail-map-3d'},
80 {command:'expectexact',text:'2D',seconds:10},
81 {command:'shot',name:'native-inline-map-tilted'},
82 {command:'tapexact',text:'North'},
83 {command:'shot',name:'native-inline-map-north'},
84 {command:'tapid',text:'trail-map-3d'},
85 {command:'expectexact',text:'3D',seconds:10},
86 {command:'shot',name:'native-inline-map-flat'},
87 {command:'scrollto',text:'Map layer'},
88 {command:'tapid',text:'trail-map-layer'},
89 {command:'tapexact',text:'Satellite'},
90 {command:'expect',text:'Map layer: Satellite',seconds:10},
91 {command:'shot',name:'native-inline-satellite'},
92 ...['Hybrid','Street','Outdoors'].flatMap((layer):SimulatorStep[]=>[
93 {command:'tapid',text:'trail-map-layer'},
94 {command:'tapexact',text:layer},
95 {command:'expect',text:`Map layer: ${layer}`,seconds:10},
96 {command:'shot',name:`native-inline-layer-${layer.toLowerCase()}`},
97 ]),
3998 {command:'scrollto',text:'Expand map'},
4099 {command:'tapid',text:'trail-map-fullscreen'},
41100 {command:'expectid',text:'trail-full-map',seconds:10},
42101 {command:'shot',name:'native-floating-profile-fullscreen'},
43102 {command:'tree',name:'native-floating-profile-fullscreen'},
103 {command:'sliderid',id:'map-elevation-slider',position:1},
104 {command:'expectexact',text:input.profileEndText,seconds:10},
105 {command:'shot',name:'native-elevation-endpoint'},
106 {command:'tree',name:'native-elevation-endpoint'},
44107 {command:'tapid',text:'map-zoom-in'},
108 {command:'shot',name:'native-fullscreen-zoom-in'},
45109 {command:'tapid',text:'map-zoom-out'},
110 {command:'shot',name:'native-fullscreen-zoom-out'},
46111 {command:'tapid',text:'map-fit'},
112 {command:'shot',name:'native-fullscreen-fit-route'},
47113 {command:'tapid',text:'map-3d'},
48114 {command:'expectexact',text:'2D',seconds:10},
115 {command:'shot',name:'native-fullscreen-map-tilted'},
49116 {command:'tapid',text:'map-north'},
117 {command:'shot',name:'native-fullscreen-map-north'},
118 {command:'tapid',text:'map-3d'},
119 {command:'expectexact',text:'3D',seconds:10},
120 {command:'shot',name:'native-fullscreen-map-flat'},
50121 {command:'tapid',text:'map-profile-toggle'},
51122 {command:'expect',text:'Elevation profile collapsed',seconds:10},
52123 {command:'shot',name:'native-profile-collapsed'},
@@ -61,8 +132,17 @@ const maps:SimulatorStep[] = [
61132 {command:'tapid',text:'map-layer'},
62133 {command:'tapexact',text:layer},
63134 {command:'expect',text:`Map layer: ${layer}`,seconds:10},
135 {command:'shot',name:`native-fullscreen-layer-${layer.toLowerCase()}`},
64136 ]),
65137 {command:'back'},
138 {command:'scrollto',text:'Weather'},
139 {command:'expectid',text:'trail-weather-heading',seconds:10},
140 {command:'shot',name:'native-trail-weather'},
141 {command:'tree',name:'native-trail-weather'},
142 {command:'scrollto',text:'Surface types'},
143 {command:'expectid',text:'trail-surfaces-heading',seconds:10},
144 {command:'shot',name:'native-trail-surfaces'},
145 {command:'tree',name:'native-trail-surfaces'},
66146 {command:'expectid',text:'trail-sticky-actions',seconds:10},
67147]
68148const reviews:SimulatorStep[] = [
@@ -79,27 +159,38 @@ const reviews:SimulatorStep[] = [
79159 {command:'scrollto',text:'Edit your review'},
80160 {command:'shot',name:'native-review-created'},
81161 {command:'tree',name:'native-review-created'},
162 {command:'exec',name:'verify-review-created'},
82163 {command:'tapid',text:'trail-write-review'},
83164 {command:'expectid',text:'trail-review-composer',seconds:10},
84 {command:'typeid',id:'trail-review-content',text:input.edit},
165 {command:'tapid',text:'trail-review-star-4'},
166 {command:'tapid',text:'trail-review-content'},
167 {command:'longpressid',text:'trail-review-content'},
168 {command:'shot',name:'native-review-edit-menu'},
169 {command:'tree',name:'native-review-edit-menu'},
170 {command:'tap',text:'Select All'},
171 {command:'type',text:input.edit},
172 {command:'valueid',id:'trail-review-content',text:input.edit},
85173 {command:'scrollto',text:'Save review'},
86174 {command:'tapid',text:'trail-review-submit'},
87175 {command:'expect',text:input.edit.trim(),seconds:15},
88176 {command:'shot',name:'native-review-edited'},
177 {command:'exec',name:'verify-review-edited'},
89178]
90179// Keep deletion observable even when editing fails earlier in its own phase.
91180// This removes the real review created above; the aggregate still fails if
92181// any part of the lifecycle fails.
93182const reviewDelete:SimulatorStep[] = [
94183 ...arrival,
95 {command:'expect',text:input.review,seconds:15},
184 {command:'expect',text:'Edit your review',seconds:15},
96185 {command:'scrollto',text:'Delete your review'},
97186 {command:'tapid',text:'trail-delete-review'},
98187 {command:'tapexact',text:'Delete review'},
99188 {command:'expect',text:'Leave a review',seconds:15},
100189 {command:'absent',text:input.review},
190 {command:'absent',text:input.edit.trim()},
101191 {command:'shot',name:'native-review-deleted'},
102192 {command:'tree',name:'native-review-deleted'},
193 {command:'exec',name:'verify-review-deleted'},
103194]
104195const fullMap:SimulatorStep[] = [
105196 ...arrival,
@@ -113,7 +204,28 @@ const sharing:SimulatorStep[] = [
113204 {command:'shot',name:'native-share-sheet'},
114205 {command:'tree',name:'native-share-sheet'},
115206 {command:'tapexact',text:'Copy'},
116 {command:'exec',name:'verify-share'},
207 {command:'openurl',url:'wildloop://search'},
208 {command:'expectid',text:'search-input',seconds:10},
209 {command:'tapid',text:'search-input'},
210 {command:'longpressid',text:'search-input'},
211 {command:'tap',text:'Paste'},
212 {command:'valueid',id:'search-input',text:`https://wildloop.org/trail/${input.trailId}`},
213 {command:'shot',name:'native-shared-link-pasted'},
214 {command:'tree',name:'native-shared-link-pasted'},
215 {command:'openurl',url:`wildloop://trail/${input.trailId}`},
216 {command:'expectid',text:'trail-sticky-actions',seconds:15},
217]
218const directions:SimulatorStep[] = [
219 ...arrival,
220 {command:'tapid',text:'trail-directions'},
221 {command:'expectapp',text:'com.apple.Maps',seconds:30},
222 {command:'shot',name:'native-sticky-directions-handoff'},
223]
224const mapDirections:SimulatorStep[] = [
225 ...fullMap,
226 {command:'tapid',text:'map-directions'},
227 {command:'expectapp',text:'com.apple.Maps',seconds:30},
228 {command:'shot',name:'native-map-directions-handoff'},
117229]
118230const gpx:SimulatorStep[] = [
119231 ...fullMap,
@@ -123,6 +235,14 @@ const gpx:SimulatorStep[] = [
123235 {command:'tree',name:'native-gpx-share-sheet'},
124236 {command:'tapexact',text:'Copy'},
125237 {command:'exec',name:'verify-gpx'},
238 {command:'openurl',url:`wildloop://trail/${input.trailId}`},
239 {command:'expectid',text:'trail-sticky-actions',seconds:15},
240 {command:'scrollto',text:'Export GPX'},
241 {command:'tapid',text:'trail-map-export'},
242 {command:'expectexact',text:'Copy',seconds:10},
243 {command:'shot',name:'native-inline-gpx-share-sheet'},
244 {command:'tapexact',text:'Copy'},
245 {command:'exec',name:'verify-gpx'},
126246]
127247const planner:SimulatorStep[] = [
128248 ...fullMap,
@@ -136,21 +256,64 @@ const planner:SimulatorStep[] = [
136256]
137257const location:SimulatorStep[] = [
138258 ...fullMap,
259 {command:'tapid',text:'map-zoom-in'},
260 {command:'tapid',text:'map-3d'},
261 {command:'labelid',id:'map-3d',text:'2D'},
262 {command:'shot',name:'native-camera-before-location'},
139263 {command:'exec',name:'seed-location'},
140264 {command:'tapid',text:'map-locate'},
141265 {command:'systemexpect',text:'Allow Once',seconds:10},
142266 {command:'systemtap',text:'Allow Once'},
143267 {command:'expect',text:'Your location is shown',seconds:20},
268 {command:'labelid',id:'map-3d',text:'2D'},
144269 {command:'tree',name:'native-location'},
145270 {command:'shot',name:'native-location'},
271 {command:'openurl',url:`wildloop://trail/${input.trailId}`},
272 {command:'expectid',text:'trail-sticky-actions',seconds:15},
273 {command:'scrollto',text:'Zoom in'},
274 {command:'tapid',text:'trail-zoom-in'},
275 {command:'tapid',text:'trail-map-3d'},
276 {command:'labelid',id:'trail-map-3d',text:'2D'},
277 {command:'shot',name:'native-inline-camera-before-location'},
278 {command:'scrollto',text:'My location'},
279 {command:'tapid',text:'trail-map-locate'},
280 {command:'expect',text:'Your location is shown',seconds:20},
281 {command:'labelid',id:'trail-map-3d',text:'2D'},
282 {command:'shot',name:'native-inline-location'},
283 {command:'tree',name:'native-inline-location'},
146284]
147285return [
148286 {name:'maps',steps:maps,completion:'PASS expectid trail-sticky-actions'},
149 {name:'reviews',steps:reviews,completion:`PASS expect ${input.edit.trim()}`},
150 {name:'review-delete',steps:reviewDelete,completion:`PASS absent ${input.review}`},
151 {name:'sharing',steps:sharing,completion:'exec verify-share: PASS native share copied the trail link'},
287 {name:'reviews',steps:reviews,completion:'exec verify-review-edited: PASS edited review retained its identity and persisted'},
288 {name:'review-delete',steps:reviewDelete,completion:'exec verify-review-deleted: PASS deleted review is absent from persistence'},
289 {name:'sharing',steps:sharing,completion:'PASS expectid trail-sticky-actions'},
290 {name:'directions',steps:directions,completion:'PASS expectapp com.apple.Maps'},
291 {name:'map-directions',steps:mapDirections,completion:'PASS expectapp com.apple.Maps'},
152292 {name:'gpx',steps:gpx,completion:'exec verify-gpx: PASS exported GPX contains the recorded route'},
153293 {name:'planner',steps:planner,completion:'PASS expectid trail-full-map'},
154294 {name:'location',steps:location,completion:'PASS expect Your location is shown'},
295 {name:'appearance',steps:[
296 {command:'exec',name:'appearance-dark'},
297 {command:'launch'},
298 {command:'openurl',url:`wildloop://trail/${input.trailId}`},
299 {command:'expect',text:input.trailName,seconds:30},
300 {command:'expectid',text:'trail-sticky-actions',seconds:15},
301 {command:'shot',name:'native-dark-trail-overview'},
302 {command:'scrollto',text:'Toggle elevation profile'},
303 {command:'shot',name:'native-dark-inline-profile'},
304 {command:'scrollto',text:'Expand map'},
305 {command:'tapid',text:'trail-map-fullscreen'},
306 {command:'expectid',text:'trail-full-map',seconds:15},
307 {command:'shot',name:'native-dark-full-map'},
308 {command:'tapid',text:'map-profile-toggle'},
309 {command:'shot',name:'native-dark-collapsed-profile'},
310 {command:'back'},
311 {command:'scrollto',text:'Weather'},
312 {command:'shot',name:'native-dark-weather'},
313 {command:'scrollto',text:'Surface types'},
314 {command:'shot',name:'native-dark-surfaces'},
315 {command:'expectid',text:'trail-sticky-actions',seconds:15},
316 {command:'exec',name:'appearance-light'},
317 ],completion:'exec appearance-light: PASS platform appearance light'},
155318]
156319}
tests/unit/mobile-e2e-runner.test.tsmodified+16-5
Changes to tests/unit/mobile-e2e-runner.test.ts
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
33import { join } from 'node:path'
44import { describe, expect, it } from 'bun:test'
5import { deepLinkFlow, maestroEnvironment, maestroReportSummary, parseAdbDevices, requestedPlatform, resolveJavaHome, selectAndroidDeepLinkActivity, selectAndroidHomePackage, selectIosSimulator, signedInTargetURL, validateAnalyticsScriptCount, validateBundledFrontend, validateGeneratedNativeApp, validateIosAppBundle, validateNativeShellStyles, validateNativeTabLinks } from '../../scripts/run-mobile-e2e'
5import { assertAndroidPlatformHealthy, deepLinkFlow, maestroEnvironment, maestroReportSummary, parseAdbDevices, requestedPlatform, resolveJavaHome, selectAndroidDeepLinkActivity, selectIosSimulator, signedInTargetURL, validateAnalyticsScriptCount, validateBundledFrontend, validateGeneratedNativeApp, validateIosAppBundle, validateNativeShellStyles, validateNativeTabLinks } from '../../scripts/run-mobile-e2e'
66import { inferDevelopmentTeam, requiredIosAppPaths, requiresDevelopmentTeam, selectAvailableIphone } from '../../scripts/run-ios-device'
77
88function writeNativeNavigationFixtures(output: string, intercepted = ''): void {
@@ -118,9 +118,10 @@ describe('mobile E2E runner', () => {
118118 expect(selectAndroidDeepLinkActivity(output, 'org.missing.app')).toBeNull()
119119 })
120120
121 it('selects the Android home package without assuming a launcher vendor', () => {
122 expect(selectAndroidHomePackage('com.google.android.apps.nexuslauncher/.NexusLauncherActivity\n')).toBe('com.google.android.apps.nexuslauncher')
123 expect(selectAndroidHomePackage('')).toBeNull()
121 it('rejects emulator crashes and ANRs rather than hiding the system launcher', () => {
122 expect(() => assertAndroidPlatformHealthy('am_resume_activity: org.wildloop.app')).not.toThrow()
123 expect(() => assertAndroidPlatformHealthy('am_anr: com.android.launcher3')).toThrow('invalidates native acceptance')
124 expect(() => assertAndroidPlatformHealthy('am_crash: org.wildloop.app')).toThrow('invalidates native acceptance')
124125 })
125126
126127 it('accepts only supported platform arguments', () => {
@@ -171,7 +172,17 @@ describe('mobile E2E runner', () => {
171172 <testcase name="pass" />
172173 <testcase name="fail"><failure>not visible</failure></testcase>
173174 </testsuite>
174 `)).toEqual({ failures: 1, tests: 2 })
175 `)).toEqual({ failures: 1, tests: 2, skipped: 0 })
176 })
177
178 it('distinguishes skipped device journeys from executed passes', () => {
179 expect(maestroReportSummary(`
180 <testsuite tests="3" errors="1" skipped="1">
181 <testcase name="pass" />
182 <testcase name="not executed"><skipped /></testcase>
183 <testcase name="device disconnected"><error>offline</error></testcase>
184 </testsuite>
185 `)).toEqual({ failures: 1, tests: 3, skipped: 1 })
175186 })
176187
177188 it('rejects a mobile bundle that lost reactive page setup', () => {
tests/unit/native-api.test.tsmodified+92-0
Changes to tests/unit/native-api.test.ts
@@ -288,3 +288,95 @@ describe('signed-out native mutation security', () => {
288288 } finally {globalThis.fetch=previous}
289289 })
290290})
291
292describe('native account-bound operations', () => {
293 it('requires a warm screen to reload after its account changes before an operation begins', async () => {
294 let account: unknown = {user:{id:7},version:1}
295 let token = 'account-seven'
296 const fetched = recorder()
297 try {
298 const api = createNativeApi({snapshots:{get:()=>account},secureStorage:{get:()=>token}})
299 await api.send('/api/me')
300 account = {user:{id:8},version:2}
301 token = 'account-eight'
302 await expect(api.bindSession()).rejects.toThrow('Your account changed')
303 expect(fetched.calls).toHaveLength(1)
304 }
305 finally { fetched.restore() }
306 })
307
308 it('refuses a warm screen after the bearer changes before the account snapshot arrives', async () => {
309 let token = 'account-seven'
310 const fetched = recorder()
311 try {
312 const api = createNativeApi({snapshots:{get:()=>({user:{id:7},version:1})},secureStorage:{get:()=>token}})
313 await api.send('/api/me')
314 token = 'account-eight'
315 await expect(api.bindSession()).rejects.toThrow('Your account changed')
316 expect(fetched.calls).toHaveLength(1)
317 }
318 finally { fetched.restore() }
319 })
320
321 it('keeps an identity check and its upload in the same account session', async () => {
322 let account: unknown = {user:{id:7},version:1}
323 let token = 'account-seven'
324 const fetched = recorder({body:'{"user":{"id":7}}'})
325 try {
326 const api = await createNativeApi({snapshots:{get:()=>account},secureStorage:{get:()=>token}}).bindSession()
327 await api.send('/api/me')
328 account = {user:{id:8},version:2}
329 token = 'account-eight'
330 await expect(api.send('/api/activities','POST',{upload_id:'run:owned'})).rejects.toThrow('Your account changed')
331 expect(fetched.calls).toHaveLength(1)
332 expect(fetched.calls[0].headers.Authorization).toBe('Bearer account-seven')
333 }
334 finally { fetched.restore() }
335 })
336
337 it('works on a cold start without an account snapshot and detects a changed keychain', async () => {
338 let token = 'cold-start-token'
339 const fetched = recorder()
340 try {
341 const api = await createNativeApi({secureStorage:{get:()=>token}}).bindSession()
342 await api.send('/api/me')
343 token = 'replacement-token'
344 await expect(api.send('/api/activities','POST',{})).rejects.toThrow('Your account changed')
345 expect(fetched.calls).toHaveLength(1)
346 }
347 finally { fetched.restore() }
348 })
349
350 it('does not bind a signed-out session or silently retarget after a keychain read', async () => {
351 let account: unknown = {user:{id:7},version:1}
352 const fetched = recorder()
353 try {
354 await expect(createNativeApi({secureStorage:{get:()=>null}}).bindSession()).rejects.toThrow('Sign in')
355 const api = createNativeApi({snapshots:{get:()=>account},secureStorage:{get:async()=>{
356 account={user:{id:8},version:2}
357 return 'previous-token'
358 }}})
359 await expect(api.bindSession()).rejects.toThrow('Your account changed')
360 expect(fetched.calls).toHaveLength(0)
361 }
362 finally { fetched.restore() }
363 })
364
365 it('does not expose an old response after a cold-start bearer changes in flight', async () => {
366 let token = 'account-seven'
367 const previous = globalThis.fetch
368 const bearers: string[] = []
369 globalThis.fetch = (async (url: any, init: any) => {
370 expect(String(url)).toBe(`${WILDLOOP_ORIGIN}/api/me`)
371 bearers.push(init.headers.Authorization)
372 token = 'account-eight'
373 return new Response('{"user":{"id":7}}')
374 }) as typeof fetch
375 try {
376 const api = await createNativeApi({secureStorage:{get:()=>token}}).bindSession()
377 await expect(api.send('/api/me')).rejects.toThrow('Your account changed')
378 expect(bearers).toEqual(['Bearer account-seven'])
379 }
380 finally { globalThis.fetch = previous }
381 })
382})
tests/unit/native-manual-activity-flow.test.tsmodified+56-20
Changes to tests/unit/native-manual-activity-flow.test.ts
@@ -1,17 +1,59 @@
11import { beforeAll, describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { answer, compileNativeBundle, nativeBundle, nodesByTestId, requests, start, waitForRequest } from '../fixtures/nativeScreen'
4import { answer, compileNativeBundle, nativeBundle, nativeNodeProps, nodesByTestId, requests, start, waitForRequest, waitForText, type NativeSession } from '../fixtures/nativeScreen'
55
66const ROOT = join(import.meta.dir, '../..')
77
88beforeAll(compileNativeBundle, 30000)
99
10function openManual(params: Record<string, unknown> = {}) {
11 return start('ManualActivity',params,['fetch','secureStorage','crypto','haptics','lifecycle','dialog'],new Map([['auth_token','native-token']]))
12}
13async function supplyUUID(manual: NativeSession) {
14 const uuid=await waitForRequest(manual,0,r=>r.payload.module==='Crypto' && r.payload.method==='randomUUID')
15 answer(manual,uuid,'e8158db5-288f-4cb3-9540-016656f716c6')
16}
17
1018describe('Wildloop native manual activity flow', () => {
19 it('keeps a system UUID and the exact manual payload when retrying a failed save',async()=>{
20 const manual=await start('ManualActivity',{},['fetch','secureStorage','crypto','haptics','lifecycle','dialog'],new Map([['auth_token','native-token']]))
21 await manual.event('distance-input','onChangeText',{text:'3.1'})
22 await manual.event('duration-input','onChangeText',{text:'28:00'})
23 await manual.event('save-activity','onPress')
24 const uuid=await waitForRequest(manual,0,r=>r.payload.module==='Crypto' && r.payload.method==='randomUUID')
25 answer(manual,uuid,'2b53ece4-8df1-49fa-9e70-752ddc901cf7')
26 const post=await waitForRequest(manual,0,r=>r.payload.module==='Network')
27 expect(JSON.parse(post.payload.args[0].body).upload_id).toBe('manual:2b53ece4-8df1-49fa-9e70-752ddc901cf7')
28 answer(manual,post,{status:503,body:JSON.stringify({message:'Try again later'})})
29 await waitForText(manual,'Try again later')
30 const before=requests(manual).length
31 await manual.event('save-activity','onPress')
32 const again=await waitForRequest(manual,before,r=>r.payload.module==='Network')
33 expect(again.payload.args[0].body).toBe(post.payload.args[0].body)
34 expect(requests(manual).filter(r=>r.payload.module==='Crypto')).toHaveLength(1)
35 })
36 it('clears private manual notes and ignores a save response after an account change',async()=>{
37 const keychain=new Map([['auth_token','native-token']])
38 const manual=await start('ManualActivity',{},['fetch','secureStorage','crypto','haptics','lifecycle','dialog'],keychain)
39 await manual.event('distance-input','onChangeText',{text:'3'})
40 await manual.event('duration-input','onChangeText',{text:'30:00'})
41 await manual.event('notes-input','onChangeText',{text:'private route notes'})
42 await manual.event('save-activity','onPress')
43 await supplyUUID(manual)
44 const post=await waitForRequest(manual,0,r=>r.payload.module==='Network')
45 keychain.set('auth_token','other-token')
46 await manual.scope.craft.snapshots.set('wildloop-session',{user:{id:13},version:1})
47 answer(manual,post,{status:201,body:JSON.stringify({activity:{id:42,userId:12}})})
48 await Bun.sleep(0)
49 expect(nativeNodeProps(manual,'notes-input').value).toBe('')
50 expect(requests(manual).some(r=>r.payload.module==='Haptics')).toBe(false)
51 expect(manual.sent.some(r=>r.type==='NAVIGATE')).toBe(false)
52 })
1153 it('uses app components and sends the real activity payload through the Craft protocol', async () => {
1254 expect(nativeBundle()).not.toContain('WebView')
1355 expect(readFileSync(join(ROOT, 'resources/native/ManualActivity.stx'), 'utf8')).toContain('<NativeRunMetric')
14 const manual = await start('ManualActivity', { userId: 12 })
56 const manual = await openManual({ userId: 12 })
1557 const render = manual.sent.find(message => message.type === 'RENDER').payload.document
1658 expect(nodesByTestId(render).has('distance-preview')).toBe(true)
1759
@@ -22,10 +64,8 @@ describe('Wildloop native manual activity flow', () => {
2264 await manual.event('notes-input', 'onChangeText', { text: 'ridge loop' })
2365 await manual.event('save-activity', 'onPress')
2466
25 const tokenRequest = requests(manual).at(-1)
26 expect(tokenRequest.payload).toMatchObject({ module: 'SecureStorage', method: 'get', args: ['auth_token'] })
27 const requestCount = requests(manual).length
28 answer(manual, tokenRequest, 'native-token')
67 await supplyUUID(manual)
68 const requestCount = 0
2969 const post = await waitForRequest(manual, requestCount, request => request.payload?.module === 'Network')
3070 const request = post.payload.args[0]
3171 expect(post.payload.module).toBe('Network')
@@ -63,10 +103,10 @@ describe('Wildloop native manual activity flow', () => {
63103 expect(haptic.payload).toMatchObject({ module: 'Haptics', method: 'impact', args: ['light'] })
64104 answer(manual, haptic, true)
65105 await Bun.sleep(0)
66 expect(manual.sent.at(-1)).toMatchObject({
106 expect(manual.sent).toContainEqual(expect.objectContaining({
67107 type: 'NAVIGATE',
68108 payload: { screen: 'ActivitySaved', params: expect.objectContaining({ id: 42, userId: 12, distance: 5.13 }) },
69 })
109 }))
70110 })
71111
72112 /*
@@ -80,18 +120,15 @@ describe('Wildloop native manual activity flow', () => {
80120 * the token is the only identity this screen needs.
81121 */
82122 it('saves on a cold start, with no route params and no user id in the body', async () => {
83 const manual = await start('ManualActivity')
123 const manual = await openManual()
84124
85125 await manual.event('distance-input', 'onChangeText', { text: '3.1' })
86126 await manual.event('duration-input', 'onChangeText', { text: '28:00' })
87127 await manual.event('save-activity', 'onPress')
88128
89 const tokenRequest = requests(manual).at(-1)
90 expect(tokenRequest.payload, 'the token is read before anything is refused')
91 .toMatchObject({ module: 'SecureStorage', method: 'get', args: ['auth_token'] })
92
93 const before = requests(manual).length
94 answer(manual, tokenRequest, 'native-token')
129 await supplyUUID(manual)
130 expect(requests(manual).some(r=>r.payload.module==='SecureStorage' && r.payload.method==='get')).toBe(true)
131 const before = 0
95132 const post = await waitForRequest(manual, before, request => request.payload?.module === 'Network')
96133 const body = JSON.parse(post.payload.args[0].body)
97134
@@ -115,13 +152,12 @@ describe('Wildloop native manual activity flow', () => {
115152 await saved.event('log-another', 'onPress')
116153 expect(saved.sent.at(-1)).toMatchObject({ type: 'NAVIGATE_REPLACE', payload: { screen: 'ManualActivity', params: { userId: 12 } } })
117154
118 const pending = await start('ManualActivity', { userId: 12 })
155 const pending = await openManual({ userId: 12 })
119156 await pending.event('distance-input', 'onChangeText', { text: '3' })
120157 await pending.event('duration-input', 'onChangeText', { text: '30:00' })
121158 await pending.event('save-activity', 'onPress')
122 const tokenRequest = requests(pending).at(-1)
123 const requestCount = requests(pending).length
124 answer(pending, tokenRequest, 'native-token')
159 await supplyUUID(pending)
160 const requestCount = 0
125161 const post = await waitForRequest(pending, requestCount, request => request.payload?.module === 'Network')
126162 pending.scope.__stxNativeUnmount()
127163 expect(pending.sent).toContainEqual(expect.objectContaining({
@@ -129,7 +165,7 @@ describe('Wildloop native manual activity flow', () => {
129165 payload: { version: 1, requestId: post.id, reason: 'screen-closed' },
130166 }))
131167
132 const fresh = await start('ManualActivity', { userId: 12 })
168 const fresh = await openManual({ userId: 12 })
133169 const operations = fresh.sent.filter(message => message.type === 'MUTATE').flatMap(message => message.payload.operations)
134170 expect(operations.some(operation => operation.patch?.children?.includes('3 mi'))).toBe(false)
135171 })
tests/unit/native-record-screen.test.tsmodified+168-7
Changes to tests/unit/native-record-screen.test.ts
@@ -3,17 +3,89 @@ import { answer, compileNativeBundle, nativeNodeStyle, nativeNodeProps, requests
33
44const checkpoint={userId:7,id:'run-7',startedAt:100000,type:'Hike',visibility:'private',gameMode:'free',targetTerritoryId:null,pauses:[],samples:[],pending:{upload_id:'run:run-7',completed_at:'2026-10-17T12:00:00Z'}}
55function ok(body:unknown,status=200){return {status,headers:{'content-type':'application/json'},body:JSON.stringify(body)}}
6async function recover(userId=7) {
7 const screen=await start('Record',{},['fetch','secureStorage','storage','geolocation','lifecycle','liveActivity'])
8 for(const request of requests(screen).filter(r=>r.payload.module==='SecureStorage')) answer(screen,request,request.payload.args[0]==='auth_token' ? 'record-token' : JSON.stringify({token:'record-token',user:{id:userId}}))
6async function recover(userId=7, keychain=new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:userId}})]])) {
7 const screen=await start('Record',{},['fetch','secureStorage','storage','geolocation','locationRecordingOwnership','lifecycle','liveActivity'],keychain)
98 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
109 answer(screen,stored,checkpoint)
11 const host=await waitForRequest(screen,0,r=>r.payload.method==='getRecordingState')
12 answer(screen,host,{active:false,paused:false,locations:[]})
1310 return screen
1411}
1512describe('native Record checkpoint recovery',()=>{
1613 beforeAll(compileNativeBundle,30000)
14 it('registers a system ID before GPS and retains it through pause, resume and save',async()=>{
15 const screen=await start('Record',{},['fetch','secureStorage','storage','storageCompareAndSet','storageSetIfAbsent','crypto','geolocation','locationRecordingOwnership','locationRecordingIntentIdentity','lifecycle','liveActivity'],new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]]))
16 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
17 answer(screen,stored,null)
18 const initialHost=await waitForRequest(screen,0,r=>r.payload.method==='getRecordingState')
19 answer(screen,initialHost,{active:false,paused:false})
20 await Bun.sleep(0)
21 await screen.event('record-start','onPress')
22 const host=await waitForRequest(screen,requests(screen).indexOf(initialHost)+1,r=>r.payload.method==='getRecordingState')
23 answer(screen,host,{active:false,paused:false})
24 const uuid=await waitForRequest(screen,0,r=>r.payload.module==='Crypto' && r.payload.method==='randomUUID')
25 answer(screen,uuid,'782f0d4a-f626-41bc-8f17-2f67e9a099bf')
26 const registration=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='setIfAbsent')
27 expect(registration.payload.args).toMatchObject(['wildloop-native-recording-v1',{userId:7,id:'782f0d4a-f626-41bc-8f17-2f67e9a099bf',phase:'starting'}])
28 expect(requests(screen).some(r=>r.payload.method==='startRecording')).toBe(false)
29 answer(screen,registration,true)
30 const nativeStart=await waitForRequest(screen,0,r=>r.payload.method==='startRecording')
31 expect(nativeStart.payload.args[0]).toMatchObject({recordingId:'782f0d4a-f626-41bc-8f17-2f67e9a099bf'})
32 const id='782f0d4a-f626-41bc-8f17-2f67e9a099bf'
33 const startedAt=Date.now()-30000
34 const locations=[{latitude:35.699,longitude:-82.38,timestamp:startedAt+1000,accuracy:5},{latitude:35.6991,longitude:-82.3801,timestamp:startedAt+2000,accuracy:5}]
35 answer(screen,nativeStart,{id,active:true,paused:false,startedAt})
36 const started=await waitForRequest(screen,requests(screen).indexOf(nativeStart)+1,r=>r.payload.method==='compareAndSet')
37 expect(started.payload.args[2]).toMatchObject({id,phase:'recording',startedAt})
38 answer(screen,started,true)
39 await waitForText(screen,'Recording continues with your phone locked.')
40 let before=requests(screen).length
41 await screen.event('record-pause','onPress')
42 const pause=await waitForRequest(screen,before,r=>r.payload.method==='pauseRecording')
43 expect(pause.payload.args[0]).toEqual({expectedId:id})
44 answer(screen,pause,{id,active:true,paused:true,startedAt})
45 const read=await waitForRequest(screen,before,r=>r.payload.method==='readRecording')
46 expect(read.payload.args[0]).toEqual({expectedId:id})
47 answer(screen,read,locations)
48 const paused=await waitForRequest(screen,before,r=>r.payload.method==='compareAndSet')
49 expect(paused.payload.args[2].pauses).toHaveLength(1)
50 answer(screen,paused,true)
51 await Bun.sleep(0)
52 before=requests(screen).length
53 await screen.event('record-pause','onPress')
54 const resume=await waitForRequest(screen,before,r=>r.payload.method==='resumeRecording')
55 expect(resume.payload.args[0]).toEqual({expectedId:id})
56 answer(screen,resume,{id,active:true,paused:false,startedAt})
57 const resumed=await waitForRequest(screen,before,r=>r.payload.method==='compareAndSet')
58 expect(resumed.payload.args[2].pauses[0].end).toBeNumber()
59 answer(screen,resumed,true)
60 await Bun.sleep(0)
61 before=requests(screen).length
62 await screen.event('record-finish','onPress')
63 const stop=await waitForRequest(screen,before,r=>r.payload.method==='stopRecording')
64 expect(stop.payload.args[0]).toEqual({expectedId:id})
65 answer(screen,stop,{id,active:false,paused:false,startedAt,locations})
66 const stopped=await waitForRequest(screen,before,r=>r.payload.method==='compareAndSet')
67 expect(stopped.payload.args[2]).toMatchObject({id,phase:'stopped'})
68 answer(screen,stopped,true)
69 const pending=await waitForRequest(screen,requests(screen).indexOf(stopped)+1,r=>r.payload.method==='compareAndSet')
70 expect(pending.payload.args[2].pending.upload_id).toBe(`run:${id}`)
71 expect(requests(screen).some(r=>r.payload.method==='acknowledgeRecording')).toBe(false)
72 answer(screen,pending,true)
73 const me=await waitForRequest(screen,before,r=>r.payload.module==='Network')
74 answer(screen,me,ok({user:{id:7}}))
75 const post=await waitForRequest(screen,requests(screen).indexOf(me)+1,r=>r.payload.module==='Network')
76 expect(JSON.parse(post.payload.args[0].body).upload_id).toBe(`run:${id}`)
77 answer(screen,post,ok({activity:{id:99,captureEligible:false}},201))
78 const ack=await waitForRequest(screen,before,r=>r.payload.method==='acknowledgeRecording')
79 expect(ack.payload.args[0]).toEqual({expectedId:id})
80 answer(screen,ack,true)
81 const clear=await waitForRequest(screen,requests(screen).indexOf(pending)+1,r=>r.payload.method==='compareAndSet')
82 expect(clear.payload.args[1]).toEqual(pending.payload.args[2])
83 expect(clear.payload.args[2]).toBeNull()
84 answer(screen,clear,true)
85 await Bun.sleep(0)
86 expect(screen.sent.some(r=>r.type==='NAVIGATE_OPEN' && JSON.stringify(r.payload).includes('/activity/99'))).toBe(true)
87 expect(requests(screen).some(r=>r.payload.method==='set' || r.payload.method==='remove')).toBe(false)
88 })
1789 it('recovers a finished upload offline without starting another GPS recording',async()=>{
1890 const screen=await recover()
1991 await waitForText(screen,'Your finished recording is saved on this device. Retry the upload when connected.')
@@ -24,17 +96,93 @@ describe('native Record checkpoint recovery',()=>{
2496 expect(requests(screen).some(r=>r.payload.module==='Network')).toBe(false)
2597 expect(shownText(screen).some(text=>text.includes('moving ·'))).toBe(true)
2698 })
99 it('retains an existing checkpoint when native registration loses a race',async()=>{
100 const screen=await start('Record',{},['fetch','secureStorage','storage','storageCompareAndSet','storageSetIfAbsent','crypto','geolocation','locationRecordingOwnership','locationRecordingIntentIdentity','lifecycle'],new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]]))
101 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
102 answer(screen,stored,null)
103 const initial=await waitForRequest(screen,0,r=>r.payload.method==='getRecordingState')
104 answer(screen,initial,{active:false,paused:false})
105 await Bun.sleep(0)
106 await screen.event('record-start','onPress')
107 const host=await waitForRequest(screen,requests(screen).indexOf(initial)+1,r=>r.payload.method==='getRecordingState')
108 answer(screen,host,{active:false,paused:false})
109 const uuid=await waitForRequest(screen,0,r=>r.payload.module==='Crypto')
110 answer(screen,uuid,'803798f7-733a-4692-9ca5-6dca46acc6e2')
111 const create=await waitForRequest(screen,0,r=>r.payload.method==='setIfAbsent')
112 answer(screen,create,false)
113 const replace=await waitForRequest(screen,0,r=>r.payload.method==='compareAndSet')
114 expect(replace.payload.args[1]).toBeNull()
115 answer(screen,replace,false)
116 await waitForText(screen,'The saved recording changed on this device. Pull down to recover it before trying again. Your track has been retained.')
117 expect(requests(screen).some(r=>r.payload.method==='startRecording' || r.payload.method==='set' || r.payload.method==='remove')).toBe(false)
118 })
119 it('acknowledges only the completed owner and never clears a replaced checkpoint',async()=>{
120 const saved={...checkpoint,phase:'stopped',endedAt:160000}
121 const screen=await start('Record',{},['fetch','secureStorage','storage','storageCompareAndSet','geolocation','locationRecordingOwnership','lifecycle'],new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]]))
122 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
123 answer(screen,stored,saved)
124 await waitForText(screen,'Your finished recording is saved on this device. Retry the upload when connected.')
125 await screen.event('record-retry','onPress')
126 const me=await waitForRequest(screen,0,r=>r.payload.module==='Network')
127 answer(screen,me,ok({user:{id:7}}))
128 const post=await waitForRequest(screen,requests(screen).indexOf(me)+1,r=>r.payload.module==='Network')
129 expect(requests(screen).some(r=>r.payload.method==='acknowledgeRecording')).toBe(false)
130 answer(screen,post,ok({activity:{id:99,captureEligible:false}},201))
131 const ack=await waitForRequest(screen,0,r=>r.payload.method==='acknowledgeRecording')
132 expect(ack.payload.args[0]).toEqual({expectedId:'run-7'})
133 expect(requests(screen).some(r=>r.payload.method==='compareAndSet')).toBe(false)
134 answer(screen,ack,true)
135 const clear=await waitForRequest(screen,0,r=>r.payload.method==='compareAndSet')
136 expect(clear.payload.args).toEqual(['wildloop-native-recording-v1',saved,null])
137 answer(screen,clear,false)
138 await waitForText(screen,'The saved recording changed on this device. Pull down to recover it before trying again. Your track has been retained.')
139 expect(requests(screen).some(r=>r.payload.method==='remove')).toBe(false)
140 expect(screen.sent.some(r=>r.type==='NAVIGATE')).toBe(false)
141 })
27142 it('does not reveal or upload another account’s saved track',async()=>{
28143 const screen=await recover(8)
29144 await waitForText(screen,'Log in as the account that started the saved recording before resuming or uploading it.')
30145 expect(requests(screen).some(r=>r.payload.module==='Network')).toBe(false)
31146 expect(requests(screen).some(r=>r.payload.method==='startRecording')).toBe(false)
147 expect(requests(screen).some(r=>r.payload.method==='getRecordingState' || r.payload.method==='readRecording')).toBe(false)
148 })
149 it('checks the saved identity atomically before recovering the host recording',async()=>{
150 const screen=await start('Record',{},['fetch','secureStorage','storage','geolocation','locationRecordingOwnership','lifecycle','liveActivity'],new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]]))
151 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
152 answer(screen,stored,{...checkpoint,pending:undefined})
153 const host=await waitForRequest(screen,0,r=>r.payload.method==='getRecordingState')
154 expect(host.payload.args[0]).toMatchObject({expectedId:'run-7'})
155 answer(screen,host,{active:true,paused:false,id:'run-7',startedAt:100000})
156 const read=await waitForRequest(screen,requests(screen).indexOf(host)+1,r=>r.payload.method==='readRecording')
157 expect(read.payload.args[0]).toMatchObject({expectedId:'run-7'})
158 })
159 it('recovers a registered start whose native acknowledgement was interrupted',async()=>{
160 const screen=await start('Record',{},['fetch','secureStorage','storage','storageCompareAndSet','geolocation','locationRecordingOwnership','lifecycle'],new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]]))
161 const intent={...checkpoint,phase:'starting',pending:undefined}
162 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
163 answer(screen,stored,intent)
164 const host=await waitForRequest(screen,0,r=>r.payload.method==='getRecordingState')
165 expect(host.payload.args[0]).toEqual({expectedId:'run-7'})
166 answer(screen,host,{active:true,paused:false,id:'run-7',startedAt:100000})
167 const read=await waitForRequest(screen,0,r=>r.payload.method==='readRecording')
168 answer(screen,read,[])
169 const durable=await waitForRequest(screen,0,r=>r.payload.method==='compareAndSet')
170 expect(durable.payload.args[1]).toMatchObject({id:'run-7',phase:'starting'})
171 expect(durable.payload.args[2]).toMatchObject({id:'run-7',phase:'recording'})
172 expect(requests(screen).some(r=>r.payload.method==='startRecording')).toBe(false)
173 })
174 it('does not read GPS when a legacy checkpoint has no verifiable host identity',async()=>{
175 const screen=await start('Record',{},['fetch','secureStorage','storage','geolocation','locationRecordingOwnership','lifecycle','liveActivity'],new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]]))
176 const stored=await waitForRequest(screen,0,r=>r.payload.module==='Storage' && r.payload.method==='get')
177 answer(screen,stored,{...checkpoint,id:null,pending:undefined})
178 await Bun.sleep(0)
179 expect(requests(screen).some(r=>r.payload.method==='getRecordingState' || r.payload.method==='readRecording')).toBe(false)
180 expect(requests(screen).some(r=>r.payload.method==='startRecording')).toBe(false)
32181 })
33182 it('retains a finished track when uploading fails and retries the same identity',async()=>{
34183 const screen=await recover()
35184 await waitForText(screen,'Your finished recording is saved on this device. Retry the upload when connected.')
36185 await screen.event('record-retry','onPress')
37 answer(screen,await waitForRequest(screen,2,r=>r.payload.module==='SecureStorage' && r.payload.args[0]==='auth_token'),'record-token')
38186 const me=await waitForRequest(screen,0,r=>r.payload.module==='Network')
39187 answer(screen,me,ok({user:{id:7}}))
40188 const post=await waitForRequest(screen,requests(screen).indexOf(me)+1,r=>r.payload.module==='Network')
@@ -46,10 +194,23 @@ describe('native Record checkpoint recovery',()=>{
46194 expect(nativeNodeProps(screen,'record-retry').disabled).toBe(false)
47195 const before=requests(screen).length
48196 await screen.event('record-retry','onPress')
49 answer(screen,await waitForRequest(screen,before,r=>r.payload.module==='SecureStorage' && r.payload.args[0]==='auth_token'),'record-token')
50197 const againMe=await waitForRequest(screen,before,r=>r.payload.module==='Network')
51198 answer(screen,againMe,ok({user:{id:7}}))
52199 const again=await waitForRequest(screen,requests(screen).indexOf(againMe)+1,r=>r.payload.module==='Network')
53200 expect(again.payload.args[0].body).toBe(post.payload.args[0].body)
54201 })
202 it('retains the pending track when the bearer changes while its owner is checked',async()=>{
203 const keychain=new Map([['auth_token','record-token'],['wildloop-account',JSON.stringify({token:'record-token',user:{id:7}})]])
204 const screen=await recover(7,keychain)
205 await waitForText(screen,'Your finished recording is saved on this device. Retry the upload when connected.')
206 const before=requests(screen).length
207 await screen.event('record-retry','onPress')
208 const me=await waitForRequest(screen,before,r=>r.payload.module==='Network')
209 keychain.set('auth_token','replacement-token')
210 answer(screen,me,ok({user:{id:7}}))
211 await Bun.sleep(0)
212 expect(requests(screen).slice(before).filter(r=>r.payload.module==='Network' && r.payload.args[0].method==='POST')).toHaveLength(0)
213 expect(requests(screen).some(r=>r.payload.module==='Storage' && r.payload.method==='remove')).toBe(false)
214 await waitForText(screen,'Your account changed. Please try again.')
215 })
55216})
tests/unit/native-review-persistence.test.tsadded+20-0
Changes to tests/unit/native-review-persistence.test.ts
@@ -0,0 +1,20 @@
1import { describe, expect, it } from 'bun:test'
2import { assertNativeReviewPersistence } from '../../scripts/run-native-trail-android-e2e'
3
4describe('native review acceptance persistence checks', () => {
5 it('requires the edited rating and text on the original review', () => {
6 const created = { id: 7, content: 'First visit.', rating: 5 }
7 const id = assertNativeReviewPersistence([created], 'create', created.content)
8 expect(id).toBe(7)
9 expect(() => assertNativeReviewPersistence([created], 'edit', 'Return visit.', id)).toThrow('expected review')
10 expect(() => assertNativeReviewPersistence([{ ...created, id: 8, content: 'Return visit.', rating: 4 }], 'edit', 'Return visit.', id)).toThrow('second review')
11 expect(assertNativeReviewPersistence([{ ...created, content: 'Return visit.', rating: 4 }], 'edit', 'Return visit.', id)).toBe(id)
12 })
13
14 it('rejects duplicate creates and a review left behind after deletion', () => {
15 const row = { id: 7, content: 'First visit.', rating: 5 }
16 expect(() => assertNativeReviewPersistence([row, { ...row, id: 8 }], 'create', row.content)).toThrow('expected review')
17 expect(() => assertNativeReviewPersistence([row], 'delete', row.content)).toThrow('did not remove')
18 expect(assertNativeReviewPersistence([], 'delete', row.content)).toBeUndefined()
19 })
20})
tests/unit/native-trail-detail-screen.test.tsmodified+61-6
Changes to tests/unit/native-trail-detail-screen.test.ts
@@ -5,13 +5,12 @@ const LINE = [[37.7, -119.5], [37.71, -119.51], [37.72, -119.5]]
55const TRAIL = { id: 7, name: 'Native Ridge Loop', latitude: 37.75, longitude: -119.55, distance: 3, elevation: 600, location: 'Yosemite, CA', surface: 'dirt;rock', geometry: JSON.stringify(LINE) }
66const OWN = { id: 5, userId: 12, userName: 'Tester', rating: 4, content: 'A quiet loop with great views.', visitDate: '2026-10-15', conditions: 'dry', photos: '["https://wildloop.org/photo.jpg"]' }
77function ok(body: unknown) { return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } }
8async function open(signedIn = true, geometry: string | null = TRAIL.geometry, reviewRows: any[] = signedIn ? [OWN] : [], personalReview: any = reviewRows.find(row=>row.userId===12) ?? null, personalStatus = 200) {
9 const screen = await start('TrailDetail', { id: '7' }, ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog', 'share', 'linking', 'files'])
10 answer(screen, await waitForRequest(screen, 0, r => r.payload?.module === 'SecureStorage'), signedIn ? 'test_token' : null)
8async function open(signedIn = true, geometry: string | null = TRAIL.geometry, reviewRows: any[] = signedIn ? [OWN] : [], personalReview: any = reviewRows.find(row=>row.userId===12) ?? null, personalStatus = 200, keychain = new Map(signedIn ? [['auth_token', 'test_token']] : [])) {
9 const screen = await start('TrailDetail', { id: '7' }, ['fetch', 'secureStorage', 'haptics', 'lifecycle', 'dialog', 'share', 'linking', 'files'], keychain)
1110 answer(screen, await waitForRequest(screen, 0, r => r.payload?.module === 'Network'), ok({ success: true, trail: { ...TRAIL, geometry } }))
1211 const review = await waitForRequest(screen, 0, r => r.payload?.args?.[0]?.url?.endsWith('/reviews'))
1312 answer(screen, review, ok({ reviews: reviewRows }))
14 answer(screen, await waitForRequest(screen, 0, r => r.payload?.args?.[0]?.url?.endsWith('/forecast')), ok({ current: { temperature: 68, label: 'Clear' }, days: [{ date: '2026-10-17', low: 50, high: 70, label: 'Clear' }], attribution: { label: 'MET Norway', url: 'https://api.met.no' } }))
13 answer(screen, await waitForRequest(screen, 0, r => r.payload?.args?.[0]?.url?.endsWith('/forecast')), ok({ current: { temperature: 68, label: 'Clear' }, days: [{ date: '2026-10-17', low: 50, high: 70, label: 'Clear', windMph: 5, precipitationMm: 2.54 }], attribution: { label: 'MET Norway', url: 'https://api.met.no' } }))
1514 answer(screen, await waitForRequest(screen, 0, r => r.payload?.args?.[0]?.url?.endsWith('/profile')), ok({ points: geometry ? [{ mi: 0, ft: 4000, lat: 37.7, lng: -119.5 }, { mi: 3, ft: 4500, lat: 37.72, lng: -119.5 }] : [] }))
1615 if (signedIn) answer(screen, await waitForRequest(screen, 0, r => r.payload?.args?.[0]?.url?.endsWith('/viewer')), {...ok({ userId:12, saved:false, review:personalReview }),status:personalStatus})
1716 await waitForVisibility(screen, 'trail-detail-loading', 'none')
@@ -30,7 +29,10 @@ describe('native trail details', () => {
3029 expect(nativeNodeProps(screen,'trail-write-review').title).toBe('Edit your review')
3130 expect(shownText(screen)).toContain('Natural')
3231 expect(shownText(screen)).toContain('Rock')
33 expect(shownText(screen)).toContain('68°F, Clear')
32 expect(shownText(screen)).toContain('68°F')
33 expect(shownText(screen)).toContain('Clear')
34 expect(shownText(screen)).toContain('50°–70°F')
35 expect(shownText(screen)).toContain('5 mph wind · 0.10 in precipitation')
3436 expect(shownText(screen)).toContain('3.0 mi · 4000–4500 ft')
3537 expect(nativeNodeProps(screen,'trail-native-map')).toMatchObject({scrollEnabled:false,zoomEnabled:false,rotateEnabled:false})
3638 for (const id of ['trail-save','trail-directions','trail-share']) expect(nativeNodeStyle(screen,id).flexGrow).toBe(1)
@@ -42,7 +44,7 @@ describe('native trail details', () => {
4244 await screen.event('trail-profile-toggle','onPress')
4345 expect(nativeNodeProps(screen,'trail-profile-toggle').accessibilityValue).toBe('Elevation profile collapsed')
4446 expect(nativeNodeProps(screen,'trail-native-map').edgePadding.bottom).toBe(88)
45 await screen.event('trail-map-preview-expand','onPress')
47 await screen.event('trail-map-fullscreen','onPress')
4648 expect(screen.sent.some(message=>message.type==='NAVIGATE_OPEN' && message.payload.path==='/trail/7/map')).toBe(true)
4749 })
4850 it('changes map layer and camera through native bindings', async () => {
@@ -180,6 +182,59 @@ describe('native trail details', () => {
180182 answer(screen,await waitForRequest(screen,before,r=>r.payload?.args?.[0]?.url?.endsWith('/viewer')),ok({userId:12,saved:false,review:null}))
181183 await waitForText(screen, 'Your review has been deleted.')
182184 })
185 it('does not delete another account’s review after an old confirmation is accepted', async () => {
186 const keychain = new Map([['auth_token', 'test_token']])
187 const screen = await open(true, TRAIL.geometry, [OWN], OWN, 200, keychain)
188 const before = requests(screen).length
189 await screen.event('trail-delete-review', 'onPress')
190 const confirm = await waitForRequest(screen, before, r => r.payload.module === 'Dialog')
191 keychain.set('auth_token', 'replacement-token')
192 await screen.scope.craft.snapshots.set('wildloop-session', {user:{id:13},version:2})
193 answer(screen, confirm, true)
194 await Bun.sleep(0)
195 expect(requests(screen).slice(before).filter(r => r.payload.module === 'Network' && r.payload.args[0].method === 'DELETE')).toHaveLength(0)
196 })
197 it('rejects a stale review draft before a changed bearer’s snapshot arrives', async () => {
198 const keychain = new Map([['auth_token', 'test_token']])
199 const screen = await open(true, TRAIL.geometry, [OWN], OWN, 200, keychain)
200 await screen.event('trail-write-review', 'onPress')
201 await screen.event('trail-review-content', 'onChangeText', {text:'This draft belongs to the original account.'})
202 keychain.set('auth_token', 'replacement-token')
203 const before = requests(screen).length
204 await screen.event('trail-review-submit', 'onPress')
205 expect(requests(screen).slice(before).filter(r => r.payload.module === 'Network' && r.payload.args[0].method === 'POST')).toHaveLength(0)
206 await waitForText(screen, 'Your account changed. Please try again.')
207 })
208 it('clears a private composer draft when the native account changes', async () => {
209 const screen = await open()
210 await screen.event('trail-write-review', 'onPress')
211 await screen.event('trail-review-content', 'onChangeText', {text:'A private draft for the original account.'})
212 await screen.scope.craft.snapshots.set('wildloop-session', {user:{id:13},version:2})
213 await Bun.sleep(0)
214 expect(nativeNodeProps(screen, 'trail-review-composer').visible).toBe(false)
215 expect(nativeNodeProps(screen, 'trail-review-content').value).toBe('')
216 })
217 it('does not save a trail using stale account credentials', async () => {
218 const keychain = new Map([['auth_token', 'test_token']])
219 const screen = await open(true, TRAIL.geometry, [OWN], OWN, 200, keychain)
220 keychain.set('auth_token', 'replacement-token')
221 const before = requests(screen).length
222 await screen.event('trail-save', 'onPress')
223 expect(requests(screen).slice(before).filter(r => r.payload.module === 'Network' && ['PUT','DELETE'].includes(r.payload.args[0].method))).toHaveLength(0)
224 await waitForText(screen, 'Your account changed. Please try again.')
225 })
226 it('ignores a photo-picker result from the previous account', async () => {
227 const screen = await open()
228 await screen.event('trail-write-review', 'onPress')
229 const before = requests(screen).length
230 await screen.event('trail-review-add-photo', 'onPress')
231 const picker = await waitForRequest(screen, before, r => r.payload.module === 'Files')
232 await screen.scope.craft.snapshots.set('wildloop-session', {user:{id:13},version:2})
233 const beforeResponse = screen.sent.length
234 answer(screen, picker, {path:'/tmp/original-account-photo.jpg',name:'original-account-photo.jpg',type:'image/jpeg',size:20000})
235 await Bun.sleep(0)
236 expect(JSON.stringify(screen.sent.slice(beforeResponse))).not.toContain('original-account-photo.jpg')
237 })
183238 it('shows the trailhead for trails without recorded geometry', async () => {
184239 const screen = await open(false, null)
185240 expect(shownText(screen)).toContain('No recorded route line. The map shows the trailhead.')
tsconfig.app.jsonmodified+1-0
Changes to tsconfig.app.json
@@ -79,6 +79,7 @@
7979 "scripts/test-recording-api.ts",
8080 "scripts/start-recording-qa.ts",
8181 "scripts/run-native-trail-e2e.ts",
82 "scripts/run-native-trail-android-e2e.ts",
8283 "tests/native/**/*.ts",
8384 "tests/unit/account-deletion.test.ts",
8485 "tests/unit/battle-rows.test.ts",