@stacksjs/defaults 0.75.24 → 0.75.26

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-dashboard
3
- description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 405 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
3
+ description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 406 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
8
8
 
9
9
  # Stacks Dashboard
10
10
 
11
- The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 405 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 406 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -113,6 +113,11 @@ await withNativeFeedback(() => saveActivity())
113
113
  The runtime is browser-safe. Craft-backed operations use the native bridge;
114
114
  supported web APIs provide fallback behavior outside a native host.
115
115
 
116
+ For a run or ride, `createRouteRecorder({ location, onUpdate })` starts, pauses,
117
+ resumes and stops the native recording, re-attaches to one that outlived the
118
+ app (`attach()`), and reports live distance and pace from `routeStats(fixes)`,
119
+ which ignores GPS drift, inaccurate fixes and the ground crossed during a pause.
120
+
116
121
  ## STX components
117
122
 
118
123
  - `<NativeAppShell>` applies iOS safe-area insets and reserves tab-bar space.
@@ -132,6 +137,16 @@ supported web APIs provide fallback behavior outside a native host.
132
137
  between views of one screen, with selection haptics.
133
138
  - `<NativeSheet v-model:open="editing" title="…">` raises a bottom sheet for a
134
139
  task that belongs to the screen, above the tab bar.
140
+ - `<NativeProgressRing :value="percent" :size="176">` fills a ring as something
141
+ completes (sets done, a countdown running out), with its content in the
142
+ slot. Colours follow `--native-ring` and `--native-ring-track`.
143
+ - A screen that is a task of its own (a workout player, a composer) marks an
144
+ element `data-native-hide-tab-bar`, and the tab bar steps away while it is
145
+ shown, its reserved space with it.
146
+ - `<Video :src="current.url">` is the framework's player (ts-video-player):
147
+ YouTube, Vimeo, HLS, DASH or a file. `src` is reactive, so one player in a
148
+ sheet can show whichever video is chosen; clearing it pauses the player.
149
+ Craft lets an https iframe load inside the app, so embeds play inline.
135
150
 
136
151
  `<NativeTabItem match="/m/workout">` keeps a tab lit on the detail screens
137
152
  opened from it. A tab bar is a `<nav>`, where a link is otherwise current only
@@ -32,6 +32,28 @@ route.options('/users', handler)
32
32
  route.health() // GET /health endpoint
33
33
  ```
34
34
 
35
+ ### Zero-allocation static responses
36
+
37
+ Use `staticResponse()` only when the response is final at registration time and
38
+ must bypass the request pipeline entirely:
39
+
40
+ ```typescript
41
+ route.staticResponse('GET', '/ready', new Response('{"ready":true}', {
42
+ headers: { 'content-type': 'application/json' },
43
+ }))
44
+ ```
45
+
46
+ Bun serves that `Response` directly from its native route table. No handler,
47
+ middleware, request ID, CSRF check or cookie, rate limit, request context,
48
+ security or CORS header mutation, compression, or response formatting runs.
49
+ Supply the final status, headers, and body bytes yourself.
50
+
51
+ Static responses return the router, not a chainable route, so `.middleware()`,
52
+ `.name()`, `.skipCsrf()`, `.requireCsrf()`, and `.rateLimit()` cannot be attached
53
+ and silently bypassed. A static response inside a group with middleware or
54
+ `apiResponse: true` throws during registration. Prefix-only groups are valid.
55
+ Duplicate method and path registrations keep the first response.
56
+
35
57
  ### Chainable Methods
36
58
  ```typescript
