fix(ui+seed): FOUC eliminated + persistent dev-user
Seed: - DevUserSeeder: idempotent — creates workspace + WorkspaceMember + sets default_workspace_id. Auto-recreates everything on migrate:fresh --seed. - DatabaseSeeder: PlanSeeder first (nexxo needs a plan), then DevUserSeeder always, DemoDataSeeder only in local/dev env. - DevUserSeederTest: create + idempotent + in-DatabaseSeeder + order check. - Removed WithoutModelEvents trait from DatabaseSeeder (was suppressing model observers needed for workspace creation). UI (nimuli-app.blade.php): - Inline JS theme-init before first paint: reads localStorage nimuli_theme, falls back to server-side user preference. Eliminates theme flash on reload. - Critical CSS in <head>: CSS vars, body opacity 0 → 1 fade (120ms), skeleton sidebar + topbar visible during JS load. - app-spinner shown until app-ready class added by markReady(). - Skeleton + spinner hidden via body.app-ready CSS selector. - livewire:navigated event re-triggers markReady() for SPA navigation. - x-cloak on mobile sidebar overlay to prevent pre-Alpine flash. Bash aliases added to ~/.bashrc: - seed-dev → db:seed --class=DevUserSeeder - migrate-fresh → migrate:fresh --seed - reset-db → migrate:fresh --seed (with confirmation message) Tests: 109/109 passing. Smoke: all routes ✅. DB sanity ✅. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
5748dd3443
commit
ba67f67d60
|
|
@ -2,20 +2,21 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
PlanSeeder::class,
|
||||
DevUserSeeder::class,
|
||||
]);
|
||||
|
||||
if (app()->environment(['local', 'dev'])) {
|
||||
$this->call([
|
||||
DemoDataSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Subscription\Models\Plan;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
|
@ -10,7 +13,7 @@ class DevUserSeeder extends Seeder
|
|||
{
|
||||
public function run(): void
|
||||
{
|
||||
User::updateOrCreate(
|
||||
$user = User::updateOrCreate(
|
||||
['email' => 'nexxo@nimuli.com'],
|
||||
[
|
||||
'name' => 'Nexxo Admin',
|
||||
|
|
@ -20,6 +23,31 @@ class DevUserSeeder extends Seeder
|
|||
'theme' => 'dark',
|
||||
]
|
||||
);
|
||||
$this->command->info('Dev-User: nexxo@nimuli.com / NimuliDev2026!');
|
||||
|
||||
$plan = Plan::firstWhere('name', 'free') ?? Plan::first();
|
||||
|
||||
$workspace = Workspace::firstOrCreate(
|
||||
['owner_id' => $user->id, 'slug' => 'nexxo'],
|
||||
[
|
||||
'name' => 'Nexxo Workspace',
|
||||
'plan_id' => $plan?->id,
|
||||
]
|
||||
);
|
||||
|
||||
WorkspaceMember::firstOrCreate(
|
||||
['workspace_id' => $workspace->id, 'user_id' => $user->id],
|
||||
['role' => 'owner', 'joined_at' => now()]
|
||||
);
|
||||
|
||||
if ($user->default_workspace_id !== $workspace->id) {
|
||||
$user->updateQuietly(['default_workspace_id' => $workspace->id]);
|
||||
}
|
||||
|
||||
$this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->command->info(' Dev-User bereit:');
|
||||
$this->command->info(' Email: nexxo@nimuli.com');
|
||||
$this->command->info(' Password: NimuliDev2026!');
|
||||
$this->command->info(' Workspace: '.$workspace->ulid);
|
||||
$this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,6 @@
|
|||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ ($title ?? '') ? $title . ' — Nimuli' : 'Nimuli' }}</title>
|
||||
|
||||
<!-- Prevent FOUC: inline critical theme vars -->
|
||||
<style>
|
||||
html { background: #0a0a0f; color: #f0f0f5; }
|
||||
[data-theme="light"] { background: #ffffff; color: #111827; }
|
||||
</style>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
|
|
@ -26,11 +15,89 @@
|
|||
<meta name="theme-color" content="#0a0a0f" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
|
||||
<!-- Theme-init: runs synchronously before any paint — eliminates FOUC -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('nimuli_theme') || '{{ auth()->user()?->theme ?? 'dark' }}';
|
||||
var theme = saved === 'system'
|
||||
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: saved;
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Critical CSS: paint baseline before Vite/Tailwind loads -->
|
||||
<style>
|
||||
:root {
|
||||
--c-bg: #0a0a0f; --c-s1: #111117; --c-s2: #16161e;
|
||||
--c-b1: rgba(255,255,255,0.08); --c-accent: #7c3aed;
|
||||
--c-t1: #f0f0f5; --c-t2: #8888a0;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--c-bg: #ffffff; --c-s1: #ffffff; --c-s2: #f9fafb;
|
||||
--c-b1: #e5e7eb; --c-accent: #7c3aed;
|
||||
--c-t1: #111827; --c-t2: #6b7280;
|
||||
}
|
||||
html { background: var(--c-bg); }
|
||||
body {
|
||||
margin: 0; padding: 0;
|
||||
background: var(--c-bg); color: var(--c-t1);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease-in;
|
||||
min-height: 100vh;
|
||||
}
|
||||
body.app-ready { opacity: 1; }
|
||||
/* Skeleton placeholders visible during JS load */
|
||||
.skel-sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
width: 240px; background: var(--c-s1);
|
||||
border-right: 1px solid var(--c-b1); z-index: 1;
|
||||
}
|
||||
.skel-topbar {
|
||||
position: fixed; top: 0; left: 240px; right: 0;
|
||||
height: 57px; background: var(--c-s1);
|
||||
border-bottom: 1px solid var(--c-b1); z-index: 1;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.skel-sidebar { display: none; }
|
||||
.skel-topbar { left: 0; }
|
||||
}
|
||||
/* Spinner: visible before app-ready, hidden after */
|
||||
.app-spinner {
|
||||
position: fixed; top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 28px; height: 28px;
|
||||
border: 2.5px solid var(--c-b1);
|
||||
border-top-color: var(--c-accent);
|
||||
border-radius: 50%;
|
||||
animation: nim-spin 0.55s linear infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
body.app-ready .app-spinner,
|
||||
body.app-ready .skel-sidebar,
|
||||
body.app-ready .skel-topbar { display: none; }
|
||||
@keyframes nim-spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
@vite(['resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-bg text-t1 font-sans antialiased" x-data>
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<body>
|
||||
<!-- Skeleton: visible during JS load to prevent layout flash -->
|
||||
<div class="skel-sidebar" aria-hidden="true"></div>
|
||||
<div class="skel-topbar" aria-hidden="true"></div>
|
||||
<div class="app-spinner" aria-hidden="true"></div>
|
||||
|
||||
<div class="bg-bg text-t1 font-sans antialiased flex h-screen overflow-hidden">
|
||||
<!-- Mobile Sidebar Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 z-20 bg-black/60 md:hidden"
|
||||
|
|
@ -43,6 +110,7 @@
|
|||
x-transition:leave-end="opacity-0"
|
||||
@click="$store.sidebar.open = false"
|
||||
aria-hidden="true"
|
||||
x-cloak
|
||||
></div>
|
||||
|
||||
<!-- Sidebar: persisted across wire:navigate to prevent re-render -->
|
||||
|
|
@ -66,6 +134,21 @@
|
|||
</div>
|
||||
|
||||
<x-modal-container />
|
||||
|
||||
@livewireScripts
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function markReady() { document.body.classList.add('app-ready'); }
|
||||
if (document.readyState === 'complete') {
|
||||
markReady();
|
||||
} else {
|
||||
window.addEventListener('load', markReady);
|
||||
setTimeout(markReady, 600);
|
||||
}
|
||||
// Re-apply on wire:navigate (SPA navigation)
|
||||
document.addEventListener('livewire:navigated', markReady);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DevUserSeeder;
|
||||
use Database\Seeders\PlanSeeder;
|
||||
|
||||
it('creates nexxo user with workspace after plan seed', function () {
|
||||
$this->seed(PlanSeeder::class);
|
||||
$this->seed(DevUserSeeder::class);
|
||||
|
||||
$user = User::firstWhere('email', 'nexxo@nimuli.com');
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->default_workspace_id)->not->toBeNull();
|
||||
expect(Workspace::find($user->default_workspace_id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — running twice does not duplicate', function () {
|
||||
$this->seed(PlanSeeder::class);
|
||||
$this->seed(DevUserSeeder::class);
|
||||
$this->seed(DevUserSeeder::class);
|
||||
|
||||
expect(User::where('email', 'nexxo@nimuli.com')->count())->toBe(1);
|
||||
expect(Workspace::where('slug', 'nexxo')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('is registered in DatabaseSeeder', function () {
|
||||
$source = file_get_contents(database_path('seeders/DatabaseSeeder.php'));
|
||||
expect($source)->toContain('DevUserSeeder::class');
|
||||
});
|
||||
|
||||
it('PlanSeeder runs before DevUserSeeder in DatabaseSeeder', function () {
|
||||
$source = file_get_contents(database_path('seeders/DatabaseSeeder.php'));
|
||||
expect(strpos($source, 'PlanSeeder::class'))->toBeLessThan(strpos($source, 'DevUserSeeder::class'));
|
||||
});
|
||||
Loading…
Reference in New Issue