@stacksjs/defaults 0.70.353 → 0.70.355

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 (125) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +17 -2
  2. package/ai/skills/stacks-browse/scripts/browse.ts +207 -6
  3. package/ai/skills/stacks-dashboard/scripts/audit.ts +0 -1
  4. package/ai/skills/stacks-email/SKILL.md +35 -15
  5. package/app/Actions/Dashboard/Auth/MeAction.ts +8 -7
  6. package/app/Actions/Dashboard/Ci/RepoRunJobsAction.ts +10 -15
  7. package/app/Actions/Dashboard/Ci/RepoRunsAction.ts +12 -14
  8. package/app/Actions/Dashboard/Ci/RunnerHistoryAction.ts +11 -10
  9. package/app/Actions/Dashboard/Ci/StatusAction.ts +2 -11
  10. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +1 -0
  11. package/app/Actions/Dashboard/Commerce/CommercePrintLogsAction.ts +3 -4
  12. package/app/Actions/Dashboard/Commerce/CommerceProductDetailAction.ts +1 -0
  13. package/app/Actions/Dashboard/Commerce/PaymentRefundAction.ts +1 -0
  14. package/app/Actions/Dashboard/Commerce/ProductUnitDefaultAction.ts +1 -0
  15. package/app/Actions/Dashboard/Commerce/TaxRateDefaultAction.ts +1 -0
  16. package/app/Actions/Dashboard/Commerce/tax-rate-records.ts +9 -0
  17. package/app/Actions/Dashboard/Content/author-input.ts +1 -1
  18. package/app/Actions/Dashboard/Content/page-input.ts +1 -1
  19. package/app/Actions/Dashboard/DashboardStatsAction.ts +0 -1
  20. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +3 -2
  21. package/app/Actions/Dashboard/Deployments/GetDeployment.ts +1 -0
  22. package/app/Actions/Dashboard/Deployments/UpdateDeployScript.ts +3 -2
  23. package/app/Actions/Dashboard/Email/InboxActivityAction.ts +28 -16
  24. package/app/Actions/Dashboard/Email/InboxAttachmentDownloadAction.ts +45 -0
  25. package/app/Actions/Dashboard/Email/InboxDeleteAction.ts +11 -6
  26. package/app/Actions/Dashboard/Email/InboxIndexAction.ts +5 -7
  27. package/app/Actions/Dashboard/Email/InboxMarkReadAction.ts +11 -6
  28. package/app/Actions/Dashboard/Email/InboxMarkUnreadAction.ts +11 -6
  29. package/app/Actions/Dashboard/Email/InboxPreferenceShowAction.ts +10 -4
  30. package/app/Actions/Dashboard/Email/InboxPreferenceUpdateAction.ts +8 -9
  31. package/app/Actions/Dashboard/Email/InboxShowAction.ts +7 -7
  32. package/app/Actions/Dashboard/Email/InboxStatsAction.ts +5 -7
  33. package/app/Actions/Dashboard/Email/inbox-request.ts +33 -0
  34. package/app/Actions/Dashboard/Infrastructure/LogIndexAction.ts +3 -4
  35. package/app/Actions/Dashboard/Infrastructure/ServerShowAction.ts +3 -6
  36. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +8 -15
  37. package/app/Actions/Dashboard/Jobs/JobRetryAction.ts +4 -3
  38. package/app/Actions/Dashboard/Jobs/JobShowAction.ts +3 -3
  39. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +5 -5
  40. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -16
  41. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +4 -3
  42. package/app/Actions/Dashboard/Kanban/BoardUpdateAction.ts +5 -5
  43. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +1 -2
  44. package/app/Actions/Dashboard/Kanban/BoardsReorderAction.ts +4 -3
  45. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +11 -9
  46. package/app/Actions/Dashboard/Kanban/CardCommentDestroyAction.ts +10 -12
  47. package/app/Actions/Dashboard/Kanban/CardCommentStoreAction.ts +19 -43
  48. package/app/Actions/Dashboard/Kanban/CardCommentUpdateAction.ts +44 -0
  49. package/app/Actions/Dashboard/Kanban/CardDestroyAction.ts +5 -5
  50. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +6 -6
  51. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -4
  52. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +7 -6
  53. package/app/Actions/Dashboard/Kanban/CardUpdateAction.ts +5 -5
  54. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +4 -3
  55. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +5 -5
  56. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +4 -3
  57. package/app/Actions/Dashboard/Kanban/ColumnUpdateAction.ts +5 -5
  58. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +4 -3
  59. package/app/Actions/Dashboard/Kanban/LabelDestroyAction.ts +4 -4
  60. package/app/Actions/Dashboard/Kanban/LabelStoreAction.ts +4 -3
  61. package/app/Actions/Dashboard/Kanban/LabelUpdateAction.ts +5 -5
  62. package/app/Actions/Dashboard/Kanban/UsersListAction.ts +1 -1
  63. package/app/Actions/Dashboard/Kanban/kanban-comment.ts +14 -0
  64. package/app/Actions/Dashboard/Kanban/kanban-model.test.ts +36 -0
  65. package/app/Actions/Dashboard/Kanban/kanban-model.ts +14 -7
  66. package/app/Actions/Dashboard/Models/ModelDestroyAction.ts +4 -3
  67. package/app/Actions/Dashboard/Models/ModelShowAction.ts +15 -18
  68. package/app/Actions/Dashboard/Models/ModelStoreAction.ts +8 -8
  69. package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +9 -9
  70. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +4 -3
  71. package/app/Actions/Dashboard/Models/model-api.ts +27 -0
  72. package/app/Actions/Dashboard/Models/model-write.ts +2 -10
  73. package/app/Actions/Dashboard/Notifications/NotificationDeliveryHistoryAction.ts +3 -4
  74. package/app/Actions/Dashboard/Notifications/NotificationDeliveryIndexAction.ts +6 -4
  75. package/app/Actions/Dashboard/Queries/QueryShowAction.ts +1 -0
  76. package/app/Actions/Dashboard/Rbac/PermissionDestroyAction.ts +4 -4
  77. package/app/Actions/Dashboard/Rbac/PermissionStoreAction.ts +3 -2
  78. package/app/Actions/Dashboard/Rbac/RoleDestroyAction.ts +4 -4
  79. package/app/Actions/Dashboard/Rbac/RolePermissionsShowAction.ts +4 -4
  80. package/app/Actions/Dashboard/Rbac/RolePermissionsSyncAction.ts +4 -3
  81. package/app/Actions/Dashboard/Rbac/RoleStoreAction.ts +3 -2
  82. package/app/Actions/Dashboard/Rbac/UserRolesShowAction.ts +4 -5
  83. package/app/Actions/Dashboard/Rbac/UserRolesSyncAction.ts +4 -4
  84. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +1 -0
  85. package/app/Actions/Dashboard/Teams/TeamInviteAction.ts +3 -3
  86. package/app/Actions/Dashboard/Teams/TeamMemberUpdateAction.ts +2 -2
  87. package/app/Actions/Dashboard/dashboard-request.ts +13 -0
  88. package/app/Models/CardComment.ts +3 -4
  89. package/app/Models/commerce/TaxRate.ts +48 -0
  90. package/functions/dashboard-api.ts +51 -10
  91. package/functions/inbox.test.ts +2 -0
  92. package/functions/inbox.ts +27 -2
  93. package/functions/public-application-url.test.ts +13 -0
  94. package/functions/public-application-url.ts +27 -0
  95. package/ide/vscode/package.json +1 -1
  96. package/package.json +1 -1
  97. package/resources/components/Dashboard/Auth/AccessTokens.stx +15 -0
  98. package/resources/components/Dashboard/Ci/CiDashboard.stx +24 -6
  99. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +23 -0
  100. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +4 -0
  101. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +9 -1
  102. package/resources/components/Dashboard/Commerce/ProductWaitlistDashboard.stx +8 -7
  103. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDashboard.stx +8 -7
  104. package/resources/components/Dashboard/Content/BlogDashboard.stx +14 -2
  105. package/resources/components/Dashboard/Content/PostsDashboard.stx +7 -1
  106. package/resources/components/Dashboard/Deployments/DeploymentDetail.stx +16 -2
  107. package/resources/components/Dashboard/Email/EmailNavigation.stx +5 -5
  108. package/resources/components/Dashboard/Email/EmailSidebar.stx +1 -1
  109. package/resources/components/Dashboard/Email/InboxAttachmentList.stx +53 -0
  110. package/resources/components/Dashboard/Email/InboxDashboard.stx +5 -4
  111. package/resources/components/Dashboard/Email/InboxMessageDetail.stx +6 -4
  112. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +63 -6
  113. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +3 -6
  114. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +4 -4
  115. package/resources/components/Dashboard/NotFoundDashboard.stx +2 -5
  116. package/resources/components/Dashboard/Queries/QueryDashboard.stx +6 -2
  117. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +4 -4
  118. package/resources/components/Dashboard/UI/EmptyState.stx +2 -2
  119. package/resources/components/Dashboard/UI/PageLayout.stx +3 -3
  120. package/resources/functions/dashboard/sidebar.ts +4 -4
  121. package/routes/dashboard-api.ts +8 -20
  122. package/views/dashboard/.discovered-models.json +24 -0
  123. package/views/dashboard/layouts/default.stx +19 -16
  124. package/views/dashboard/stores/ci.ts +13 -7
  125. package/views/dashboard/stores/kanban.ts +48 -11
