@stacksjs/defaults 0.70.224 → 0.70.225

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.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.70.224",
5
+ "version": "0.70.225",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.224",
5
+ "version": "0.70.225",
6
6
  "description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
7
7
  "author": "Chris Breuer",
8
8
  "license": "MIT",
@@ -1,23 +1,35 @@
1
1
  <script server>
2
- // Web sidebar for the dashboard. Framework-owned nav chrome: the markup is
3
- // built server-side in resources/functions/dashboard/sidebar.ts and the look
4
- // lives in the plain-CSS <style> block below (stx does not expand Crosswind
5
- // @apply in a layout <style>, so it is hand-written). Sections come
6
- // from the discovered-models manifest + config/dashboard.ts toggles.
7
- // Project-root-anchored (`~/`), not relative. The stx client bundler resolves a
8
- // relative import against the PAGE that extends this layout, not against this
9
- // file, so `../../..` silently pointed somewhere different for every page depth
10
- // and the bundle failed for any page more than one level under views/dashboard.
11
- import { buildSidebarChunks } from '~/storage/framework/defaults/resources/functions/dashboard/sidebar'
2
+ // Dashboard chrome. The navigation pane is `@stacksjs/components`' <Sidebar>
3
+ // in its `macos` theme — the same component Craft apps use, so the dashboard
4
+ // and a native Craft build render the identical source list.
5
+ //
6
+ // This used to be a hand-rolled <aside> plus ~180 lines of plain CSS, because
7
+ // @stacksjs/components 0.2.9x shipped only its hashed dist/ output and the
8
+ // component fatal-errored with an ENOENT (stacksjs/stacks#1989). That package
9
+ // now publishes its .stx sources, so the real component resolves and the
10
+ // duplicate markup is gone.
11
+ //
12
+ // Project-root-anchored (`~/`), not relative: the stx client bundler resolves
13
+ // a relative import against the PAGE that extends this layout, not against
14
+ // this file, so `../../..` pointed somewhere different for every page depth.
15
+ import { buildWebSidebarSections } from '~/storage/framework/defaults/resources/functions/dashboard/sidebar'
12
16
 
13
- // Pre-rendered, self-contained sidebar HTML (home link, section nav, settings
14
- // link), built server-side and interpolated raw below. This intentionally does
15
- // NOT depend on `@stacksjs/components`' <Sidebar>: 0.2.9x stopped shipping
16
- // tag-resolvable .stx sources (only hashed dist/ output), so the component
17
- // fatal-errored the whole dashboard with an ENOENT (stacksjs/stacks#1989). Each
18
- // rendered <a> already carries data-required-roles, which the applyRoleFilter
19
- // effect below reads directly for role gating.
20
- export const sidebarChunks = buildSidebarChunks()
17
+ export const sidebarSections = buildWebSidebarSections()
18
+
19
+ // Role gating (stacksjs/stacks#1843). Models can declare
20
+ // `dashboard.roles: ['admin', 'dev']`; rows for those models are hidden from
21
+ // viewers who hold none of them. Emitted as an id-keyed map rather than a
22
+ // `data-required-roles` attribute on each row, which keeps the generic
23
+ // Sidebar component free of Stacks-specific attributes.
24
+ export const sidebarRoleMap = JSON.stringify(
25
+ Object.fromEntries(
26
+ sidebarSections.flatMap(section =>
27
+ section.items
28
+ .filter(item => item.roles && item.roles.length > 0)
29
+ .map(item => [item.id, item.roles]),
30
+ ),
31
+ ),
32
+ )
21
33
  </script>
22
34
 
23
35
  <!-- Dashboard icon stylesheet. Most dashboard icons are applied via dynamic
@@ -42,13 +54,6 @@ canvas[id$="_chart"] {
42
54
  height: 100% !important;
43
55
  }
44
56
 
