@stacksjs/defaults 0.75.467 → 0.75.473

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-mobile/SKILL.md +15 -2
  3. package/ai/skills/stacks-native/CATALOG.md +15 -15
  4. package/ai/skills/stacks-storage/UPLOADS-AND-DISKS.md +19 -0
  5. package/app/Actions/Dashboard/Content/FileDestroyAction.ts +3 -4
  6. package/app/Actions/Dashboard/Content/FileDirectoryStoreAction.ts +3 -4
  7. package/app/Actions/Dashboard/Content/FileDuplicateAction.ts +3 -4
  8. package/app/Actions/Dashboard/Content/FileFavoriteAction.ts +3 -4
  9. package/app/Actions/Dashboard/Content/FileIndexAction.ts +3 -5
  10. package/app/Actions/Dashboard/Content/FileRenameAction.ts +3 -4
  11. package/app/Actions/Dashboard/Content/FileReprocessAction.ts +3 -4
  12. package/app/Actions/Dashboard/Content/FileTagsAction.ts +3 -4
  13. package/app/Actions/Dashboard/Content/FileUploadAction.ts +3 -4
  14. package/app/Actions/Dashboard/Content/FileVisibilityAction.ts +3 -4
  15. package/app/Actions/Dashboard/Content/file-response.test.ts +61 -0
  16. package/app/Actions/Dashboard/Content/file-response.ts +18 -0
  17. package/app/Actions/Dashboard/dashboard-response.ts +20 -0
  18. package/functions/dashboard-badge.test.ts +42 -0
  19. package/functions/dashboard-badge.ts +104 -0
  20. package/functions/dashboard-button.test.ts +33 -0
  21. package/functions/dashboard-button.ts +44 -0
  22. package/ide/vscode/package.json +1 -1
  23. package/package.json +4 -4
  24. package/resources/components/Dashboard/Analytics/AnalyticsHub.stx +10 -1
  25. package/resources/components/Dashboard/Data/DataRecords.stx +1 -1
  26. package/resources/components/Dashboard/Email/InboxMessageList.stx +1 -1
  27. package/resources/components/Dashboard/MobileSidebar.stx +4 -1
  28. package/resources/components/Dashboard/Models/ModelsOverview.stx +1 -1
  29. package/resources/components/Dashboard/UI/Badge.stx +21 -44
  30. package/resources/components/Dashboard/UI/Button.stx +13 -24
  31. package/resources/components/Dashboard/UI/Card.stx +8 -0
  32. package/resources/functions/dashboard/sidebar.ts +12 -3
  33. package/views/dashboard/layouts/default.stx +43 -18
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { badgeClasses, badgeClassToggles, badgeDotClasses, badgeDotClassToggles, badgeShowsDot } from './dashboard-badge'
3
+
4
+ describe('dashboard badge classes', () => {
5
+ test('a variant picks its colour', () => {
6
+ expect(badgeClasses('info')).toContain('bg-cyan-500/15 text-cyan-600')
7
+ expect(badgeClasses('success')).toContain('bg-green-500/15 text-green-600')
8
+ expect(badgeClasses('danger', 'sm', 'full', true)).toContain('border-red-500/40 text-red-600')
9
+ expect(badgeDotClasses('warning')).toContain('bg-orange-500')
10
+ })
11
+
12
+ test('an unknown or missing value falls back rather than printing undefined', () => {
13
+ expect(badgeClasses('nope', 'huge')).toBe(badgeClasses())
14
+ expect(badgeClasses(undefined)).toContain('bg-black/[0.06] text-neutral-600')
15
+ expect(badgeDotClasses('nope')).toBe(badgeDotClasses())
16
+ expect(badgeClasses()).not.toContain('undefined')
17
+ })
18
+
19
+ test('a flag that arrived as an attribute string reads as the attribute means', () => {
20
+ expect(badgeClasses('info', 'sm', 'full', 'false')).toBe(badgeClasses('info'))
21
+ expect(badgeClasses('info', 'sm', 'full', 'true')).toBe(badgeClasses('info', 'sm', 'full', true))
22
+ expect(badgeShowsDot('true')).toBe(true)
23
+ expect(badgeShowsDot('')).toBe(true)
24
+ expect(badgeShowsDot('false')).toBe(false)
25
+ expect(badgeShowsDot(undefined)).toBe(false)
26
+ })
27
+
28
+ test('the live toggles turn a grey first paint into exactly the bound variant', () => {
29
+ // What the browser does with an object `:class`: add the true keys, remove
30
+ // the false ones, leave every other class alone.
31
+ const apply = (start: string, toggles: Record<string, boolean>) => {
32
+ const list = new Set(start.split(' '))
33
+ for (const [token, on] of Object.entries(toggles)) on ? list.add(token) : list.delete(token)
34
+ return [...list].sort()
35
+ }
36
+ const sorted = (classes: string) => classes.split(' ').sort()
37
+
38
+ expect(apply(badgeClasses('default'), badgeClassToggles('info'))).toEqual(sorted(badgeClasses('info')))
39
+ expect(apply(badgeClasses('success', 'xs'), badgeClassToggles('danger', 'lg', 'default', 'true'))).toEqual(sorted(badgeClasses('danger', 'lg', 'default', true)))
40
+ expect(apply(badgeDotClasses('default'), badgeDotClassToggles('warning'))).toEqual(sorted(badgeDotClasses('warning')))
41
+ })
42
+ })
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The classes a dashboard `<Badge>` renders.
3
+ *
4
+ * A pure function of the props, shared by the badge's server render (the first
5
+ * paint) and its client script (every later value). The client half is why
6
+ * this is a module: most callers bind `:variant` to data that arrives after
7
+ * hydration, the class computed at the server render never changed, and every
8
+ * such badge stayed the default grey (stacksjs/stacks#3179). A server script's
9
+ * function cannot cross to the client, so both halves import it from here.
10
+ */
11
+
12
+ export type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info'
13
+ export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg'
14
+
15
+ const sizeClasses: Record<string, string> = {
16
+ xs: 'text-[10px] px-1.5 py-0.5',
17
+ sm: 'text-[11px] px-2 py-0.5',
18
+ md: 'text-[12px] px-2.5 py-0.5',
19
+ lg: 'text-[13px] px-3 py-1'
20
+ }
21
+
22
+ const solidVariants: Record<string, string> = {
23
+ default: 'bg-black/[0.06] text-neutral-600 dark:bg-white/10 dark:text-neutral-300',
24
+ primary: 'bg-blue-500/15 text-blue-600 dark:bg-blue-400/20 dark:text-blue-400',
25
+ success: 'bg-green-500/15 text-green-600 dark:bg-green-400/20 dark:text-green-400',
26
+ warning: 'bg-orange-500/15 text-orange-600 dark:bg-orange-400/20 dark:text-orange-400',
27
+ danger: 'bg-red-500/15 text-red-600 dark:bg-red-400/20 dark:text-red-400',
28
+ info: 'bg-cyan-500/15 text-cyan-600 dark:bg-cyan-400/20 dark:text-cyan-400'
29
+ }
30
+
31
+ const outlineVariants: Record<string, string> = {
32
+ default: 'border border-black/15 text-neutral-600 dark:border-white/20 dark:text-neutral-300',
33
+ primary: 'border border-blue-500/40 text-blue-600 dark:border-blue-400/50 dark:text-blue-400',
34
+ success: 'border border-green-500/40 text-green-600 dark:border-green-400/50 dark:text-green-400',
35
+ warning: 'border border-orange-500/40 text-orange-600 dark:border-orange-400/50 dark:text-orange-400',
36
+ danger: 'border border-red-500/40 text-red-600 dark:border-red-400/50 dark:text-red-400',
37
+ info: 'border border-cyan-500/40 text-cyan-600 dark:border-cyan-400/50 dark:text-cyan-400'
38
+ }
39
+
40
+ const dotColors: Record<string, string> = {
41
+ default: 'bg-neutral-400 dark:bg-neutral-500',
42
+ primary: 'bg-blue-500 dark:bg-blue-400',
43
+ success: 'bg-green-500 dark:bg-green-400',
44
+ warning: 'bg-orange-500 dark:bg-orange-400',
45
+ danger: 'bg-red-500 dark:bg-red-400',
46
+ info: 'bg-cyan-500 dark:bg-cyan-400'
47
+ }
48
+
49
+ /**
50
+ * A prop that arrived as an attribute is a string, so `outline="false"` must
51
+ * not read as on.
52
+ */
53
+ function flag(value: unknown): boolean {
54
+ return value === true || value === 'true' || value === ''
55
+ }
56
+
57
+ export function badgeClasses(variant: unknown = 'default', size: unknown = 'sm', rounded: unknown = 'full', outline: unknown = false): string {
58
+ return [
59
+ 'inline-flex items-center font-medium tracking-tight',
60
+ 'transition-all duration-150',
61
+ sizeClasses[String(size)] ?? sizeClasses.sm,
62
+ rounded === 'full' ? 'rounded-full' : 'rounded-md',
63
+ (flag(outline) ? outlineVariants : solidVariants)[String(variant)] ?? solidVariants.default,
64
+ ].join(' ')
65
+ }
66
+
67
+ export function badgeDotClasses(variant: unknown = 'default'): string {
68
+ return `w-1.5 h-1.5 rounded-full mr-1.5 ${dotColors[String(variant)] ?? dotColors.default}`
69
+ }
70
+
71
+ export function badgeShowsDot(dot: unknown): boolean {
72
+ return flag(dot)
73
+ }
74
+
75
+ function tokens(...classLists: string[]): string[] {
76
+ return [...new Set(classLists.flatMap(list => list.split(/\s+/)).filter(Boolean))]
77
+ }
78
+
79
+ const switchedTokens = tokens(
80
+ ...Object.values(sizeClasses),
81
+ ...Object.values(solidVariants),
82
+ ...Object.values(outlineVariants),
83
+ 'rounded-full rounded-md',
84
+ )
85
+ const dotTokens = tokens(...Object.values(dotColors))
86
+
87
+ /**
88
+ * The badge's classes as a `:class` object: every class any size, shape or
89
+ * variant can set, each on or off.
90
+ *
91
+ * Not a string. A string `:class` is appended to the classes the element was
92
+ * rendered with, so a badge first painted grey kept the grey classes next to
93
+ * its live ones, and which colour showed depended on stylesheet order. The
94
+ * object form removes what the current props do not want.
95
+ */
96
+ export function badgeClassToggles(variant?: unknown, size?: unknown, rounded?: unknown, outline?: unknown): Record<string, boolean> {
97
+ const wanted = new Set(badgeClasses(variant, size, rounded, outline).split(/\s+/))
98
+ return Object.fromEntries(switchedTokens.map(token => [token, wanted.has(token)]))
99
+ }
100
+
101
+ export function badgeDotClassToggles(variant?: unknown): Record<string, boolean> {
102
+ const wanted = new Set(badgeDotClasses(variant).split(/\s+/))
103
+ return Object.fromEntries(dotTokens.map(token => [token, wanted.has(token)]))
104
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { buttonVariantClasses, buttonVariantToggles } from './dashboard-button'
3
+
4
+ // What the browser does with an object `:class`: add the true keys, remove the
5
+ // false ones, leave every other class alone.
6
+ function apply(start: string, toggles: Record<string, boolean>): string[] {
7
+ const list = new Set(start.split(/\s+/).filter(Boolean))
8
+ for (const [token, on] of Object.entries(toggles)) on ? list.add(token) : list.delete(token)
9
+ return [...list].sort()
10
+ }
11
+
12
+ const sorted = (classes: string) => classes.split(/\s+/).filter(Boolean).sort()
13
+
14
+ describe('dashboard button variant toggles', () => {
15
+ test('a bound variant replaces the first paint\'s colour', () => {
16
+ const base = 'inline-flex items-center h-8'
17
+ const painted = `${base} ${buttonVariantClasses.primary}`
18
+ expect(apply(painted, buttonVariantToggles('danger'))).toEqual(sorted(`${base} ${buttonVariantClasses.danger}`))
19
+ })
20
+
21
+ test('the caller\'s own classes survive even when a variant uses them too', () => {
22
+ const caller = 'border shadow-sm w-full'
23
+ const painted = `${caller} ${buttonVariantClasses.secondary}`
24
+ const result = apply(painted, buttonVariantToggles('primary', caller))
25
+ for (const token of caller.split(' ')) expect(result).toContain(token)
26
+ expect(result).not.toContain('bg-white/80')
27
+ })
28
+
29
+ test('bare keeps nothing of a variant and an unknown one falls back to primary', () => {
30
+ expect(apply(buttonVariantClasses.success, buttonVariantToggles('bare'))).toEqual([])
31
+ expect(apply('', buttonVariantToggles('nope'))).toEqual(sorted(buttonVariantClasses.primary))
32
+ })
33
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The colour classes of a dashboard `<Button>` variant.
3
+ *
4
+ * Shared by the button's server render and its client script. The client half
5
+ * exists for callers that bind `:variant` to client state - the migration
6
+ * plan's confirm button turns `danger` when the plan is destructive - which
7
+ * the server render never sees, so the button kept the variant it was first
8
+ * painted with (stacksjs/stacks#3180).
9
+ */
10
+
11
+ export const buttonVariantClasses: Record<string, string> = {
12
+ primary: 'bg-gradient-to-b from-blue-500 to-blue-600 text-white shadow-sm shadow-blue-600/25 hover:from-blue-400 hover:to-blue-500 active:from-blue-600 active:to-blue-700 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-500 dark:hover:to-blue-600',
13
+ secondary: 'bg-white/80 dark:bg-white/10 text-neutral-800 dark:text-neutral-100 border border-black/10 dark:border-white/10 shadow-sm hover:bg-white dark:hover:bg-white/15 active:bg-neutral-100 dark:active:bg-white/5',
14
+ outline: 'bg-transparent text-neutral-700 dark:text-neutral-200 border border-black/15 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/8 active:bg-black/10 dark:active:bg-white/10',
15
+ ghost: 'bg-transparent text-neutral-600 dark:text-neutral-300 hover:bg-black/5 dark:hover:bg-white/8 active:bg-black/10 dark:active:bg-white/12',
16
+ danger: 'bg-gradient-to-b from-red-500 to-red-600 text-white shadow-sm shadow-red-600/25 hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700',
17
+ success: 'bg-gradient-to-b from-green-500 to-green-600 text-white shadow-sm shadow-green-600/25 hover:from-green-400 hover:to-green-500 active:from-green-600 active:to-green-700',
18
+ warning: 'bg-gradient-to-b from-orange-500 to-orange-600 text-white shadow-sm shadow-orange-600/25 hover:from-orange-400 hover:to-orange-500 active:from-orange-600 active:to-orange-700',
19
+ /**
20
+ * No chrome at all: the caller supplies every visual through `class`.
21
+ *
22
+ * For an affordance that has to sit inside someone else's layout and carry
23
+ * no box of its own - a sortable table header fills its `<th>`, and the
24
+ * padding and fixed height the sizes impose would change the header row.
25
+ * What it still gets, and the reason to come through this component rather
26
+ * than write a raw `<button>`, is the shared focus-visible ring, the
27
+ * disabled and aria-disabled handling, and the press feedback.
28
+ * See stacksjs/stacks#2926.
29
+ */
30
+ bare: ''
31
+ }
32
+
33
+ /**
34
+ * The variant's classes as a `:class` object: every class any variant sets,
35
+ * each on or off, so a live change removes the first paint's colour instead
36
+ * of stacking on it. Classes the caller passed in `class` are left alone even
37
+ * when a variant also uses them.
38
+ */
39
+ export function buttonVariantToggles(variant: unknown, callerClass: unknown = ''): Record<string, boolean> {
40
+ const own = new Set(String(callerClass || '').split(/\s+/).filter(Boolean))
41
+ const wanted = new Set((buttonVariantClasses[String(variant)] ?? buttonVariantClasses.primary).split(/\s+/).filter(Boolean))
42
+ const all = new Set(Object.values(buttonVariantClasses).flatMap(list => list.split(/\s+/)).filter(Boolean))
43
+ return Object.fromEntries([...all].filter(token => !own.has(token)).map(token => [token, wanted.has(token)]))
44
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.467",
5
+ "version": "0.75.473",
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.467",
5
+ "version": "0.75.473",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -45,9 +45,9 @@
45
45
  "dependencies": {
46
46
  "@iconify-json/f7": "^1.2.2",
47
47
  "@iconify-json/hugeicons": "^1.2.35",
48
- "@stacksjs/mobile": "0.75.467",
49
- "@stacksjs/sanitizer": "0.2.798",
50
- "@stacksjs/stx": "0.2.798",
48
+ "@stacksjs/mobile": "0.75.473",
49
+ "@stacksjs/sanitizer": "0.2.800",
50
+ "@stacksjs/stx": "0.2.800",
51
51
  "@xterm/addon-fit": "^0.11.0",
52
52
  "@xterm/xterm": "^6.0.0",
53
53
  "ts-qr-codes": "^0.1.8"
@@ -1,3 +1,12 @@
1
+ <script server>
2
+ import { loadDiscoveredManifest } from '@stacksjs/defaults/resources/functions/dashboard/sidebar'
3
+
4
+ // The Commerce card follows the sidebar's commerce section: with the feature
5
+ // off its models are never loaded, so the page it opens could only answer
6
+ // 503 (stacksjs/stacks#3183).
7
+ export const commerceAnalytics = loadDiscoveredManifest().sections.commerce
8
+ </script>
9
+
1
10
  <script client>
2
11
  import type { AnalyticsOverview } from '../../../../functions/analytics'
3
12
  import { fetchWebAnalytics } from '../../../../functions/analytics'
@@ -17,7 +26,7 @@ const analyticsCards = [
17
26
  { name: 'Countries', description: 'Geographic data when provided', icon: 'i-hugeicons-globe-02', to: '/analytics/countries' },
18
27
  { name: 'Devices', description: 'Device classes from user agents', icon: 'i-hugeicons-smart-phone-01', to: '/analytics/devices' },
19
28
  { name: 'Browsers', description: 'Browser families from user agents', icon: 'i-hugeicons-browser', to: '/analytics/browsers' },
20
- ]
29
+ ].filter(card => commerceAnalytics || !card.to.startsWith('/analytics/commerce/'))
21
30
 
22
31
  function formatNumber(value: number | undefined): string {
23
32
  return new Intl.NumberFormat().format(value || 0)
@@ -239,7 +239,7 @@ onMount(() => {
239
239
  </div>
240
240
  <label class="block relative w-full sm:w-72">
241
241
  <span class="sr-only">Search {{ page().title.toLowerCase() }}</span>
242
- <span class="flex absolute inset-y-0 left-0 items-center pl-3 text-neutral-400 i-hugeicons-search-01"></span>
242
+ <span aria-hidden="true" class="absolute left-3 top-1/2 h-4 w-4 text-neutral-400 pointer-events-none -translate-y-1/2 i-hugeicons-search-01"></span>
243
243
  <input x-model="search" type="search" :placeholder="'Search ' + page().title.toLowerCase()" class="block pl-9 pr-3 py-2 w-full text-neutral-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-neutral-300 rounded-lg dark:border-neutral-700 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20">
244
244
  </label>
245
245
  </div>
@@ -41,7 +41,7 @@ function listClass(): string {
41
41
  <div class="px-4 py-2 bg-white dark:bg-neutral-700 border-b border-gray-200 dark:border-neutral-600">
42
42
  <label for="inbox-search" class="sr-only">Search messages</label>
43
43
  <div class="relative">
44
- <span class="flex absolute inset-y-0 left-0 items-center pl-3 text-gray-400 pointer-events-none i-hugeicons-search-01"></span>
44
+ <span aria-hidden="true" class="absolute left-3 top-1/2 h-4 w-4 text-gray-400 pointer-events-none -translate-y-1/2 i-hugeicons-search-01"></span>
45
45
  <input id="inbox-search" x-model="search" type="search" placeholder="Search messages" class="block placeholder-gray-500 dark:placeholder-gray-400 pl-10 pr-3 py-2 w-full text-gray-900 text-sm dark:text-neutral-100 bg-gray-50 dark:bg-neutral-600 border border-gray-300 rounded-md dark:border-neutral-600 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" @input="emit('search', $event.target.value)" />
46
46
  </div>
47
47
  </div>
@@ -57,9 +57,12 @@ function handlePanelClick(event: MouseEvent): void {
57
57
  closeSidebar()
58
58
  }
59
59
 
60
+ // Widening past the breakpoint closes the drawer the same way a tap does, so
61
+ // the scroll lock is released and the layout gets its `close` to take its
62
+ // sidebar back. Setting the state alone did neither.
60
63
  effect(() => {
61
64
  if (isDesktop() && isOpen())
62
- isOpen.set(false)
65
+ closeSidebar()
63
66
  })
64
67
 
65
68
  useEventListener('keydown', handleKeydown)
@@ -141,7 +141,7 @@ onMount(() => {
141
141
  <div class="flex flex-col gap-2 sm:flex-row w-full sm:w-auto">
142
142
  <label class="block relative w-full sm:w-64">
143
143
  <span class="sr-only">Search models</span>
144
- <span class="flex absolute inset-y-0 left-0 items-center pl-3 text-neutral-400 i-hugeicons-search-01"></span>
144
+ <span aria-hidden="true" class="absolute left-3 top-1/2 h-4 w-4 text-neutral-400 pointer-events-none -translate-y-1/2 i-hugeicons-search-01"></span>
145
145
  <input x-model="search" type="search" placeholder="Search models" class="block pl-9 pr-3 py-2 w-full text-neutral-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-neutral-300 rounded-lg dark:border-neutral-700 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20">
146
146
  </label>
147
147
  <label>
@@ -1,4 +1,6 @@
1
1
  <script server>
2
+ import { badgeClasses, badgeDotClasses } from '../../../../functions/dashboard-badge'
3
+
2
4
  interface BadgeProps {
3
5
  variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info'
4
6
  size?: 'xs' | 'sm' | 'md' | 'lg'
@@ -14,53 +16,28 @@ const { variant = 'default', size = 'sm', rounded = 'full', outline = false, dot
14
16
  // `{{ text }}` rendered the validator function source into every Badge.
15
17
  // Use the slot for the badge label instead.
16
18
 
17
- const sizeClasses = {
18
- xs: 'text-[10px] px-1.5 py-0.5',
19
- sm: 'text-[11px] px-2 py-0.5',
20
- md: 'text-[12px] px-2.5 py-0.5',
21
- lg: 'text-[13px] px-3 py-1'
22
- }
23
-
24
- const solidVariants = {
25
- default: 'bg-black/[0.06] text-neutral-600 dark:bg-white/10 dark:text-neutral-300',
26
- primary: 'bg-blue-500/15 text-blue-600 dark:bg-blue-400/20 dark:text-blue-400',
27
- success: 'bg-green-500/15 text-green-600 dark:bg-green-400/20 dark:text-green-400',
28
- warning: 'bg-orange-500/15 text-orange-600 dark:bg-orange-400/20 dark:text-orange-400',
29
- danger: 'bg-red-500/15 text-red-600 dark:bg-red-400/20 dark:text-red-400',
30
- info: 'bg-cyan-500/15 text-cyan-600 dark:bg-cyan-400/20 dark:text-cyan-400'
31
- }
32
-
33
- const outlineVariants = {
34
- default: 'border border-black/15 text-neutral-600 dark:border-white/20 dark:text-neutral-300',
35
- primary: 'border border-blue-500/40 text-blue-600 dark:border-blue-400/50 dark:text-blue-400',
36
- success: 'border border-green-500/40 text-green-600 dark:border-green-400/50 dark:text-green-400',
37
- warning: 'border border-orange-500/40 text-orange-600 dark:border-orange-400/50 dark:text-orange-400',
38
- danger: 'border border-red-500/40 text-red-600 dark:border-red-400/50 dark:text-red-400',
39
- info: 'border border-cyan-500/40 text-cyan-600 dark:border-cyan-400/50 dark:text-cyan-400'
40
- }
41
-
42
- const dotColors = {
43
- default: 'bg-neutral-400 dark:bg-neutral-500',
44
- primary: 'bg-blue-500 dark:bg-blue-400',
45
- success: 'bg-green-500 dark:bg-green-400',
46
- warning: 'bg-orange-500 dark:bg-orange-400',
47
- danger: 'bg-red-500 dark:bg-red-400',
48
- info: 'bg-cyan-500 dark:bg-cyan-400'
49
- }
50
-
51
- const badgeClass = [
52
- 'inline-flex items-center font-medium tracking-tight',
53
- 'transition-all duration-150',
54
- sizeClasses[size],
55
- rounded === 'full' ? 'rounded-full' : 'rounded-md',
56
- outline ? outlineVariants[variant] : solidVariants[variant]
57
- ].join(' ')
19
+ // The first paint. The client script keeps both in step afterwards.
20
+ const badgeClass = badgeClasses(variant, size, rounded, outline)
21
+ const dotClass = badgeDotClasses(variant)
22
+ </script>
23
+ <script client>
24
+ import { badgeClassToggles, badgeDotClassToggles, badgeShowsDot } from '../../../../functions/dashboard-badge'
58
25
 
59
- const dotClass = 'w-1.5 h-1.5 rounded-full mr-1.5 ' + dotColors[variant]
26
+ // Read live as well: callers bind `:variant` to data that arrives after
27
+ // hydration, and the value the server render saw never changed
28
+ // (stacksjs/stacks#3179).
29
+ const liveVariant = useReactiveProp('variant', 'default')
30
+ const liveSize = useReactiveProp('size', 'sm')
31
+ const liveRounded = useReactiveProp('rounded', 'full')
32
+ const liveOutline = useReactiveProp('outline', false)
33
+ const liveDot = useReactiveProp('dot', false)
34
+ const liveBadgeClass = derived(() => badgeClassToggles(liveVariant(), liveSize(), liveRounded(), liveOutline()))
35
+ const liveDotClass = derived(() => badgeDotClassToggles(liveVariant()))
36
+ const showDot = derived(() => badgeShowsDot(liveDot()))
60
37
  </script>
61
- <span class="{{ badgeClass }}">
38
+ <span class="{{ badgeClass }}" :class="liveBadgeClass()">
62
39
  @if(dot)
63
- <span class="{{ dotClass }}"></span>
40
+ <span class="{{ dotClass }}" :class="liveDotClass()" :if="showDot()"></span>
64
41
  @endif
65
42
  <slot />
66
43
  </span>
@@ -1,4 +1,6 @@
1
1
  <script server>
2
+ import { buttonVariantClasses } from '../../../../functions/dashboard-button'
3
+
2
4
  interface ButtonProps {
3
5
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'bare'
4
6
  size?: 'xs' | 'sm' | 'md' | 'lg'
@@ -32,29 +34,6 @@ const {
32
34
  href = '',
33
35
  } = defineProps<ButtonProps>()
34
36
 
35
- // Variant classes
36
- const variantClasses = {
37
- primary: 'bg-gradient-to-b from-blue-500 to-blue-600 text-white shadow-sm shadow-blue-600/25 hover:from-blue-400 hover:to-blue-500 active:from-blue-600 active:to-blue-700 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-500 dark:hover:to-blue-600',
38
- secondary: 'bg-white/80 dark:bg-white/10 text-neutral-800 dark:text-neutral-100 border border-black/10 dark:border-white/10 shadow-sm hover:bg-white dark:hover:bg-white/15 active:bg-neutral-100 dark:active:bg-white/5',
39
- outline: 'bg-transparent text-neutral-700 dark:text-neutral-200 border border-black/15 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/8 active:bg-black/10 dark:active:bg-white/10',
40
- ghost: 'bg-transparent text-neutral-600 dark:text-neutral-300 hover:bg-black/5 dark:hover:bg-white/8 active:bg-black/10 dark:active:bg-white/12',
41
- danger: 'bg-gradient-to-b from-red-500 to-red-600 text-white shadow-sm shadow-red-600/25 hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700',
42
- success: 'bg-gradient-to-b from-green-500 to-green-600 text-white shadow-sm shadow-green-600/25 hover:from-green-400 hover:to-green-500 active:from-green-600 active:to-green-700',
43
- warning: 'bg-gradient-to-b from-orange-500 to-orange-600 text-white shadow-sm shadow-orange-600/25 hover:from-orange-400 hover:to-orange-500 active:from-orange-600 active:to-orange-700',
44
- /**
45
- * No chrome at all: the caller supplies every visual through `class`.
46
- *
47
- * For an affordance that has to sit inside someone else's layout and carry
48
- * no box of its own - a sortable table header fills its `<th>`, and the
49
- * padding and fixed height the sizes impose would change the header row.
50
- * What it still gets, and the reason to come through this component rather
51
- * than write a raw `<button>`, is the shared focus-visible ring, the
52
- * disabled and aria-disabled handling, and the press feedback.
53
- * See stacksjs/stacks#2926.
54
- */
55
- bare: ''
56
- }
57
-
58
37
  // Size classes
59
38
  const sizeClasses = {
60
39
  xs: 'text-[11px] px-2 py-1 rounded-md gap-1 h-6',
@@ -85,10 +64,17 @@ const buttonClass = [
85
64
  fullWidth ? 'w-full' : '',
86
65
  // `bare` takes neither: its whole point is to impose no box.
87
66
  variant === 'bare' ? '' : (iconOnly ? iconOnlySizes[size] : sizeClasses[size]),
88
- variantClasses[variant]
67
+ buttonVariantClasses[variant]
89
68
  ].filter(Boolean).join(' ')
90
69
  </script>
91
70
  <script client>
71
+ import { buttonVariantToggles } from '../../../../functions/dashboard-button'
72
+
73
+ // A bound `:variant` changes after the server render; the toggles move the
74
+ // colour with it (stacksjs/stacks#3180).
75
+ const liveVariant = useReactiveProp('variant', 'primary')
76
+ const liveCallerClass = useReactiveProp('class', '')
77
+ const variantToggles = derived(() => buttonVariantToggles(liveVariant(), liveCallerClass()))
92
78
  const liveDisabled = useReactiveProp('disabled', false)
93
79
  const liveLoading = useReactiveProp('loading', false)
94
80
  const livePressed = useReactiveProp('pressed', false)
@@ -117,6 +103,7 @@ const liveAriaExpanded = useReactiveProp('ariaExpanded', '')
117
103
  :download="liveDownload() || undefined"
118
104
  :aria-label="liveAriaLabel() || undefined"
119
105
  class="{{ buttonClass }}"
106
+ :class="variantToggles()"
120
107
  :target="liveTarget() || undefined"
121
108
  :rel="liveRel() || undefined"
122
109
  :aria-busy="liveLoading() ? 'true' : 'false'"
@@ -134,6 +121,7 @@ const liveAriaExpanded = useReactiveProp('ariaExpanded', '')
134
121
  :type="liveType()"
135
122
  :form="liveForm() || undefined"
136
123
  class="{{ buttonClass }}"
124
+ :class="variantToggles()"
137
125
  :aria-label="liveAriaLabel() || undefined"
138
126
  :aria-busy="liveLoading() ? 'true' : 'false'"
139
127
  :aria-pressed="String(livePressed())"
@@ -149,6 +137,7 @@ const liveAriaExpanded = useReactiveProp('ariaExpanded', '')
149
137
  :type="liveType()"
150
138
  :form="liveForm() || undefined"
151
139
  class="{{ buttonClass }}"
140
+ :class="variantToggles()"
152
141
  :aria-label="liveAriaLabel() || undefined"
153
142
  :aria-haspopup="liveAriaHasPopup() || undefined"
154
143
  :aria-expanded="liveAriaExpanded() || undefined"
@@ -56,6 +56,12 @@ const cardClass = [
56
56
  clickableClass,
57
57
  ].join(' ')
58
58
  </script>
59
+ <script client>
60
+ // A bound `:subtitle` is empty when the card renders on the server, so the
61
+ // line above never rendered at all; this one follows the value once it
62
+ // arrives (stacksjs/stacks#3180). A static subtitle still paints first.
63
+ const liveSubtitle = useReactiveProp('subtitle', '')
64
+ </script>
59
65
 
60
66
  <article class="{{ cardClass }}" @if(clickable) role="button" tabindex="0" @endif>
61
67
  @if($slots.header)
@@ -70,6 +76,8 @@ const cardClass = [
70
76
  @endif
71
77
  @if(subtitle)
72
78
  <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">{{ subtitle }}</p>
79
+ @else
80
+ <p :if="liveSubtitle()" class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">{{ liveSubtitle() }}</p>
73
81
  @endif
74
82
  </div>
75
83
  @if($slots.actions)
@@ -471,7 +471,9 @@ export function buildNavSections(
471
471
  { to: '/marketing/social-posts', icon: 'clock', text: 'Social Posts' },
472
472
  { to: '/marketing/campaigns', icon: 'rocket', text: 'Campaigns' },
473
473
  { to: '/marketing/abandoned-carts', icon: 'cart', text: 'Abandoned Carts' },
474
- { to: '/marketing/reviews', icon: 'star', text: 'Reviews' },
474
+ // Product reviews are commerce records, so with commerce off this page
475
+ // could only say so (stacksjs/stacks#3186).
476
+ ...(toggles.commerce ? [{ to: '/marketing/reviews', icon: 'star', text: 'Reviews' }] : []),
475
477
  ...categoryNavItems(discoveredModels, 'marketing', new Set(['campaign'])),
476
478
  ]])
477
479
  }
@@ -491,8 +493,15 @@ export function buildNavSections(
491
493
  { to: '/analytics/browsers', icon: 'globe', text: 'Browsers' },
492
494
  { to: '/analytics/events', icon: 'bolt', text: 'Events' },
493
495
  { to: '/analytics/blog', icon: 'document', text: 'Blog' },
494
- { to: '/analytics/commerce/web', icon: 'cart', text: 'Commerce' },
495
- { to: '/analytics/commerce/sales', icon: 'sale-tag', text: 'Sales' },
496
+ // Commerce's analytics follow the commerce section. With the feature
497
+ // off its models are never loaded, so Sales could only answer 503
498
+ // (stacksjs/stacks#3183).
499
+ ...(toggles.commerce
500
+ ? [
501
+ { to: '/analytics/commerce/web', icon: 'cart', text: 'Commerce' },
502
+ { to: '/analytics/commerce/sales', icon: 'sale-tag', text: 'Sales' },
503
+ ]
504
+ : []),
496
505
  { to: '/analytics/marketing', icon: 'megaphone', text: 'Marketing' },
497
506
  ]])
498
507
  }
@@ -129,6 +129,20 @@ body {
129
129
  left: var(--stx-sidebar-width, 250px);
130
130
  transition: left 0.3s ease-out;
131
131
  }
132
+ /*
133
+ * The window's sidebar pane while it is in the mobile drawer: it fills the
134
+ * drawer instead of sitting fixed at its window width, and gives up the
135
+ * window's z-index so the drawer's close button stays on top of it.
136
+ */
137
+ [data-dashboard-mobile-sidebar] > [data-stx-component-wrapper] {
138
+ height: 100%;
139
+ }
140
+ [data-dashboard-mobile-sidebar] [data-stx-sidebar] {
141
+ position: relative;
142
+ z-index: auto;
143
+ width: 100% !important;
144
+ height: 100%;
145
+ }
132
146
  @media (max-width: 1023px) {
133
147
  [data-stx-content] {
134
148
  margin-left: 0;
@@ -589,11 +603,34 @@ function handleSidebarItemClick(payload: { item?: { href?: string }, event?: { p
589
603
  navigate(href)
590
604
  }
591
605
 
592
- function syncMobileSidebar() {
606
+ // Where the Sidebar sat in the window while it is lent to the drawer.
607
+ let lentSidebar: { element: HTMLElement, parent: Node, next: Node | null } | null = null
608
+
609
+ // The Sidebar's component wrapper moves, not just its pane: the wrapper is
610
+ // where the layout's `@itemClick` listens, so a pane moved without it kept
611
+ // selecting rows but never navigated.
612
+ function showSidebarInDrawer() {
613
+ const pane = sidebarHost.current?.querySelector<HTMLElement>('[data-stx-sidebar]')
614
+ const element = pane?.closest<HTMLElement>('[data-stx-component-wrapper]') ?? pane
615
+ const drawer = mobileSidebarHost.current
616
+ if (element?.parentNode && drawer && !lentSidebar) {
617
+ lentSidebar = { element, parent: element.parentNode, next: element.nextSibling }
618
+ drawer.appendChild(element)
619
+ }
593
620
  applyRoleFilter()
594
621
  syncActiveSidebarRow()
595
622
  }
596
623
 
624
+ // Back where it came from, before anything else looks for it. The drawer's
625
+ // markup is already gone by now, so the Sidebar is put back from the
626
+ // reference kept above rather than looked up.
627
+ function returnSidebarFromDrawer() {
628
+ if (!lentSidebar) return
629
+ const { element, parent, next } = lentSidebar
630
+ lentSidebar = null
631
+ parent.insertBefore(element, next && next.parentNode === parent ? next : null)
632
+ }
633
+
597
634
  function handleCraftSidebarSelection(event: CraftSidebarSelectEvent | undefined): void {
598
635
  if (!event?.itemId) return
599
636
  for (const pane of sidebarPanes()) {
@@ -658,23 +695,11 @@ onDestroy(() => {
658
695
  </Sidebar>
659
696
  </div>
660
697
 
661
- <MobileSidebar @open="syncMobileSidebar()">
662
- <div ref="dashboardMobileSidebarHost" class="h-full">
663
- <Sidebar
664
- :sections="sidebarSections"
665
- theme="macos"
666
- width="320"
667
- placement="static"
668
- className="h-full max-w-full"
669
- @itemClick="handleSidebarItemClick($event)"
670
- >
671
- <template #header>
672
- <!-- A drawer, not the window edge: no window buttons land on it, so
673
- there is nothing to draw and nothing to reserve. -->
674
- <SidebarHeader windowControls="none" />
675
- </template>
676
- </Sidebar>
677
- </div>
698
+ <!-- The drawer holds the same Sidebar as the window, moved in while it is open
699
+ (`showSidebarInDrawer`). A second Sidebar here rendered all its rows again
700
+ on every page and doubled each page's first render (stacksjs/stacks#3177). -->
701
+ <MobileSidebar @open="showSidebarInDrawer()" @close="returnSidebarFromDrawer()">
702
+ <div ref="dashboardMobileSidebarHost" data-dashboard-mobile-sidebar class="h-full"></div>
678
703
  </MobileSidebar>
679
704
 
680
705
  <main data-stx-content>