@stacksjs/defaults 0.75.136 → 0.75.138

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 413 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 414 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, 413 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 414 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -63,7 +63,7 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
63
63
  | `@stacksjs/mobile` | [`stacks-mobile`](../stacks-mobile/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/mobile/src/index.ts) | 12 |
64
64
  | `@stacksjs/model-meta` | [`stacks-models`](../stacks-models/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/model-meta/src/index.ts) | 1 |
65
65
  | `@stacksjs/newsletter` | [`stacks-newsletter`](../stacks-newsletter/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/newsletter/src/index.ts) | 7 |
66
- | `@stacksjs/notifications` | [`stacks-notifications`](../stacks-notifications/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/index.ts) | 11 |
66
+ | `@stacksjs/notifications` | [`stacks-notifications`](../stacks-notifications/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/index.ts) | 12 |
67
67
  | `@stacksjs/objects` | [`stacks-objects`](../stacks-objects/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/objects/src/index.ts) | 4 |
68
68
  | `@stacksjs/orm` | [`stacks-orm`](../stacks-orm/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/orm/src/index.ts) | 60 |
69
69
  | `@stacksjs/pagination` | [`stacks-pagination`](../stacks-pagination/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/pagination/src/index.ts) | 0 |
@@ -607,11 +607,11 @@ Evidence directory: [7 retained tests](https://github.com/stacksjs/stacks/blob/m
607
607
 
608
608
  Guidance: [`stacks-notifications`](../stacks-notifications/SKILL.md). Source: [storage/framework/core/notifications/src/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/index.ts).
609
609
 
610
- Declared runtime exports: `BroadcastNotificationDriver`, `DatabaseNotificationDriver`, `bulkSetPreferences`, `emailParagraphs`, `ensureSuccessfulEmailResult`, `ensureSuccessfulNotificationResults`, `filterChannelsByPreferences`, `getNotificationPreferences`, `makeDeliveryRecord`, `notification`, `notificationChannel`, `notificationEmailHtml`, `notificationEmailText`, `notify`, `recordNotificationDelivery`, `resolveDeliveryRecipient`, `setNotificationPreference`, `useBroadcast`, `useChat`, `useDatabase`, `useEmail`, `useNotification`, `usePush`, `useSMS`, `wherePreferenceCategory`.
610
+ Declared runtime exports: `BroadcastNotificationDriver`, `DEFAULT_SMS_OPT_OUT`, `DEFAULT_UNSUBSCRIBE_NOTICE`, `DatabaseNotificationDriver`, `bulkSetPreferences`, `emailParagraphs`, `ensureSuccessfulEmailResult`, `ensureSuccessfulNotificationResults`, `filterChannelsByPreferences`, `getNotificationPreferences`, `listUnsubscribeHeaders`, `makeDeliveryRecord`, `notification`, `notificationChannel`, `notificationEmailHtml`, `notificationEmailMessage`, `notificationEmailText`, `notificationSmsBody`, `notificationUnsubscribeLinks`, `notificationUnsubscribeToken`, `notify`, `parseNotificationUnsubscribeToken`, `recordNotificationDelivery`, `resolveDeliveryRecipient`, `setNotificationPreference`, `unsubscribeTokenFromRequest`, `unsubscribeWithToken`, `useBroadcast`, `useChat`, `useDatabase`, `useEmail`, `useNotification`, `usePush`, `useSMS`, `wherePreferenceCategory`.
611
611
 
612
- Local source modules: [src/delivery.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/delivery.ts), [src/drivers/broadcast.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/drivers/broadcast.ts), [src/drivers/database.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/drivers/database.ts), [src/drivers/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/drivers/index.ts), [src/email-body.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/email-body.ts), [src/preferences.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/preferences.ts).
612
+ Local source modules: [src/delivery.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/delivery.ts), [src/drivers/broadcast.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/drivers/broadcast.ts), [src/drivers/database.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/drivers/database.ts), [src/drivers/index.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/drivers/index.ts), [src/email-body.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/email-body.ts), [src/preferences.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/preferences.ts), [src/unsubscribe.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/src/unsubscribe.ts).
613
613
 
614
- Evidence directory: [11 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
614
+ Evidence directory: [12 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/notifications/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
615
615
 
616
616
  ### @stacksjs/objects
617
617
 
@@ -78,6 +78,44 @@ missing optional schema compatibility from real persistence failures. Apply
78
78
  model-driven migrations before relying on persistence. Inspect channel results
79
79
  and transport results rather than assuming a delivery log is provider proof.
80
80
 
81
+ ## Marketing: unsubscribe links and opt-out lines
82
+
83
+ Give a marketing notification `payload.unsubscribe`, built per person with
84
+ `notificationUnsubscribeLinks(userId, { notice })`:
85
+
86
+ ```ts
87
+ await notify(
88
+ { userId, email },
89
+ { subject, body, unsubscribe: notificationUnsubscribeLinks(userId, { notice: 'You get these because marketing email is on.' }) },
90
+ ['email'],
91
+ { category: 'marketing' },
92
+ )
93
+ ```
94
+
95
+ The email's footer then shows the notice, **Unsubscribe** (`url`, default
96
+ `{APP_URL}/unsubscribe?token=`) and **Email preferences** (`/settings`) instead
97
+ of "do not reply"; the text part spells the link out; and the message carries
98
+ `List-Unsubscribe: <oneClickUrl>` plus `List-Unsubscribe-Post:
99
+ List-Unsubscribe=One-Click` (RFC 8058, default
100
+ `{APP_URL}/api/notifications/unsubscribe?token=`). `payload.headers` adds
101
+ other headers. The pure builders are `notificationEmailMessage`,
102
+ `notificationEmailHtml`, `notificationEmailText` and `listUnsubscribeHeaders`.
103
+
104
+ The token (`notificationUnsubscribeToken`, `parseNotificationUnsubscribeToken`)
105
+ is `base64url("{userId}:{channel}:{category}:{hmac}")` signed with `APP_KEY`,
106
+ which must be set in production. It does not expire. It turns off one
107
+ preference (channel + category), unlike `@stacksjs/email`'s tokens, which
108
+ suppress an address for good. The app owns the page and the route; the route's
109
+ handler is `unsubscribeWithToken(await unsubscribeTokenFromRequest(request))`,
110
+ which reads the query string (one-click form POSTs) before a JSON body, and
111
+ answers 422 when it returns null. Ask before unsubscribing on the page, so a
112
+ link scanner opening the URL changes nothing.
113
+
114
+ A `marketing` text ends with "Reply STOP to opt out." (`notificationSmsBody`).
115
+ `payload.smsOptOut` replaces the line (and adds it to other categories), and
116
+ `false` leaves it out. STOP only becomes an opt-out the app sees when inbound
117
+ SMS is wired up; see `stacks-sms`.
118
+
81
119
  ## Channel factories
82
120
 
83
121
  - `useEmail(driver?)` returns a sendable transport. Without a driver it uses
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.136",
5
+ "version": "0.75.138",
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.136",
5
+ "version": "0.75.138",
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.35",
58
- "@stacksjs/mobile": "0.75.136",
58
+ "@stacksjs/mobile": "0.75.138",
59
59
  "@stacksjs/sanitizer": "^0.2.422",
60
60
  "@xterm/addon-fit": "^0.11.0",
61
61
  "@xterm/xterm": "^6.0.0",
@@ -0,0 +1,169 @@
1
+ <script server>
2
+ interface NativeBarButtonProps {
3
+ /** Where the button goes, such as `/m/messages`. */
4
+ href: string
5
+ /** What a screen reader calls the button. */
6
+ label: string
7
+ /** The SF Symbol the native bar draws for it (`bubble.left`), drawn here with its Iconify stand-in. */
8
+ symbol?: string
9
+ /** An Iconify class for a symbol the stand-ins below don't cover. */
10
+ icon?: string
11
+ /** A picture (the account's own photo), drawn round inside the button. */
12
+ image?: string
13
+ /** A short title instead of the symbol: an account's initials. */
14
+ title?: string
15
+ /** A count on the button. Zero and empty draw nothing. */
16
+ badge?: string | number
17
+ /** `count` draws the number, `dot` a red dot (unread notifications). */
18
+ badgeStyle?: 'count' | 'dot'
19
+ /** `accent` sits a shade stronger in dark mode (chat); `account` is the default. */
20
+ variant?: 'accent' | 'account'
21
+ }
22
+
23
+ const { symbol = '', icon = '' } = defineProps<NativeBarButtonProps>()
24
+
25
+ // The same names the native bar takes (mobileHeaderOptions-style rightButtons),
26
+ // so one list of buttons describes both. Written out so the stylesheet
27
+ // generator sees every class.
28
+ const standIns: Record<string, string> = {
29
+ 'bubble.left': 'i-lucide-message-square',
30
+ 'bubble.left.and.bubble.right': 'i-lucide-messages-square',
31
+ 'person.crop.circle': 'i-lucide-circle-user-round',
32
+ 'bell': 'i-lucide-bell',
33
+ 'gearshape': 'i-lucide-settings',
34
+ 'plus': 'i-lucide-plus',
35
+ 'magnifyingglass': 'i-lucide-search',
36
+ 'square.and.arrow.up': 'i-lucide-share',
37
+ }
38
+
39
+ export const glyphClass = `${icon || standIns[symbol] || 'i-lucide-circle-user-round'} native-bar-glyph`
40
+ </script>
41
+
42
+ <script client>
43
+ import { haptics } from '@stacksjs/mobile'
44
+
45
+ // A round button at the top right of a phone screen, drawn exactly as the
46
+ // app's native bar draws its buttons (Craft's accountNavigationButton in
47
+ // CraftNativeScreen.swift): a 44pt circle tinted with the accent, a 20pt
48
+ // glyph, a 36pt photo inset in the circle, a count badge or a red dot at its
49
+ // top right. The native screens (Today, Calendar) and the web screens of the
50
+ // same app then show one set of buttons, not two that almost match.
51
+ //
52
+ // Every prop through useReactiveProp, under names of its own: read as plain
53
+ // {{ label }}, a host's client signal of the same name would be shown instead.
54
+ // What shows (picture, title or glyph) is written in the template itself: a
55
+ // derived only the template reads is dropped by the bundler's tree-shake.
56
+ const barHref = useReactiveProp<string>('href', '')
57
+ const barLabel = useReactiveProp<string>('label', '')
58
+ const barImage = useReactiveProp<string>('image', '')
59
+ const barTitle = useReactiveProp<string>('title', '')
60
+ const barBadge = useReactiveProp<string | number>('badge', '')
61
+ const barBadgeStyle = useReactiveProp<string>('badgeStyle', 'count')
62
+ const barVariant = useReactiveProp<string>('variant', 'account')
63
+
64
+ const barCount = derived(() => {
65
+ const value = barBadge()
66
+ return value === '' || value === 0 || value === '0' || value == null ? '' : Number(value) > 99 ? '99+' : String(value)
67
+ })
68
+ const barDot = derived(() => barBadgeStyle() === 'dot')
69
+ const barPhoto = derived(() => (barImage() ? `background-image: url("${String(barImage()).replace(/"/g, '%22')}")` : ''))
70
+
71
+ function tapBarButton(): void {
72
+ void haptics.selection()
73
+ }
74
+ </script>
75
+
76
+ <a :href="barHref()" class="native-bar-button" :class="barVariant() === 'accent' ? 'native-bar-accent' : ''" draggable="false" data-stx-link data-stx-prefetch="eager" @click="tapBarButton()">
77
+ <span class="native-bar-label">{{ barLabel() }}<span :if="barCount()">, {{ barCount() }} unread</span></span>
78
+ <span :if="barImage()" aria-hidden="true" class="native-bar-photo" :style="barPhoto()"></span>
79
+ <span :if="!barImage() && barTitle()" aria-hidden="true" class="native-bar-title">{{ barTitle() }}</span>
80
+ <!-- :show, not :if: an :if branch is re-rendered in the browser, where the
81
+ server's glyphClass doesn't exist, and the glyph silently never drew. -->
82
+ <i :show="!barImage() && !barTitle()" aria-hidden="true" class="{{ glyphClass }}"></i>
83
+ <span :if="barCount() && !barDot()" aria-hidden="true" class="native-bar-count">{{ barCount() }}</span>
84
+ <span :if="barCount() && barDot()" aria-hidden="true" class="native-bar-dot"></span>
85
+ </a>
86
+
87
+ <style>
88
+ /* The values are Craft's (CraftNativeScreen.swift, accountNavigationButton):
89
+ keep them in step, or the web and native screens drift apart again. */
90
+ .native-bar-button {
91
+ position: relative;
92
+ display: inline-flex;
93
+ flex: none;
94
+ align-items: center;
95
+ justify-content: center;
96
+ width: 2.75rem;
97
+ height: 2.75rem;
98
+ border-radius: 999px;
99
+ color: rgb(28 77 217);
100
+ background: rgb(38 99 235 / 0.08);
101
+ text-decoration: none;
102
+ -webkit-tap-highlight-color: transparent;
103
+ -webkit-touch-callout: none;
104
+ transition: transform 120ms ease, opacity 120ms ease;
105
+ }
106
+
107
+ .native-bar-button:active { transform: scale(0.94); opacity: 0.85; }
108
+
109
+ .native-bar-label {
110
+ position: absolute;
111
+ width: 1px;
112
+ height: 1px;
113
+ overflow: hidden;
114
+ clip: rect(0 0 0 0);
115
+ white-space: nowrap;
116
+ }
117
+
118
+ .native-bar-glyph { width: 1.25rem; height: 1.25rem; }
119
+
120
+ .native-bar-title {
121
+ font-size: 0.8125rem;
122
+ font-weight: 600;
123
+ letter-spacing: 0.01em;
124
+ }
125
+
126
+ .native-bar-photo {
127
+ width: 2.25rem;
128
+ height: 2.25rem;
129
+ border-radius: 999px;
130
+ background: center / cover no-repeat;
131
+ }
132
+
133
+ .native-bar-count {
134
+ position: absolute;
135
+ top: 0;
136
+ right: 0;
137
+ min-width: 1.125rem;
138
+ height: 1.125rem;
139
+ padding: 0 0.25rem;
140
+ border-radius: 999px;
141
+ color: white;
142
+ background: rgb(0 122 255);
143
+ font-size: 0.625rem;
144
+ font-weight: 700;
145
+ line-height: 1.125rem;
146
+ text-align: center;
147
+ }
148
+
149
+ .native-bar-dot {
150
+ position: absolute;
151
+ top: 0;
152
+ right: 0;
153
+ width: 0.5625rem;
154
+ height: 0.5625rem;
155
+ border-radius: 999px;
156
+ background: rgb(255 59 48);
157
+ }
158
+
159
+ .dark .native-bar-button {
160
+ color: rgb(148 196 252);
161
+ background: rgb(38 99 235 / 0.12);
162
+ }
163
+
164
+ .dark .native-bar-accent { background: rgb(38 99 235 / 0.24); }
165
+
166
+ @media (prefers-reduced-motion: reduce) {
167
+ .native-bar-button { transition: none; }
168
+ }
169
+ </style>
@@ -39,6 +39,19 @@ describe('Native components', () => {
39
39
  expect(button).not.toContain('<img')
40
40
  })
41
41
 
42
+ it('draws bar buttons as the native bar does, from the same SF Symbol names', () => {
43
+ const button = read('NativeBarButton')
44
+ // Craft's accountNavigationButton: a 44pt tinted circle, a 36pt photo inset.
45
+ expect(button).toContain('width: 2.75rem;')
46
+ expect(button).toContain('background: rgb(38 99 235 / 0.08);')
47
+ expect(button).toContain('width: 2.25rem;')
48
+ expect(button).toContain("'bubble.left': 'i-lucide-message-square'")
49
+ expect(button).toContain("useReactiveProp<string | number>('badge', '')")
50
+ expect(button).toContain('data-stx-link data-stx-prefetch="eager"')
51
+ // A photo is a background, not an <img>: apps check that every image goes through their delivery.
52
+ expect(button).not.toContain('<img')
53
+ })
54
+
42
55
  it('lifts the launch splash once the first screen has painted', () => {
43
56
  expect(read('NativeAppShell')).toContain('requestAnimationFrame(() => requestAnimationFrame(() => splash.hide()))')
44
57
  })
@@ -4,7 +4,8 @@
4
4
  // breaks kept, addresses as links) and, when the notification names one, a
5
5
  // button to where it happened. Same <EmailLayout> as the other prebaked
6
6
  // mailers (stacksjs/stacks#1901); an app's own resources/emails/notification
7
- // wins over this one.
7
+ // wins over this one. A notification with `unsubscribe` (marketing) says why
8
+ // it came and links to Unsubscribe in the footer instead of "do not reply".
8
9
  const appName = props.appName || 'Stacks'
9
10
  const primaryColor = props.primaryColor || '#3451b2'
10
11
  const subject = props.subject || appName
@@ -12,6 +13,9 @@ const paragraphs = Array.isArray(props.paragraphs) ? props.paragraphs : []
12
13
  const actionUrl = props.actionUrl || ''
13
14
  const actionLabel = props.actionLabel || 'Open'
14
15
  const preheader = props.preheader || ''
16
+ const unsubscribeUrl = props.unsubscribeUrl || ''
17
+ const unsubscribeNotice = props.unsubscribeNotice || 'You are receiving this because these emails are turned on in your settings.'
18
+ const preferencesUrl = props.preferencesUrl || ''
15
19
  const year = props.year || new Date().getFullYear()
16
20
  </script>
17
21
 
@@ -36,7 +40,12 @@ const year = props.year || new Date().getFullYear()
36
40
  <template #footer>
37
41
  <tr>
38
42
  <td style="padding:20px 40px;background-color:#f9fafb;border-top:1px solid #e5e7eb;">
39
- <p style="margin:0;color:#6b7280;font-size:12px;text-align:center;">This is an automated message from {{ appName }}. Please do not reply to this email.</p>
43
+ @if(unsubscribeUrl)
44
+ <p style="margin:0;color:#6b7280;font-size:12px;text-align:center;">{{ unsubscribeNotice }}</p>
45
+ <p style="margin:8px 0 0 0;color:#6b7280;font-size:12px;text-align:center;"><a href="{{ unsubscribeUrl }}" style="color:#6b7280;text-decoration:underline;">Unsubscribe</a>@if(preferencesUrl) &middot; <a href="{{ preferencesUrl }}" style="color:#6b7280;text-decoration:underline;">Email preferences</a>@endif</p>
46
+ @else
47
+ <p style="margin:0;color:#6b7280;font-size:12px;text-align:center;">This is an automated message from {{ appName }}. Please do not reply to this email.</p>
48
+ @endif
40
49
  <p style="margin:10px 0 0 0;color:#9ca3af;font-size:11px;text-align:center;">&copy; {{ year }} {{ appName }}. All rights reserved.</p>
41
50
  </td>
42
51
  </tr>