45
- /*
46
- * Dashboard body + sidebar. Plain CSS on purpose: stx serves a layout's raw
47
- * style rules verbatim, but it does NOT expand Crosswind @apply here, so these
48
- * are hand-written. Class names match the markup built in
49
- * resources/functions/dashboard/sidebar.ts. Fixed 250px pane: Home pinned top,
50
- * sections scroll in the middle, Settings pinned bottom.
51
- */
52
57
  /*
53
58
  * The window background is owned by the main content pane, not the body element.
54
59
  * In the native Craft window the WKWebView is transparent and an
@@ -61,156 +66,22 @@ canvas[id$="_chart"] {
61
66
  body {
62
67
  background: transparent;
63
68
  }
69
+ /*
70
+ * The pane offset follows the sidebar's own width variable, which <Sidebar>
71
+ * writes onto this element via `shell-selector`. The literal fallback keeps
72
+ * the layout correct on the first paint, before the controller mounts, and
73
+ * the collapsed state animates because the variable changes rather than a
74
+ * class toggling a hard-coded margin.
75
+ */
64
76
  [data-stx-content] {
65
77
  min-height: 100vh;
78
+ margin-left: var(--stx-sidebar-width, 250px);
66
79
  background: #f4f4f5;
80
+ transition: margin-left 0.3s ease-out;
67
81
  }
68
82
  .dark [data-stx-content] {
69
83
  background: #0a0a0a;
70
84
  }
71
- .dashboard-sidebar {
72
- position: fixed;
73
- inset: 0 auto 0 0;
74
- width: 250px;
75
- display: flex;
76
- flex-direction: column;
77
- padding: 0 12px 10px;
78
- overflow: hidden;
79
- /* Translucent so the native NSVisualEffectView vibrancy reads through in the
80
- Craft window; the blur is the frosted-glass fallback in a plain browser,
81
- where there is no material behind the pane. */
82
- background: rgba(255, 255, 255, 0.55);
83
- -webkit-backdrop-filter: saturate(180%) blur(24px);
84
- backdrop-filter: saturate(180%) blur(24px);
85
- /* No hard border: a soft cast shadow to the right lifts the translucent pane
86
- ABOVE the content, so it reads as a glass layer laid on top (Finder-style)
87
- rather than a flat column butted beside it. The 1px inset is the hairline. */
88
- box-shadow: 1px 0 0 rgba(0, 0, 0, 0.05), 12px 0 34px -10px rgba(0, 0, 0, 0.18);
89
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
90
- -webkit-font-smoothing: antialiased;
91
- z-index: 40;
92
- }
93
- .sidebar-header {
94
- flex-shrink: 0;
95
- height: 38px;
96
- }
97
- .sidebar-nav {
98
- flex: 1 1 auto;
99
- min-height: 0;
100
- overflow-y: auto;
101
- scrollbar-width: none;
102
- }
103
- .sidebar-nav::-webkit-scrollbar {
104
- width: 0;
105
- }
106
- .sidebar-link {
107
- display: flex;
108
- align-items: center;
109
- gap: 10px;
110
- flex-shrink: 0;
111
- height: 30px;
112
- padding: 0 10px;
113
- border-radius: 6px;
114
- color: #3a3a3c;
115
- font-size: 13px;
116
- font-weight: 450;
117
- text-decoration: none;
118
- white-space: nowrap;
119
- overflow: hidden;
120
- text-overflow: ellipsis;
121
- transition: background-color 0.12s ease, color 0.12s ease;
122
- }
123
- .sidebar-link:hover {
124
- background: rgba(0, 0, 0, 0.05);
125
- }
126
- .sidebar-link[data-active="true"] {
127
- background: #007aff;
128
- color: #fff;
129
- }
130
- .sidebar-link-home {
131
- font-weight: 550;
132
- }
133
- .sidebar-nav .sidebar-link {
134
- margin-top: 1px;
135
- }
136
- .sidebar-icon {
137
- display: inline-flex;
138
- align-items: center;
139
- justify-content: center;
140
- flex-shrink: 0;
141
- width: 18px;
142
- height: 18px;
143
- opacity: 0.82;
144
- }
145
- .sidebar-icon svg {
146
- width: 18px;
147
- height: 18px;
148
- }
149
- .sidebar-link[data-active="true"] .sidebar-icon {
150
- opacity: 1;
151
- }
152
- .sidebar-section {
153
- margin-top: 12px;
154
- }
155
- .sidebar-section-label {
156
- display: flex;
157
- align-items: center;
158
- justify-content: space-between;
159
- width: 100%;
160
- height: 22px;
161
- padding: 0 10px;
162
- background: none;
163
- border: 0;
164
- cursor: pointer;
165
- color: #8a8a8e;
166
- font-size: 11px;
167
- font-weight: 600;
168
- letter-spacing: 0.03em;
169
- text-transform: uppercase;
170
- }
171
- .sidebar-section-label:hover {
172
- color: #6a6a6e;
173
- }
174
- .sidebar-section-list {
175
- list-style: none;
176
- margin: 2px 0 0;
177
- padding: 0;
178
- }
179
- .sidebar-section.collapsed .sidebar-section-list {
180
- display: none;
181
- }
182
- .sidebar-chevron {
183
- width: 7px;
184
- height: 7px;
185
- border-right: 1.5px solid currentColor;
186
- border-bottom: 1.5px solid currentColor;
187
- transform: rotate(45deg);
188
- transition: transform 0.15s ease;
189
- opacity: 0.7;
190
- }
191
- .sidebar-section.collapsed .sidebar-chevron {
192
- transform: rotate(-45deg);
193
- }
194
- .dark .dashboard-sidebar {
195
- background: rgba(28, 28, 30, 0.55);
196
- box-shadow: 1px 0 0 rgba(255, 255, 255, 0.06), 12px 0 34px -10px rgba(0, 0, 0, 0.55);
197
- }
198
- .dark .sidebar-link {
199
- color: #d1d1d6;
200
- }
201
- .dark .sidebar-link:hover {
202
- background: rgba(255, 255, 255, 0.06);
203
- }
204
- .dark .sidebar-link[data-active="true"] {
205
- background: #0a84ff;
206
- color: #fff;
207
- }
208
- .dark .sidebar-section-label {
209
- color: #8d8d92;
210
- }
211
- .dark .sidebar-section-label:hover {
212
- color: #aeaeb2;
213
- }
214
85
  </style>
