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
boban 2026-05-16 12:53:29 +02:00
parent 5748dd3443
commit ba67f67d60
4 changed files with 168 additions and 21 deletions

View File

@ -2,20 +2,21 @@
namespace Database\Seeders; namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void public function run(): void
{ {
$this->call([ $this->call([
PlanSeeder::class,
DevUserSeeder::class, DevUserSeeder::class,
]); ]);
if (app()->environment(['local', 'dev'])) {
$this->call([
DemoDataSeeder::class,
]);
}
} }
} }

View File

@ -2,6 +2,9 @@
namespace Database\Seeders; 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 App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -10,7 +13,7 @@ class DevUserSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
User::updateOrCreate( $user = User::updateOrCreate(
['email' => 'nexxo@nimuli.com'], ['email' => 'nexxo@nimuli.com'],
[ [
'name' => 'Nexxo Admin', 'name' => 'Nexxo Admin',
@ -20,6 +23,31 @@ class DevUserSeeder extends Seeder
'theme' => 'dark', '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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
} }
} }

View File

@ -6,17 +6,6 @@
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ ($title ?? '') ? $title . ' — Nimuli' : 'Nimuli' }}</title> <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 --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> <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="#0a0a0f" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> <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']) @vite(['resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
<body class="bg-bg text-t1 font-sans antialiased" x-data> <body>
<div class="flex h-screen overflow-hidden"> <!-- 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 --> <!-- Mobile Sidebar Overlay -->
<div <div
class="fixed inset-0 z-20 bg-black/60 md:hidden" class="fixed inset-0 z-20 bg-black/60 md:hidden"
@ -43,6 +110,7 @@
x-transition:leave-end="opacity-0" x-transition:leave-end="opacity-0"
@click="$store.sidebar.open = false" @click="$store.sidebar.open = false"
aria-hidden="true" aria-hidden="true"
x-cloak
></div> ></div>
<!-- Sidebar: persisted across wire:navigate to prevent re-render --> <!-- Sidebar: persisted across wire:navigate to prevent re-render -->
@ -66,6 +134,21 @@
</div> </div>
<x-modal-container /> <x-modal-container />
@livewireScripts @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> </body>
</html> </html>

View File

@ -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'));
});