diff --git a/app/Livewire/Dashboard/Index.php b/app/Livewire/Dashboard/Index.php
index 88dd339..0f08fd8 100644
--- a/app/Livewire/Dashboard/Index.php
+++ b/app/Livewire/Dashboard/Index.php
@@ -14,7 +14,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 5]);
+ return view('livewire.dashboard.placeholder');
}
diff --git a/app/Livewire/Notifications/Index.php b/app/Livewire/Notifications/Index.php
index 3dbff69..353518e 100644
--- a/app/Livewire/Notifications/Index.php
+++ b/app/Livewire/Notifications/Index.php
@@ -15,7 +15,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 4]);
+ return view('livewire.notifications.placeholder');
}
diff --git a/app/Livewire/PhotoHelp/Index.php b/app/Livewire/PhotoHelp/Index.php
index d524d61..0b22fee 100644
--- a/app/Livewire/PhotoHelp/Index.php
+++ b/app/Livewire/PhotoHelp/Index.php
@@ -13,7 +13,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 3]);
+ return view('livewire.photo-help.placeholder');
}
public function render(): View
diff --git a/app/Livewire/Progress/Index.php b/app/Livewire/Progress/Index.php
index a2aa60b..3d8443e 100644
--- a/app/Livewire/Progress/Index.php
+++ b/app/Livewire/Progress/Index.php
@@ -13,7 +13,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 4]);
+ return view('livewire.progress.placeholder');
}
public function render(): View
diff --git a/app/Livewire/Rewards/Index.php b/app/Livewire/Rewards/Index.php
index 67bdd0f..a2db27c 100644
--- a/app/Livewire/Rewards/Index.php
+++ b/app/Livewire/Rewards/Index.php
@@ -13,7 +13,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 4]);
+ return view('livewire.rewards.placeholder');
}
public function render(): View
diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php
index b5f5862..afc0a18 100644
--- a/app/Livewire/Settings/Index.php
+++ b/app/Livewire/Settings/Index.php
@@ -14,7 +14,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 4]);
+ return view('livewire.settings.placeholder');
}
diff --git a/app/Livewire/Subjects/Index.php b/app/Livewire/Subjects/Index.php
index 1afc2eb..e003b0d 100644
--- a/app/Livewire/Subjects/Index.php
+++ b/app/Livewire/Subjects/Index.php
@@ -13,7 +13,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 4]);
+ return view('livewire.subjects.placeholder');
}
public function render(): View
diff --git a/app/Livewire/Tasks/Index.php b/app/Livewire/Tasks/Index.php
index 6b6c35d..bcc32b2 100644
--- a/app/Livewire/Tasks/Index.php
+++ b/app/Livewire/Tasks/Index.php
@@ -15,7 +15,7 @@ class Index extends Component
{
public static function placeholder(array $params = []): View
{
- return view('components.skeleton.page', ['rows' => 5]);
+ return view('livewire.tasks.placeholder');
}
@@ -54,10 +54,15 @@ class Index extends Component
}
// Future: redirect to a real lesson runner. For now open detail modal.
- // Must target wire-elements modal component explicitly — Livewire 4
- // server-side dispatch does not auto-broadcast to other components.
- $this->dispatch('openModal', component: 'tasks.modals.detail', arguments: ['task' => $task])
- ->to('livewire-ui-modal');
+ // Use $this->js() to emit a browser-side Livewire dispatch — server-side
+ // $this->dispatch()->to(...) in Livewire 4 + wire-elements drops named
+ // payload args, causing "$component dependency unresolved" in Modal::openModal.
+ $payload = json_encode([
+ 'component' => 'tasks.modals.detail',
+ 'arguments' => ['task' => $task],
+ ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+
+ $this->js("Livewire.dispatch('openModal', {$payload})");
}
public function repeatTask(string $key): void
diff --git a/resources/views/components/skeleton/card.blade.php b/resources/views/components/skeleton/card.blade.php
new file mode 100644
index 0000000..5529599
--- /dev/null
+++ b/resources/views/components/skeleton/card.blade.php
@@ -0,0 +1,16 @@
+@props([
+ 'lines' => 3,
+ 'header' => true,
+])
+
+
merge(['class' => 'bg-bg-soft border border-line rounded-xl p-5 shadow-sm animate-pulse flex flex-col gap-3']) }}>
+ @if($header)
+
+ @endif
+ @for($i = 0; $i < (int) $lines; $i++)
+
+ @endfor
+
diff --git a/resources/views/components/skeleton/header.blade.php b/resources/views/components/skeleton/header.blade.php
new file mode 100644
index 0000000..cc8b7e9
--- /dev/null
+++ b/resources/views/components/skeleton/header.blade.php
@@ -0,0 +1,14 @@
+@props([
+ 'cta' => true,
+])
+
+merge(['class' => 'flex justify-between items-end gap-6 animate-pulse']) }}>
+
+ @if($cta)
+
+ @endif
+
diff --git a/resources/views/components/skeleton/stat-tile.blade.php b/resources/views/components/skeleton/stat-tile.blade.php
new file mode 100644
index 0000000..b7bea24
--- /dev/null
+++ b/resources/views/components/skeleton/stat-tile.blade.php
@@ -0,0 +1,5 @@
+merge(['class' => 'bg-bg-soft border border-line rounded-xl p-4 shadow-sm animate-pulse']) }}>
+
+
+
+
diff --git a/resources/views/components/skeleton/subject-card.blade.php b/resources/views/components/skeleton/subject-card.blade.php
new file mode 100644
index 0000000..3c095e9
--- /dev/null
+++ b/resources/views/components/skeleton/subject-card.blade.php
@@ -0,0 +1,10 @@
+merge(['class' => 'bg-bg-soft border border-line rounded-xl p-4 shadow-sm animate-pulse']) }}>
+
+
+
+
+
+
diff --git a/resources/views/components/skeleton/table-row.blade.php b/resources/views/components/skeleton/table-row.blade.php
new file mode 100644
index 0000000..38a838e
--- /dev/null
+++ b/resources/views/components/skeleton/table-row.blade.php
@@ -0,0 +1,11 @@
+merge(['class' => 'grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0 animate-pulse']) }}>
+
+
+
+
diff --git a/resources/views/livewire/dashboard/placeholder.blade.php b/resources/views/livewire/dashboard/placeholder.blade.php
new file mode 100644
index 0000000..7b5752b
--- /dev/null
+++ b/resources/views/livewire/dashboard/placeholder.blade.php
@@ -0,0 +1,22 @@
+
+
+
+
+ @for($i = 0; $i < 4; $i++)@endfor
+
+
+
+
+
+ @for($i = 0; $i < 4; $i++)@endfor
+
+
+
+
+
diff --git a/resources/views/livewire/notifications/placeholder.blade.php b/resources/views/livewire/notifications/placeholder.blade.php
new file mode 100644
index 0000000..0e6d293
--- /dev/null
+++ b/resources/views/livewire/notifications/placeholder.blade.php
@@ -0,0 +1,13 @@
+
+
+
+
+ @for($i = 0; $i < 2; $i++)
+
+ @endfor
+
+
+
+ @for($i = 0; $i < 5; $i++)@endfor
+
+
diff --git a/resources/views/livewire/photo-help/placeholder.blade.php b/resources/views/livewire/photo-help/placeholder.blade.php
new file mode 100644
index 0000000..82c1217
--- /dev/null
+++ b/resources/views/livewire/photo-help/placeholder.blade.php
@@ -0,0 +1,12 @@
+{{-- Photo placeholder mirrors page x-data to avoid Alpine "preview is not defined" race during hydration --}}
+
diff --git a/resources/views/livewire/progress/placeholder.blade.php b/resources/views/livewire/progress/placeholder.blade.php
new file mode 100644
index 0000000..4d05b7c
--- /dev/null
+++ b/resources/views/livewire/progress/placeholder.blade.php
@@ -0,0 +1,14 @@
+
+
+
+
+ @for($i = 0; $i < 4; $i++)@endfor
+
+
+
+
+
+
diff --git a/resources/views/livewire/rewards/placeholder.blade.php b/resources/views/livewire/rewards/placeholder.blade.php
new file mode 100644
index 0000000..d7ed525
--- /dev/null
+++ b/resources/views/livewire/rewards/placeholder.blade.php
@@ -0,0 +1,6 @@
+
+
+
+ @for($i = 0; $i < 8; $i++)@endfor
+
+
diff --git a/resources/views/livewire/settings/placeholder.blade.php b/resources/views/livewire/settings/placeholder.blade.php
new file mode 100644
index 0000000..524f4eb
--- /dev/null
+++ b/resources/views/livewire/settings/placeholder.blade.php
@@ -0,0 +1,15 @@
+
+
+
+
+ {{-- Sub-nav --}}
+
+ @for($i = 0; $i < 5; $i++)
+
+ @endfor
+
+
+ {{-- Section card --}}
+
+
+
diff --git a/resources/views/livewire/subjects/placeholder.blade.php b/resources/views/livewire/subjects/placeholder.blade.php
new file mode 100644
index 0000000..dc9d078
--- /dev/null
+++ b/resources/views/livewire/subjects/placeholder.blade.php
@@ -0,0 +1,6 @@
+
+
+
+ @for($i = 0; $i < 8; $i++)@endfor
+
+
diff --git a/resources/views/livewire/tasks/placeholder.blade.php b/resources/views/livewire/tasks/placeholder.blade.php
new file mode 100644
index 0000000..b853dac
--- /dev/null
+++ b/resources/views/livewire/tasks/placeholder.blade.php
@@ -0,0 +1,15 @@
+
+
+
+ {{-- Filter pills --}}
+
+ @for($i = 0; $i < 3; $i++)
+
+ @endfor
+
+
+ {{-- Task table --}}
+
+ @for($i = 0; $i < 5; $i++)@endfor
+
+
diff --git a/tests/Feature/Livewire/LazySkeletonTest.php b/tests/Feature/Livewire/LazySkeletonTest.php
deleted file mode 100644
index 1fd8d9b..0000000
--- a/tests/Feature/Livewire/LazySkeletonTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-toBeTrue();
-});
-
-it('skeleton template has animate-pulse + data-testid="page-skeleton"', function () {
- $tpl = file_get_contents(base_path('resources/views/components/skeleton/page.blade.php'));
- expect($tpl)->toContain('animate-pulse');
- expect($tpl)->toContain('data-testid="page-skeleton"');
- expect($tpl)->toContain('bg-line');
-});
-
-foreach ($pages as $pageClass) {
- it("{$pageClass} declares #[Lazy] attribute", function () use ($pageClass) {
- $ref = new ReflectionClass($pageClass);
- $attrs = $ref->getAttributes(Lazy::class);
- expect($attrs)->not->toBeEmpty("Class {$pageClass} must have #[Lazy] attribute");
- });
-
- it("{$pageClass} has placeholder() static method returning skeleton view", function () use ($pageClass) {
- $ref = new ReflectionClass($pageClass);
- expect($ref->hasMethod('placeholder'))->toBeTrue("Class {$pageClass} must define placeholder()");
-
- $method = $ref->getMethod('placeholder');
- expect($method->isStatic())->toBeTrue("{$pageClass}::placeholder() must be static");
-
- $view = $pageClass::placeholder([]);
- expect($view)->toBeInstanceOf(Illuminate\Contracts\View\View::class);
- expect($view->getName())->toBe('components.skeleton.page');
- });
-}
diff --git a/tests/Feature/Livewire/PerPageSkeletonTest.php b/tests/Feature/Livewire/PerPageSkeletonTest.php
new file mode 100644
index 0000000..04bc15a
--- /dev/null
+++ b/tests/Feature/Livewire/PerPageSkeletonTest.php
@@ -0,0 +1,55 @@
+ ['view' => 'livewire.dashboard.placeholder', 'marker' => 'dashboard-skeleton'],
+ SubjectsIndex::class => ['view' => 'livewire.subjects.placeholder', 'marker' => 'subjects-skeleton'],
+ TasksIndex::class => ['view' => 'livewire.tasks.placeholder', 'marker' => 'tasks-skeleton'],
+ PhotoHelpIndex::class => ['view' => 'livewire.photo-help.placeholder', 'marker' => 'photo-help-skeleton'],
+ RewardsIndex::class => ['view' => 'livewire.rewards.placeholder', 'marker' => 'rewards-skeleton'],
+ ProgressIndex::class => ['view' => 'livewire.progress.placeholder', 'marker' => 'progress-skeleton'],
+ NotificationsIndex::class => ['view' => 'livewire.notifications.placeholder', 'marker' => 'notifications-skeleton'],
+ SettingsIndex::class => ['view' => 'livewire.settings.placeholder', 'marker' => 'settings-skeleton'],
+];
+
+foreach ($placeholderMap as $class => $meta) {
+ it("{$class} returns its OWN placeholder view ({$meta['view']})", function () use ($class, $meta) {
+ $view = $class::placeholder([]);
+ expect($view->getName())->toBe($meta['view']);
+ });
+
+ it("placeholder for {$class} has unique data-testid (\"{$meta['marker']}\")", function () use ($class, $meta) {
+ $html = $class::placeholder([])->render();
+ expect($html)->toContain($meta['marker']);
+ });
+}
+
+it('photo-help placeholder pre-declares Alpine x-data with preview state', function () {
+ // Fixes "Alpine Expression Error: preview is not defined" during Lazy
+ // hydration — placeholder must mount the same x-data scope as the real view.
+ $html = PhotoHelpIndex::placeholder([])->render();
+ expect($html)->toContain('x-data');
+ expect($html)->toContain('preview');
+});
+
+it('skeleton blade components exist for composition', function () {
+ foreach (['stat-tile', 'subject-card', 'table-row', 'header', 'card'] as $component) {
+ $path = "resources/views/components/skeleton/{$component}.blade.php";
+ expect(file_exists(base_path($path)))->toBeTrue("Missing skeleton component: {$path}");
+ }
+});
diff --git a/tests/Feature/Livewire/TasksStartButtonTest.php b/tests/Feature/Livewire/TasksStartButtonTest.php
index 1dd4e74..51e3d7a 100644
--- a/tests/Feature/Livewire/TasksStartButtonTest.php
+++ b/tests/Feature/Livewire/TasksStartButtonTest.php
@@ -66,16 +66,17 @@ it('startTask action runs and adds key to startedKeys', function () {
->assertSet('startedKeys', ['t1']);
});
-it('startTask dispatches openModal event targeted at livewire-ui-modal', function () {
- Livewire::test(TasksIndex::class)
- ->call('startTask', 't1')
- ->assertDispatched('openModal');
-});
+it('startTask emits browser-side Livewire.dispatch via $this->js()', function () {
+ $tester = Livewire::test(TasksIndex::class)->call('startTask', 't1');
-it('startTask dispatch targets livewire-ui-modal with component=tasks.modals.detail', function () {
- Livewire::test(TasksIndex::class)
- ->call('startTask', 't1')
- ->assertDispatchedTo('livewire-ui-modal', 'openModal');
+ // Livewire 4 stores js() output in component effects under 'xjs' key
+ $effects = $tester->effects ?? [];
+ $scripts = $effects['xjs'] ?? $effects['js'] ?? [];
+ $allScripts = implode("\n", is_array($scripts) ? array_map(fn($s) => is_array($s) ? json_encode($s) : (string) $s, $scripts) : [(string) $scripts]);
+
+ expect($allScripts)->toContain("Livewire.dispatch('openModal'");
+ expect($allScripts)->toContain('tasks.modals.detail');
+ expect($allScripts)->toContain('t1');
});
it('startTask ignores unknown task keys', function () {