@@ -5,7 +5,7 @@
5
5
  // (Action: Dashboard/Ci/StatusAction → @stacksjs/github). Active tab +
6
6
  // status filter live in the `ciStore` so they survive SPA navigation.
7
7
  //
8
- // No <script server>, no window./document. reads per #1838 the page
8
+ // No <script server>, no window./document. reads - per #1838 the page
9
9
  // is pure presentation against the store.
10
10
  import { useRole } from '~/storage/framework/defaults/views/dashboard/composables/useRole'
11
11
  import { ciStore } from '~/storage/framework/defaults/views/dashboard/stores/ci'
@@ -17,7 +17,7 @@
17
17
  // Role check is now backed by the real RBAC store + `/api/dashboard/auth/me`
18
18
  // (stacksjs/stacks#1843). `isDev()` returns true if the authenticated user
19
19
  // holds the `admin` or `dev` role, OR if the request is unauthenticated
20
- // (i.e. local dev dashboard with no bearer token) see `useRole.ts` for
20
+ // (i.e. local dev dashboard with no bearer token) - see `useRole.ts` for
21
21
  // the full resolution chain.
22
22
  const { isDev } = useRole()
23
23
  const canView = derived(() => isDev())
@@ -129,7 +129,7 @@
129
129
 
130
130
  // ─── Runner pressure sparkline (stacksjs/stacks#1850) ────────────
131
131
  //
132
- // Inline SVG sparkline no chart lib. The path is built off the
132
+ // Inline SVG sparkline - no chart lib. The path is built off the
133
133
  // active tab's sample history with a simple linear scale: x maps
134
134
  // to sample index (oldest left, newest right), y maps to queue
135
135
  // depth normalised against the larger of the org's cap or the
@@ -139,6 +139,8 @@
139
139
  const tab = activeTab()
140
140
  return (ci.runnerHistoryByOrg()[tab] || [])
