@stacksjs/defaults 0.75.72 → 0.75.74
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.
- package/ai/skills/stacks-ai/SKILL.md +56 -10
- package/ai/skills/stacks-buddy/SKILL.md +4 -4
- package/ai/skills/stacks-chat/SKILL.md +11 -5
- package/ai/skills/stacks-cloud/SKILL.md +10 -7
- package/ai/skills/stacks-email/SKILL.md +5 -3
- package/ai/skills/stacks-models/SKILL.md +4 -1
- package/ai/skills/stacks-push/SKILL.md +30 -5
- package/ai/skills/stacks-queue/SKILL.md +1 -1
- package/ai/skills/stacks-realtime/SKILL.md +61 -15
- package/ai/skills/stacks-scheduler/SKILL.md +6 -6
- package/app/Actions/Auth/RefreshTokenAction.ts +3 -4
- package/app/Actions/Dashboard/Buddy/BuddyChatAction.ts +4 -7
- package/app/Actions/Dashboard/Buddy/BuddyChatStateAction.ts +2 -4
- package/app/Actions/Dashboard/Buddy/buddy-chat.test.ts +23 -8
- package/app/Actions/Dashboard/Buddy/buddy-chat.ts +45 -19
- package/app/Actions/Dashboard/Settings/mail-settings.ts +19 -10
- package/app/Middleware/Auth.ts +9 -11
- package/app/Middleware/Cors.ts +50 -14
- package/app/Middleware/Csrf.ts +9 -8
- package/functions/buddy-chat.ts +7 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +3 -3
- package/resources/components/Dashboard/Buddy/BuddyAssistant.stx +5 -3
- package/resources/components/Dashboard/Settings/MailSettingsDashboard.stx +8 -6
- package/resources/components/NativeAppShell.stx +20 -0
- package/resources/components/NativeComponents.test.ts +21 -0
- package/resources/components/NativeNavBar.stx +40 -6
- package/resources/components/NativeTabBar.stx +4 -1
- package/resources/components/NativeTabItem.stx +4 -3
- package/vcs/github/workflows/buddy-bot.yml +2 -2
- package/vcs/github/workflows/ci.yml +3 -3
- 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
|
|
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
|
|
8
|
-
*
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
):
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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)
|
package/app/Middleware/Auth.ts
CHANGED
|
@@ -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
|
-
|
|
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> {
|
package/app/Middleware/Cors.ts
CHANGED
|
@@ -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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
headers.set('Access-Control-Allow-Headers',
|
|
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(', '))
|
package/app/Middleware/Csrf.ts
CHANGED
|
@@ -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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
/**
|
package/functions/buddy-chat.ts
CHANGED
|
@@ -6,8 +6,13 @@ export interface BuddyChatMessage {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export interface BuddyChatState {
|
|
9
|
-
provider
|
|
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
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.74",
|
|
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.
|
|
5
|
+
"version": "0.75.74",
|
|
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.
|
|
59
|
-
"@stacksjs/sanitizer": "^0.2.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.74",
|
|
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
|
-
|
|
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()?.
|
|
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('
|
|
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
|
-
|
|
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">
|
|
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="
|
|
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="
|
|
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;
|
|
@@ -142,6 +148,20 @@ const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${cla
|
|
|
142
148
|
padding-bottom: var(--native-safe-area-bottom);
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
/* While the keyboard is up, the tab bar steps aside, as iOS does: left in
|
|
152
|
+
place it rode up on top of the keyboard and took a fifth of the screen
|
|
153
|
+
from whatever was being typed. Hidden rather than removed, so the
|
|
154
|
+
content's clearance below stays and nothing jumps when it comes back. */
|
|
155
|
+
.native-app-shell.native-mobile:has(:is(input:not([type=checkbox], [type=radio], [type=range], [type=button], [type=submit], [type=file]), textarea, select, [contenteditable=true]):focus) .native-app-tab-slot {
|
|
156
|
+
visibility: hidden;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@media (max-width: 767.98px) and (pointer: coarse) {
|
|
160
|
+
.native-app-shell:has(:is(input:not([type=checkbox], [type=radio], [type=range], [type=button], [type=submit], [type=file]), textarea, select, [contenteditable=true]):focus) .native-app-tab-slot {
|
|
161
|
+
visibility: hidden;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
/* A phone app does not select its chrome on a long press, or flash it grey
|
|
146
166
|
on a tap. Text inside the content is left selectable. */
|
|
147
167
|
.native-app-shell.native-mobile .native-app-tab-slot,
|
|
@@ -40,6 +40,27 @@ describe('Native components', () => {
|
|
|
40
40
|
expect(bar).toContain("useReactiveProp<string>('title', '')")
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
it('steps the tab bar aside while the keyboard is up', () => {
|
|
44
|
+
const shell = read('NativeAppShell')
|
|
45
|
+
expect(shell).toContain('textarea, select, [contenteditable=true]):focus) .native-app-tab-slot')
|
|
46
|
+
expect(shell).toContain('visibility: hidden;')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('keeps every text field big enough that iOS does not zoom into it', () => {
|
|
50
|
+
expect(read('NativeAppShell')).toContain('.native-app-shell :is(input, textarea, select) { font-size: max(16px, 1em); }')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('keeps the tab a screen was opened from lit', () => {
|
|
54
|
+
expect(read('NativeTabBar')).toContain('data-stx-sticky-active')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('names the screen the back button returns to, and gives way to the title', () => {
|
|
58
|
+
const bar = read('NativeNavBar')
|
|
59
|
+
expect(bar).toContain('enterNavTrail(navTrail(), window.location.pathname + window.location.search)')
|
|
60
|
+
expect(bar).toContain('@container (max-width: 4.5rem)')
|
|
61
|
+
expect(bar).toContain("useReactiveProp<boolean>('titleOnScroll', false)")
|
|
62
|
+
})
|
|
63
|
+
|
|
43
64
|
it('lifts an open sheet out of the router\'s stacking context', () => {
|
|
44
65
|
const sheet = read('NativeSheet')
|
|
45
66
|
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="
|
|
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>{{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
}
|