Skip to content

Commit 34d66f8

Browse files
fix(dashboard): fix buddy dev --dashboard white screen (sidebar ENOENT + Craft HTTPS cert) (#2191)
* fix(dashboard): own the sidebar chrome so dev dashboard stops white-screening `buddy dev --dashboard` rendered a blank/white window because the global dashboard layout resolved `<Sidebar>` from @stacksjs/components, and 0.2.9x stopped shipping tag-resolvable .stx sources (only hashed dist/ output). The component no longer resolved, the stx plugin fell back to a filesystem search, and every dashboard page fatal-errored with "ENOENT ... open 'sidebar'" (#1989). In the native Craft window that surfaced as a white screen. Render the sidebar from the framework's own buildSidebarChunks() HTML inside a plain <aside data-stx-sidebar>, styled with plain CSS in the layout's leading <style> block (stx serves raw layout CSS verbatim but does not expand @apply there). This drops the dependency on the external component for core nav chrome, so the dashboard renders regardless of which @stacksjs/components version is installed. Role gating still works via the data-required-roles the markup emits; section collapse, SPA nav, and active highlight are wired against the .sidebar-link markup. Refs #1989 * fix(dashboard): load the native Craft window over http, not the https pretty URL Craft's WKWebView rejects the tlsx local-CA cert ("The certificate for this server is invalid") because Craft installs no didReceiveAuthenticationChallenge handler, so opening https://dashboard.stacks.localhost fails the provisional navigation and the window stays blank / flashes then clears. Verified directly: a WKWebView snapshot over the https URL fails with that error, while the same webview renders the dashboard perfectly over http://localhost (both normal and the window's transparent titlebarHidden mode). Point the native window at the http localhost origin, which needs no cert. The browser-facing "Local" URL stays https for anyone opening it in a real browser. Refs #1989 #2191
1 parent 368314e commit 34d66f8

2 files changed

Lines changed: 203 additions & 64 deletions

File tree

storage/framework/core/actions/src/dev/dashboard.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,15 @@ const initialUrl = dashboardHttpsUrl && proxyStarted
571571
? `${dashboardHttpsUrl}/`
572572
: `${dashboardLocalUrl}/`
573573

574+
// The native Craft window loads over http://localhost, NOT the pretty HTTPS URL
575+
// shown above. Craft's WKWebView rejects the tlsx local-CA cert ("The
576+
// certificate for this server is invalid") because Craft installs no
577+
// didReceiveAuthenticationChallenge handler to trust it, so the HTTPS URL fails
578+
// to load and the window goes blank / flashes then clears. Plain http on
579+
// loopback needs no cert and renders identically in the webview. The browser
580+
// "Local" URL above stays HTTPS for anyone opening it in a real browser.
581+
const nativeWindowUrl = `${dashboardLocalUrl}/`
582+
574583
// Print vite-style output
575584
const elapsedMs = (Bun.nanoseconds() - startTime) / 1_000_000
576585

@@ -729,7 +738,7 @@ if (createApp) {
729738
}
730739

731740
const app = createApp!({
732-
url: initialUrl,
741+
url: nativeWindowUrl,
733742
quiet: !verbose,
734743
...(craftBinaryPath && { craftPath: craftBinaryPath }),
735744
window: {

storage/framework/defaults/views/dashboard/layouts/default.stx

Lines changed: 193 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
<script server>
2-
// macOS-style web sidebar (@stacksjs/components' <Sidebar theme="macos">).
3-
// Sections come from the discovered-models manifest + config/dashboard.ts
4-
// toggles — the same data that used to drive the native NSOutlineView.
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 @apply <style> block at the bottom of this file. Sections come
5+
// from the discovered-models manifest + config/dashboard.ts toggles.
56
// Project-root-anchored (`~/`), not relative. The stx client bundler resolves a
67
// relative import against the PAGE that extends this layout, not against this
78
// file, so `../../..` silently pointed somewhere different for every page depth
89
// and the bundle failed for any page more than one level under views/dashboard.
9-
import { buildWebSidebarSections } from '~/storage/framework/defaults/resources/functions/dashboard/sidebar'
10+
import { buildSidebarChunks } from '~/storage/framework/defaults/resources/functions/dashboard/sidebar'
1011

11-
export const sidebarSections = buildWebSidebarSections()
12-
13-
// itemId → roles, for the client-side role filter below.
14-
export const sidebarRoleAttrs = JSON.stringify(
15-
sidebarSections.flatMap(section => section.items
16-
.filter(item => item.roles && item.roles.length > 0)
17-
.map(item => ({ id: item.id, roles: item.roles }))),
18-
)
12+
// Pre-rendered, self-contained sidebar HTML (home link, section nav, settings
13+
// link), built server-side and interpolated raw below. This intentionally does
14+
// NOT depend on `@stacksjs/components`' <Sidebar>: 0.2.9x stopped shipping
15+
// tag-resolvable .stx sources (only hashed dist/ output), so the component
16+
// fatal-errored the whole dashboard with an ENOENT (stacksjs/stacks#1989). Each
17+
// rendered <a> already carries data-required-roles, which the applyRoleFilter
18+
// effect below reads directly for role gating.
19+
export const sidebarChunks = buildSidebarChunks()
1920
</script>
2021

2122
<style>
@@ -31,6 +32,155 @@ canvas[id$="_chart"] {
3132
width: 100% !important;
3233
height: 100% !important;
3334
}
35+
36+
/*
37+
* Dashboard body + sidebar. Plain CSS on purpose: stx serves a layout's raw
38+
* style rules verbatim, but it does NOT expand Crosswind @apply here, so these
39+
* are hand-written. Class names match the markup built in
40+
* resources/functions/dashboard/sidebar.ts. Fixed 250px pane: Home pinned top,
41+
* sections scroll in the middle, Settings pinned bottom.
42+
*/
43+
body {
44+
background: #f4f4f5;
45+
}
46+
.dark body {
47+
background: #0a0a0a;
48+
}
49+
.dashboard-sidebar {
50+
position: fixed;
51+
inset: 0 auto 0 0;
52+
width: 250px;
53+
display: flex;
54+
flex-direction: column;
55+
padding: 0 12px 10px;
56+
overflow: hidden;
57+
background: #fff;
58+
border-right: 1px solid rgba(0, 0, 0, 0.08);
59+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
60+
-webkit-font-smoothing: antialiased;
61+
z-index: 40;
62+
}
63+
.sidebar-header {
64+
flex-shrink: 0;
65+
height: 38px;
66+
}
67+
.sidebar-nav {
68+
flex: 1 1 auto;
69+
min-height: 0;
70+
overflow-y: auto;
71+
scrollbar-width: none;
72+
}
73+
.sidebar-nav::-webkit-scrollbar {
74+
width: 0;
75+
}
76+
.sidebar-link {
77+
display: flex;
78+
align-items: center;
79+
gap: 10px;
80+
flex-shrink: 0;
81+
height: 30px;
82+
padding: 0 10px;
83+
border-radius: 6px;
84+
color: #3a3a3c;
85+
font-size: 13px;
86+
font-weight: 450;
87+
text-decoration: none;
88+
white-space: nowrap;
89+
overflow: hidden;
90+
text-overflow: ellipsis;
91+
transition: background-color 0.12s ease, color 0.12s ease;
92+
}
93+
.sidebar-link:hover {
94+
background: rgba(0, 0, 0, 0.05);
95+
}
96+
.sidebar-link[data-active="true"] {
97+
background: #007aff;
98+
color: #fff;
99+
}
100+
.sidebar-link-home {
101+
font-weight: 550;
102+
}
103+
.sidebar-nav .sidebar-link {
104+
margin-top: 1px;
105+
}
106+
.sidebar-icon {
107+
display: inline-flex;
108+
align-items: center;
109+
justify-content: center;
110+
flex-shrink: 0;
111+
width: 18px;
112+
height: 18px;
113+
opacity: 0.82;
114+
}
115+
.sidebar-icon svg {
116+
width: 18px;
117+
height: 18px;
118+
}
119+
.sidebar-link[data-active="true"] .sidebar-icon {
120+
opacity: 1;
121+
}
122+
.sidebar-section {
123+
margin-top: 12px;
124+
}
125+
.sidebar-section-label {
126+
display: flex;
127+
align-items: center;
128+
justify-content: space-between;
129+
width: 100%;
130+
height: 22px;
131+
padding: 0 10px;
132+
background: none;
133+
border: 0;
134+
cursor: pointer;
135+
color: #8a8a8e;
136+
font-size: 11px;
137+
font-weight: 600;
138+
letter-spacing: 0.03em;
139+
text-transform: uppercase;
140+
}
141+
.sidebar-section-label:hover {
142+
color: #6a6a6e;
143+
}
144+
.sidebar-section-list {
145+
list-style: none;
146+
margin: 2px 0 0;
147+
padding: 0;
148+
}
149+
.sidebar-section.collapsed .sidebar-section-list {
150+
display: none;
151+
}
152+
.sidebar-chevron {
153+
width: 7px;
154+
height: 7px;
155+
border-right: 1.5px solid currentColor;
156+
border-bottom: 1.5px solid currentColor;
157+
transform: rotate(45deg);
158+
transition: transform 0.15s ease;
159+
opacity: 0.7;
160+
}
161+
.sidebar-section.collapsed .sidebar-chevron {
162+
transform: rotate(-45deg);
163+
}
164+
.dark .dashboard-sidebar {
165+
background: #1c1c1e;
166+
border-right-color: rgba(255, 255, 255, 0.08);
167+
}
168+
.dark .sidebar-link {
169+
color: #d1d1d6;
170+
}
171+
.dark .sidebar-link:hover {
172+
background: rgba(255, 255, 255, 0.06);
173+
}
174+
.dark .sidebar-link[data-active="true"] {
175+
background: #0a84ff;
176+
color: #fff;
177+
}
178+
.dark .sidebar-section-label {
179+
color: #8d8d92;
180+
}
181+
.dark .sidebar-section-label:hover {
182+
color: #aeaeb2;
183+
}
34184
</style>
35185

36186
<script>
@@ -258,66 +408,52 @@ effect(() => {
258408
})
259409
</script>
260410

261-
<Sidebar
262-
:sections="sidebarSections"
263-
theme="macos"
264-
width="250"
265-
collapseMode="hidden"
266-
shellSelector="[data-stx-shell]"
267-
persistKey="dashboard-sidebar-collapsed"
268-
>
269-
<template #header>
270-
<!-- Traffic lights are native (titlebarHidden window) — this strip only
271-
reserves their space and acts as the window drag region. -->
272-
<SidebarHeader :showWindowControls="false" />
273-
</template>
274-
</Sidebar>
275-
276-
<!-- Role map for the wiring script below; interpolated server-side. The div is
277-
hidden and holds JSON as text, because a JSON-typed script tag would get
278-
rewritten by the layout script processor (see the importmap comment above).
279-
280-
Do not name that tag literally here. The script processor rewrites script
281-
tags inside comments too, and eating this comment's closing marker turns
282-
everything below into comment text, blanking the page. -->
283-
<div data-sidebar-roles hidden>{{ sidebarRoleAttrs }}</div>
411+
<aside data-stx-sidebar class="dashboard-sidebar" role="navigation" aria-label="Sidebar">
412+
<!-- Traffic lights are native (titlebarHidden window) — this strip reserves
413+
their space at the top of the pane so the first nav row clears them. -->
414+
<div class="sidebar-header"></div>
415+
{{{ sidebarChunks.top }}}
416+
<nav class="sidebar-nav">{{{ sidebarChunks.nav }}}</nav>
417+
{{{ sidebarChunks.bottom }}}
418+
</aside>
284419

285420
<script client>
286-
// Web sidebar wiring: SPA navigation, initial selection, role gating.
421+
// Web sidebar wiring: collapse restore, active highlight, SPA navigation.
422+
// Role gating is handled by the applyRoleFilter effect above, which reads the
423+
// data-required-roles attributes the server emits directly on each link.
287424
onMount(() => {
288-
const roleNode = document.querySelector('div[data-sidebar-roles]')
289-
let sidebarRoleList = []
290-
try { sidebarRoleList = JSON.parse(roleNode?.textContent || '[]') } catch {}
291-
292425
const pane = document.querySelector('[data-stx-sidebar]')
293426
if (!pane) return
294427

295-
// Role-gate rows (picked up by the applyRoleFilter effect above).
296-
for (const entry of sidebarRoleList) {
297-
const row = pane.querySelector(`[data-sidebar-row][data-item-id="${entry.id}"]`)
298-
if (row) row.setAttribute('data-required-roles', entry.roles.join(','))
299-
}
428+
// Restore per-section collapsed state persisted by the section toggle
429+
// (the inline onclick in renderSection writes localStorage['sidebar-collapsed']).
430+
try {
431+
const saved = JSON.parse(localStorage.getItem('sidebar-collapsed') || '{}')
432+
for (const section of pane.querySelectorAll('.sidebar-section')) {
433+
if (saved[section.getAttribute('data-section')]) section.classList.add('collapsed')
434+
}
435+
} catch {}
300436

301-
// Highlight the row for the current route on hard loads.
302-
const activeClasses = (pane.dataset.activeClass || '').split(' ').filter(Boolean)
303-
const current = pane.querySelector(`[data-sidebar-item][href="${window.location.pathname}"]`)
304-
if (current && !pane.querySelector('[data-sidebar-item][data-active="true"]')) {
305-
current.setAttribute('data-active', 'true')
306-
current.classList.add(...activeClasses)
307-
}
437+
// Highlight the link for the current route on hard loads.
438+
const current = pane.querySelector(`a.sidebar-link[href="${window.location.pathname}"]`)
439+
if (current) current.setAttribute('data-active', 'true')
308440

309441
// SPA-navigate sidebar links; fall back to full loads when the router
310-
// isn't available.
442+
// isn't available or the target is external.
311443
pane.addEventListener('click', (event) => {
312-
const link = event.target.closest('a[data-sidebar-item]')
313-
if (!link || typeof navigate !== 'function') return
444+
const link = event.target.closest('a.sidebar-link')
445+
if (!link) return
446+
const href = link.getAttribute('href')
447+
if (!href || href.startsWith('http') || typeof navigate !== 'function') return
314448
event.preventDefault()
315-
navigate(link.getAttribute('href'))
449+
for (const a of pane.querySelectorAll('a.sidebar-link[data-active]')) a.removeAttribute('data-active')
450+
link.setAttribute('data-active', 'true')
451+
navigate(href)
316452
})
317453
})
318454
</script>
319455

320-
<main data-stx-content data-stx-shell style="margin-left: var(--stx-sidebar-width, 250px); transition: margin-left 0.3s ease-out">
456+
<main data-stx-content style="margin-left: 250px">
321457
@slot('content')
322458
</main>
323459

@@ -344,9 +480,3 @@ onMount(() => {
344480
</div>
345481
@endforeach
346482
</div>
347-
348-
<style>
349-
body {
350-
@apply bg-neutral-100 dark:bg-neutral-950;
351-
}
352-
</style>

0 commit comments

Comments
 (0)