141
141
  })
142
+ const runnerHistoryError = derived(() => ci.runnerHistoryErrors()[activeTab()] || '')
143
+ const loadingHistory = derived(() => ci.loadingRunnerHistory()[activeTab()] === true)
142
144
 
143
145
  effect(() => {
144
146
  const tab = activeTab()
@@ -173,7 +175,12 @@
173
175
  return samples && samples.length >= 2
174
176
  }
175
177
 
176
- // Top-level fetch — runs on both hard reload (SSR hydration) and SPA
178
+ function retryRunnerHistory() {
179
+ const tab = activeTab()
180
+ if (tab) ci.loadRunnerHistory(tab, true)
181
+ }
182
+
183
+ // Top-level fetch - runs on both hard reload (SSR hydration) and SPA
177
184
  // mount, since `<script client>` re-executes on every entry. The store
178
185
  // tracks its own loading flag so back-to-back calls don't double-fire.
179
186
  ci.load()
@@ -195,7 +202,7 @@
195
202
  <p class="mt-1 text-sm text-zinc-500">GitHub Actions health across the configured orgs. Configure in <code class="text-xs">config/dashboard.ts</code>.</p>
196
203
  </header>
197
204
 
198
- <!-- Disabled state the surface is off in config/dashboard.ts -->
205
+ <!-- Disabled state - the surface is off in config/dashboard.ts -->
199
206
  <div :if="disabled" class="px-5 py-8 text-center bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 rounded-lg dark:border-zinc-800">
200
207
  <p class="text-sm text-zinc-600 dark:text-zinc-400">CI tracking is disabled.</p>
201
208
  <p class="mt-1 text-xs text-zinc-500">Enable it in <code>config/dashboard.ts</code> by setting <code>ci.enabled = true</code> and listing one or more orgs.</p>
@@ -247,7 +254,7 @@
247
254
  <span class="text-zinc-300 dark:text-zinc-700">·</span>
248
255
  <span class="text-zinc-500 dark:text-zinc-400" :text="runnersText"></span>
249
256
  </div>
250
- <!-- Sparkline showing recent queue depth for the active org
257
+ <!-- Sparkline showing recent queue depth for the active org -
251
258
  appears only when alerts are on (history collection is gated
252
259
  on `ci.alerts.enabled`). Drawn inline as a single SVG path
253
260
  so we don't pull a chart library for 20 datapoints. -->
@@ -257,6 +264,17 @@
257
264
  <path :d="sparklinePath()" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
258
265
  </svg>
259
266
  </div>
267
+ <div :if="loadingHistory() && !hasHistory()" role="status" class="flex gap-2 items-center mb-5 text-zinc-500 text-xs dark:text-zinc-400">
268
+ <span aria-hidden="true" class="h-3.5 w-3.5 animate-spin i-hugeicons-loading-03"></span>
269
+ <span>Loading runner history</span>
270
+ </div>
271
+ <div :if="runnerHistoryError()" role="alert" class="flex flex-wrap gap-3 items-center justify-between mb-5 px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 rounded-lg dark:border-amber-900/50">
272
+ <div>
273
+ <p class="font-medium text-amber-800 text-xs dark:text-amber-200">Runner history unavailable</p>
274
+ <p class="mt-0.5 text-amber-700 text-xs dark:text-amber-300" :text="runnerHistoryError"></p>
275
+ </div>
276
+ <Button variant="secondary" size="xs" :loading="loadingHistory" @click="retryRunnerHistory()">Retry</Button>
277
+ </div>
260
278
 
261
279
  <!-- Repo cards -->
262
280
  <div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(340px, 1fr))">
@@ -15,6 +15,8 @@ const country = state('')
15
15
  const region = state('North America')
16
16
  const status = state<'active' | 'inactive'>('active')
17
17
  const isDefault = state(false)
18
+ const code = state('')
19
+ const exemptible = state(false)
18
20
  let hydrationKey = ''
19
21
 
20
22
  const regions = ['North America', 'South America', 'Europe', 'Asia', 'Africa', 'Oceania', 'Antarctica'] as const
@@ -36,6 +38,8 @@ effect(() => {
36
38
  region.set(current?.region || 'North America')
37
39
  status.set(current?.status || 'active')
38
40
  isDefault.set(current?.isDefault || false)
41
+ code.set(current?.code || '')
42
+ exemptible.set(current?.exemptible || false)
39
43
  })
40
44
 
41
45
  function submit(): void {
@@ -49,6 +53,8 @@ function submit(): void {
49
53
  region: region(),
50
54
  status: status(),
51
55
  isDefault: isDefault(),
56
+ code: code().trim(),
57
+ exemptible: exemptible(),
52
58
  })
53
59
  }
54
60
 
