diff --git a/docs/tasks/24-8.md b/docs/tasks/24-8.md
index 09fb4e2..f901215 100644
--- a/docs/tasks/24-8.md
+++ b/docs/tasks/24-8.md
@@ -1,7 +1,7 @@
---
id: 24-8
title: 24-8 Articles and Legal Pages
-status: Ready for Dev
+status: QA Review
date: 2026-10-31
parent: 24
---
diff --git a/docs/tasks/manual-tests/24-8.md b/docs/tasks/manual-tests/24-8.md
new file mode 100644
index 0000000..7ed913c
--- /dev/null
+++ b/docs/tasks/manual-tests/24-8.md
@@ -0,0 +1,15 @@
+# Manual tests — 24-8 Articles and Legal Pages
+
+Automated (passed 2026-10-31, `tests/Feature/Design/ArticlesLegalRedesignTest.php`, 9 tests; Design dir 106 incl. old `PostsTest`): `/posts` section head,
+one h1, debounced search input, text-only post cards; search filters, shows the count line and clear action; empty-state text;
+pagination wrapper present with 12 posts; article page meta/h1/prose/back link, unpublished post is 404; `/page/terms` shows title,
+sanitised content (script stripped) and last-updated line; terms/privacy views one h1; `/admin/posts` loads no public assets.
+UI loop (`/posts`, `/page/terms`): no horizontal scroll at 390/768/1440.
+
+Ruling logged: `x-site.section-head` gained an optional `tag` prop (default `h2`); `/posts` and `/booking` pass `tag="h1"` so each page has exactly one h1 (the spec's own acceptance criterion).
+
+- [ ] `/posts`: type in the search box; after a short pause the list filters and "N results" appears; Clear resets.
+- [ ] With more than 10 published posts: pagination pills at the bottom, current page forest green.
+- [ ] Open an article: date, large display heading, readable body, Back button.
+- [ ] `/page/terms` and `/page/privacy`: white card with the CMS text and "last updated" line; check Arabic and English.
+- [ ] 390px on each page: no sideways scroll.
diff --git a/docs/tasks/statuses.md b/docs/tasks/statuses.md
index ef40e68..4b67a1e 100644
--- a/docs/tasks/statuses.md
+++ b/docs/tasks/statuses.md
@@ -144,6 +144,6 @@
| 24-5 | 24-5 Home Booking Card (Compact Booking and Day Strip) | QA Review |
| 24-6 | 24-6 Booking Page and Booking Success | QA Review |
| 24-7 | 24-7 About Page | QA Review |
-| 24-8 | 24-8 Articles and Legal Pages | Ready for Dev |
+| 24-8 | 24-8 Articles and Legal Pages | QA Review |
| 24-9 | 24-9 Auth Pages Type and Controls | Ready for Dev |
| 24-10 | 24-10 Redesign Integration Verification | Ready for Dev |
diff --git a/resources/css/public.css b/resources/css/public.css
index ae4c0eb..22d6ba8 100644
--- a/resources/css/public.css
+++ b/resources/css/public.css
@@ -62,7 +62,7 @@ html[dir=ltr] body.site .site-progress{transform-origin:left}
body.site section{position:relative}
body.site .sec{padding:120px 0}
body.site .sec-head{display:flex;align-items:end;justify-content:space-between;gap:32px;margin-bottom:54px;flex-wrap:wrap}
-body.site .sec-head h2{font-size:clamp(2rem,3.4vw,3rem);margin-top:12px}
+body.site .sec-head h2,body.site .sec-head h1{font-size:clamp(2rem,3.4vw,3rem);margin-top:12px}
body.site .sec-head p{max-width:30em;color:var(--muted);margin:0}
@@ -338,6 +338,26 @@ body.site .row-static:hover{background:none;color:inherit;transform:none;box-sha
body.site .row-static::before{display:none}
@media (max-width:760px){body.site .row-static{grid-template-columns:52px 1fr}}
+/* ---------- Articles and legal pages (24-8) ---------- */
+body.site .search{min-height:48px;border-radius:999px;padding:12px 20px;border:1.5px solid rgba(45,54,36,.18);background:#fff;width:min(360px,100%);font:400 1rem var(--body);color:var(--ink);transition:border-color .2s,box-shadow .2s}
+body.site .search:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 4px rgba(166,137,102,.2)}
+body.site .search-info{color:var(--muted);margin:0 0 24px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
+body.site .search-info .btn{min-height:44px;padding:8px 18px}
+body.site .posts-index .post:last-child{grid-column:auto}
+body.site .pagination-wrap{margin-top:40px}
+body.site .pagination-wrap nav>div:first-child{display:none}
+body.site .pagination-wrap nav a,body.site .pagination-wrap nav span[aria-disabled] span,body.site .pagination-wrap nav span[aria-current] span{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:0 14px;border-radius:999px;border:1.5px solid var(--line);background:#fff;color:var(--ink);text-decoration:none;font-weight:500}
+body.site .pagination-wrap nav a:hover{border-color:var(--gold)}
+body.site .pagination-wrap nav span[aria-current] span{background:var(--forest);border-color:var(--forest);color:var(--cream)}
+body.site .pagination-wrap nav span[aria-disabled] span{opacity:.5}
+body.site .site-prose :is(h2,h3,h4){font-family:var(--display)}
+body.site .site-prose a{color:var(--olive)}
+body.site .site-prose{font-size:1.06rem;line-height:1.85}
+body.site .post-meta,body.site article>.meta{display:block;font-size:.85rem;font-weight:600;color:var(--gold-ink)}
+body.site .legal-card{background:#fff;border-radius:16px;box-shadow:var(--shadow-1);padding:40px}
+body.site .legal-updated{font-size:.85rem;color:var(--muted);margin:16px 0 0}
+@media (max-width:760px){body.site .legal-card{padding:24px}}
+
/* ---------- Reveal (hidden only when JavaScript runs) ---------- */
body.site.js [data-reveal]{opacity:0;transform:translateY(28px);transition:opacity .9s var(--ease),transform .9s var(--ease)}
body.site.js [data-reveal].in{opacity:1;transform:none}
diff --git a/resources/views/components/site/section-head.blade.php b/resources/views/components/site/section-head.blade.php
index b58e9f0..d3503ab 100644
--- a/resources/views/components/site/section-head.blade.php
+++ b/resources/views/components/site/section-head.blade.php
@@ -1,9 +1,9 @@
-@props(['label', 'title', 'lede' => null])
+@props(['label', 'title', 'lede' => null, 'tag' => 'h2'])
{{ $label }}
-
{{ $title }}
+ <{{ $tag }} data-reveal data-d="1">{{ $title }}{{ $tag }}>
@if ($lede)
{{ $lede }}
diff --git a/resources/views/livewire/pages/booking.blade.php b/resources/views/livewire/pages/booking.blade.php
index ccb1c17..fe6a7c5 100644
--- a/resources/views/livewire/pages/booking.blade.php
+++ b/resources/views/livewire/pages/booking.blade.php
@@ -278,7 +278,7 @@ new #[Layout('components.layouts.public')] class extends Component
@else
-
+
diff --git a/resources/views/livewire/pages/posts/index.blade.php b/resources/views/livewire/pages/posts/index.blade.php
index 62d7e14..5895bf0 100644
--- a/resources/views/livewire/pages/posts/index.blade.php
+++ b/resources/views/livewire/pages/posts/index.blade.php
@@ -55,91 +55,44 @@ new #[Layout('components.layouts.public')] class extends Component
}
}; ?>
-
-
-
{{ __('posts.posts') }}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@if($search)
-
- @endif
-
-
-
- @if($search)
-
- @if($posts->total() > 0)
- {{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
- @else
- {{ __('posts.no_results', ['query' => $search]) }}
- @endif
-
- @endif
-
-
-
- @forelse($posts as $post)
-
-
-
-
-
-
- @if($search)
- {!! $this->highlightSearch($post->getExcerpt(), $search) !!}
- @else
- {{ $post->getExcerpt() }}
- @endif
-
-
-
- {{ __('posts.read_more') }} →
-
-
- @empty
-
- @if($search)
-
-
{{ __('posts.no_results', ['query' => $search]) }}
-
- {{ __('posts.clear_search') }}
-
+
+ @if($posts->total() > 0)
+ {{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
@else
-
-
{{ __('posts.no_posts') }}
+ {{ __('posts.no_results', ['query' => $search]) }}
@endif
-
- @endforelse
-
+
+
+ @endif
-
- {{ $posts->links() }}
+ @if($posts->count() > 0)
+
+ @foreach($posts as $post)
+
+ @endforeach
+
+ @elseif(! $search)
+
{{ __('posts.no_posts') }}
+ @endif
+
+
-
-
+
diff --git a/resources/views/livewire/pages/posts/show.blade.php b/resources/views/livewire/pages/posts/show.blade.php
index 8b05373..2681617 100644
--- a/resources/views/livewire/pages/posts/show.blade.php
+++ b/resources/views/livewire/pages/posts/show.blade.php
@@ -18,24 +18,17 @@ new #[Layout('components.layouts.public')] class extends Component
}
}; ?>
-
-
-
- {{ $post->getTitle() }}
+
+
+ {{ $post->published_at?->translatedFormat('l, d F Y') ?? $post->created_at->translatedFormat('l, d F Y') }}
+ {{ $post->getTitle() }}
-
-
+
+ {!! $post->getBody() !!}
+
-
- {!! $post->getBody() !!}
-
-
-
-
-
+
+
+
diff --git a/resources/views/pages/legal.blade.php b/resources/views/pages/legal.blade.php
index bf9ba1e..e0c0969 100644
--- a/resources/views/pages/legal.blade.php
+++ b/resources/views/pages/legal.blade.php
@@ -1,15 +1,17 @@
-
-
{{ $page->getTitle() }}
-
- @if ($page->getContent())
- {!! clean($page->getContent()) !!}
- @else
-
{{ __('pages.content_coming_soon') }}
- @endif
+
+
+
{{ $page->getTitle() }}
+
+ @if ($page->getContent())
+ {!! clean($page->getContent()) !!}
+ @else
+
{{ __('pages.content_coming_soon') }}
+ @endif
+
+
+ {{ __('pages.last_updated') }}: {{ $page->updated_at->format('M d, Y') }}
+
-
- {{ __('pages.last_updated') }}: {{ $page->updated_at->format('M d, Y') }}
-
-
+
diff --git a/resources/views/pages/privacy.blade.php b/resources/views/pages/privacy.blade.php
index 11a0de5..948c458 100644
--- a/resources/views/pages/privacy.blade.php
+++ b/resources/views/pages/privacy.blade.php
@@ -1,8 +1,10 @@
-
-
{{ __('footer.privacy') }}
-
-
{{ __('Privacy policy content will be added here.') }}
+
+
+
{{ __('footer.privacy') }}
+
+
{{ __('Privacy policy content will be added here.') }}
+
-
+
diff --git a/resources/views/pages/terms.blade.php b/resources/views/pages/terms.blade.php
index 0634821..3d8573f 100644
--- a/resources/views/pages/terms.blade.php
+++ b/resources/views/pages/terms.blade.php
@@ -1,8 +1,10 @@
-
-
{{ __('footer.terms') }}
-
-
{{ __('Terms of service content will be added here.') }}
+
+
+
{{ __('footer.terms') }}
+
+
{{ __('Terms of service content will be added here.') }}
+
-
+
diff --git a/tests/Feature/Design/ArticlesLegalRedesignTest.php b/tests/Feature/Design/ArticlesLegalRedesignTest.php
new file mode 100644
index 0000000..d194fe9
--- /dev/null
+++ b/tests/Feature/Design/ArticlesLegalRedesignTest.php
@@ -0,0 +1,86 @@
+published()->count(3)->create();
+
+ $html = $this->withSession(['locale' => 'en'])->get('/posts')->assertOk()->getContent();
+
+ expect(substr_count($html, '
toBe(1);
+ expect($html)
+ ->toContain('class="sec-head"')
+ ->toContain('wire:model.live.debounce.300ms="search"')
+ ->toContain('class="search"')
+ ->toContain('posts-index');
+ expect(substr_count($html, 'class="post"'))->toBe(3);
+
+ preg_match('~(.*?)~s', $html, $block);
+ expect($block[1])->not->toContain('
![]()
published()->create(['title' => ['en' => 'Alimony guide', 'ar' => 'دليل النفقة']]);
+ Post::factory()->published()->create(['title' => ['en' => 'Contracts', 'ar' => 'العقود']]);
+
+ app()->setLocale('en');
+ Volt::test('pages.posts.index')->set('search', 'Alimony')
+ ->assertSee('Alimony guide')->assertDontSee('Contracts')
+ ->assertSee(__('posts.search_results', ['count' => 1, 'query' => 'Alimony']))
+ ->assertSee(__('posts.clear_search'))
+ ->set('search', 'zzzz')->assertSee(__('posts.no_results', ['query' => 'zzzz']));
+});
+
+test('empty index shows the no posts message', function () {
+ Volt::test('pages.posts.index')->assertSee(__('posts.no_posts'));
+});
+
+test('pagination is wrapped and styled when there are more than ten posts', function () {
+ Post::factory()->published()->count(12)->create();
+
+ expect($this->get('/posts')->assertOk()->getContent())->toContain('pagination-wrap');
+});
+
+test('a published post shows meta, h1, body and a back link; unpublished is 404', function () {
+ $post = Post::factory()->published()->create();
+ $html = $this->get(route('posts.show', $post))->assertOk()->getContent();
+
+ expect($html)->toContain('class="meta"')->toContain('site-prose')->toContain(route('posts.index'))->toContain(__('posts.back_to_posts'));
+ expect(substr_count($html, '
toBe(1);
+
+ $draft = Post::factory()->draft()->create();
+ $this->get(route('posts.show', $draft))->assertNotFound();
+});
+
+test('legal pages show title, sanitised content and the last updated line', function () {
+ Page::factory()->terms()->create(['content_en' => '
Hello
', 'content_ar' => '
مرحبا
']);
+
+ $html = $this->withSession(['locale' => 'en'])->get('/page/terms')->assertOk()->getContent();
+
+ expect($html)->toContain('Terms of Service')->toContain('Hello')->not->toContain('