215
86
 
216
87
  <script>
@@ -274,86 +145,6 @@ body {
274
145
  </script>
275
146
 
276
147
  <script client>
277
- // Craft native sidebar integration
278
- // Maps sidebar itemIds to STX page routes for SPA navigation
279
- const sidebarRoutes = {
280
- 'home': '/',
281
- 'dependencies': '/dependencies',
282
- 'services': '/health',
283
- 'ci': '/ci',
284
- 'components': '/components',
285
- 'functions': '/functions',
286
- 'releases': '/releases',
287
- 'packages': '/packages',
288
- 'content-dashboard': '/content/dashboard',
289
- 'files': '/content/files',
290
- 'blog': '/content/blog',
291
- 'pages': '/content/pages',
292
- 'posts': '/content/posts',
293
- 'categories': '/content/categories',
294
- 'tags': '/content/tags',
295
- 'comments': '/content/comments',
296
- 'authors': '/content/authors',
297
- 'seo': '/content/seo',
298
- 'deployments': '/deployments',
299
- 'requests': '/requests',
300
- 'realtime': '/realtime',
301
- 'actions': '/actions',
302
- 'commands': '/commands',
303
- 'queue': '/queue',
304
- 'jobs': '/jobs',
305
- 'inbox': '/inbox',
306
- 'queries': '/queries',
307
- 'notifications': '/notifications/dashboard',
308
- 'data-dashboard': '/data/dashboard',
309
- 'activity': '/data/activity',
310
- 'users': '/data/users',
311
- 'teams': '/data/teams',
312
- 'subscribers': '/data/subscribers',
313
- 'commerce-dashboard': '/commerce/dashboard',
314
- 'pos': '/commerce/pos',
315
- 'customers': '/commerce/customers',
316
- 'orders': '/commerce/orders',
317
- 'products': '/commerce/products',
318
- 'coupons': '/commerce/coupons',
319
- 'gift-cards': '/commerce/gift-cards',
320
- 'payments': '/commerce/payments',
321
- 'delivery': '/commerce/delivery',
322
- 'taxes': '/commerce/taxes',
323
- 'lists': '/marketing/lists',
324
- 'social-posts': '/marketing/social-posts',
325
- 'campaigns': '/marketing/campaigns',
326
- 'marketing-reviews': '/marketing/reviews',
327
- 'analytics-web': '/analytics/web',
328
- 'analytics-blog': '/analytics/blog',
329
- 'analytics-goals': '/analytics/goals',
330
- 'analytics-commerce': '/analytics/commerce/web',
331
- 'analytics-sales': '/analytics/commerce/sales',
332
- 'analytics-marketing': '/analytics/marketing',
333
- 'cloud': '/cloud',
334
- 'servers': '/servers',
335
- 'serverless': '/serverless',
336
- 'dns': '/dns',
337
- 'permissions': '/management/permissions',
338
- 'kanban': '/kanban',
339
- 'mailboxes': '/mailboxes',
340
- 'logs': '/logs',
341
- 'buddy': '/buddy',
342
- 'environment': '/environment',
343
- 'access-tokens': '/access-tokens',
344
- 'settings': '/settings',
345
- }
346
-
347
- // Handle Craft native sidebar selection events
348
- window.craft = window.craft || {}
349
- window.craft._sidebarSelectHandler = (event) => {
350
- const url = sidebarRoutes[event.itemId]
351
- || (event.itemId && event.itemId.startsWith('model-') ? '/models/' + event.itemId.slice(6) : null)
352
- if (url) {
353
- navigate(url)
354
- }
355
- }
356
-
357
148
  // Global toast queue. Composables across the dashboard call
358
149
  // `pushToast(...)` (defined in defaults/functions/toasts.ts) when
359
150
  // network requests fail or succeed; that signal is rendered in the
@@ -382,20 +173,17 @@ const toastWrapperClass = (type) => `pointer-events-auto overflow-hidden bg-whit
382
173
 
383
174
  // Role-gated sidebar filter (stacksjs/stacks#1843).
384
175
  //
385
- // The server emits sidebar `<a>` items with `data-required-roles="…"` for
386
- // any model that declares `dashboard.roles: ['admin', 'dev', …]`. We
387
- // walk those nodes on hydration and on auth-store changes, hiding rows
388
- // the current viewer can't access.
389
- //
390
176
  // Permissive default: when the viewer is unauthenticated (the localhost
391
- // dev dashboard) `isDev()` returns true so every gated row stays visible.
392
- // In production deployments where `/api/dashboard/*` sits behind auth,
393
- // the endpoint returns real role data and the filter hides what the
394
- // viewer can't reach.
395
- // Guarded: `useStore('auth')` THROWS when the store was never registered, and a
396
- // throw here aborts scope registration for the entire page, leaving every signal
397
- // on every dashboard page unbound. A missing auth store must degrade to the
398
- // permissive default described above, not take the dashboard down with it.
177
+ // dev dashboard) every gated row stays visible. In production deployments
178
+ // where `/api/dashboard/*` sits behind auth, the endpoint returns real role
179
+ // data and the filter hides what the viewer can't reach.
180
+ //
181
+ // Guarded: `useStore('auth')` THROWS when the store was never registered, and
182
+ // a throw here aborts scope registration for the entire page, leaving every
183
+ // signal on every dashboard page unbound. A missing auth store must degrade to
184
+ // the permissive default, not take the dashboard down with it.
185
+ const sidebarRoles = {{ sidebarRoleMap }}
186
+
399
187
  const auth = (() => {
400
188
  try {
401
189
  const store = useStore('auth')
@@ -408,59 +196,64 @@ const auth = (() => {
408
196
  })()
409
197
 
410
198
  function applyRoleFilter() {
411
- // No auth store means no role data, which is the unauthenticated case: show
412
- // every gated row rather than hiding the whole sidebar.
413
199
  const roleList = auth ? auth.roles() : []
414
200
  const unauth = auth ? auth.unauthenticated() : true
415
- const nodes = document.querySelectorAll('[data-required-roles]')
416
- for (const el of nodes) {
417
- const required = (el.getAttribute('data-required-roles') || '')
418
- .split(',').map(s => s.trim()).filter(Boolean)
419
- if (required.length === 0) continue
420
- // Unauthenticated viewers (no auth wired) see everything — keeps the
421
- // dev dashboard usable. Once authenticated, role membership becomes
422
- // the authoritative answer.
201
+ for (const [itemId, required] of Object.entries(sidebarRoles)) {
202
+ const row = document.querySelector(`[data-sidebar-row][data-item-id="${itemId}"]`)
203
+ if (!row) continue
204
+ // Unauthenticated viewers see everything — keeps the dev dashboard
205
+ // usable. Once authenticated, role membership is authoritative. CSS
206
+ // `display: none` keeps the DOM intact for SPA nav, so a later role
207
+ // change can re-show the row without a re-render.
423
208
  const allowed = unauth || required.some(r => roleList.includes(r))
424
- // Hide the parent <li> when the link is inside one; otherwise the
425
- // link itself. Either way the row disappears from the rendered
426
- // sidebar; CSS `display: none` keeps the DOM intact for SPA nav so
427
- // a later role change can re-show without a re-render.
428
- const target = el.closest('li') || el
429
- target.style.display = allowed ? '' : 'none'
209
+ row.style.display = allowed ? '' : 'none'
430
210
  }
431
211
  }
432
212
 
433
213
  effect(() => {
434
- // `effect` re-runs whenever `auth.roles()` / `auth.unauthenticated()`
435
- // change. Includes the first run, which executes after hydration once
436
- // the auth fetch resolves.
214
+ // Re-runs whenever `auth.roles()` / `auth.unauthenticated()` change,
215
+ // including the first run after the auth fetch resolves.
437
216
  applyRoleFilter()
438
217
  })
439
218
  </script>
440
219
 
441
- <aside data-stx-sidebar class="dashboard-sidebar" role="navigation" aria-label="Sidebar">
442
- <!-- Traffic lights are native (titlebarHidden window) — this strip reserves
443
- their space at the top of the pane so the first nav row clears them. -->
444
- <div class="sidebar-header"></div>
445
- {{{ sidebarChunks.top }}}
446
- <nav class="sidebar-nav">{{{ sidebarChunks.nav }}}</nav>
447
- {{{ sidebarChunks.bottom }}}
448
- </aside>
220
+ <Sidebar
221
+ :sections="sidebarSections"
222
+ theme="macos"
223
+ width="250"
224
+ placement="fixed"
225
+ persist-key="stacks-dashboard-sidebar"
226
+ shell-selector="[data-stx-content]"
227
+ >
228
+ <template #header>
229
+ <!-- Chrome strip. The traffic lights are native (the Craft window is
230
+ titlebarHidden), so this reserves their space and acts as the window
231
+ drag region; in a plain browser it is the same empty strip macOS
232
+ sidebars carry above their first row. -->
233
+ <SidebarHeader :show-window-controls="false" />
234
+ </template>
235
+ </Sidebar>
449
236
 
450
237
  <script>
451
- // Web sidebar wiring: collapse restore, active highlight, SPA navigation.
238
+ // Sidebar wiring the component intentionally leaves to the host app: SPA
239
+ // navigation, keeping the highlight in sync with the real route, and Craft's
240
+ // native selection events.
241
+ //
452
242
  // Runs as a plain <script> IIFE (like the import-map script above), NOT via
453
243
  // onMount: stx gives a LAYOUT no reactive scope of its own, so its onMount /
454
- // effect callbacks never fire ("global mountCallbacks: 0") — the previous
455
- // onMount version silently never ran, which is why no link was ever highlighted.
456
- // A DOMContentLoaded IIFE plus the stx:navigate event cover hard loads and every
457
- // SPA route change without depending on a scope mount.
244
+ // effect callbacks never fire ("global mountCallbacks: 0"). A DOMContentLoaded
245
+ // IIFE plus the stx:navigate event cover hard loads and every SPA route change
246
+ // without depending on a scope mount.
458
247
  ;(function () {
459
248
  if (typeof document === 'undefined') return
460
249
 
461
- // Highlight the sidebar link for the CURRENT route. Longest-prefix match, so a
250
+ function rows(pane) {
251
+ return pane.querySelectorAll('a[data-sidebar-item][href]')
252
+ }
253
+
254
+ // Highlight the sidebar row for the CURRENT route. Longest-prefix match, so a
462
255
  // nested/detail page (e.g. /commerce/products/42) still lights up its section
463
- // link (/commerce/products); the root link (/) only ever matches the home page.
256
+ // row (/commerce/products); the root row (/) only ever matches the home page.
464
257
  function syncActive(url) {
465
258
  var pane = document.querySelector('[data-stx-sidebar]')
466
259
  if (!pane) return
@@ -471,7 +264,7 @@ effect(() => {
471
264
  var cur = norm(path)
472
265
  var best = null
473
266
  var bestLen = -1
474
- var links = pane.querySelectorAll('a.sidebar-link[href]')
267
+ var links = rows(pane)
475
268
  for (var i = 0; i < links.length; i++) {
476
269
  var href = links[i].getAttribute('href') || ''
477
270
  if (href.indexOf('http') === 0) continue
@@ -483,51 +276,61 @@ effect(() => {
483
276
  bestLen = h.length
484
277
  }
485
278
  }
486
- var actives = pane.querySelectorAll('a.sidebar-link[data-active]')
487
- for (var j = 0; j < actives.length; j++) actives[j].removeAttribute('data-active')
488
- if (best) best.setAttribute('data-active', 'true')
279
+ var actives = pane.querySelectorAll('[data-sidebar-item][data-active="true"]')
280
+ for (var j = 0; j < actives.length; j++) {
281
+ actives[j].removeAttribute('data-active')
282
+ actives[j].removeAttribute('aria-current')
283
+ }
284
+ if (best) {
285
+ best.setAttribute('data-active', 'true')
286
+ best.setAttribute('aria-current', 'page')
287
+ }
489
288
  }
490
289
 
491
290
  function init() {
492
291
  var pane = document.querySelector('[data-stx-sidebar]')
493
292
  if (!pane) return
494
293
 
495
- // Restore per-section collapsed state persisted by the section toggle
496
- // (the inline onclick in renderSection writes localStorage['sidebar-collapsed']).
497
- try {
498
- var saved = JSON.parse(localStorage.getItem('sidebar-collapsed') || '{}')
499
- var sections = pane.querySelectorAll('.sidebar-section')
500
- for (var i = 0; i < sections.length; i++) {
501
- if (saved[sections[i].getAttribute('data-section')]) sections[i].classList.add('collapsed')
502
- }
503
- } catch (e) {}
504
-
505
294
  syncActive()
506
295
 
507
296
  // Bind route + click listeners once. This script can re-run on SPA fragment
508
297
  // swaps, so guard against stacking duplicate handlers on the persistent pane.
509
- if (!pane.__sidebarWired) {
510
- pane.__sidebarWired = true
298
+ if (pane.__sidebarWired) return
299
+ pane.__sidebarWired = true
300
+
301
+ // Follow EVERY route change, not just sidebar clicks. navigate() dispatches
302
+ // stx:navigate (content links, programmatic navigate, and popstate
303
+ // back/forward all route through it), so the highlight tracks the real
304
+ // route instead of only updating when a sidebar row is clicked.
305
+ window.addEventListener('stx:navigate', function (event) {
306
+ syncActive(event && event.detail && event.detail.url)
307
+ })
511
308
 
512
- // Follow EVERY route change, not just sidebar clicks. navigate() dispatches
513
- // stx:navigate (content links, programmatic navigate, and popstate
514
- // back/forward all route through it), so the highlight tracks the real
515
- // route instead of only updating when a sidebar item is clicked.
516
- window.addEventListener('stx:navigate', function (event) {
517
- syncActive(event && event.detail && event.detail.url)
518
- })
309
+ // SPA-navigate sidebar rows; fall back to full loads when the router isn't
310
+ // available or the target is external. The component already moves
311
+ // `data-active` on click, and the stx:navigate listener re-syncs after.
312
+ pane.addEventListener('click', function (event) {
313
+ var link = event.target.closest('a[data-sidebar-item]')
314
+ if (!link) return
315
+ // Disclosure chevrons live inside the row and only expand a subtree.
316
+ if (event.target.closest('[data-sidebar-disclosure]')) return
317
+ var href = link.getAttribute('href')
318
+ if (!href || href.indexOf('http') === 0 || typeof navigate !== 'function') return
319
+ event.preventDefault()
320
+ navigate(href)
321
+ })
519
322
 
520
- // SPA-navigate sidebar links; fall back to full loads when the router isn't
521
- // available or the target is external. The highlight is driven by the
522
- // stx:navigate listener above, so we don't toggle data-active here.
523
- pane.addEventListener('click', function (event) {
524
- var link = event.target.closest('a.sidebar-link')
525
- if (!link) return
526
- var href = link.getAttribute('href')
527
- if (!href || href.indexOf('http') === 0 || typeof navigate !== 'function') return
528
- event.preventDefault()
529
- navigate(href)
530
- })
323
+ // Craft native sidebar selection. When a Craft build installs a real
324
+ // NSOutlineView, it reports the selected row's id; resolve that back to a
325
+ // route from the rendered markup rather than a hand-maintained id-to-URL
326
+ // table, so the two can never drift apart.
327
+ window.craft = window.craft || {}
328
+ window.craft._sidebarSelectHandler = function (event) {
329
+ var id = event && event.itemId
330
+ if (!id) return
331
+ var row = pane.querySelector('[data-sidebar-row][data-item-id="' + id + '"] a[data-sidebar-item][href]')
332
+ var href = row && row.getAttribute('href')
333
+ if (href) navigate(href)
531
334
  }
532
335
  }
533
336
 
@@ -536,7 +339,7 @@ effect(() => {
536
339
  })();
537
340
  </script>
538
341
 
539
- <main data-stx-content style="margin-left: 250px">
342
+ <main data-stx-content>
540
343
  @slot('content')
541
344
  </main>
542
345