@@ -105,6 +111,11 @@ function errorMessages(): string[] {
105
111
  </select>
106
112
  </label>
107
113
  </div>
114
+ <label class="block">
115
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Code</span>
116
+ <input x-model="code" type="text" placeholder="sales" class="mt-1 px-3 py-2 w-full font-mono text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
117
+ <span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">What the application matches on. Renaming the rate above does not change what is charged; changing this can.</span>
118
+ </label>
108
119
  <label class="flex gap-3 items-start p-3 border border-gray-200 rounded-lg dark:border-neutral-700">
109
120
  <input x-model="isDefault" type="checkbox" class="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded" />
110
121
  <span>
@@ -112,6 +123,18 @@ function errorMessages(): string[] {
112
123
  <span class="block mt-0.5 text-gray-500 text-xs dark:text-neutral-400">Selecting this clears the default flag from every other rate.</span>
113
124
  </span>
114
125
  </label>
126
+ {{--
127
+ Spelled out rather than labelled "exempt", because the two read
128
+ alike and mean opposite things: this marks the component as one an
129
+ exemption can lift, not one that is currently unpaid.
130
+ --}}
131
+ <label class="flex gap-3 items-start p-3 border border-gray-200 rounded-lg dark:border-neutral-700">
132
+ <input x-model="exemptible" type="checkbox" class="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded" />
133
+ <span>
134
+ <span class="block font-medium text-gray-700 text-sm dark:text-neutral-200">Can be exempted</span>
135
+ <span class="block mt-0.5 text-gray-500 text-xs dark:text-neutral-400">Customers who qualify for an exemption are not charged this component. Leave off for anything owed regardless - an excise or a city levy is usually still due.</span>
136
+ </span>
137
+ </label>
115
138
  </div>
116
139
  </div>
117
140
  <footer class="flex gap-2 justify-end px-6 py-4 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
@@ -16,6 +16,8 @@ interface TaxRateWritePayload {
16
16
  region: string
17
17
  status: 'active' | 'inactive'
18
18
  isDefault: boolean
19
+ code: string
20
+ exemptible: boolean
19
21
  }
20
22
 
21
23
  const emptySummary: TaxRateSummary = { total: 0, active: 0, countries: 0, averageRate: 0, defaultConflicts: 0 }
@@ -136,6 +138,8 @@ async function saveTax(payload: TaxRateWritePayload): Promise<void> {
136
138
  region: payload.region,
137
139
  status: payload.status,
138
140
  isDefault: payload.isDefault,
141
+ code: payload.code,
142
+ exemptible: payload.exemptible,
139
143
  }
140
144
 
141
145
  saving.set(true)
@@ -39,9 +39,17 @@ function formatDate(value: string): string {
39
39
  <template :for="record in records()" :key="record.id">
40
40
  <tr class="align-top">
41
41
  <td class="px-4 py-4">
42
- <div class="flex gap-2 items-center">
42
+ <div class="flex flex-wrap gap-2 items-center">
43
43
  <p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.name }}</p>
44
44
  <span :if="record.isDefault" class="px-2 py-0.5 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-full">Default</span>
45
+ {{--
46
+ Both badges change what a customer is charged, so they sit
47
+ next to the name rather than in a column someone has to
48
+ scroll to. The code is what application code matches on;
49
+ renaming the rate must not change the bill.
50
+ --}}
51
+ <span :if="record.code" class="px-2 py-0.5 font-mono text-gray-600 text-xs dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700 rounded-full">{{ record.code }}</span>
52
+ <span :if="record.exemptible" class="px-2 py-0.5 font-medium text-amber-700 text-xs dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 rounded-full" title="Not charged to a customer who qualifies for an exemption">Exemptible</span>
45
53
  </div>
46
54
  <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.type }}</p>
47
55
  </td>
@@ -23,6 +23,13 @@ const deleteRecord = state<ProductWaitlistRecord | null>(null)
23
23
  const isSaving = state(false)
24
24
  const isDeleting = state(false)
25
25
  const validationErrors = state<Record<string, string>>({})
26
+ const metrics = derived(() => [
27
+ { label: 'Recorded', value: summary().total },
28
+ { label: 'Waiting', value: summary().waiting },
29
+ { label: 'Notified', value: summary().notified },
30
+ { label: 'Purchased', value: summary().purchased },
31
+ { label: 'Conversion', value: `${summary().conversionRate}%` },
32
+ ])
26
33
 
