@stacksjs/defaults 0.75.71 → 0.75.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +38 -10
  2. package/ai/skills/stacks-buddy/SKILL.md +4 -4
  3. package/ai/skills/stacks-chat/SKILL.md +11 -5
  4. package/ai/skills/stacks-cloud/SKILL.md +10 -7
  5. package/ai/skills/stacks-email/SKILL.md +5 -3
  6. package/ai/skills/stacks-models/SKILL.md +4 -1
  7. package/ai/skills/stacks-push/SKILL.md +30 -5
  8. package/ai/skills/stacks-queue/SKILL.md +1 -1
  9. package/ai/skills/stacks-realtime/SKILL.md +61 -15
  10. package/ai/skills/stacks-scheduler/SKILL.md +6 -6
  11. package/app/Actions/Auth/RefreshTokenAction.ts +3 -4
  12. package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +4 -7
  13. package/app/Actions/Dashboard/Buddy/BuddyChatStateAction.ts +2 -4
  14. package/app/Actions/Dashboard/Buddy/buddy-chat.test.ts +23 -8
  15. package/app/Actions/Dashboard/Buddy/buddy-chat.ts +45 -19
  16. package/app/Actions/Dashboard/Settings/mail-settings.ts +19 -10
  17. package/app/Middleware/Auth.ts +9 -11
  18. package/app/Middleware/Cors.ts +50 -14
  19. package/app/Middleware/Csrf.ts +9 -8
  20. package/functions/buddy-chat.ts +7 -2
  21. package/ide/vscode/package.json +1 -1
  22. package/package.json +3 -3
  23. package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +5 -3
  24. package/resources/components/Dashboard/Settings/MailSettingsDashboard.stx +8 -6
  25. package/resources/components/NativeAppShell.stx +6 -0
  26. package/resources/components/NativeComponents.test.ts +15 -0
  27. package/resources/components/NativeNavBar.stx +40 -6
  28. package/resources/components/NativeTabBar.stx +4 -1
  29. package/resources/components/NativeTabItem.stx +4 -3
  30. package/vcs/github/workflows/buddy-bot.yml +2 -2
  31. package/vcs/github/workflows/ci.yml +3 -3
  32. package/vcs/github/workflows/release.yml +1 -1
@@ -1,11 +1,12 @@
1
1
  import type { AIMessage, AIProvider, ConfiguredAIOptions } from '@stacksjs/ai'
2
+ import { getAIProviderConfiguration } from '@stacksjs/ai'
2
3
 
3
4
  /**
4
- * The two keys this check reads out of the environment.
5
+ * The keys this check reads out of the environment.
5
6
  *
6
7
  * The index signature is what makes `process.env` assignable: `ProcessEnv`
7
- * declares no named members, so a bare two-key interface shares nothing with
8
- * it and TypeScript rejects the call - the one thing every caller passes.
8
+ * declares no named members, so a bare interface shares nothing with it and
9
+ * TypeScript rejects the call - the one thing every caller passes.
9
10
  */
