User: 'workspace stays in URL only until tab change' + 'use random 25-char
string or UUID for workspace identifier'.
Migration:
- workspaces.public_id: varchar(25) unique nullable.
- Backfill existing rows with Str::random(25).
Workspace model:
- Auto-fill public_id on create via booted() with collision-check loop.
- Override getRouteKeyName() → 'public_id' so route-model-binding uses
the opaque public_id everywhere (not the guessable slug).
Routes:
- Authed routes wrapped in prefix 'w/{wsId}' → URL shape
/w/bPSyuQpMDI0bl0LBKlwVOLdsl/dashboard etc.
- Bare /dashboard /sites /servers etc. now redirect to current
workspace's prefixed URL (covers external bookmarks / first visit).
ResolveCurrentWorkspace middleware:
- Resolution priority: route param {wsId} → ?workspace query →
session sticky → user.current_workspace_id → first owned.
- On resolve: URL::defaults(['wsId' => public_id]) auto-injects param
into every route() call, so all Volt wire:navigate / @route()
helpers carry the workspace through the entire SPA session.
- forceFill + saveQuietly to avoid triggering observers on user.
Sidebar:
- Workspace-switch links now point to /w/{public_id}/dashboard (not
the slug ?workspace=…).
TestCase:
- URL::defaults({wsId: 'test-workspace'}) so route('dashboard') etc.
generates valid URLs in tests.
- actingAsWithWorkspace() helper for tests that need a real workspace
bound to a logged-in user.
Browser-verified: click Sidebar → Sites navigates to
/w/bPSyuQpMDI0bl0LBKlwVOLdsl/sites, public_id sticks across every
internal navigation. 36/36 Page-Pest still green.
User: no logout button + clicking workspace switcher in sidebar did nothing.
Sidebar component:
- Workspace switcher now wraps in Alpine x-data={ wsOpen: false }.
Click toggles dropdown, @click.outside + escape-key close it.
- Dropdown lists all workspaces the user can access (owned +
TeamMember memberships), shows active one with accent checkmark.
Fallback: if list is empty, shows just the current workspace so
users never see an empty switcher.
- '+ Neuer Workspace' row at bottom of workspaces list.
- User section: shows auth user's name (header) + email.
- 'Profil' link → route('profile').
- 'Einstellungen' link → route('settings.index').
- 'Abmelden' button: POST form with CSRF to route('logout'),
red-colored row.
- Workspace switch via ?workspace=<slug> query param — middleware
ResolveCurrentWorkspace picks it up, persists in session +
user.current_workspace_id, then page reloads with new scope.
- Sidebar nav title now reads workspace name dynamically.
- Plan-box footer reads real plan + sites/quota + has 'Upgrade →'
link to settings.
CSS additions: clu-ws-dropdown (white 98% bg + glass shadow, z-index
60 to sit above sticky topbar 20 + sidebar 30), clu-ws-section
(uppercase muted header), clu-ws-item (hover bg + active accent
soft), clu-ws-divider (1px hairline). [x-cloak] global rule prevents
FOUC.
Browser-verified: clicking AC avatar + 'Acme Agency PRO' panel
expands dropdown with 'Acme Agency PRO · 87 sites' (current,
checkmark), Neuer Workspace, ADMIN section with email + Profil +
Einstellungen + red Abmelden. 0 console errors. 36/36 Page tests
green.
User accepted 5-step plan: Workspace → Sub-Sidebar → Switcher → Plan/
Quota → Billing.
This step adds the workspace layer underneath everything:
- Workspace model with HasUuids + relations (sites/servers/clusters/
backups/members/invitations/activities) + plan-based quotas + feature
flags + isOverQuota() + hasFeature() helpers.
- Plan presets: free/starter/pro/agency/enterprise with sites,
servers, team, storage_gb, features map (audit/monitoring/
email_relay/multi_workspace/white_label/reports/sso).
- BelongsToWorkspace trait: global scope filters by app('current
Workspace'), creating-hook auto-fills workspace_id from container.
Falls back to no-scope when no workspace bound (tests, console).
- Migrations: workspaces table + workspace_id foreignUuid added to
all 10 domain tables (clusters/servers/sites/backups/team_members/
invitations/security_events/certificates/activities/crons).
- users gets current_workspace_id nullable FK.
- team_members unique changed to composite (workspace_id, user_id) so
one user can belong to multiple workspaces.
- ResolveCurrentWorkspace middleware: resolves active workspace from
?workspace=slug → session → user.current_workspace_id → first owned.
Wired into web middleware group via bootstrap/app.php.
- WorkspaceFactory + workspace_id added to all 10 domain factories
so tests/seed work without explicit workspace context.
- DatabaseSeeder rewritten: creates Marie Weber as owner of 'Acme
Agency' (Pro plan) workspace + 'Marie · Personal' (Free) workspace
for switcher demo. Binds app('currentWorkspace') so seeded sites/
servers/backups/etc. land in the right workspace.
- Sidebar component reads auth user's current workspace for the ws-
switch panel — no more hard-coded 'Acme Cluster' string.
Full Pest regression: 47/47 green. 2 workspaces seeded:
- Acme Agency (pro): 87 sites, 6 servers, 128 sec events, 102 backups
- Marie · Personal (free): 0 sites
Bug 1 (visual): .clu-toggle is a <span> which is inline by default;
width:36px height:20px were being ignored so toggles rendered as zero-
width inline bars. Added display:inline-block + vertical-align:middle.
Bug 2 (no state): toggle-rows in vhost / ssl / access / plugins /
redirects / staging tabs were pure static markup with no wire:click.
Clicking did nothing.
Fix: generic $settings array on the Volt component (one key per toggle)
+ toggleSetting(key) action + isOn(key) helper. All toggle-rows now
emit wire:click=toggleSetting('namespace.key') and read the live state
via @if($this->isOn('namespace.key')) on @endif.
Per-plugin auto-update toggles read settings['plugin.<slug>.auto'] so
each row flips independently.
Tabs covered: vHost (server_tokens, http2_push), SSL (ocsp/auto_renew/
caa_check/ct_log), SSH-FTP access policy (password_auth/no_root/
fail2ban/ip_allowlist/ssh_2fa), Plugins (per-plugin auto + 5 policy
toggles), Redirects (keep_qs/case_insens), Staging (robots_block/
email_sandbox/payments_test).
Browser-verified: clicking Yoast SEO auto-update toggle flips from
blue ON to grey OFF instantly via Livewire. 0 console errors. 5/5
SiteDetailsTest still green.
Bug: When .clu-sidebar uses position:fixed it's removed from grid flow,
so the only remaining grid child (main) gets auto-placed into column 1
(the 264px reserved column), making main render in a tiny strip with
sidebar floating on top — exactly what the user screenshot showed.
Fix: Drop grid-template-columns. Use .clu-app-grid { padding-left: 264px }
so the fixed sidebar sits in the reserved gutter and main becomes a
plain block that fills the remaining width.
Mobile (≤920px): padding-left reset to 0 since sidebar slides in as
overlay drawer instead of occupying space.
User reported sidebar scrolling with content + missing bottom padding +
unresponsive site details/header.
Sidebar:
- Changed from position:sticky (which moved with grid context) to
position:fixed top:14 left:14 width:236 height:calc(100vh-28px).
Sidebar now genuinely never moves with scroll.
- Grid template adjusted to 264px first column to reserve the space the
fixed sidebar occupies (236 + 14 margin + 14 gap).
Main:
- layouts/app.blade.php + placeholder.blade.php padding bumped from
'14px 14px 0' to '14px' all-around — content now has equal bottom
breathing room.
Mobile (≤920px):
- Sidebar already had drawer behaviour, kept.
- Topbar: flex-wrap, smaller padding (10/12), shrunk title/sub typography,
search takes full row at order:99.
- Site-hero (site details): flex:1 1 100% forces title onto its own row,
smaller favicon (34px) + smaller h1 (15px) + flex-wrap sub-line.
- Status chip in topbar hidden on mobile (was clipping behind site title).
- clu-dtabs (6-tab bar): wrap with smaller pad/font so all 6 fit on 420px.
- File manager: tree+editor stack vertically; tree max-height 280px,
editor 460px.
- Inline two-col grids (Installation/Schnellaktionen, Sites+Activity,
WP+Salts, OPcache+Extensions) force to single column.
User-reported issues + browser-verified:
1. **Chart.js race condition** — inline @push('scripts') ran before
the @vite type=module script that imports Chart. Wrapped Chart init
in a poll loop that retries every 30ms until window.Chart resolves.
Re-fires on livewire:navigated for SPA navigation.
2. **Hamburger always visible on desktop** — .clu-icon-btn defined
display:grid AFTER .clu-burger {display:none}. Chained selector
'.clu-burger.clu-icon-btn' (specificity 0,2,0) now wins both at
default and inside the <920px media query.
3. **Modal in CluPilot glass style** —
- app/Livewire/Modals/ConfirmDelete.php (class extends ModalComponent)
with title/message/confirmLabel/confirmEvent/payload props,
confirm() dispatches the named event + closes the modal.
- resources/views/livewire/modals/confirm-delete.blade.php
renders clu-modal-card with icon + title/message head + foot
containing cancel (clu-ghost-btn) + delete (clu-btn-primary
clu-btn-danger with red gradient).
- CSS overrides for the wire-elements default Tailwind v2 markup:
bg-gray-500 backdrop dimmer, transparent modal-container, z-index
bumped to 100 so it sits above sticky topbar + sidebar.
- @source hints in app.css so Tailwind v4 scans
vendor/wire-elements/modal blade templates and generates the
sm:inline-block / sm:h-screen utilities the modal needs to center.
- Demo: Settings → Danger Zone → 'Workspace löschen' opens modal.
4. **AppServiceProvider Vite::usePreloadTagAttributes(false)** kept.
5. Browser console clean across dashboard / sites / sites/{id} /
settings + open modal (verified via Chrome MCP).
Sidebar layout (per user request: header+footer fix, only nav scrollable):
- Restructured clu-sidebar into 3 flex-children: clu-sidebar-head (brand +
ws-switch fixed at top), clu-sidebar-scroll (nav, overflow-y:auto in
middle), clu-sidebar-foot (plan-box fixed at bottom).
Mobile sidebar (per user request: drawer + hamburger):
- <920px: sidebar position fixed, translateX(-110%) hidden, .open class
slides in. clu-sidebar-backdrop overlay dimms main + click-to-close.
- New x-clu.burger component with hamburger icon, visible only <920px
via display:grid in media query. Injected into all 9 topbars.
- Alpine state sidebarOpen on <body>, nav-item click closes drawer
after navigation.
Linking (per user request: sites detail unreachable):
- Sites index table + dashboard top-sites table: domain cell now <a>
to route('sites.show', $site) with wire:navigate.
Charts (per user request: chart.js):
- composer/npm: chart.js ^4.5.
- resources/js/app.js: register all chart.js plugins, expose window.Chart
for inline page scripts.
- Dashboard: added Traffic-24h line chart (Chart.js line+gradient fill)
between metrics and 2-col row. Init re-runs on livewire:navigated for
wire:navigate compatibility.
Modal (per user request: wire-elements):
- @livewire('wire-elements-modal') already in layouts/app.blade.php
master layout per wire-elements/modal v3 docs (single mount). Open
pattern: wire:click="$dispatch('openModal', { component: 'name' })".
Full Pest regression: 47/47 green.
- app.css: CSS quote chars in .clu-quote-text::before/::after were
literal smart quotes which broke as terminating string delimiters in
the tailwind v4 parser. Use unicode escapes \201E and \201C.
- tests/TestCase.php: force DB_CONNECTION=sqlite + :memory: in setUp.
phpunit.xml env section was being ignored by pest 3 runner, so
RefreshDatabase truncated the real MySQL between turns.
- .env.testing for clarity (sqlite memory).