27
34
  function rows(): ProductWaitlistRecord[] {
28
35
  const query = search().trim().toLowerCase()
@@ -166,13 +173,7 @@ onMount(() => {
166
173
  </div>
167
174
 
168
175
  <section class="grid gap-3 grid-cols-2 lg:grid-cols-5">
169
- <template :for="item in [
170
- { label: 'Recorded', value: summary().total },
171
- { label: 'Waiting', value: summary().waiting },
172
- { label: 'Notified', value: summary().notified },
173
- { label: 'Purchased', value: summary().purchased },
174
- { label: 'Conversion', value: summary().conversionRate + '%' }
175
- ]">
176
+ <template :for="item in metrics()">
176
177
  <div class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
177
178
  <p class="text-gray-500 text-xs uppercase dark:text-neutral-400">{{ item.label }}</p>
178
179
  <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
@@ -26,6 +26,13 @@ const isSaving = state(false)
26
26
  const isDeleting = state(false)
27
27
  const busyId = state('')
28
28
  const validationErrors = state<Record<string, string>>({})
29
+ const metrics = derived(() => [
30
+ { label: 'Waiting', value: summary().waiting },
31
+ { label: 'Quoted wait', value: `${summary().averageQuotedWait} min` },
32
+ { label: 'Seated', value: summary().seated },
33
+ { label: 'No show', value: summary().noShow },
34
+ { label: 'Seating rate', value: `${summary().seatingRate}%` },
35
+ ])
29
36
 
30
37
  function rows(): RestaurantWaitlistRecord[] {
31
38
  const query = search().trim().toLowerCase()
@@ -220,13 +227,7 @@ onMount(() => {
220
227
  <div :if="loadError()" class="p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ loadError() }}</div>
221
228
 
222
229
  <section class="grid gap-3 grid-cols-2 lg:grid-cols-5">
223
- <template :for="item in [
224
- { label: 'Waiting', value: summary().waiting },
225
- { label: 'Quoted wait', value: summary().averageQuotedWait + ' min' },
226
- { label: 'Seated', value: summary().seated },
227
- { label: 'No show', value: summary().noShow },
228
- { label: 'Seating rate', value: summary().seatingRate + '%' }
229
- ]">
230
+ <template :for="item in metrics()">
230
231
  <div class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
231
232
  <p class="text-gray-500 text-xs uppercase dark:text-neutral-400">{{ item.label }}</p>
232
233
  <p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ item.value }}</p>
@@ -1,3 +1,9 @@
1
+ <script server>
2
+ import { publicApplicationUrl } from '../../../../functions/public-application-url'
3
+
4
+ export const blogUrl = publicApplicationUrl('/blog')
5
+ </script>
6
+
1
7
  <template>
2
8
  <div class="p-6 min-h-screen max-w-full text-neutral-950 dark:text-neutral-100 bg-neutral-50 dark:bg-neutral-950">
3
9
  <div class="flex flex-col gap-6 mx-auto min-w-0 max-w-7xl w-full">
@@ -10,7 +16,7 @@
10
16
  </p>
11
17
  </div>
12
18
  <div class="flex flex-wrap gap-2">
13
- <Button tag="a" href="/blog" target="_blank" rel="noopener" variant="secondary">
19
+ <Button tag="a" href="{{ blogUrl }}" target="_blank" rel="noopener" variant="secondary">
14
20
  <span class="h-4 w-4 i-hugeicons-view"></span>
15
21
  View blog
16
22
  </Button>
@@ -123,7 +129,7 @@
123
129
  <div class="flex gap-1 justify-end">
124
130
  <a
125
131
  x-show="!post.draft"
126
- :href="'/blog/' + post.slug"
132
+ :href="publicPostUrl(post.slug)"
127
133
  target="_blank"
128
134
  rel="noopener"
129
135
  :title="'View ' + post.title"
@@ -264,6 +270,8 @@
264
270
  // markdown files in content/blog. It owns no state; the signals below do.
265
271
  import { deleteBlogPost, fetchBlogPost, fetchBlogPosts, saveBlogPost } from '~/storage/framework/defaults/functions/blog'
266
272
 
273
+ const publicBlogBaseUrl = {{ blogUrl }}
274
+
267
275
  // Fixed-length list purely to render the loading skeleton rows.
268
276
  const skeletonRows = [1, 2, 3, 4]
269
277
 
@@ -291,6 +299,10 @@ const formFeatured = state(false)
291
299
 
292
300
  const postToDelete = state(null)
293
301
 
302
+ function publicPostUrl(slug) {
303
+ return `${publicBlogBaseUrl}/${encodeURIComponent(String(slug))}`
304
+ }
305
+
294
306
  // Read by the delete dialog's copy; kept as a derived signal so the template
295
307
  // never has to reach through a possibly-null post.
296
308
  const deleteTargetSlug = derived(() => {
@@ -1,3 +1,9 @@
1
+ <script server>
2
+ import { publicApplicationUrl } from '../../../../functions/public-application-url'
3
+
4
+ export const blogUrl = publicApplicationUrl('/blog')
5
+ </script>
6
+
1
7
  <script client>
2
8
  import { dashboardApi } from '../../../../functions/dashboard-api'
3
9
  import { pushToast } from '../../../../functions/toasts'
@@ -229,7 +235,7 @@ onMount(() => { void loadPosts() })
229
235
  <div class="space-y-6">
230
236
  <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
231
237
  <div><p class="font-semibold text-blue-600 text-xs tracking-wide uppercase dark:text-blue-400">CMS</p><h1 class="mt-1 font-bold text-2xl text-gray-900 dark:text-white">Posts</h1><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Manage persisted articles, publishing, authors, and native taxonomies.</p></div>
232
- <div class="flex flex-wrap gap-2"><Button href="/blog" target="_blank" rel="noreferrer" variant="secondary"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-view"></span>View blog</Button><Button :loading="loading()" variant="secondary" @click="loadPosts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button><Button @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>New post</Button></div>
238
+ <div class="flex flex-wrap gap-2"><Button href="{{ blogUrl }}" target="_blank" rel="noreferrer" variant="secondary"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-view"></span>View blog</Button><Button :loading="loading()" variant="secondary" @click="loadPosts()"><span :if="!loading()" aria-hidden="true" class="h-4 w-4 i-hugeicons-refresh"></span>Refresh</Button><Button @click="openCreate()"><span aria-hidden="true" class="h-4 w-4 i-hugeicons-add-01"></span>New post</Button></div>
233
239
  </header>
234
240
 
235
241
  <div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadPosts()">Retry</Button></div>
@@ -44,6 +44,20 @@ function statusLabel(status: string): string {
44
44
  return status.charAt(0).toUpperCase() + status.slice(1)
45
45
  }
46
46
 
47
+ function deploymentUrl(): string {
48
+ const value = record()?.url?.trim()
49
+ if (!value)
50
+ return ''
51
+
52
+ try {
53
+ const url = new URL(value)
54
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : ''
55
+ }
56
+ catch {
57
+ return ''
58
+ }
59
+ }
60
+
47
61
  async function loadDeployment(): Promise<void> {
48
62
  const route = useRoute()
49
63
  const id = String(route.params.id || '')
@@ -131,9 +145,9 @@ onMount(() => {
131
145
  <dt class="text-gray-500 dark:text-neutral-400">Created</dt>
132
146
  <dd class="mt-1 text-gray-900 dark:text-white">{{ formatDate(record()?.createdAt || '') }}</dd>
133
147
  </div>
134
- <div :if="record()?.url">
148
+ <div :if="deploymentUrl()">
135
149
  <dt class="text-gray-500 dark:text-neutral-400">Deployment URL</dt>
136
- <dd class="mt-1"><a :href="record()?.url || '#'" target="_blank" rel="noopener noreferrer" class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500">{{ record()?.url }}</a></dd>
150
+ <dd class="mt-1"><a :href="deploymentUrl()" target="_blank" rel="noopener noreferrer" class="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500">{{ deploymentUrl() }}</a></dd>
137
151
  </div>
138
152
  </dl>
139
153
  </div>
@@ -8,17 +8,17 @@ function linkClass(name: string): string {
8
8
  }
9
9
  </script>
10
10
 
11
- <nav aria-label="Mail sections" class="inline-flex gap-1 p-1 bg-gray-100 dark:bg-neutral-900 rounded-lg">
12
- <StxLink to="/inbox" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('inbox')">
11
+ <nav aria-label="Mail sections" class="flex overflow-x-auto gap-0.5 sm:gap-1 p-1 max-w-full bg-gray-100 dark:bg-neutral-900 rounded-lg">
12
+ <StxLink to="/inbox" :class="'px-2 sm:px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('inbox')">
13
13
  Inbox
14
14
  </StxLink>
15
- <StxLink to="/inbox/activity" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('activity')">
15
+ <StxLink to="/inbox/activity" :class="'px-2 sm:px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('activity')">
16
16
  Activity
17
17
  </StxLink>
18
- <StxLink to="/inbox/captured" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('captured')">
18
+ <StxLink to="/inbox/captured" :class="'px-2 sm:px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('captured')">
19
19
  Captured
20
20
  </StxLink>
21
- <StxLink to="/inbox/settings" :class="'px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('settings')">
21
+ <StxLink to="/inbox/settings" :class="'px-2 sm:px-3 py-1.5 font-medium text-sm rounded-md transition-colors ' + linkClass('settings')">
22
22
  Settings
23
23
  </StxLink>
24
24
  </nav>
@@ -47,7 +47,7 @@ function mailboxLabel(): string {
47
47
  }
48
48
  </script>
49
49
 
50
- <aside class="flex flex-col flex-shrink-0 w-64 bg-white dark:bg-neutral-800 border-gray-200 border-r dark:border-neutral-600">
50
+ <aside class="flex flex-col flex-shrink-0 w-full md:w-64 bg-white dark:bg-neutral-800 border-b border-gray-200 md:border-b-0 md:border-r dark:border-neutral-600">
51
51
  <div class="p-3 border-b border-gray-200 dark:border-neutral-600">
52
52
  <div class="flex items-center space-x-2">
53
53
  <div class="flex flex-shrink-0 items-center justify-center h-8 w-8 text-gray-700 dark:text-white bg-gray-200 dark:bg-neutral-700 rounded">
@@ -0,0 +1,53 @@
1
+ <script client>
2
+ import type { DashboardInboxAttachment } from '../../../../functions/inbox'
3
+ import { downloadDashboardInboxAttachment } from '../../../../functions/inbox'
4
+ import { pushToast } from '../../../../functions/toasts'
5
+
6
+ const attachments = useReactiveProp<DashboardInboxAttachment[]>('attachments', [])
7
+ const mailbox = useReactiveProp('mailbox', '')
8
+ const messageId = useReactiveProp('messageId', '')
9
+ const downloadingId = state('')
10
+
11
+ function formatBytes(bytes: number): string {
12
+ if (!Number.isFinite(bytes) || bytes <= 0)
13
+ return 'Unknown size'
14
+
15
+ const units = ['B', 'KB', 'MB', 'GB']
16
+ const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
17
+ const value = bytes / 1024 ** unitIndex
18
+ return `${value >= 10 || unitIndex === 0 ? Math.round(value) : value.toFixed(1)} ${units[unitIndex]}`
19
+ }
20
+
21
+ async function download(attachment: DashboardInboxAttachment): Promise<void> {
22
+ downloadingId.set(attachment.id)
23
+ try {
24
+ await downloadDashboardInboxAttachment(messageId(), attachment, mailbox())
25
+ }
26
+ catch (error) {
27
+ pushToast('error', 'Could not download attachment', { detail: String(error) })
28
+ }
29
+ finally {
30
+ downloadingId.set('')
31
+ }
32
+ }
33
+ </script>
34
+
35
+ <section :if="attachments().length" aria-label="Message attachments" class="mb-4 mx-4 p-3 bg-gray-50 dark:bg-neutral-800 border border-gray-200 rounded-md dark:border-neutral-600">
36
+ <div class="flex gap-2 items-center text-gray-800 dark:text-neutral-200">
37
+ <span aria-hidden="true" class="h-5 w-5 i-hugeicons-attachment-01"></span>
38
+ <h3 class="font-medium text-sm">Attachments</h3>
39
+ <span class="text-gray-500 text-xs dark:text-neutral-400">{{ attachments().length }}</span>
40
+ </div>
41
+ <ul class="mt-3 space-y-2">
42
+ <li :for="attachment in attachments()" class="flex gap-3 items-center justify-between p-2 bg-white dark:bg-neutral-700 border border-gray-200 rounded-md dark:border-neutral-600">
43
+ <div class="min-w-0">
44
+ <p class="font-medium text-gray-900 text-sm truncate dark:text-white">{{ attachment.name }}</p>
45
+ <p class="mt-0.5 text-gray-500 text-xs dark:text-neutral-400">{{ formatBytes(attachment.size) }}</p>
46
+ </div>
47
+ <Button size="sm" :loading="downloadingId() === attachment.id" :disabled="Boolean(downloadingId())" @click="download(attachment)">
48
+ <span :if="downloadingId() !== attachment.id" aria-hidden="true" class="h-4 w-4 i-hugeicons-download-04"></span>
49
+ Download
50
+ </Button>
51
+ </li>
52
+ </ul>
53
+ </section>
@@ -121,7 +121,7 @@ async function openEmail(value: DashboardInboxEmail | CustomEvent<DashboardInbox
121
121
  deletePending.set(false)
122
122
  replyText.set('')
123
123
 
124
- if (!email.bodyHtml && !email.bodyText) {
124
+ if (!email.detailsLoaded) {
125
125
  try {
126
126
  const body = await fetchDashboardInboxBody(email.id, mailbox())
127
127
  emails.set(emails().map(item => item.id === email.id ? { ...item, ...body } : item))
@@ -228,7 +228,7 @@ onMount(() => {
228
228
  </script>
229
229
 
230
230
  <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
231
- <div class="flex min-h-[calc(100vh-8rem)]">
231
+ <div class="flex flex-col md:flex-row min-h-[calc(100vh-8rem)]">
232
232
  <EmailSidebar
233
233
  activeFolder="inbox"
234
234
  :folders="folders"
@@ -237,10 +237,10 @@ onMount(() => {
237
237
  @compose="compose"
238
238
  />
239
239
 
240
- <div class="flex overflow-hidden flex-1 flex-col">
240
+ <div class="flex overflow-hidden flex-1 flex-col min-w-0">
241
241
  <header class="px-4 py-3 bg-white dark:bg-neutral-700 border-b border-gray-200 dark:border-neutral-600">
242
242
  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
243
- <div class="flex gap-4 items-center">
243
+ <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center min-w-0">
244
244
  <h1 class="font-bold text-gray-900 text-xl dark:text-white">Inbox</h1>
245
245
  <EmailNavigation active="inbox" />
246
246
  </div>
@@ -277,6 +277,7 @@ onMount(() => {
277
277
  :replying="isReplying"
278
278
  :replyText="replyText"
279
279
  :canSendReply="canSendReply"
280
+ :mailbox="mailbox"
280
281
  @close="closeEmail"
281
282
  @toggle-read="toggleSelectedRead"
282
283
  @request-delete="requestDelete"
@@ -7,6 +7,7 @@ const deleting = useReactiveProp('deleting', false)
7
7
  const replying = useReactiveProp('replying', false)
8
8
  const replyText = useReactiveProp('replyText', '')
9
9
  const canSendReply = useReactiveProp('canSendReply', false)
10
+ const mailbox = useReactiveProp('mailbox', '')
10
11
  const emit = defineEmits()
11
12
 
12
13
  function message(): DashboardInboxEmail | null {
@@ -69,10 +70,11 @@ function formatDate(value: string): string {
69
70
  :text="message()?.bodyText || ''"
70
71
  :title="message()?.subject || 'Inbound email preview'"
71
72
  />
72
- <div :if="message()?.hasAttachments" class="flex gap-3 items-start mb-4 mx-4 p-3 text-gray-700 text-sm dark:text-neutral-300 bg-gray-50 dark:bg-neutral-800 border border-gray-200 rounded-md dark:border-neutral-600">
73
- <span class="flex-shrink-0 mt-0.5 h-5 w-5 i-hugeicons-attachment-01"></span>
74
- <p>This message includes attachments. Attachment downloads are not exposed by the inbox API yet.</p>
75
- </div>
73
+ <InboxAttachmentList
74
+ :attachments="message()?.attachments || []"
75
+ :mailbox="mailbox"
76
+ :messageId="message()?.id || ''"
77
+ />
76
78
  </div>
77
79
 
78
80
  <div class="p-4 bg-white dark:bg-neutral-700 border-gray-200 border-t dark:border-neutral-600">
@@ -1,5 +1,5 @@
1
1
  <script client>
2
- // Kanban board view (stacksjs/stacks#1846 Phase 2).
2
+ // Kanban board view.
3
3
  //
4
4
  // Optimistic-update pattern:
5
5
  // 1. The DnD handler mutates the store immediately (so the card
@@ -29,7 +29,7 @@
29
29
  const columns = derived(() => kanban.currentColumns())
30
30
  const allLabels = derived(() => kanban.currentLabels())
31
31
 
32
- // Card detail modal state read straight from the store. The
32
+ // Card detail modal state - read straight from the store. The
33
33
  // modal itself lives at the bottom of this file.
34
34
  const openCard = derived(() => kanban.openCard())
35
35
  const loadingCard = derived(() => kanban.loadingCard())
@@ -48,6 +48,9 @@
48
48
  const titleDraft = state('')
49
49
  const descDraft = state('')
50
50
  const commentDraft = state('')
51
+ const editingCommentId = state(null)
52
+ const commentEditDraft = state('')
53
+ const savingCommentId = state(null)
51
54
  const showLabelPicker = state(false)
52
55
  const showAssigneePicker = state(false)
53
56
  const showNewLabelComposer = state(false)
@@ -83,6 +86,9 @@
83
86
  descDraft.set(c ? (c.description || '') : '')
84
87
  dueDateDraft.set(c?.dueDate ? c.dueDate.slice(0, 10) : '')
85
88
  commentDraft.set('')
89
+ editingCommentId.set(null)
90
+ commentEditDraft.set('')
91
+ savingCommentId.set(null)
86
92
  }
87
93
  // Sync drafts when the open card changes (open / close / refetch).
88
94
  effect(() => { openCard(); Promise.resolve().then(syncDraftsFromCard) })
@@ -134,6 +140,40 @@
134
140
  if (ok) commentDraft.set('')
135
141
  }
136
142
 
143
+ function startCommentEdit(id, body) {
144
+ editingCommentId.set(id)
145
+ commentEditDraft.set(body)
146
+ }
147
+
148
+ function cancelCommentEdit() {
149
+ editingCommentId.set(null)
150
+ commentEditDraft.set('')
151
+ }
152
+
153
+ async function saveCommentEdit(id) {
154
+ const body = commentEditDraft().trim()
155
+ if (!body || savingCommentId()) return
156
+ savingCommentId.set(id)
157
+ try {
158
+ if (await kanban.updateComment(id, body))
159
+ cancelCommentEdit()
160
+ }
161
+ finally {
162
+ savingCommentId.set(null)
163
+ }
164
+ }
165
+
166
+ function handleCommentEditKeydown(event, id) {
167
+ if (event.key === 'Escape') {
168
+ event.preventDefault()
169
+ cancelCommentEdit()
170
+ }
171
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
172
+ event.preventDefault()
173
+ void saveCommentEdit(id)
174
+ }
175
+ }
176
+
137
177
  function requestDelete(kind: DeleteKind, id: number, hasCards = false): void {
138
178
  pendingDelete.set({ kind, id, hasCards })
139
179
  }
@@ -219,7 +259,7 @@
219
259
  }
220
260
 
221
261
  // Pull the id from the URL through the navigation composable.
222
- // (stacksjs/stacks#1838 no vanilla DOM in dashboard pages).
262
+ // (stacksjs/stacks#1838 - no vanilla DOM in dashboard pages).
223
263
  const id = useNavigation().urlIdParam()
224
264
  if (id !== null)
225
265
  kanban.loadBoard(id)
@@ -647,7 +687,7 @@
647
687
  </div>
648
688
  </div>
649
689
 
650
- <!-- ───────────── Card detail modal (Phase 3) ───────────── -->
690
+ <!-- Card detail modal -->
651
691
  <!-- Opens on card click. Fixed positioned with a backdrop; closes
652
692
  on backdrop click + Escape. Sections (top → bottom):
653
693
  · Title (inline-editable, blurs to commit)
@@ -739,9 +779,26 @@
739
779
  <div class="flex gap-2 items-baseline">
740
780
  <span class="font-medium text-xs text-zinc-700 dark:text-zinc-200" :text="cm.authorName || cm.authorEmail || 'Unknown'"></span>
741
781
  <span class="text-[10px] text-zinc-400" :text="formatTimestamp(cm.createdAt)"></span>
742
- <Button variant="ghost" size="xs" @click="requestDelete('comment', cm.id)"><span class="text-red-500">Delete</span></Button>
782
+ <template :if="editingCommentId() !== cm.id">
783
+ <Button variant="ghost" size="xs" @click="startCommentEdit(cm.id, cm.body)">Edit</Button>
784
+ <Button variant="ghost" size="xs" @click="requestDelete('comment', cm.id)"><span class="text-red-500">Delete</span></Button>
785
+ </template>
786
+ </div>
787
+ <p :if="editingCommentId() !== cm.id" class="mt-0.5 text-xs text-zinc-700 whitespace-pre-wrap dark:text-zinc-300" :text="cm.body"></p>
788
+ <div :if="editingCommentId() === cm.id" class="mt-2">
789
+ <textarea
790
+ x-model="commentEditDraft"
791
+ @keydown="handleCommentEditKeydown($event, cm.id)"
792
+ rows="3"
793
+ maxlength="10000"
794
+ :aria-label="'Edit comment by ' + (cm.authorName || cm.authorEmail || 'Unknown')"
795
+ class="px-3 py-2 w-full text-sm text-zinc-800 dark:text-zinc-200 bg-white dark:bg-zinc-950 border border-zinc-200 rounded dark:border-zinc-800 focus:border-violet-400 focus:outline-none dark:focus:border-violet-600 resize-y"
796
+ ></textarea>
797
+ <div class="flex gap-2 justify-end mt-2">
798
+ <Button variant="secondary" size="xs" :disabled="savingCommentId() === cm.id" @click="cancelCommentEdit()">Cancel</Button>
799
+ <Button size="xs" :loading="savingCommentId() === cm.id" :disabled="!commentEditDraft().trim()" @click="saveCommentEdit(cm.id)">Save comment</Button>
800
+ </div>
743
801
  </div>
744
- <p class="mt-0.5 text-xs text-zinc-700 whitespace-pre-wrap dark:text-zinc-300" :text="cm.body"></p>
745
802
  </div>
746
803
  </li>
747
804
  </ul>
@@ -1,12 +1,9 @@
1
1
  <script client>
2
- // Kanban — Boards index (stacksjs/stacks#1846, Phase 1).
3
- //
4
2
  // Lists every non-archived board with a card-count badge. Clicking a
5
- // board navigates to `/kanban/[id]` Phase 2 ships the actual
6
- // drag-and-drop board view at that URL.
3
+ // board navigates to its complete drag-and-drop view.
7
4
  //
8
5
  // Follows #1838 conventions: no <script server>, no vanilla DOM
9
- // reads, all state via the store. Role gate via useRole() the
6
+ // reads, all state via the store. Role gate via useRole() - the
10
7
  // surface is dev-mode-only (admins + devs see it, clients don't).
11
8
  import { useRole } from '~/storage/framework/defaults/views/dashboard/composables/useRole'
12
9
  import { kanbanStore } from '~/storage/framework/defaults/views/dashboard/stores/kanban'
@@ -175,7 +172,7 @@
175
172
  <p class="mt-2 text-xs text-zinc-500">Most common: migrations haven't run yet. Try <code>./buddy migrate</code>.</p>
176
173
  </div>
177
174
 
178
- <!-- Empty state DB reachable but no boards yet -->
175
+ <!-- Empty state - DB reachable but no boards yet -->
179
176
  <div :if="!loading && !error && !hasBoards" class="px-6 py-12 text-center border border-dashed border-zinc-300 rounded-lg dark:border-zinc-700">
180
177
  <span class="block mb-3 mx-auto h-10 w-10 text-zinc-400 i-hugeicons-layout-3-column"></span>
181
178
  <p class="font-medium text-sm text-zinc-700 dark:text-zinc-300">No boards yet</p>