@stacksjs/defaults 0.70.370 → 0.70.375
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-mobile/SKILL.md +154 -0
- package/ai/skills/stacks-new-feature/SKILL.md +1 -1
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/ai/skills/stacks-validation/SKILL.md +7 -7
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -1
- package/resources/components/NativeAppShell.stx +75 -0
- package/resources/components/NativeHealthButton.stx +49 -0
- package/resources/components/NativeNetworkBanner.stx +53 -0
- package/resources/components/NativePermissionButton.stx +61 -0
- package/resources/components/NativeShareButton.stx +35 -0
- package/resources/components/NativeTabBar.stx +74 -0
- package/resources/components/NativeTabItem.stx +19 -0
- package/resources/components/index.ts +7 -0
- package/vcs/github/labeler.yml +150 -0
- package/vcs/github/workflows/ci.yml +7 -0
- package/vcs/github/workflows/labeler.yml +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stacks-mobile
|
|
3
|
+
description: Use when building native iOS or Android applications from a Stacks and STX codebase with Craft, including mobile configuration, native capabilities, safe areas, haptics, sharing, and mobile build output.
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Bun >= 1.3.0, TypeScript, Xcode for iOS project generation
|
|
6
|
+
allowed-tools: Read Edit Write Bash Grep Glob
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Stacks Mobile
|
|
10
|
+
|
|
11
|
+
Stacks mobile applications reuse the same STX views, components, routes, and
|
|
12
|
+
API as the web application. Craft owns the platform project and native bridge;
|
|
13
|
+
Stacks owns application configuration and build orchestration.
|
|
14
|
+
|
|
15
|
+
## Key paths
|
|
16
|
+
|
|
17
|
+
- App configuration: `config/mobile.ts`
|
|
18
|
+
- Mobile runtime: `storage/framework/core/mobile/src/`
|
|
19
|
+
- Platform build actions: `storage/framework/core/actions/src/build/ios.ts` and `build/android.ts`
|
|
20
|
+
- Reusable STX components: `storage/framework/defaults/resources/components/Native*.stx`
|
|
21
|
+
- Generated iOS project: `storage/framework/mobile/ios/` (ignored build output)
|
|
22
|
+
|
|
23
|
+
## Build
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
buddy build:ios
|
|
27
|
+
buddy build:android
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The build validates `config/mobile.ts`, initializes a Craft iOS project,
|
|
31
|
+
selects either a remote application URL or bundled web assets, generates the
|
|
32
|
+
Xcode project with xcodegen, and records source, capability, and Craft builder
|
|
33
|
+
revision provenance in `stacks-mobile.json`.
|
|
34
|
+
|
|
35
|
+
For local Craft development, point Stacks at Craft's builder source:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
CRAFT_IOS_SRC=/absolute/path/to/craft/packages/ios/src/index.ts buddy build:ios
|
|
39
|
+
CRAFT_ANDROID_SRC=/absolute/path/to/craft/packages/android/src/index.ts buddy build:android
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`STACKS_IOS_SKIP_XCODEGEN=1` and `STACKS_ANDROID_SKIP_GRADLE=1` are only for
|
|
43
|
+
source-level CI and tests. Shippable projects must be generated and compiled
|
|
44
|
+
with Xcode or Gradle respectively.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import type { MobileConfig } from '@stacksjs/types'
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
ios: {
|
|
53
|
+
appName: 'My App',
|
|
54
|
+
bundleId: 'com.example.app',
|
|
55
|
+
url: 'https://example.com',
|
|
56
|
+
fallbackWebAssets: 'dist',
|
|
57
|
+
deploymentTarget: '16.0',
|
|
58
|
+
orientations: ['portrait'],
|
|
59
|
+
urlSchemes: ['myapp'],
|
|
60
|
+
capabilities: {
|
|
61
|
+
haptics: true,
|
|
62
|
+
share: true,
|
|
63
|
+
geolocation: true,
|
|
64
|
+
secureStorage: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
android: {
|
|
68
|
+
appName: 'My App',
|
|
69
|
+
packageName: 'com.example.app',
|
|
70
|
+
url: 'https://example.com',
|
|
71
|
+
fallbackWebAssets: 'dist',
|
|
72
|
+
capabilities: {
|
|
73
|
+
haptics: true,
|
|
74
|
+
share: true,
|
|
75
|
+
geolocation: true,
|
|
76
|
+
secureStorage: true,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
} satisfies MobileConfig
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Choose exactly one primary content source:
|
|
83
|
+
|
|
84
|
+
- `url`: load the deployed Stacks application and keep server-rendered routes.
|
|
85
|
+
- `webAssets`: bundle a static distribution containing `index.html` and every
|
|
86
|
+
referenced asset.
|
|
87
|
+
- `fallbackWebAssets`: with `url`, bundle a static distribution that Craft
|
|
88
|
+
loads when the remote application is unreachable on cold start.
|
|
89
|
+
|
|
90
|
+
Only enable capabilities the product uses. Craft turns enabled capabilities
|
|
91
|
+
into native bridge availability and required iOS privacy descriptions.
|
|
92
|
+
|
|
93
|
+
## Runtime API
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { haptics, keepAwake, location, pushNotifications, share, withNativeFeedback } from '@stacksjs/mobile'
|
|
97
|
+
|
|
98
|
+
await haptics.selection()
|
|
99
|
+
const position = await location.getCurrentPosition({ enableHighAccuracy: true })
|
|
100
|
+
await location.startRecording({ enableHighAccuracy: true })
|
|
101
|
+
await keepAwake.enable()
|
|
102
|
+
const pushToken = await pushNotifications.register()
|
|
103
|
+
await share.share({ title: 'Route', url: 'https://example.com/routes/1' })
|
|
104
|
+
await withNativeFeedback(() => saveActivity())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The runtime is browser-safe. Craft-backed operations use the native bridge;
|
|
108
|
+
supported web APIs provide fallback behavior outside a native host.
|
|
109
|
+
|
|
110
|
+
## STX components
|
|
111
|
+
|
|
112
|
+
- `<NativeAppShell>` applies iOS safe-area insets and reserves tab-bar space.
|
|
113
|
+
- `<NativeTabBar>` provides the accessible navigation shell and selection haptics.
|
|
114
|
+
- `<NativeTabItem>` provides each route, active state, label, and Iconify icon.
|
|
115
|
+
- `<NativeShareButton>` opens the native share sheet and reports feedback.
|
|
116
|
+
- `<NativeNetworkBanner>` reflects native connectivity changes and announces offline state accessibly.
|
|
117
|
+
- `<NativePermissionButton>` wraps permission status, requests, haptics, and the native Settings escape hatch.
|
|
118
|
+
- `<NativeHealthButton>` requests the minimal Apple Health or Android Health Connect grants.
|
|
119
|
+
|
|
120
|
+
Use Iconify classes for tab icons. Keep native operations inside reusable
|
|
121
|
+
components or TypeScript composables, never through `window.*` in an STX
|
|
122
|
+
script.
|
|
123
|
+
|
|
124
|
+
## Health and watch surfaces
|
|
125
|
+
|
|
126
|
+
Enable `healthKit` on iOS or `healthConnect` on Android, then use the shared
|
|
127
|
+
`health` service to request only the record types the product needs. Completed
|
|
128
|
+
recordings can be written back with `health.saveWorkout(...)`; treat permission
|
|
129
|
+
revocation as a normal runtime state and never block saving the application's
|
|
130
|
+
own activity when a health write fails.
|
|
131
|
+
|
|
132
|
+
Enable `watchApp` to generate and embed the SwiftUI watchOS companion. The
|
|
133
|
+
shared `watchConnectivity` service exchanges commands and the latest recording
|
|
134
|
+
context without exposing `WCSession` to STX templates. Set
|
|
135
|
+
`ios.watchDeploymentTarget` when the default watchOS 9.0 target is not suitable.
|
|
136
|
+
|
|
137
|
+
## Validation
|
|
138
|
+
|
|
139
|
+
Before finishing mobile work:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
buddy lint
|
|
143
|
+
bun run typecheck:app
|
|
144
|
+
buddy test
|
|
145
|
+
buddy build:ios
|
|
146
|
+
buddy build:android
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
On a Mac with full Xcode selected, compile the generated project for an iOS
|
|
150
|
+
Simulator (including embedded extensions and watchOS dependencies) and a
|
|
151
|
+
physical-device archive. Compile the Android project with Gradle when Android is
|
|
152
|
+
configured. Verify permission prompts, safe-area
|
|
153
|
+
layout, deep links, offline/error states, background transitions, and native
|
|
154
|
+
feedback on device.
|
|
@@ -104,8 +104,8 @@ export default defineModel({
|
|
|
104
104
|
required: true,
|
|
105
105
|
unique: false,
|
|
106
106
|
validation: {
|
|
107
|
-
rule: schema.string().
|
|
108
|
-
message: {
|
|
107
|
+
rule: schema.string().max(100),
|
|
108
|
+
message: { max: 'Name is too long' }
|
|
109
109
|
},
|
|
110
110
|
factory: (faker) => faker.lorem.word()
|
|
111
111
|
},
|
|
@@ -121,8 +121,8 @@ import { schema } from '@stacksjs/validation'
|
|
|
121
121
|
```typescript
|
|
122
122
|
// String types
|
|
123
123
|
schema.string() // StringValidatorType
|
|
124
|
-
schema.string().
|
|
125
|
-
schema.string().
|
|
124
|
+
schema.string().min(2) // chain: min length
|
|
125
|
+
schema.string().max(100) // chain: max length
|
|
126
126
|
schema.string().email() // chain: must be email
|
|
127
127
|
schema.string().url() // chain: must be URL
|
|
128
128
|
schema.string().matches(/pattern/) // chain: regex match
|
|
@@ -189,10 +189,10 @@ export default defineModel({
|
|
|
189
189
|
attributes: {
|
|
190
190
|
name: {
|
|
191
191
|
validation: {
|
|
192
|
-
rule: schema.string().
|
|
192
|
+
rule: schema.string().min(2).max(100),
|
|
193
193
|
message: {
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
min: 'Name must be at least 2 characters',
|
|
195
|
+
max: 'Name cannot exceed 100 characters',
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
},
|
|
@@ -226,9 +226,9 @@ export default defineModel({
|
|
|
226
226
|
|
|
227
227
|
bio: {
|
|
228
228
|
validation: {
|
|
229
|
-
rule: schema.string().
|
|
229
|
+
rule: schema.string().max(500),
|
|
230
230
|
message: {
|
|
231
|
-
|
|
231
|
+
max: 'Bio cannot exceed 500 characters',
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
},
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.375",
|
|
6
6
|
"description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"license": "MIT",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@iconify-json/f7": "^1.2.2",
|
|
48
48
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
49
|
+
"@stacksjs/mobile": "^0.70.375",
|
|
49
50
|
"@stacksjs/sanitizer": "^0.2.113"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
interface NativeAppShellProps {
|
|
3
|
+
class?: string
|
|
4
|
+
tabBar?: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const { tabBar = false } = defineProps<NativeAppShellProps>()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<script client>
|
|
11
|
+
const className = useReactiveProp<string>('class', '')
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<div data-native-app-shell class="native-app-shell" :class="className()">
|
|
15
|
+
<main class="native-app-content">
|
|
16
|
+
<slot />
|
|
17
|
+
</main>
|
|
18
|
+
@if(tabBar)
|
|
19
|
+
<footer class="native-app-tab-slot">
|
|
20
|
+
<slot name="tab-bar" />
|
|
21
|
+
</footer>
|
|
22
|
+
@endif
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
.native-app-shell {
|
|
27
|
+
--native-safe-area-top: env(safe-area-inset-top, 0px);
|
|
28
|
+
--native-safe-area-right: env(safe-area-inset-right, 0px);
|
|
29
|
+
--native-safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
|
30
|
+
--native-safe-area-left: env(safe-area-inset-left, 0px);
|
|
31
|
+
|
|
32
|
+
min-height: 100dvh;
|
|
33
|
+
color: inherit;
|
|
34
|
+
background: inherit;
|
|
35
|
+
padding-top: var(--native-safe-area-top);
|
|
36
|
+
padding-right: var(--native-safe-area-right);
|
|
37
|
+
padding-left: var(--native-safe-area-left);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.native-app-shell::before {
|
|
41
|
+
content: '';
|
|
42
|
+
position: fixed;
|
|
43
|
+
top: 0;
|
|
44
|
+
right: 0;
|
|
45
|
+
left: 0;
|
|
46
|
+
z-index: 35;
|
|
47
|
+
height: var(--native-safe-area-top);
|
|
48
|
+
pointer-events: none;
|
|
49
|
+
background: var(--native-safe-area-background, Canvas);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.native-app-content {
|
|
53
|
+
min-height: calc(100dvh - var(--native-safe-area-top));
|
|
54
|
+
padding-bottom: var(--native-safe-area-bottom);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.native-app-shell:has(.native-app-tab-slot) .native-app-content {
|
|
58
|
+
padding-bottom: calc(5rem + var(--native-safe-area-bottom));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.native-app-sticky-top {
|
|
62
|
+
top: var(--native-safe-area-top);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.native-app-tab-slot {
|
|
66
|
+
position: fixed;
|
|
67
|
+
right: 0;
|
|
68
|
+
bottom: 0;
|
|
69
|
+
left: 0;
|
|
70
|
+
z-index: 30;
|
|
71
|
+
padding-right: var(--native-safe-area-right);
|
|
72
|
+
padding-bottom: var(--native-safe-area-bottom);
|
|
73
|
+
padding-left: var(--native-safe-area-left);
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { HealthDataType } from '@stacksjs/mobile'
|
|
3
|
+
import { device, haptics, health, isNativeMobile } from '@stacksjs/mobile'
|
|
4
|
+
|
|
5
|
+
const types = useReactiveProp<HealthDataType[]>('types', ['steps', 'distance', 'activeEnergy', 'heartRate', 'workouts'])
|
|
6
|
+
const className = useReactiveProp<string>('class', 'inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold')
|
|
7
|
+
const status = state<'idle' | 'requesting' | 'connected' | 'denied' | 'unavailable' | 'error'>('idle')
|
|
8
|
+
const emit = defineEmits()
|
|
9
|
+
|
|
10
|
+
const provider = derived(() => device.isIOS() ? 'Apple Health' : device.isAndroid() ? 'Health Connect' : 'Health')
|
|
11
|
+
const buttonLabel = derived(() => {
|
|
12
|
+
if (status() === 'requesting') return `Connecting ${provider()}`
|
|
13
|
+
if (status() === 'connected') return `${provider()} connected`
|
|
14
|
+
if (status() === 'unavailable') return `${provider()} unavailable`
|
|
15
|
+
return `Connect ${provider()}`
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
async function connectHealth(): Promise<void> {
|
|
19
|
+
if (!isNativeMobile()) {
|
|
20
|
+
status.set('unavailable')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (status() === 'requesting' || status() === 'connected') return
|
|
24
|
+
status.set('requesting')
|
|
25
|
+
await haptics.selection()
|
|
26
|
+
try {
|
|
27
|
+
const allowed = await health.requestAuthorization(types())
|
|
28
|
+
status.set(allowed ? 'connected' : 'denied')
|
|
29
|
+
emit('change', { connected: allowed, provider: provider() })
|
|
30
|
+
await haptics.notification(allowed ? 'success' : 'warning')
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
status.set('error')
|
|
34
|
+
emit('error', error)
|
|
35
|
+
await haptics.notification('error')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
:class="className()"
|
|
43
|
+
:disabled="status() === 'requesting' || status() === 'connected' || status() === 'unavailable'"
|
|
44
|
+
:aria-busy="status() === 'requesting' ? 'true' : 'false'"
|
|
45
|
+
@click="connectHealth()"
|
|
46
|
+
>
|
|
47
|
+
<i aria-hidden="true" class="h-5 w-5 i-hugeicons-health"></i>
|
|
48
|
+
<span>{{ buttonLabel() }}</span>
|
|
49
|
+
</button>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import { network } from '@stacksjs/mobile'
|
|
3
|
+
import { onDestroy, onMount } from 'stx'
|
|
4
|
+
|
|
5
|
+
const className = useReactiveProp<string>('class', '')
|
|
6
|
+
const offlineLabel = useReactiveProp<string>('offlineLabel', 'You are offline. Changes will sync automatically.')
|
|
7
|
+
const connected = state(true)
|
|
8
|
+
let removeListener: (() => void) | null = null
|
|
9
|
+
|
|
10
|
+
function applyStatus(status: { isConnected: boolean }): void {
|
|
11
|
+
connected.set(status.isConnected)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
onMount(() => {
|
|
15
|
+
void network.getStatus().then(applyStatus).catch(() => connected.set(false))
|
|
16
|
+
removeListener = network.onChange(applyStatus)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
onDestroy(() => removeListener?.())
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<div
|
|
23
|
+
:if="!connected()"
|
|
24
|
+
role="status"
|
|
25
|
+
aria-live="polite"
|
|
26
|
+
class="native-network-banner"
|
|
27
|
+
:class="className()"
|
|
28
|
+
>
|
|
29
|
+
<i aria-hidden="true" class="h-4 w-4 i-hugeicons-wifi-disconnected-01"></i>
|
|
30
|
+
<span>{{ offlineLabel() }}</span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
.native-network-banner {
|
|
35
|
+
position: sticky;
|
|
36
|
+
top: env(safe-area-inset-top, 0px);
|
|
37
|
+
z-index: 50;
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: 0.5rem;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
min-height: 2.75rem;
|
|
43
|
+
padding: 0.625rem 1rem;
|
|
44
|
+
color: rgb(120 53 15);
|
|
45
|
+
background: rgb(254 243 199 / 0.96);
|
|
46
|
+
font-size: 0.8125rem;
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@media (prefers-color-scheme: dark) {
|
|
51
|
+
.native-network-banner { color: rgb(254 243 199); background: rgb(120 53 15 / 0.96); }
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import type { PermissionStatus, PermissionType } from '@stacksjs/mobile'
|
|
3
|
+
import { haptics, permissions } from '@stacksjs/mobile'
|
|
4
|
+
import { onMount } from 'stx'
|
|
5
|
+
|
|
6
|
+
const permission = useReactiveProp<PermissionType>('permission', 'location')
|
|
7
|
+
const label = useReactiveProp<string>('label', 'Allow access')
|
|
8
|
+
const grantedLabel = useReactiveProp<string>('grantedLabel', 'Access allowed')
|
|
9
|
+
const className = useReactiveProp<string>('class', 'inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold')
|
|
10
|
+
const status = state<PermissionStatus>('undetermined')
|
|
11
|
+
const busy = state(false)
|
|
12
|
+
const emit = defineEmits()
|
|
13
|
+
|
|
14
|
+
onMount(() => {
|
|
15
|
+
void permissions.check(permission()).then(status.set).catch(() => status.set('undetermined'))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
async function requestAccess(): Promise<void> {
|
|
19
|
+
if (busy() || status() === 'granted') return
|
|
20
|
+
busy.set(true)
|
|
21
|
+
await haptics.selection()
|
|
22
|
+
try {
|
|
23
|
+
const next = await permissions.request(permission())
|
|
24
|
+
status.set(next)
|
|
25
|
+
emit('change', next)
|
|
26
|
+
await haptics.notification(next === 'granted' ? 'success' : 'warning')
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
emit('error', error)
|
|
30
|
+
await haptics.notification('error')
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
busy.set(false)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function openNativeSettings(): Promise<void> {
|
|
38
|
+
await permissions.openSettings()
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<div class="inline-flex flex-col gap-2">
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
:class="className()"
|
|
46
|
+
:disabled="busy() || status() === 'granted'"
|
|
47
|
+
:aria-busy="busy() ? 'true' : 'false'"
|
|
48
|
+
@click="requestAccess()"
|
|
49
|
+
>
|
|
50
|
+
<i aria-hidden="true" :class="status() === 'granted' ? 'i-hugeicons-checkmark-circle-02' : 'i-hugeicons-shield-01'" class="h-5 w-5"></i>
|
|
51
|
+
<span>{{ busy() ? 'Requesting access' : status() === 'granted' ? grantedLabel() : label() }}</span>
|
|
52
|
+
</button>
|
|
53
|
+
<button
|
|
54
|
+
:if="status() === 'denied' || status() === 'restricted'"
|
|
55
|
+
type="button"
|
|
56
|
+
class="min-h-11 text-sm underline underline-offset-4"
|
|
57
|
+
@click="openNativeSettings()"
|
|
58
|
+
>
|
|
59
|
+
Open device settings
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script client>
|
|
2
|
+
import { haptics, share } from '@stacksjs/mobile'
|
|
3
|
+
|
|
4
|
+
const title = useReactiveProp<string | undefined>('title', undefined)
|
|
5
|
+
const text = useReactiveProp<string | undefined>('text', undefined)
|
|
6
|
+
const url = useReactiveProp<string | undefined>('url', undefined)
|
|
7
|
+
const className = useReactiveProp<string>('class', 'inline-flex gap-2 items-center justify-center min-h-11 px-4 font-semibold text-sm rounded-xl')
|
|
8
|
+
const label = useReactiveProp<string>('label', 'Share')
|
|
9
|
+
const status = state<'idle' | 'sharing' | 'shared' | 'error'>('idle')
|
|
10
|
+
|
|
11
|
+
async function openShareSheet(): Promise<void> {
|
|
12
|
+
if (status() === 'sharing') return
|
|
13
|
+
status.set('sharing')
|
|
14
|
+
await haptics.impact('light')
|
|
15
|
+
try {
|
|
16
|
+
await share.share({ title: title(), text: text(), url: url() })
|
|
17
|
+
status.set('shared')
|
|
18
|
+
await haptics.notification('success')
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
status.set('error')
|
|
22
|
+
await haptics.notification('error')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
:class="className()"
|
|
30
|
+
:aria-busy="status() === 'sharing' ? 'true' : 'false'"
|
|
31
|
+
@click="openShareSheet()"
|
|
32
|
+
>
|
|
33
|
+
<i aria-hidden="true" class="h-5 w-5 i-hugeicons-share-08"></i>
|
|
34
|
+
<span>{{ status() === 'sharing' ? 'Sharing' : label() }}</span>
|
|
35
|
+
</button>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
interface NativeTabBarProps {
|
|
3
|
+
label?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { label = 'App navigation' } = defineProps<NativeTabBarProps>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script client>
|
|
10
|
+
import { haptics } from '@stacksjs/mobile'
|
|
11
|
+
|
|
12
|
+
function selectTab(): void {
|
|
13
|
+
void haptics.selection()
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<nav aria-label="{{ label }}" class="native-tab-bar" @click="selectTab()">
|
|
18
|
+
<slot />
|
|
19
|
+
</nav>
|
|
20
|
+
|
|
21
|
+
<style>
|
|
22
|
+
.native-tab-bar {
|
|
23
|
+
display: grid;
|
|
24
|
+
grid-auto-flow: column;
|
|
25
|
+
grid-auto-columns: 1fr;
|
|
26
|
+
min-height: 4.5rem;
|
|
27
|
+
border-top: 1px solid rgb(148 163 184 / 0.22);
|
|
28
|
+
background: rgb(255 255 255 / 0.9);
|
|
29
|
+
backdrop-filter: blur(24px) saturate(160%);
|
|
30
|
+
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.native-tab-item {
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
gap: 0.2rem;
|
|
37
|
+
align-items: center;
|
|
38
|
+
justify-content: center;
|
|
39
|
+
min-width: 2.75rem;
|
|
40
|
+
color: rgb(100 116 139);
|
|
41
|
+
font-size: 0.6875rem;
|
|
42
|
+
font-weight: 600;
|
|
43
|
+
line-height: 1;
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
transition: color 150ms ease, transform 150ms ease;
|
|
46
|
+
-webkit-tap-highlight-color: transparent;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.native-tab-item:active { transform: scale(0.96); }
|
|
50
|
+
.native-tab-item.is-active { color: rgb(5 150 105); }
|
|
51
|
+
.native-tab-icon { width: 1.35rem; height: 1.35rem; }
|
|
52
|
+
|
|
53
|
+
.dark .native-tab-bar {
|
|
54
|
+
border-color: rgb(71 85 105 / 0.55);
|
|
55
|
+
background: rgb(15 23 42 / 0.9);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.dark .native-tab-item { color: rgb(148 163 184); }
|
|
59
|
+
.dark .native-tab-item.is-active { color: rgb(52 211 153); }
|
|
60
|
+
|
|
61
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
62
|
+
.native-tab-bar {
|
|
63
|
+
background: rgb(255 255 255);
|
|
64
|
+
backdrop-filter: none;
|
|
65
|
+
-webkit-backdrop-filter: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.dark .native-tab-bar { background: rgb(15 23 42); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@media (prefers-reduced-motion: reduce) {
|
|
72
|
+
.native-tab-item { transition: none; }
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
interface NativeTabItemProps {
|
|
3
|
+
label: string
|
|
4
|
+
href: string
|
|
5
|
+
icon: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { href, icon, label } = defineProps<NativeTabItemProps>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<StxLink
|
|
12
|
+
to="{{ href }}"
|
|
13
|
+
aria-label="{{ label }}"
|
|
14
|
+
activeClass="is-active"
|
|
15
|
+
class="native-tab-item"
|
|
16
|
+
>
|
|
17
|
+
<i aria-hidden="true" class="{{ icon }} native-tab-icon"></i>
|
|
18
|
+
<span>{{ label }}</span>
|
|
19
|
+
</StxLink>
|
|
@@ -12,4 +12,11 @@ export { default as DashboardNavbar } from './Dashboard/Navbar.stx'
|
|
|
12
12
|
export * from './Dashboard'
|
|
13
13
|
export { default as Audio } from './Audio.stx'
|
|
14
14
|
export { default as Image } from './Image.stx'
|
|
15
|
+
export { default as NativeAppShell } from './NativeAppShell.stx'
|
|
16
|
+
export { default as NativeHealthButton } from './NativeHealthButton.stx'
|
|
17
|
+
export { default as NativeNetworkBanner } from './NativeNetworkBanner.stx'
|
|
18
|
+
export { default as NativePermissionButton } from './NativePermissionButton.stx'
|
|
19
|
+
export { default as NativeShareButton } from './NativeShareButton.stx'
|
|
20
|
+
export { default as NativeTabBar } from './NativeTabBar.stx'
|
|
21
|
+
export { default as NativeTabItem } from './NativeTabItem.stx'
|
|
15
22
|
export { default as Video } from './Video.stx'
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# actions/labeler@v6 format (structured match rules).
|
|
2
|
+
# Each label maps to changed-files > any-glob-to-any-file.
|
|
3
|
+
#
|
|
4
|
+
# Scoped to the paths an application actually owns. The framework's own
|
|
5
|
+
# labeler carries a label per internal package; in an app those are installed
|
|
6
|
+
# dependencies, not code under review.
|
|
7
|
+
|
|
8
|
+
app:
|
|
9
|
+
- changed-files:
|
|
10
|
+
- any-glob-to-any-file:
|
|
11
|
+
- 'app/**'
|
|
12
|
+
|
|
13
|
+
actions:
|
|
14
|
+
- changed-files:
|
|
15
|
+
- any-glob-to-any-file:
|
|
16
|
+
- 'app/Actions/**'
|
|
17
|
+
|
|
18
|
+
commands:
|
|
19
|
+
- changed-files:
|
|
20
|
+
- any-glob-to-any-file:
|
|
21
|
+
- 'app/Commands/**'
|
|
22
|
+
|
|
23
|
+
events:
|
|
24
|
+
- changed-files:
|
|
25
|
+
- any-glob-to-any-file:
|
|
26
|
+
- 'app/Events/**'
|
|
27
|
+
- 'app/Listeners/**'
|
|
28
|
+
- 'app/Events.ts'
|
|
29
|
+
- 'app/Listener.ts'
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
- changed-files:
|
|
33
|
+
- any-glob-to-any-file:
|
|
34
|
+
- 'app/Jobs/**'
|
|
35
|
+
|
|
36
|
+
mail:
|
|
37
|
+
- changed-files:
|
|
38
|
+
- any-glob-to-any-file:
|
|
39
|
+
- 'app/Mail/**'
|
|
40
|
+
|
|
41
|
+
middleware:
|
|
42
|
+
- changed-files:
|
|
43
|
+
- any-glob-to-any-file:
|
|
44
|
+
- 'app/Middleware/**'
|
|
45
|
+
- 'app/Middleware.ts'
|
|
46
|
+
|
|
47
|
+
models:
|
|
48
|
+
- changed-files:
|
|
49
|
+
- any-glob-to-any-file:
|
|
50
|
+
- 'app/Models/**'
|
|
51
|
+
|
|
52
|
+
notifications:
|
|
53
|
+
- changed-files:
|
|
54
|
+
- any-glob-to-any-file:
|
|
55
|
+
- 'app/Notifications/**'
|
|
56
|
+
|
|
57
|
+
config:
|
|
58
|
+
- changed-files:
|
|
59
|
+
- any-glob-to-any-file:
|
|
60
|
+
- 'config/**'
|
|
61
|
+
|
|
62
|
+
database:
|
|
63
|
+
- changed-files:
|
|
64
|
+
- any-glob-to-any-file:
|
|
65
|
+
- 'database/**'
|
|
66
|
+
|
|
67
|
+
migrations:
|
|
68
|
+
- changed-files:
|
|
69
|
+
- any-glob-to-any-file:
|
|
70
|
+
- 'database/migrations/**'
|
|
71
|
+
|
|
72
|
+
resources:
|
|
73
|
+
- changed-files:
|
|
74
|
+
- any-glob-to-any-file:
|
|
75
|
+
- 'resources/**'
|
|
76
|
+
|
|
77
|
+
components:
|
|
78
|
+
- changed-files:
|
|
79
|
+
- any-glob-to-any-file:
|
|
80
|
+
- 'resources/components/**'
|
|
81
|
+
|
|
82
|
+
views:
|
|
83
|
+
- changed-files:
|
|
84
|
+
- any-glob-to-any-file:
|
|
85
|
+
- 'resources/views/**'
|
|
86
|
+
- 'resources/layouts/**'
|
|
87
|
+
- 'resources/partials/**'
|
|
88
|
+
|
|
89
|
+
functions:
|
|
90
|
+
- changed-files:
|
|
91
|
+
- any-glob-to-any-file:
|
|
92
|
+
- 'resources/functions/**'
|
|
93
|
+
|
|
94
|
+
stores:
|
|
95
|
+
- changed-files:
|
|
96
|
+
- any-glob-to-any-file:
|
|
97
|
+
- 'resources/stores/**'
|
|
98
|
+
|
|
99
|
+
lang:
|
|
100
|
+
- changed-files:
|
|
101
|
+
- any-glob-to-any-file:
|
|
102
|
+
- 'resources/lang/**'
|
|
103
|
+
- 'locales/**'
|
|
104
|
+
|
|
105
|
+
routes:
|
|
106
|
+
- changed-files:
|
|
107
|
+
- any-glob-to-any-file:
|
|
108
|
+
- 'routes/**'
|
|
109
|
+
- 'app/Routes.ts'
|
|
110
|
+
|
|
111
|
+
tests:
|
|
112
|
+
- changed-files:
|
|
113
|
+
- any-glob-to-any-file:
|
|
114
|
+
- 'tests/**'
|
|
115
|
+
|
|
116
|
+
docs:
|
|
117
|
+
- changed-files:
|
|
118
|
+
- any-glob-to-any-file:
|
|
119
|
+
- 'docs/**'
|
|
120
|
+
- 'config/docs.ts'
|
|
121
|
+
- '**/*.md'
|
|
122
|
+
|
|
123
|
+
content:
|
|
124
|
+
- changed-files:
|
|
125
|
+
- any-glob-to-any-file:
|
|
126
|
+
- 'content/**'
|
|
127
|
+
|
|
128
|
+
public:
|
|
129
|
+
- changed-files:
|
|
130
|
+
- any-glob-to-any-file:
|
|
131
|
+
- 'public/**'
|
|
132
|
+
|
|
133
|
+
cloud:
|
|
134
|
+
- changed-files:
|
|
135
|
+
- any-glob-to-any-file:
|
|
136
|
+
- 'cloud/**'
|
|
137
|
+
- 'config/cloud.ts'
|
|
138
|
+
|
|
139
|
+
deps:
|
|
140
|
+
- changed-files:
|
|
141
|
+
- any-glob-to-any-file:
|
|
142
|
+
- 'package.json'
|
|
143
|
+
- 'bun.lock'
|
|
144
|
+
- 'deps.yml'
|
|
145
|
+
- 'pantry.lock'
|
|
146
|
+
|
|
147
|
+
ci:
|
|
148
|
+
- changed-files:
|
|
149
|
+
- any-glob-to-any-file:
|
|
150
|
+
- '.github/**'
|
|
@@ -110,5 +110,12 @@ jobs:
|
|
|
110
110
|
- name: Set Application Key
|
|
111
111
|
run: bun buddy key:generate
|
|
112
112
|
|
|
113
|
+
# Feature tests query real tables, and a fresh checkout has no database
|
|
114
|
+
# file at all: it is gitignored, being machine-local state. Without this
|
|
115
|
+
# the suite fails in beforeAll with "no such table", which reads like a
|
|
116
|
+
# broken test rather than a missing schema.
|
|
117
|
+
- name: Migrate the database
|
|
118
|
+
run: bun buddy migrate
|
|
119
|
+
|
|
113
120
|
- name: Test Suite
|
|
114
121
|
run: bun run test
|