37
59
  route.get('/admin', handler)
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.24",
5
+ "version": "0.75.26",
6
6
  "description": "A modern Stacks development environment.",
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.24",
5
+ "version": "0.75.26",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.27",
58
- "@stacksjs/mobile": "^0.75.24",
58
+ "@stacksjs/mobile": "^0.75.26",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -132,6 +132,16 @@ const shellClass = derived(() => `${nativeMobile() ? 'native-mobile ' : ''}${cla
132
132
  z-index: 30;
133
133
  }
134
134
 
135
+ /* A screen that is a task of its own — a workout player, a composer —
136
+ marks itself with data-native-hide-tab-bar, and the tab bar steps away
137
+ while it is shown, as iOS does for a pushed screen that hides the bottom
138
+ bar. Its clearance goes with it. */
139
+ .native-app-shell:has([data-native-hide-tab-bar]) { --native-tab-bar-height: 0px; }
140
+ .native-app-shell:has([data-native-hide-tab-bar]) .native-app-tab-slot { display: none; }
141
+ .native-app-shell:has([data-native-hide-tab-bar]) .native-app-content {
142
+ padding-bottom: var(--native-safe-area-bottom);
143
+ }
144
+
135
145
  /* A phone app does not select its chrome on a long press, or flash it grey
136
146
  on a tap. Text inside the content is left selectable. */
137
147
  .native-app-shell.native-mobile .native-app-tab-slot,
@@ -50,4 +50,17 @@ describe('Native components', () => {
50
50
  expect(control).toContain('haptics.selection()')
51
51
  expect(control).toContain("emit('change', value)")
52
52
  })
53
+
54
+ it('steps the tab bar away for a screen that is a task of its own', () => {
55
+ const shell = read('NativeAppShell')
56
+ expect(shell).toContain('.native-app-shell:has([data-native-hide-tab-bar]) .native-app-tab-slot { display: none; }')
57
+ expect(shell).toContain('.native-app-shell:has([data-native-hide-tab-bar]) { --native-tab-bar-height: 0px; }')
58
+ })
59
+
60
+ it('fills a ring to its value, clamped, with its content in the middle', () => {
61
+ const ring = read('NativeProgressRing')
62
+ expect(ring).toContain('Math.max(0, Math.min(100, Number(value()) || 0))')
63
+ expect(ring).toContain('conic-gradient(var(--native-ring')
64
+ expect(ring).toContain('<slot />')
65
+ })
53
66
  })
@@ -1,22 +1,43 @@
1
1
  <script client>
2
- import { network } from '@stacksjs/mobile'
2
+ import { lifecycle, network, whenNativeMobile } from '@stacksjs/mobile'
3
3
  import { onDestroy, onMount } from 'stx'
4
4
 
5
5
  const className = useReactiveProp<string>('class', '')
6
6
  const offlineLabel = useReactiveProp<string>('offlineLabel', 'You are offline. Changes will sync automatically.')
7
7
  const connected = state(true)
8
8
  let removeListener: (() => void) | null = null
9
+ let removeLifecycle: (() => void) | null = null
9
10
 
10
11
  function applyStatus(status: { isConnected: boolean }): void {
11
12
  connected.set(status.isConnected)
12
13
  }
13
14
 
15
+ // Asking can fail (the bridge not ready yet at launch, a request that timed
16
+ // out): that is no answer, not "offline". Treating it as offline stuck the
17
+ // banner on screen while everything was loading fine.
18
+ function refresh(): void {
19
+ void network.getStatus().then(applyStatus).catch(() => {})
20
+ }
21
+
14
22
  onMount(() => {
15
- void network.getStatus().then(applyStatus).catch(() => connected.set(false))
23
+ refresh()
16
24
  removeListener = network.onChange(applyStatus)
25
+ // Once the native host is there, ask it; and again whenever the app comes
26
+ // back to the front, where a change may have gone by unseen.
27
+ void whenNativeMobile().then(native => native && refresh())
28
+ removeLifecycle = lifecycle.onStateChange((appState) => {
29
+ if (appState === 'active') refresh()
30
+ })
17
31
  })
18
32
 
19
- onDestroy(() => removeListener?.())
33
+ // The webview's own view, for the moments the host has not spoken yet.
34
+ useEventListener('online', () => connected.set(true))
35
+ useEventListener('offline', () => refresh())
36
+
37
+ onDestroy(() => {
38
+ removeListener?.()
39
+ removeLifecycle?.()
40
+ })
20
41
  </script>
21
42
 
22
43
  <!--
@@ -0,0 +1,48 @@
1
+ <script client>
2
+ /**
3
+ * A ring that fills as something completes: sets done, a countdown running
4
+ * out. Whatever goes inside it — a count, a time — is the slot.
5
+ *
6
+ * `value` is 0–100. Colours follow `--native-ring` and `--native-ring-track`,
7
+ * its thickness `--native-ring-width`.
8
+ */
9
+ const value = useReactiveProp<number>('value', 0)
10
+ const size = useReactiveProp<number>('size', 56)
11
+ const label = useReactiveProp<string>('label', '')
12
+
13
+ const ringStyle = derived(() => {
14
+ const percent = Math.max(0, Math.min(100, Number(value()) || 0))
15
+ const px = Math.max(16, Number(size()) || 56)
16
+ return `width:${px}px;height:${px}px;background:conic-gradient(var(--native-ring, rgb(16 185 129)) ${percent}%, var(--native-ring-track, rgb(148 163 184 / 0.22)) 0)`
17
+ })
18
+ </script>
19
+
20
+ <div class="native-ring" role="img" :aria-label="label()" :style="ringStyle()" data-native-progress-ring>
21
+ <div class="native-ring-center">
22
+ <slot />
23
+ </div>
24
+ </div>
25
+
26
+ <style>
27
+ .native-ring {
28
+ position: relative;
29
+ flex-shrink: 0;
30
+ border-radius: 9999px;
31
+ transition: background 200ms linear;
32
+ }
33
+
34
+ .native-ring-center {
35
+ position: absolute;
36
+ inset: var(--native-ring-width, 5px);
37
+ display: flex;
38
+ flex-direction: column;
39
+ align-items: center;
40
+ justify-content: center;
41
+ border-radius: 9999px;
42
+ background: var(--native-ring-center, var(--native-safe-area-background, Canvas));
43
+ }
44
+
45
+ @media (prefers-reduced-motion: reduce) {
46
+ .native-ring { transition: none; }
47
+ }
48
+ </style>
@@ -44,17 +44,35 @@ const controlslist = props.controlslist || 'nodownload'
44
44
  const className = props.class || ''
45
45
  const playerSources = (sources.length ? sources : src ? [{ src }] : []).map(source => props.drm && (source.type === 'application/dash+xml' || source.src.split(/[?#]/, 1)[0].toLowerCase().endsWith('.mpd')) ? { ...source, type: 'application/dash+xml', drm: props.drm } : source)
46
46
  const playerTracks = tracks.map(track => ({ ...track, language: track.srclang }))
47
+ // A YouTube or Vimeo link is a web page, not a media file: the native <video>
48
+ // fallback cannot play it, so without the player it offers the link instead.
49
+ const isEmbed = /^https?:\/\/(?:[\w-]+\.)*(?:youtube\.com|youtube-nocookie\.com|youtu\.be|vimeo\.com)\//i.test(src)
47
50
  const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026')
48
51
  </script>
49
52
 
50
53
  <script client>
51
54
  import { registerElements } from 'ts-video-player/elements'
52
55
  registerElements()
56
+
57
+ // `src` and `poster` follow the parent, so one player can show whichever
58
+ // video is chosen: `<Video :src="current.url" />` in a sheet. An empty `src`
59
+ // pauses it, which is what closing that sheet should do.
60
+ const liveSrc = useReactiveProp<string>('src', '')
61
+ const livePoster = useReactiveProp<string>('poster', '')
62
+ const element = useRef('player')
63
+
64
+ effect(() => {
65
+ if (!liveSrc())
66
+ (element.current as { player?: { pause: () => void } } | null)?.player?.pause()
67
+ })
53
68
  </script>
54
69
 
55
70
  <figure class="m-0 {{ className }}">
56
71
  <video-player
72
+ ref="player"
57
73
  src="{{ src }}"
74
+ :src="liveSrc() || null"
75
+ :poster="livePoster() || null"
58
76
  data-media-config="{{ playerConfig }}"
59
77
  @if(poster) poster="{{ poster }}" @endif
60
78
  @if(controls) controls @endif
@@ -72,6 +90,9 @@ registerElements()
72
90
  style="aspect-ratio: {{ ratio }};"
73
91
  >
74
92
  <media-error-display></media-error-display>
93
+ @if(isEmbed)
94
+ <a data-native-fallback href="{{ src }}" target="_blank" rel="noopener" class="flex items-center justify-center h-full w-full text-sm text-white underline">Watch {{ title }}</a>
95
+ @else
75
96
  <video data-native-fallback src="{{ src }}" @if(poster) poster="{{ poster }}" @endif @if(width) width="{{ width }}" @endif @if(height) height="{{ height }}" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="{{ preload }}" @if(crossorigin) crossorigin="{{ crossorigin }}" @endif controlslist="{{ controlslist }}" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="{{ title }}" class="object-contain h-full w-full">
76
97
  @foreach (sources as source)
77
98
  <source src="{{ source.src }}" @if(source.type) type="{{ source.type }}" @endif>
@@ -81,6 +102,7 @@ registerElements()
81
102
  @endforeach
82
103
  Your browser does not support video playback.
83
104
  </video>
105
+ @endif
84
106
  <video-skin>
85
107
  <media-seek-button slot="left" seconds="-10" aria-label="Back 10 seconds"></media-seek-button>
86
108
  <media-play-button slot="left" aria-label="Play or pause"></media-play-button>
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const video = readFileSync(join(import.meta.dir, 'Video.stx'), 'utf8')
6
+
7
+ describe('Video', () => {
8
+ it('follows a reactive src and poster, so one player can show whichever video is chosen', () => {
9
+ expect(video).toContain(`useReactiveProp<string>('src', '')`)
10
+ expect(video).toContain(`:src="liveSrc() || null"`)
11
+ expect(video).toContain(`:poster="livePoster() || null"`)
12
+ })
13
+
14
+ it('pauses when its src is cleared, as closing a sheet does', () => {
15
+ expect(video).toContain(`useRef('player')`)
16
+ expect(video).toContain('ref="player"')
17
+ expect(video).toMatch(/if \(!liveSrc\(\)\)[\s\S]*?\.pause\(\)/)
18
+ })
19
+
20
+ it('never hands a YouTube or Vimeo page to the native <video> fallback', () => {
21
+ const isEmbed = new RegExp(video.match(/const isEmbed = \/(.+)\/i\.test/)![1]!, 'i')
22
+ for (const url of ['https://www.youtube.com/watch?v=aclHkVaku9U', 'https://youtu.be/aclHkVaku9U', 'https://m.youtube.com/shorts/aclHkVaku9U', 'https://vimeo.com/76979871'])
23
+ expect(isEmbed.test(url)).toBe(true)
24
+ for (const url of ['https://cdn.example.com/clip.mp4', '/media/demo/manifest.mpd', 'https://example.com/youtube.com/fake.mp4'])
25
+ expect(isEmbed.test(url)).toBe(false)
26
+ expect(video).toContain('@if(isEmbed)')
27
+ })
28
+ })
@@ -17,6 +17,7 @@ export { default as NativeHealthButton } from './NativeHealthButton.stx'
17
17
  export { default as NativeNavBar } from './NativeNavBar.stx'
18
18
  export { default as NativeNetworkBanner } from './NativeNetworkBanner.stx'
19
19
  export { default as NativePermissionButton } from './NativePermissionButton.stx'
20
+ export { default as NativeProgressRing } from './NativeProgressRing.stx'
20
21
  export { default as NativePullToRefresh } from './NativePullToRefresh.stx'
21
22
  export { default as NativeSegmentedControl } from './NativeSegmentedControl.stx'
22
23
  export { default as NativeShareButton } from './NativeShareButton.stx'