10
11
  export interface BuddyProviderEnvironment {
11
12
  ANTHROPIC_API_KEY?: string
@@ -13,25 +14,50 @@ export interface BuddyProviderEnvironment {
13
14
  [key: string]: string | undefined
14
15
  }
15
16
 
16
- export function resolveBuddyProvider(config: ConfiguredAIOptions): AIProvider {
17
- const value = String(config.default || 'openai').toLowerCase()
18
- if (value === 'anthropic' || value.startsWith('anthropic.'))
19
- return 'anthropic'
20
- if (value === 'ollama')
21
- return 'ollama'
22
- return 'openai'
17
+ export interface BuddyProviderStatus {
18
+ /** `null` when config/ai.ts names a provider Stacks has no driver for. */
19
+ provider: AIProvider | null
20
+ configured: boolean
21
+ /** Why Buddy cannot answer yet, for the dashboard to show as is. */
22
+ problem: string | null
23
+ /** The environment key that would configure the provider, when one would. */
24
+ missingKey: string | null
23
25
  }
24
26
 
25
- export function isBuddyProviderConfigured(
26
- provider: AIProvider,
27
+ const PROVIDER_KEYS: Partial<Record<AIProvider, string>> = {
28
+ anthropic: 'ANTHROPIC_API_KEY',
29
+ openai: 'OPENAI_API_KEY',
30
+ }
31
+
32
+ /**
33
+ * Which provider Buddy answers with and whether it can, from the same
34
+ * resolution `createAIClient()` uses. Buddy kept its own copy, which sent
35
+ * every default it did not recognise - a Bedrock model, `bedrock`, a typo -
36
+ * to OpenAI, and treated a provider reachable through a `baseUrl` as
37
+ * unconfigured. An unconfigured app still defaults to OpenAI.
38
+ */
39
+ export function buddyProviderStatus(
27
40
  config: ConfiguredAIOptions,
28
- environment: BuddyProviderEnvironment,
29
- ): boolean {
30
- if (provider === 'anthropic')
31
- return Boolean(config.drivers?.anthropic?.apiKey || environment.ANTHROPIC_API_KEY)
32
- if (provider === 'openai')
33
- return Boolean(config.drivers?.openai?.apiKey || environment.OPENAI_API_KEY)
34
- return true
41
+ environment: BuddyProviderEnvironment = process.env,
42
+ ): BuddyProviderStatus {
43
+ let configuration
44
+ try {
45
+ configuration = getAIProviderConfiguration({ ...config, default: config.default || 'openai' }, undefined, environment)
46
+ }
47
+ catch (error) {
48
+ return { provider: null, configured: false, problem: (error as Error).message, missingKey: null }
49
+ }
50
+
51
+ if (configuration.configured)
52
+ return { provider: configuration.provider, configured: true, problem: null, missingKey: null }
53
+
54
+ const missingKey = PROVIDER_KEYS[configuration.provider] ?? null
55
+ return {
56
+ provider: configuration.provider,
57
+ configured: false,
58
+ problem: missingKey ? `${missingKey} is not configured.` : `${configuration.provider} is not configured.`,
59
+ missingKey,
60
+ }
35
61
  }
36
62
 
37
63
  export function buddySystemPrompt(): string {
@@ -161,7 +161,9 @@ export async function readMailSettings(options: EnvironmentFileOptions = {}): Pr
161
161
  apiKeyConfigured: configured(entries.MAILGUN_API_KEY),
162
162
  },
163
163
  mailtrap: {
164
- host: entries.MAILTRAP_HOST ?? 'https://sandbox.api.mailtrap.io/api/send',
164
+ // Empty means the driver decides: the sandbox inbox when MAILTRAP_INBOX_ID
165
+ // is set, real delivery through the Sending API when it is not.
166
+ host: entries.MAILTRAP_HOST ?? '',
165
167
  inboxId: entries.MAILTRAP_INBOX_ID ?? '',
166
168
  tokenConfigured: configured(entries.MAILTRAP_TOKEN),
167
169
  },
@@ -353,16 +355,23 @@ export async function updateMailSettings(
353
355
  fields,
354
356
  )
355
357
 
356
- try {
357
- const url = new URL(host)
358
- if (url.protocol !== 'https:')
359
- fields['mailtrap.host'] = 'Mailtrap API requests must use HTTPS.'
360
- }
361
- catch {
362
- fields['mailtrap.host'] = 'Enter a valid Mailtrap API URL.'
358
+ // Both optional, as in the driver: an inbox id captures mail in that
359
+ // sandbox inbox, no inbox id delivers it through the Sending API, and a
360
+ // blank host means Mailtrap's own host for whichever of the two it is.
361
+ if (host) {
362
+ try {
363
+ const url = new URL(host)
364
+ if (url.protocol !== 'https:')
365
+ fields['mailtrap.host'] = 'Mailtrap API requests must use HTTPS.'
366
+ else if (/sandbox\.api\.mailtrap\.io$/i.test(url.hostname) && !inboxId)
367
+ fields['mailtrap.inboxId'] = 'The Mailtrap sandbox needs the inbox ID to capture into. Leave the host blank to deliver mail instead.'
368
+ }
369
+ catch {
370
+ fields['mailtrap.host'] = 'Enter a valid Mailtrap API URL.'
371
+ }
363
372
  }
364
- if (!/^\d+$/.test(inboxId) || Number(inboxId) < 1)
365
- fields['mailtrap.inboxId'] = 'Enter a positive Mailtrap inbox ID.'
373
+ if (inboxId && (!/^\d+$/.test(inboxId) || Number(inboxId) < 1))
374
+ fields['mailtrap.inboxId'] = 'Enter a positive Mailtrap inbox ID, or leave it blank to deliver mail.'
366
375
  if (!token && input.mailtrap?.clearToken !== true && !configured(currentEntries.MAILTRAP_TOKEN))
367
376
  fields['mailtrap.token'] = 'Enter a Mailtrap API token.'
368
377
  if (input.mailtrap?.clearToken === true && !token)
@@ -2,22 +2,20 @@ import { Auth } from '@stacksjs/auth/authentication'
2
2
  import { authCookieName } from '@stacksjs/auth/cookie'
3
3
  import { sessionUser } from '@stacksjs/auth/session-auth'
4
4
  import { HttpError } from '@stacksjs/error-handling'
5
- import { Middleware } from '@stacksjs/router'
5
+ import { Middleware, parseBearerToken } from '@stacksjs/router'
6
6
 
7
+ /**
8
+ * The request's bearer token, read the way the CSRF check reads it: the
9
+ * scheme name is case-insensitive. This required exactly `Bearer `, so
10
+ * `bearer abc` carried no token here while CSRF treated the request as
11
+ * token-authenticated and exempt - and authentication then fell back to the
12
+ * session cookie, a cookie-authenticated request with CSRF skipped.
13
+ */
7
14
  function bearerToken(request: any): string | null {
8
- if (typeof request.bearerToken === 'function') {
9
- const token = request.bearerToken()
10
- if (token)
11
- return token
12
- }
13
-
14
15
  const header
15
16
  = (typeof request.header === 'function' && request.header('authorization'))
16
17
  || request.headers?.get?.('authorization')
17
- || request.headers?.get?.('Authorization')
18
- return typeof header === 'string' && header.startsWith('Bearer ')
19
- ? header.slice(7)
20
- : null
18
+ return parseBearerToken(typeof header === 'string' ? header : null)
21
19
  }
22
20
 
23
21
  async function stampTokenUser(request: any, token: string): Promise<void> {
@@ -55,9 +55,10 @@ import { Middleware } from '@stacksjs/router'
55
55
  * **Credentials safety**
56
56
  *
57
57
  * When `credentials: true`, the spec forbids `Access-Control-Allow-Origin: *`.
58
- * In that case we echo the request's Origin (only if it matches the
59
- * configured allow-list) or omit the header entirely — never `*`. This
60
- * prevents the most common credentials-bypass misconfiguration.
58
+ * In that case we echo the request's Origin only when it is on an explicit
59
+ * allow-list (or a predicate accepts it), and omit the header otherwise -
60
+ * never `*`. A wildcard policy with credentials allows no origin at all,
61
+ * since reflecting every Origin would hand any site credentialed access.
61
62
  *
62
63
  * @example
63
64
  * ```ts
@@ -147,8 +148,18 @@ function computeAllowOrigin(
147
148
  if (!isOriginAllowed(requestOrigin, cfg.origin))
148
149
  return null
149
150
 
151
+ // A wildcard policy with credentials allows no origin. The spec forbids
152
+ // `*` beside `Allow-Credentials`, and echoing the request's Origin instead -
153
+ // what this did - grants every site on the internet credentialed access:
154
+ // any page could read a signed-in user's API responses. Name the origins
155
+ // that may send credentials, or turn credentials off.
156
+ if (cfg.credentials && cfg.origin === '*') {
157
+ warnWildcardWithCredentials()
158
+ return null
159
+ }
160
+
150
161
  // Credentials mode forbids the literal `*` per the CORS spec — browsers
151
- // reject the response if both are present. Echo the actual origin
162
+ // reject the response if both are present. Echo the matched origin
152
163
  // instead so the Allow-Credentials response is usable.
153
164
  if (cfg.credentials)
154
165
  return requestOrigin
@@ -161,6 +172,15 @@ function computeAllowOrigin(
161
172
  return requestOrigin
162
173
  }
163
174
 
175
+ let warnedWildcardWithCredentials = false
176
+
177
+ function warnWildcardWithCredentials(): void {
178
+ if (warnedWildcardWithCredentials)
179
+ return
180
+ warnedWildcardWithCredentials = true
181
+ console.warn('[cors] `origin: \'*\'` with `credentials: true` allows no cross-origin request: list the origins that may send credentials in config/cors.ts, or set credentials to false.')
182
+ }
183
+
164
184
  /**
165
185
  * Append a token to a comma-separated header value if not already present
166
186
  * (case-insensitive). Returns the new header value.
@@ -184,6 +204,21 @@ function appendVary(existing: string | null, token: string): string {
184
204
  * throw buildPreflightResponse(request, resolveCorsConfig())
185
205
  * ```
186
206
  */
207
+ /**
208
+ * Which of the headers a preflight asks for the policy allows: those named in
209
+ * `allowedHeaders` (case-insensitively), or all of them when it holds `*`.
210
+ * With nothing requested, the configured list.
211
+ */
212
+ function allowedRequestHeaders(requested: string | null, cfg: ResolvedCorsConfig): string[] {
213
+ const wanted = (requested ?? '').split(',').map(header => header.trim()).filter(Boolean)
214
+ if (wanted.length === 0)
215
+ return cfg.allowedHeaders.filter(header => header !== '*')
216
+ if (cfg.allowedHeaders.includes('*'))
217
+ return wanted
218
+ const allowed = new Set(cfg.allowedHeaders.map(header => header.toLowerCase()))
219
+ return wanted.filter(header => allowed.has(header.toLowerCase()))
220
+ }
221
+
187
222
  export function buildPreflightResponse(request: Request, cfg: ResolvedCorsConfig): Response {
188
223
  const headers = new Headers()
189
224
  const requestOrigin = request.headers.get('origin')
@@ -196,7 +231,8 @@ export function buildPreflightResponse(request: Request, cfg: ResolvedCorsConfig
196
231
  // shared caches don't blend two clients' answers.
197
232
  headers.set('Vary', 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers')
198
233
 
199
- if (cfg.credentials)
234
+ // Credentials are offered only to an origin that is allowed.
235
+ if (cfg.credentials && allowOrigin !== null)
200
236
  headers.set('Access-Control-Allow-Credentials', 'true')
201
237
 
202
238
  // Echo the requested method if it's in our allow-list; otherwise advertise
@@ -209,13 +245,13 @@ export function buildPreflightResponse(request: Request, cfg: ResolvedCorsConfig
209
245
  : cfg.methods
210
246
  headers.set('Access-Control-Allow-Methods', methods.join(', '))
211
247
 
212
- // Echo the requested headers (typically lowercase) — the browser sends
213
- // them and the spec allows us to reflect rather than enumerate.
214
- const requestedHeaders = request.headers.get('access-control-request-headers')
215
- if (requestedHeaders)
216
- headers.set('Access-Control-Allow-Headers', requestedHeaders)
217
- else if (cfg.allowedHeaders.length > 0)
218
- headers.set('Access-Control-Allow-Headers', cfg.allowedHeaders.join(', '))
248
+ // The requested headers the policy allows. Every requested header used to
249
+ // be echoed back, so `allowedHeaders` allowed everything: a page could send
250
+ // any custom header it liked. A `*` entry still allows any header, which
251
+ // the spec honours only without credentials.
252
+ const allowedHeaders = allowedRequestHeaders(request.headers.get('access-control-request-headers'), cfg)
253
+ if (allowedHeaders.length > 0)
254
+ headers.set('Access-Control-Allow-Headers', allowedHeaders.join(', '))
219
255
 
220
256
  if (cfg.maxAge > 0)
221
257
  headers.set('Access-Control-Max-Age', String(cfg.maxAge))
@@ -248,7 +284,7 @@ export function applyCorsHeaders(request: Request, response: Response, cfg?: Res
248
284
  if (allowOrigin !== null)
249
285
  response.headers.set('Access-Control-Allow-Origin', allowOrigin)
250
286
  response.headers.set('Vary', appendVary(response.headers.get('Vary'), 'Origin'))
251
- if (config.credentials)
287
+ if (config.credentials && allowOrigin !== null)
252
288
  response.headers.set('Access-Control-Allow-Credentials', 'true')
253
289
  if (config.exposedHeaders.length > 0)
254
290
  response.headers.set('Access-Control-Expose-Headers', config.exposedHeaders.join(', '))
@@ -262,7 +298,7 @@ export function applyCorsHeaders(request: Request, response: Response, cfg?: Res
262
298
  if (allowOrigin !== null)
263
299
  newHeaders.set('Access-Control-Allow-Origin', allowOrigin)
264
300
  newHeaders.set('Vary', appendVary(newHeaders.get('Vary'), 'Origin'))
265
- if (config.credentials)
301
+ if (config.credentials && allowOrigin !== null)
266
302
  newHeaders.set('Access-Control-Allow-Credentials', 'true')
267
303
  if (config.exposedHeaders.length > 0)
268
304
  newHeaders.set('Access-Control-Expose-Headers', config.exposedHeaders.join(', '))
@@ -1,7 +1,7 @@
1
1
  import { timingSafeEqual } from 'node:crypto'
2
2
  import { HttpError } from '@stacksjs/error-handling/http-error'
3
3
  import type { EnhancedRequest } from '@stacksjs/router'
4
- import { Middleware } from '@stacksjs/router/middleware'
4
+ import { Middleware, parseBearerToken } from '@stacksjs/router/middleware'
5
5
 
6
6
  /**
7
7
  * CSRF Protection Middleware (default-on for unsafe methods)
@@ -203,11 +203,11 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
203
203
  // has already embedded it in every form it drew. Generating a second token
204
204
  // here would store one string in the browser while the page carries another,
205
205
  // which fails in a way indistinguishable from having no token at all.
206
- if (!minted) {
207
- const cookieHeader = req.headers.get('cookie') || ''
208
- if (cookieHeader.includes(`${CSRF_COOKIE_NAME}=`) || cookieHeader.includes('csrf-token='))
209
- return response
210
- }
206
+ // By the cookie's exact name (`csrfCookieToken` parses the jar): a
207
+ // substring test counted NextAuth's `next-auth.csrf-token` as ours, so the
208
+ // real cookie was never set and every form post failed.
209
+ if (!minted && csrfCookieToken(req) !== '')
210
+ return response
211
211
 
212
212
  // A token already on its way to the browser counts as present, exactly like
213
213
  // one in the request. Something upstream can mint before the render - the
@@ -318,8 +318,9 @@ function safeEqual(a: string, b: string): boolean {
318
318
  * an ambient cookie credential, so cross-site forgery doesn't apply).
319
319
  */
320
320
  function hasBearerToken(req: Request): boolean {
321
- const auth = req.headers.get('authorization')
322
- return typeof auth === 'string' && /^bearer /i.test(auth)
321
+ // The same parser Auth uses, so a request is exempt here exactly when Auth
322
+ // will authenticate it by that token rather than by its cookie.
323
+ return parseBearerToken(req.headers.get('authorization')) !== null
323
324
  }
324
325
 
325
326
  /**
@@ -6,8 +6,13 @@ export interface BuddyChatMessage {
6
6
  }
7
7
 
8
8
  export interface BuddyChatState {
9
- provider: 'anthropic' | 'openai' | 'ollama'
9
+ /** `null` when config/ai.ts names a provider Stacks has no driver for. */
10
+ provider: 'anthropic' | 'openai' | 'ollama' | 'bedrock' | null
10
11
  configured: boolean
12
+ /** Why Buddy cannot answer yet. */
13
+ problem: string | null
14
+ /** The environment key that would configure the provider, when one would. */
15
+ missingKey: string | null
11
16
  history: BuddyChatMessage[]
12
17
  repository: {
13
18
  name: string
@@ -18,7 +23,7 @@ export interface BuddyChatState {
18
23
 
19
24
  export interface BuddyChatResponse {
20
25
  content: string
21
- provider: BuddyChatState['provider']
26
+ provider: NonNullable<BuddyChatState['provider']>
22
27
  model: string
23
28
  usage: {
24
29
  promptTokens: number
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.71",
5
+ "version": "0.75.73",
6
6
  "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.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.71",
5
+ "version": "0.75.73",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,8 +55,8 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.71",
59
- "@stacksjs/sanitizer": "^0.2.348",
58
+ "@stacksjs/mobile": "^0.75.73",
59
+ "@stacksjs/sanitizer": "^0.2.374",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
62
62
  }
@@ -18,6 +18,8 @@ function providerName(): string {
18
18
  return 'Anthropic'
19
19
  if (provider === 'ollama')
20
20
  return 'Ollama'
21
+ if (provider === 'bedrock')
22
+ return 'Amazon Bedrock'
21
23
  return 'AI provider'
22
24
  }
23
25
 
@@ -108,10 +110,10 @@ onMount(() => {
108
110
  <div>
109
111
  <h2 class="font-semibold text-amber-900 dark:text-amber-200">Configure {{ providerName() }}</h2>
110
112
  <p class="mt-1 text-amber-800 text-sm dark:text-amber-300">
111
- Add the provider key to your environment, then restart the dashboard. Secrets remain server-side and are never returned to this page.
113
+ {{ chatState()?.problem }} Secrets remain server-side and are never returned to this page.
112
114
  </p>
113
- <code class="inline-block mt-3 px-2 py-1 font-mono text-amber-900 text-xs dark:text-amber-200 bg-amber-100 dark:bg-amber-950 rounded">
114
- {{ chatState()?.provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY' }}
115
+ <code :if="chatState()?.missingKey" class="inline-block mt-3 px-2 py-1 font-mono text-amber-900 text-xs dark:text-amber-200 bg-amber-100 dark:bg-amber-950 rounded">
116
+ {{ chatState()?.missingKey }}
115
117
  </code>
116
118
  <StxLink to="/environment" class="inline-flex items-center ml-3 font-medium text-amber-800 text-sm dark:text-amber-300">
117
119
  Open environment
@@ -83,7 +83,7 @@ const mailgunApiKey = state('')
83
83
  const mailgunApiKeyConfigured = state(false)
84
84
  const clearMailgunApiKey = state(false)
85
85
 
86
- const mailtrapHost = state('https://sandbox.api.mailtrap.io/api/send')
86
+ const mailtrapHost = state('')
87
87
  const mailtrapInboxId = state('')
88
88
  const mailtrapToken = state('')
89
89
  const mailtrapTokenConfigured = state(false)
@@ -152,9 +152,11 @@ const driverReady = derived(() => {
152
152
  && (mailgunApiKeyConfigured() || mailgunApiKey()),
153
153
  )
154
154
  if (driver() === 'mailtrap') {
155
+ // Host and inbox are optional: no inbox delivers through the Sending API.
156
+ // Only a sandbox host needs one, since the sandbox captures into an inbox.
157
+ const sandbox = /sandbox\.api\.mailtrap\.io/i.test(mailtrapHost())
155
158
  return Boolean(
156
- mailtrapHost()
157
- && mailtrapInboxId()
159
+ (!sandbox || mailtrapInboxId())
158
160
  && (mailtrapTokenConfigured() || mailtrapToken()),
159
161
  )
160
162
  }
@@ -464,13 +466,13 @@ onMount(() => void loadSettings())
464
466
  <section :if="driver() === 'mailtrap'" class="p-5 bg-white dark:bg-neutral-900 border border-stone-200 rounded-xl dark:border-neutral-700">
465
467
  <div class="mb-5">
466
468
  <h2 class="font-semibold text-stone-900 dark:text-white">Mailtrap</h2>
467
- <p class="mt-1 text-sm text-stone-500 dark:text-neutral-400">Use the HTTPS API endpoint, inbox ID, and write-only bearer token.</p>
469
+ <p class="mt-1 text-sm text-stone-500 dark:text-neutral-400">With an inbox ID, mail is captured in that testing inbox. Without one, it is delivered through the Sending API.</p>
468
470
  </div>
469
471
  <div class="grid gap-4 md:grid-cols-2">
470
472
  <div class="md:col-span-2">
471
- <Input v-model:value="mailtrapHost" type="url" label="API base URL" placeholder="https://sandbox.api.mailtrap.io/api/send" required :error="fieldError('mailtrap.host')" />
473
+ <Input v-model:value="mailtrapHost" type="url" label="API base URL (optional)" placeholder="Mailtrap's own host" :error="fieldError('mailtrap.host')" />
472
474
  </div>
473
- <Input v-model:value="mailtrapInboxId" label="Inbox ID" placeholder="123456" required :error="fieldError('mailtrap.inboxId')" />
475
+ <Input v-model:value="mailtrapInboxId" label="Testing inbox ID (optional)" placeholder="123456" :error="fieldError('mailtrap.inboxId')" />
474
476
  <Input
475
477
  v-model:value="mailtrapToken"
476
478
  type="password"
@@ -76,6 +76,12 @@ const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${cla
76
76
  padding-left: var(--native-safe-area-left);
77
77
  }
78
78
 
79
+ /* iOS zooms the page into a text field smaller than 16px when it takes
80
+ focus, and inside an app nothing zooms it back out: the whole screen
81
+ stayed magnified and shifted sideways after typing a comment. A field in
82
+ the shell is never smaller than that. */
83
+ .native-app-shell :is(input, textarea, select) { font-size: max(16px, 1em); }
84
+
79
85
  .native-app-shell::before {
80
86
  content: '';
81
87
  position: fixed;
@@ -40,6 +40,21 @@ describe('Native components', () => {
40
40
  expect(bar).toContain("useReactiveProp<string>('title', '')")
41
41
  })
42
42
 
43
+ it('keeps every text field big enough that iOS does not zoom into it', () => {
44
+ expect(read('NativeAppShell')).toContain('.native-app-shell :is(input, textarea, select) { font-size: max(16px, 1em); }')
45
+ })
46
+
47
+ it('keeps the tab a screen was opened from lit', () => {
48
+ expect(read('NativeTabBar')).toContain('data-stx-sticky-active')
49
+ })
50
+
51
+ it('names the screen the back button returns to, and gives way to the title', () => {
52
+ const bar = read('NativeNavBar')
53
+ expect(bar).toContain('enterNavTrail(navTrail(), window.location.pathname + window.location.search)')
54
+ expect(bar).toContain('@container (max-width: 4.5rem)')
55
+ expect(bar).toContain("useReactiveProp<boolean>('titleOnScroll', false)")
56
+ })
57
+
43
58
  it('lifts an open sheet out of the router\'s stacking context', () => {
44
59
  const sheet = read('NativeSheet')
45
60
  expect(sheet).toContain('main:has(.native-sheet.is-open) { view-transition-name: none; }')
@@ -7,28 +7,51 @@ interface NativeNavBarProps {
7
7
  backLabel?: string
8
8
  /** `false` for a pushed screen: the title sits in the bar from the start. */
9
9
  large?: boolean
10
+ /**
11
+ * For a pushed screen that draws its own heading: the bar leaves the title
12
+ * out until that heading has scrolled away, rather than showing it twice.
13
+ */
14
+ titleOnScroll?: boolean
10
15
  }
11
16
 
12
17
  const { title = '', back = '', backLabel = 'Back', large = true } = defineProps<NativeNavBarProps>()
13
18
  </script>
14
19
 
15
20
  <script client>
16
- import { goBack, haptics, observePageScroll } from '@stacksjs/mobile'
21
+ import { backLabelFor, canGoBack, enterNavTrail, goBack, haptics, navTrail, observePageScroll } from '@stacksjs/mobile'
17
22
 
18
23
  const isLarge = useReactiveProp<boolean>('large', true)
24
+ const onScroll = useReactiveProp<boolean>('titleOnScroll', false)
19
25
  // Reactive, so a screen whose name arrives with its data can pass it in.
20
26
  const navTitle = useReactiveProp<string>('title', '')
21
27
  const scrolled = state(false)
22
28
  let stopScroll: (() => void) | null = null
23
29
 
30
+ // The back button names the screen it returns to, as on iOS: the one the app
31
+ // actually came from, which a fixed label cannot know (a workout opened from
32
+ // Today is not going back to the calendar). Each bar records its screen's
33
+ // name on the window's trail of screens.
34
+ const fallbackLabel = useReactiveProp<string>('backLabel', 'Back')
35
+ const previousTitle = state('')
36
+ const backText = derived(() => backLabelFor({ path: '', title: previousTitle() }, fallbackLabel()))
37
+ let here: { path: string, title: string } | null = null
38
+ effect(() => {
39
+ const name = navTitle()
40
+ if (here) here.title = name
41
+ })
42
+
24
43
  onMount(() => {
25
44
  // The large title is about 44px tall; past it, its name belongs in the bar.
26
45
  stopScroll = observePageScroll(offset => scrolled.set(offset > 40))
46
+ const { current, previous } = enterNavTrail(navTrail(), window.location.pathname + window.location.search)
47
+ here = current
48
+ current.title = navTitle()
49
+ if (previous && canGoBack()) previousTitle.set(previous.title)
27
50
  })
28
51
  onDestroy(() => stopScroll?.())
29
52
 
30
53
  const barClass = derived(() => {
31
- const collapsed = !isLarge() || scrolled()
54
+ const collapsed = (!isLarge() && !onScroll()) || scrolled()
32
55
  return `native-nav-bar${collapsed ? ' is-collapsed' : ''}${scrolled() ? ' is-scrolled' : ''}`
33
56
  })
34
57
 
@@ -44,9 +67,9 @@ function tapBack(event: MouseEvent): void {
44
67
  <header :class="barClass()">
45
68
  <div class="native-nav-side">
46
69
  @if(back)
47
- <a href="{{ back }}" class="native-nav-back" aria-label="{{ backLabel }}" draggable="false" @click="tapBack">
70
+ <a href="{{ back }}" class="native-nav-back" :aria-label="backText()" draggable="false" @click="tapBack">
48
71
  <i aria-hidden="true" class="i-lucide-chevron-left native-nav-back-icon"></i>
49
- <span>{{ backLabel }}</span>
72
+ <span class="native-nav-back-label">{{ backText() }}</span>
50
73
  </a>
51
74
  @endif
52
75
  <slot name="leading" />
@@ -107,7 +130,10 @@ function tapBack(event: MouseEvent): void {
107
130
  background: var(--native-bar-background-dark, rgb(2 6 23 / 0.86));
108
131
  }
109
132
 
110
- .native-nav-side { display: flex; gap: 0.25rem; align-items: center; min-width: 0; }
133
+ /* Each side is a container, so the back button can tell when its column is
134
+ too narrow for its word and keep only the chevron, as iOS does, rather
135
+ than running into the title. */
136
+ .native-nav-side { display: flex; gap: 0.25rem; align-items: center; min-width: 0; container-type: inline-size; }
111
137
  .native-nav-actions { justify-content: flex-end; }
112
138
 
113
139
  .native-nav-title {
@@ -124,8 +150,12 @@ function tapBack(event: MouseEvent): void {
124
150
 
125
151
  .native-nav-bar.is-collapsed .native-nav-title { opacity: 1; transform: none; }
126
152
 
153
+ /* A title that is not showing takes no room from the sides either. */
154
+ .native-nav-bar:not(.is-collapsed) .native-nav-title { max-width: 0; }
155
+
127
156
  .native-nav-back {
128
157
  display: inline-flex;
158
+ max-width: 100%;
129
159
  gap: 0.1rem;
130
160
  align-items: center;
131
161
  min-height: 2.75rem;
@@ -139,7 +169,11 @@ function tapBack(event: MouseEvent): void {
139
169
 
140
170
  .dark .native-nav-back { color: var(--native-accent-dark, rgb(96 165 250)); }
141
171
  .native-nav-back:active { opacity: 0.55; }
142
- .native-nav-back-icon { width: 1.6rem; height: 1.6rem; }
172
+ .native-nav-back-icon { flex-shrink: 0; width: 1.6rem; height: 1.6rem; }
173
+ .native-nav-back-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
174
+ @container (max-width: 4.5rem) {
175
+ .native-nav-back-label { display: none; }
176
+ }
143
177
 
144
178
  .native-nav-large-title.is-empty { display: none; }
145
179
 
@@ -14,7 +14,10 @@ function selectTab(): void {
14
14
  }
15
15
  </script>
16
16
 
17
- <nav aria-label="{{ label }}" class="native-tab-bar" @click="selectTab()">
17
+ <!-- `data-stx-sticky-active`: each tab owns the screens opened from it, as on
18
+ iOS, so a workout opened from Today keeps Today lit; a tab's `match`
19
+ decides only for a screen reached with no tab current yet (a deep link). -->
20
+ <nav aria-label="{{ label }}" class="native-tab-bar" data-stx-sticky-active @click="selectTab()">
18
21
  <slot />
19
22
  </nav>
20
23
 
@@ -6,9 +6,10 @@ interface NativeTabItemProps {
6
6
  /** A count on the icon. Zero and empty draw nothing. */
7
7
  badge?: string | number
8
8
  /**
9
- * More paths that belong to this tab, space-separated: a workout opened from
10
- * the calendar keeps the Calendar tab lit. A tab bar is a <nav>, where a
11
- * link is otherwise current only on its own page.
9
+ * More paths that belong to this tab, space-separated, for a screen
10
+ * reached with no tab current yet: a deep link to a workout lights the
11
+ * Calendar tab. Opened from a tab, a screen keeps that tab lit instead
12
+ * (the tab bar's sticky nav).
12
13
  */
13
14
  match?: string
14
15
  }
@@ -83,9 +83,9 @@ jobs:
83
83
  persist-credentials: true
84
84
 
85
85
  - name: Setup Pantry
86
- uses: pantry-pm/pantry/packages/action@605fd435116c4666ea81150add528760632e0bfb # v0.11.69
86
+ uses: pantry-pm/pantry/packages/action@d899150a11cd12157ea360e21f6800a85f900446 # v0.11.71
87
87
  with:
88
- version: '0.11.69'
88
+ version: '0.11.71'
89
89
  install: 'false'
90
90
 
91
91
  - name: Install Dependencies