@stacksjs/defaults 0.75.23 → 0.75.25
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-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-mobile/SKILL.md +10 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/NativeAppShell.stx +10 -0
- package/resources/components/NativeComponents.test.ts +14 -1
- package/resources/components/NativeNavBar.stx +7 -3
- package/resources/components/NativeProgressRing.stx +48 -0
- package/resources/components/Video.stx +22 -0
- package/resources/components/Video.test.ts +28 -0
- package/resources/components/index.ts +1 -0
|
@@ -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
|
|
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,
|
|
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/`
|
|
@@ -132,6 +132,16 @@ supported web APIs provide fallback behavior outside a native host.
|
|
|
132
132
|
between views of one screen, with selection haptics.
|
|
133
133
|
- `<NativeSheet v-model:open="editing" title="…">` raises a bottom sheet for a
|
|
134
134
|
task that belongs to the screen, above the tab bar.
|
|
135
|
+
- `<NativeProgressRing :value="percent" :size="176">` fills a ring as something
|
|
136
|
+
completes (sets done, a countdown running out), with its content in the
|
|
137
|
+
slot. Colours follow `--native-ring` and `--native-ring-track`.
|
|
138
|
+
- A screen that is a task of its own (a workout player, a composer) marks an
|
|
139
|
+
element `data-native-hide-tab-bar`, and the tab bar steps away while it is
|
|
140
|
+
shown, its reserved space with it.
|
|
141
|
+
- `<Video :src="current.url">` is the framework's player (ts-video-player):
|
|
142
|
+
YouTube, Vimeo, HLS, DASH or a file. `src` is reactive, so one player in a
|
|
143
|
+
sheet can show whichever video is chosen; clearing it pauses the player.
|
|
144
|
+
Craft lets an https iframe load inside the app, so embeds play inline.
|
|
135
145
|
|
|
136
146
|
`<NativeTabItem match="/m/workout">` keeps a tab lit on the detail screens
|
|
137
147
|
opened from it. A tab bar is a `<nav>`, where a link is otherwise current only
|
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.75.
|
|
5
|
+
"version": "0.75.25",
|
|
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.
|
|
58
|
+
"@stacksjs/mobile": "^0.75.25",
|
|
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,
|
|
@@ -29,7 +29,7 @@ describe('Native components', () => {
|
|
|
29
29
|
it('backs out through history, and sticks within the page rather than its wrapper', () => {
|
|
30
30
|
const bar = read('NativeNavBar')
|
|
31
31
|
expect(bar).toContain('if (goBack()) event.preventDefault()')
|
|
32
|
-
expect(bar).toContain('.native-nav { display: contents; }')
|
|
32
|
+
expect(bar).toContain('[data-stx-scope]:has(> .native-nav) { display: contents; }')
|
|
33
33
|
expect(bar).toContain("useReactiveProp<string>('title', '')")
|
|
34
34
|
})
|
|
35
35
|
|
|
@@ -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
|
})
|
|
@@ -38,8 +38,8 @@ function tapBack(event: MouseEvent): void {
|
|
|
38
38
|
}
|
|
39
39
|
</script>
|
|
40
40
|
|
|
41
|
-
<!-- `display: contents` on
|
|
42
|
-
parent, and
|
|
41
|
+
<!-- `display: contents` on this box and on stx's own wrapper: a sticky element
|
|
42
|
+
sticks within its parent, and either box would give the bar no room. -->
|
|
43
43
|
<div class="native-nav" data-native-nav-bar>
|
|
44
44
|
<header :class="barClass()">
|
|
45
45
|
<div class="native-nav-side">
|
|
@@ -63,7 +63,11 @@ function tapBack(event: MouseEvent): void {
|
|
|
63
63
|
</div>
|
|
64
64
|
|
|
65
65
|
<style>
|
|
66
|
-
|
|
66
|
+
/* The element stx wraps each component in is a box too, as tall as the bar
|
|
67
|
+
and its title; the bar stuck inside it and scrolled away with the title.
|
|
68
|
+
Both boxes step aside so the bar sticks to the page. */
|
|
69
|
+
.native-nav,
|
|
70
|
+
[data-stx-scope]:has(> .native-nav) { display: contents; }
|
|
67
71
|
|
|
68
72
|
.native-nav-bar {
|
|
69
73
|
position: sticky;
|
|
@@ -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'
|