# Architecture — Godsfavour Okpara Portfolio CMS

**Status:** Proposed (Phase 2). Awaiting approval before Phase 3.
**Stack:** PHP 8.4 · Laravel 13 · Filament 5 (Livewire 4) · MySQL 8+/9 · Redis · Blade + Tailwind CSS 4 + Alpine.js (CSP build) · Vite 8
**References:** PRD sections are cited as `§n` (from `docs/prd/portfolio-prd.md`). Master prompt sections are cited as `MP §n`. Decisions are recorded as `ADR-nnn` in `DECISIONS.md`.

---

## 1. Principles

1. **Content and presentation are decoupled** (§23, NFR-08, MP §22). All professional copy lives in the database or in settings. Blade, PHP, JS, and CSS hold only structure, behaviour, and brand-neutral microcopy (`lang/`).
2. **One read-side data layer** (§23.2, §32 intent). Controllers and blocks get content only through `Queries/*` and `PageResolver`. Templates never run ad-hoc queries.
3. **Every page is a `Page`.** System routes are backed by `Page` records (keyed by `system_key`) that own SEO, intro, and sections. Dynamic listings are locked blocks inside those pages.
4. **Section-driven composition.** One `content_sections` store, one `BlockRegistry`, and one renderer serve pages, the homepage, and case studies alike.
5. **Safe by default.** Unpublished content returns 404. Placeholders look like placeholders. Anonymised projects never leak. Hidden-when-empty is enforced in code (`shouldRender`) rather than left to editors.
6. **No speculative abstraction.** Repositories, interfaces, and events appear only where something genuinely swaps or decouples: analytics drivers, CAPTCHA drivers, booking providers, and cache invalidation.

## 2. Layered structure

Models stay in `app/Models`, following the Laravel and Filament convention, so Filament discovery, factories, and morph maps stay idiomatic (ADR-004). Domain logic lives in modules under `app/Domain`.

```
app/
  Domain/
    Content/       Actions (SaveSections, PublishContent, SetHomepage, DuplicatePage, DuplicateSection),
                   Blocks/ (BlockContract, AbstractBlock, BlockRegistry, BlockViewModel, Definitions/*Block.php),
                   Rendering/ (SectionRenderer, ReferenceBatch), Queries/ (PageResolver, NavigationQuery),
                   Publishing/ (HasPublishing trait, PublishStatus enum, LiveScope rules),
                   Redirects/ (RedirectService, SlugChanged event, CreateRedirectOnSlugChange listener),
                   Slugs/ (ReservedSlugs, SlugValidator)
    Portfolio/     Actions (PublishProject, DuplicateProject), Queries (ProjectQuery, RelatedProjectsResolver,
                   AdjacentProjectResolver), Support (OrganisationPresenter — the anonymisation gate)
    Career/        Queries (ExperienceQuery, SkillQuery, CertificationQuery)
    Insights/      Actions, Queries (ArticleQuery, RelatedArticlesResolver), Support (ReadingTimeCalculator)
    Endorsements/  Actions (PublishRecommendation — consent gate), Queries (RecommendationQuery)
    Engagement/    Contact/ (SubmitContactMessage, ContactSubmissionReceived, SpamGuard, Captcha/*),
                   Booking/ (BookingProvider enum, ProviderAdapter contract + adapters, BookingPresenter),
                   Cv/ (CvRepository, ReplaceCv action, CvStreamer)
    Site/          Settings/*Settings.php, SeoResolver, StructuredDataBuilder, CtaResolver
    Analytics/     AnalyticsEvent enum, AnalyticsTracker contract, Drivers/*, EventCatalogueExporter
    Media/         MediaRules (collections, MIME, sizes), AltTextRules, ConversionProfiles
    Shared/        ContentSanitizer, Enums base, Cache/ContentCache, ValueObjects (Cta, SeoData, Link)
  Models/          Eloquent models
  Filament/        Resources/, Pages/ (settings pages, dashboard), Widgets/, Schemas/ (SeoFormSchema,
                   PublishingFormSchema, MediaFieldSchema, SectionsFieldSchema, CtaFieldSchema)
  Http/            Controllers/ (thin), Requests/, Middleware/ (SecurityHeaders, ContentSecurityPolicy,
                   HandleRedirects, ShareSiteContext)
  View/Components/ Blade class components
  Policies/
  Console/Commands/ (CreateAdmin, PurgeDemo, PublishScheduled, GenerateSitemap, ExportAnalyticsCatalogue)
resources/views/
  layouts/ components/ blocks/ pages/ partials/ errors/ vendor/
```

## 3. Database design

### 3.1 Conventions

- InnoDB, `utf8mb4_unicode_ci`, and foreign keys with explicit `onDelete` on every relation.
- Indexes on every slug (unique), `status`, `published_at`, `sort_order`, and FK column. Unique constraints on pivot pairs.
- **Soft deletes** only on `pages`, `projects`, `articles`, `experiences`, `recommendations`, `certifications`, where recovery is valuable (ADR-010).
- **JSON** only for variable-shape data: `content_sections.data`, booking meeting types and social links (inside settings payloads), `seo_meta.structured_data_override`, `navigation_items.meta`, and media `custom_properties`.
- **Polymorphism** only where several owners share the same concept: `content_sections` (Page / Project / Article), `seo_meta` (Page / Project / Article), `media` (Spatie), `activity_log` (Spatie), `taggables` (Project / Experience / Article), and `navigation_items.linkable`. All morph types go through `Relation::enforceMorphMap()` with short aliases (`page`, `project`, `article`, and so on), so class renames never corrupt data.

### 3.2 Publishing model (shared)

`PublishStatus` enum: `draft`, `scheduled`, `published`, `archived`. Columns: `status` (string, indexed) and `published_at` (nullable datetime, indexed).

A record is **live** when `status = published AND (published_at IS NULL OR published_at <= now())` **or** `status = scheduled AND published_at <= now()`. This rule is defined once, in the `HasPublishing` trait (`scopeLive`, `isLive()`), and every public query uses it. The `portfolio:publish-scheduled` command runs every minute. It moves due `scheduled` rows to `published` and dispatches `ContentPublished`, which invalidates the cache and queues sitemap regeneration. The scope above is already correct even if the scheduler is late, so content goes live on time either way; only cache freshness depends on the command.

Publishable models: Page, Project, Experience, Certification, Article, Recommendation.

### 3.3 Tables

**Content**

| Table | Key columns | Notes |
|---|---|---|
| `pages` | id, title, slug (unique), system_key (nullable, unique), template (enum `default`/`narrative`/`landing`), intro (text, nullable), excerpt, homepage_flag (nullable bool, **unique**: stores `true` or `NULL`, so the DB enforces "exactly one homepage"), status, published_at, is_demo, timestamps, soft deletes | System pages cannot be deleted (policy plus Action guard). Slugs are locked for system keys bound to fixed routes. The featured image is a media `cover` collection. |
| `content_sections` | id, sectionable_type, sectionable_id, block_type (string, validated against the registry), data (JSON), sort_order, is_visible, anchor_id (nullable; unique per owner via `(sectionable_type, sectionable_id, anchor_id)`), admin_label, is_locked, timestamps | Index `(sectionable_type, sectionable_id, sort_order)`. Owners: Page, Project, Article. |
| `seo_meta` | id, seoable_type, seoable_id (unique pair), seo_title, meta_description, canonical_url, og_title, og_description, robots_index (bool), robots_follow (bool), structured_data_override (JSON, super admin only), timestamps | The OG image is media on the owner (`og_image` collection). Kept as a morph table rather than repeated columns on three tables (ADR-011). |
| `navigation_menus` | id, key (unique: `primary`, `footer`, `footer_secondary`, `mobile_cta`), name | Seeded and not deletable. |
| `navigation_items` | id, navigation_menu_id (FK cascade), parent_id (nullable FK self, cascade; max depth 1), label, link_type (enum: page/project/article/route/external/anchor/cv/booking), linkable_type/linkable_id (nullable morph), target (string, nullable: URL / route name / anchor), open_in_new_tab, analytics_key, is_visible, sort_order, meta (JSON, nullable) | Links to unpublished targets are dropped at render time. |
| `redirects` | id, from_path (unique), to_path, status_code (301/302), hits, last_hit_at, is_active, is_automatic, timestamps | Created automatically on a published slug change. Chains are collapsed (A→B, B→C becomes A→C), and loops are rejected. |

**Portfolio**

| Table | Key columns | Notes |
|---|---|---|
| `projects` | id, title, slug (unique), summary, role, organisation (nullable), is_anonymised, public_organisation_label (nullable), confidentiality_cleared (bool, default false), timeline_label, start_date, end_date, challenge, objective, approach, solution (text, nullable: short card/overview fields per §23.1), key_contribution, outcome_summary, is_featured, featured_order, status, published_at, is_demo, timestamps, soft deletes | Media: `thumbnail` (single), `gallery` (multiple), `logo` (single), `og_image`. Long-form narrative lives in case-study sections. |
| `project_categories` | id, name, slug (unique), description, sort_order | |
| `project_category_project` | project_id, project_category_id (unique pair, cascade) | |
| `project_related` | project_id, related_project_id, sort_order (unique pair, cascade) | Manual selection. Falls back to automatic matching by shared category or tag. |
| `experience_project` | experience_id, project_id (unique pair) | |
| `project_recommendation` | project_id, recommendation_id (unique pair) | Optional in-context quotes. |

**Organisation display rule:** every template, SEO meta tag, JSON-LD block, alt text, and sitemap entry reads the organisation through `OrganisationPresenter`. That presenter returns the real name and logo **only** when `is_anonymised = false AND confidentiality_cleared = true`. Otherwise it returns `public_organisation_label` or nothing. No other code path reads `organisation` for public output. A test scans the rendered HTML of every surface for the raw organisation string.

**Career**

| Table | Key columns | Notes |
|---|---|---|
| `experiences` | id, organisation, job_title, location, employment_type (nullable enum), start_date, end_date (nullable), is_current, overview (rich), responsibilities (JSON ordered list of strings), achievements (JSON ordered list), sort_order (nullable override), status, published_at, is_demo, timestamps, soft deletes | Validation: `is_current` ⇒ `end_date` is null; `end_date >= start_date`. Default order: `is_current desc, start_date desc`, with `sort_order` overriding when set. Responsibilities and achievements are JSON lists because they are owned, ordered, plain strings that are never queried individually (ADR-012). |
| `experience_recommendation` | experience_id, recommendation_id | |
| `skill_categories` | id, name, slug (unique), description, sort_order | |
| `skills` | id, skill_category_id (FK cascade), name, sort_order, is_visible | **No proficiency fields** (§14.2). |
| `certifications` | id, name, issuer, issued_on (date, nullable), issued_year (nullable smallint), expires_on (nullable), credential_id, credential_url, is_placeholder, sort_order, status, published_at, is_demo, timestamps, soft deletes | Media `logo`. When `is_placeholder` is true, there is no outbound link and the placeholder treatment is applied. |

**Insights**

| Table | Key columns | Notes |
|---|---|---|
| `authors` | id, name, slug, title, bio, links (JSON), is_default (unique-nullable flag) | The owner is seeded as the default author (§16.1). |
| `article_categories` | id, name, slug (unique), description, sort_order | |
| `articles` | id, title, slug (unique), excerpt, content (sanitised HTML), article_category_id (FK nullOnDelete), author_id (FK restrictOnDelete), reading_time_minutes (computed), reading_time_override (nullable), status, published_at, is_demo, timestamps, soft deletes | Media `cover` (alt required), `og_image`. May also own `content_sections` for rich layouts (optional). |
| `article_related` | article_id, related_article_id, sort_order | Manual. Falls back to the same category, then shared tags. |

**Tags** (shared, typed; ADR-009)

| Table | Key columns | Notes |
|---|---|---|
| `tags` | id, type (enum `tool`, `topic`), name, slug; unique `(type, slug)` | `tool` is used by projects and experiences (and shown on project cards). `topic` is used by articles. |
| `taggables` | tag_id, taggable_type, taggable_id; unique triple; index `(taggable_type, taggable_id)` | |

**Endorsements**

| Table | Key columns | Notes |
|---|---|---|
| `recommendations` | id, recommender_name, recommender_title, recommender_organisation, relationship (enum: direct_manager, skip_level_manager, peer, cross_functional, direct_report, client, stakeholder, other), relationship_label (nullable custom), quote (text), excerpt (nullable), source_platform (enum: linkedin, email, letter, other), source_url (nullable), recommended_on (date), is_featured, sort_order, publication_consent_confirmed (bool, default false), status, published_at, timestamps, soft deletes | Media `photo` (alt required). **Publishing requires consent**, enforced in `PublishRecommendation`, in model validation, and in a DB-level guard in the `saving` observer (status cannot become published/scheduled without consent). Demo seeding creates **zero** of these. |

**Engagement**

| Table | Key columns | Notes |
|---|---|---|
| `documents` | id, type (enum `cv`, `other`), title, version_label, is_current (unique-nullable flag per type), uploaded_by (FK users nullOnDelete), timestamps | Media `file` on the **private** disk. `ReplaceCv` uploads a new document and flips `is_current` in one transaction. Old versions are kept but never served. |
| `contact_submissions` | id, name, email, subject, message, company, opportunity_type, linkedin_url, ip_hash (HMAC-SHA256 with app key), user_agent (max 255), status (enum new/read/replied/archived/spam), read_at, timestamps | `Prunable` after `ContactSettings::retention_days`. Not soft deleted. |
| `analytics_events` | id, event (string, indexed), location (nullable), subject_type/subject_id (nullable), path (nullable), occurred_at (indexed) | First-party `database` driver for key server-side events (CV views/downloads, contact success). **No IP, no user agent, no personal data.** `Prunable` after a configurable period. Feeds the dashboard widgets. |

**Platform** (from packages)

`users` (+ `is_active`, `last_login_at`), `password_reset_tokens`, `sessions`, `cache`, `jobs`, `failed_jobs`, `roles`, `permissions`, `model_has_roles`, `model_has_permissions`, `role_has_permissions` (spatie/permission), `settings` (spatie/settings), `media` (spatie/medialibrary), `activity_log` (spatie/activitylog). Filament MFA columns are added to `users` (app authentication secret and recovery codes) per Filament 5's documented migration.

### 3.4 ERD

```mermaid
erDiagram
    PAGES ||--o{ CONTENT_SECTIONS : "sectionable"
    PROJECTS ||--o{ CONTENT_SECTIONS : "sectionable (case study)"
    ARTICLES ||--o{ CONTENT_SECTIONS : "sectionable (optional)"
    PAGES ||--o| SEO_META : "seoable"
    PROJECTS ||--o| SEO_META : "seoable"
    ARTICLES ||--o| SEO_META : "seoable"

    PROJECTS }o--o{ PROJECT_CATEGORIES : "project_category_project"
    PROJECTS }o--o{ PROJECTS : "project_related"
    PROJECTS }o--o{ EXPERIENCES : "experience_project"
    PROJECTS }o--o{ RECOMMENDATIONS : "project_recommendation"
    EXPERIENCES }o--o{ RECOMMENDATIONS : "experience_recommendation"

    TAGS ||--o{ TAGGABLES : ""
    PROJECTS ||--o{ TAGGABLES : "taggable (tool)"
    EXPERIENCES ||--o{ TAGGABLES : "taggable (tool)"
    ARTICLES ||--o{ TAGGABLES : "taggable (topic)"

    SKILL_CATEGORIES ||--o{ SKILLS : "has"
    ARTICLE_CATEGORIES ||--o{ ARTICLES : "categorises"
    AUTHORS ||--o{ ARTICLES : "writes"
    ARTICLES }o--o{ ARTICLES : "article_related"

    NAVIGATION_MENUS ||--o{ NAVIGATION_ITEMS : "has"
    NAVIGATION_ITEMS ||--o{ NAVIGATION_ITEMS : "parent (depth 1)"
    NAVIGATION_ITEMS }o--o| PAGES : "linkable (morph)"

    USERS ||--o{ DOCUMENTS : "uploaded_by"
    MEDIA }o--|| PROJECTS : "model (morph)"

    PAGES {
        bigint id PK
        string slug UK
        string system_key UK "nullable"
        string template
        boolean homepage_flag UK "true or NULL"
        string status
        datetime published_at
    }
    CONTENT_SECTIONS {
        bigint id PK
        string sectionable_type
        bigint sectionable_id
        string block_type
        json data
        int sort_order
        boolean is_visible
        string anchor_id "unique per owner"
        boolean is_locked
    }
    PROJECTS {
        bigint id PK
        string slug UK
        string organisation "nullable"
        boolean is_anonymised
        string public_organisation_label
        boolean confidentiality_cleared
        boolean is_featured
        int featured_order
        string status
        datetime published_at
    }
    EXPERIENCES {
        bigint id PK
        string organisation
        string job_title
        date start_date
        date end_date "nullable"
        boolean is_current
        json responsibilities
        json achievements
    }
    RECOMMENDATIONS {
        bigint id PK
        string recommender_name
        string relationship
        text quote
        string source_platform
        boolean publication_consent_confirmed
        boolean is_featured
    }
    CERTIFICATIONS {
        bigint id PK
        string name
        string issuer
        string credential_url
        boolean is_placeholder
    }
    ARTICLES {
        bigint id PK
        string slug UK
        bigint article_category_id FK
        bigint author_id FK
        int reading_time_minutes
        int reading_time_override
    }
    DOCUMENTS {
        bigint id PK
        string type
        boolean is_current "unique per type"
        string version_label
    }
    CONTACT_SUBMISSIONS {
        bigint id PK
        string email
        string ip_hash
        string status
    }
    REDIRECTS {
        bigint id PK
        string from_path UK
        string to_path
        int status_code
    }
    TAGS {
        bigint id PK
        string type
        string slug "unique with type"
    }
```

(The spatie `media`, `activity_log`, `settings`, and permission tables are omitted from the diagram for clarity.)

## 4. Routing

All routes are named. They are registered in this order in `routes/web.php`, with the catch-all **last**.

| Method | URL | Name | Handler |
|---|---|---|---|
| GET | `/` | `home` | `PageController@home` → page with `homepage_flag` |
| GET | `/about` | `about` | `PageController@system('about')` |
| GET | `/experience` | `experience` | `PageController@system('experience')` |
| GET | `/projects` | `projects.index` | `PageController@system('projects_index')` |
| GET | `/projects/{project:slug}` | `projects.show` | `ProjectController@show` |
| GET | `/skills` | `skills` | system page |
| GET | `/certifications` | `certifications` | system page |
| GET | `/insights` | `insights.index` | system page (`?category=&tag=` query filters) |
| GET | `/insights/category/{category:slug}` | `insights.category` | Same listing, pre-filtered; canonical points to it |
| GET | `/insights/{article:slug}` | `insights.show` | `ArticleController@show` |
| GET | `/contact` | `contact` | system page |
| POST | `/contact` | `contact.store` | `ContactController@store` (throttle `contact`) |
| GET | `/book` | `book` | system page; 404 when booking is disabled; link-out when the embed is off |
| GET | `/cv` | `cv.view` | `CvController@view` (inline; throttle `cv`) |
| GET | `/cv/download` | `cv.download` | `CvController@download` (attachment; throttle `cv`) |
| GET | `/sitemap.xml` | `sitemap` | Static file generated by job; regenerated on demand if missing |
| GET | `/robots.txt` | `robots` | Generated; disallow-all outside production |
| GET | `/preview/{type}/{id}` | `preview` | `signed` + `auth` + policy `view` + throttle; noindex; bypasses the live scope and cache |
| GET | `/404` | `not-found` | Renders the `not_found` page with status 404 (PRD §6.1) |
| GET | `/{slug}` | `pages.show` | Catch-all for CMS pages (`where('slug', '[a-z0-9-]+')`) |

- `HandleRedirects` middleware runs on the web group **before** route resolution produces a 404. It looks up `redirects` (from cache) by normalised path.
- `ReservedSlugs` holds every first-level system segment, the admin path (`config('portfolio.admin_path')`), `livewire`, `filament`, `storage`, `build`, `vendor`, `up`, `sitemap.xml`, `robots.txt`, `preview`, `cv`, `book`, `404`, `api`, and `.well-known`. Page slugs are validated against this list, and a test asserts it stays in sync with the route list.
- Public route binding resolves only **live** records (a custom binding through `scopeLive`). Draft, scheduled-future, archived, and soft-deleted content returns **404**, never 403.
- **Error pages:** `errors/404.blade.php` renders the `not_found` page's heading, message, and links through `PageResolver`, wrapped in try/catch with safe hardcoded fallbacks from `lang/` if the DB is unreachable. `500` and `503` are static and depend on no database.
- The catch-all is compatible with `route:cache`. Everything is a controller route; there are no closures.

## 5. Page builder

### 5.1 Contract

```php
interface BlockContract
{
    public function key(): string;                    // stored in content_sections.block_type
    public function label(): string;
    public function icon(): string;                   // heroicon name
    public function description(): string;
    public function group(): BlockGroup;              // Hero & Intro, Content, Evidence, Engagement, Layout, Case study
    /** @return list<BlockScope> */
    public function scopes(): array;                  // page, homepage, case_study, article
    /** @return array<\Filament\Schemas\Components\Component|\Filament\Forms\Components\Field> */
    public function schema(): array;
    /** @return array<string, mixed> */
    public function rules(): array;                   // enforced server-side by SaveSections
    public function defaults(): array;
    public function references(array $data): ReferenceSet; // entity IDs this block needs (for batching)
    public function resolve(array $data, ResolvedReferences $refs): BlockViewModel;
    public function view(): string;                   // e.g. 'blocks.hero'
    public function shouldRender(BlockViewModel $vm): bool;
    public function requiresPermission(): ?string;
    public function headingFor(array $data): ?string; // admin item label and section-nav label
}
```

`AbstractBlock` supplies sensible defaults. `BlockRegistry` is a singleton built from `config/blocks.php`, which is the list of block classes. **Adding a block = one class + one Blade view + one line in `config/blocks.php`.** There are no migrations, controller changes, or page changes. `docs/architecture/PAGE-BUILDER.md` will contain a worked example (Phase 4).

### 5.2 Rendering pipeline

`<x-sections :owner="$page" />` → `SectionRenderer::render($owner)`:

1. Loads visible sections ordered by `sort_order` (one query, or a cache hit).
2. Drops unknown `block_type`s and logs a warning. This is never fatal.
3. Two-pass batch resolution: each block reports its `references()` (e.g. `projects: [3, 7]`, `recommendations: featured`). `ReferenceBatch` merges them and runs **one query per entity type**, applying `live()` and the eager loads (`media`, `categories`, `tags`). Unpublished or deleted IDs drop out silently.
4. Each block's `resolve()` builds a typed view model. `shouldRender()` filters out empty blocks: zero recommendations, unverified-only metrics, empty grids, and similar cases.
5. Renders each view with `$vm` and `$anchor`.

The renderer also exposes the resolved list, so the project template can build the in-page section navigation from blocks that have an `anchor_id` and a heading and are flagged `include_in_nav`.

**H1 guarantee:** blocks render H2 and below. The `hero` block owns the H1 on pages that start with a hero; otherwise the page template renders the page title as the H1. A test asserts exactly one `<h1>` on every route.

### 5.3 Admin UX (ADR-006)

A **relationship-backed `Repeater` over `content_sections`** (`Repeater::make('sections')->relationship()->orderColumn('sort_order')`), rather than a Builder plus a sync action. Reasons: it persists natively to the morph relation, makes reordering and duplication (`->cloneable()`) first-class, and gives per-item control over deletion (to hide the delete action for locked items).

- Each item has a searchable, grouped `block_type` select filtered by the owner's scope and the user's permission. It is disabled after creation, which avoids orphaned data.
- A dynamic `Group` (`statePath('data')`) whose schema comes from `BlockRegistry::get($get('block_type'))->schema()`.
- Collapsible items, labelled `"{Block label} — {headline}"` through `headingFor()`.
- Toggles for `is_visible` and `anchor_id` (auto-slugified from the heading, and editable).
- Locked items: the delete action is hidden, the type is immutable, and a "Required" badge is shown. `SaveSections` **also** rejects removing a locked block server-side.
- A "Preview" header action opens the signed preview URL in a new tab.
- **Transactional save:** the Edit page runs inside a DB transaction (Filament 5 panel/page database-transaction support). `SaveSections` validates every item's `data` against `rules()` before commit and throws a `ValidationException` mapped to the item's fields.

*Filament 5 API names above are verified against vendor source in Phase 3/4. Version-specific code must not be written from memory.*

### 5.4 Block catalogue

| Key | Scopes | Notes |
|---|---|---|
| `hero` | homepage, page | Eyebrow, name, title, value proposition, photo + alt; CTAs **max 3** (validation `max:3`), each with label, `CtaType` (view_work / cv / contact / booking / internal / external), `CtaStyle` (primary / secondary / tertiary), and analytics key. Variant layout when there is no photo. Owns the H1. |
| `rich_text` | page, case_study, article | Sanitised on save and at render. |
| `image_text` | page | Image left/right; stacks on mobile; alt required. |
| `two_column` | page | Two rich-text columns. |
| `pull_quote` | page, case_study | Highlight statement (§13.2). |
| `final_cta` | homepage, page | Heading, text, parallel "Let's Connect" and "Book a Call" (booking CTA hidden centrally when booking is off). Navy section. |
| `capabilities_grid` | homepage, page | Repeater (min 4): heroicon (picker limited to the line set), title, one-liner. Auto-fit grid that is safe at 5+ items. |
| `process_steps` | homepage, page | 3–5 recommended; admin warning above 5. |
| `metrics` | page | label, value, context, `is_verified`; renders only verified items (admin warning for unverified). |
| `featured_projects` | homepage, page | Mode `auto_featured` or `manual` (ordered IDs); count 3–4; "View All Projects" CTA. |
| `project_grid` | page | Category- or tag-filtered static grid. |
| `projects_listing` | page (**locked** on `/projects`) | Featured first, client-side filters, Load more. |
| `experience_preview` | homepage, page | Latest 1–2 roles or a summary statement + CTA. |
| `experience_timeline_full` | page (**locked** on `/experience`) | Hybrid timeline + expandable cards + CV button. |
| `skills_grid` / `skills_full` | homepage/page / **locked** on `/skills` | Categorised tags; no proficiency. |
| `certifications_preview` / `certifications_full` | homepage/page / **locked** on `/certifications` | 3–5 + CTA / full grid; placeholder treatment. |
| `recommendations_preview` / `recommendations_grid` | homepage, page | `shouldRender` is false at zero. Carousel for 2+, a single card for 1. |
| `article_grid` / `insights_listing` | page / **locked** on `/insights` | |
| `contact_form` | page (**locked** on `/contact`) | Also renders direct email + CV button + booking as a parallel option. |
| `booking_embed` / `booking_cta` | page (`booking_embed` **locked** on `/book`) | Meeting types; lazy embed + fallback. |
| `cv_cta` | page | Uses `cv-button` with the location prop. |
| `gallery` | page | Images + alt + captions. |
| `video_embed` | page, case_study | YouTube (nocookie), Vimeo, Loom only. The URL is parsed to an ID; editors never supply raw HTML. |
| `logo_grid` | page | Only cleared, non-anonymised organisations / uploaded logos with alt. |
| `social_links` | page | From `SocialSettings`. |
| `related_content` | page | Manual picks of projects or articles. |
| `divider` / `spacer` | all | Preset sizes `sm`/`md`/`lg` only. |
| `custom_embed` | — | **Omitted** (ADR-014). The allowlisted video, booking, and `cs_embed` blocks cover every PRD need. It can be added later as a super-admin-only block. |
| `cs_section` | case_study | `section_type` enum: 16 PRD §11.1 types + `custom`; heading override, rich body, figures (image + alt + caption), links, `include_in_nav`. Renders nothing if the body and figures are empty. |
| `cs_contribution` | case_study | Two distinct panels: "What I owned" / "What the team delivered" (labels editable, PRD defaults). |
| `cs_metrics` | case_study | Verified only. |
| `cs_gallery`, `cs_embed`, `cs_links` | case_study | Gallery / allowlisted embed / external links with `external_link_click`. |
| `cs_quote` | case_study | Selects a published, consented recommendation. |

**Project detail template** (not blocks): breadcrumbs → header (title, role, timeline, organisation through `OrganisationPresenter`, categories, tools) → sticky section nav (sidebar ≥1024px, anchor bar below) → sections → Related Projects → Previous/Next + Back to Projects → sticky Contact CTA.

### 5.5 Default seeded layouts (`EssentialSeeder`)

| System page | Default blocks (🔒 = locked) |
|---|---|
| Home | hero → rich_text (Professional Snapshot) → capabilities_grid → featured_projects → process_steps → experience_preview → certifications_preview → recommendations_preview → final_cta |
| About (`narrative` template) | image_text (Who I am) → rich_text ×6 (§13.1 blocks) → pull_quote → final_cta |
| Experience | 🔒 experience_timeline_full → cv_cta → final_cta |
| Projects | 🔒 projects_listing → final_cta |
| Skills | 🔒 skills_full → final_cta |
| Certifications | 🔒 certifications_full → final_cta |
| Insights | 🔒 insights_listing |
| Contact | 🔒 contact_form (includes booking + email + CV) |
| Book | 🔒 booking_embed |
| Not found | (template-driven: heading, message, links) |

Essential seed copy uses only PRD-supplied strings (e.g. §19.2 messages, CTA labels). Every other text field uses the PRD bracket convention (`[Professional Snapshot Content]`), so it is obviously a placeholder until the owner replaces it.

## 6. Filament admin

- **Panel:** a single panel `admin` at `config('portfolio.admin_path')` (`ADMIN_PATH`, default `admin`; production should use a non-obvious value). Brand name and logo come from `GeneralSettings`. **Built-in MFA** (TOTP app authentication + recovery codes) is required for Super Admin and optional for other roles. Login is rate-limited (Filament default + throttle). Password reset is enabled. `Password::defaults()` is strengthened (min 12, mixed case, numbers, uncompromised in production). There is no registration.
- **Navigation groups:**
  - **Content:** Pages, Projects, Project Categories, Experience, Skill Categories (Skills as a relation manager), Certifications, Articles, Article Categories, Tags, Authors, Recommendations.
  - **Site Management:** Navigation Menus, Homepage (shortcut to the homepage record), General Settings, Footer, SEO Defaults, Social Links, CTA Settings, Booking, Contact Settings, Analytics, Redirects, CV & Documents.
  - **Inbox:** Contact Submissions (unread badge).
  - **Media:** Media Library (read-oriented browser over `media`, with alt-text editing and usage).
  - **System** (Super Admin): Users, Roles & Permissions, Activity Log, Security Settings, Maintenance (clear content cache, regenerate sitemap, regenerate conversions).
- **Resource standard:** tabs Content / Sections / Media / SEO / Publishing / Relationships, built from shared schema classes. Status badges, filters, search, permission-gated bulk actions (publish/unpublish/archive/delete). Reorderable tables where there is `sort_order`. Live slug generation on create, with a redirect warning on published slug edits. Restricted RichEditor toolbar (bold, italic, link, h2–h4, ul/ol, blockquote, code) matching the sanitiser. Actions: Preview / Publish / Unpublish / Schedule / Archive / Duplicate, all delegating to `Domain` Actions.
- **Inline content-rule warnings:** consent missing (publish blocked), placeholder certification badge, organisation not cleared, unverified metrics, more than 5 process steps, missing alt text (save blocked), and a "Demo" badge on `is_demo` records.
- **Dashboard widgets:** content counts by status, the 5 latest submissions, CV views/downloads (30 days, from `analytics_events`), and quick links.
- **Project creation:** a simple create form (basics + card fields); the case study is edited in the Sections tab. A wizard is not used, because it adds little for a single owner.

## 7. Media (ADR-007)

- `spatie/laravel-medialibrary` 11 + the official Filament 5 plugin (`SpatieMediaLibraryFileUpload`).
- Collections are defined centrally in `MediaRules`:

| Collection | Models | Rules |
|---|---|---|
| `thumbnail` | Project | single; jpg/png/webp/avif; ≤ 5 MB |
| `gallery` | Project | multiple; same |
| `cover` | Page, Article | single; same |
| `logo` | Project, Certification | single; raster only (SVG disallowed, ADR-013) |
| `photo` | Recommendation, Author, block images | single |
| `og_image` | Page, Project, Article, settings | single; min 1200×630 |
| `file` | Document | single; **PDF only**; ≤ 10 MB; **private disk** |

- Custom properties: `alt` (required for images, enforced by form rules **and** a model-level `MediaAltTextGuard` before public use) and `caption`.
- Block images are stored as media attached to the owner. The block `data` holds media UUIDs and per-image alt/caption, and the renderer batch-loads them.
- Conversions (queued): `thumb` 480w, `card` 800w, `hero` 1600w, `og` 1200×630 crop. Responsive images (`withResponsiveImages()`) in WebP; AVIF only if GD supports it (checked at boot). EXIF is stripped by re-encoding.
- Uploads: server-side MIME sniffing (`finfo`), extension allowlist, size limits from `config/portfolio.php`, and randomised file names (`FileNamer`).
- `<x-media.image>`: `srcset`/`sizes`, width/height, `loading="lazy"` by default, `fetchpriority="high"` + eager for the LCP image, required alt (decorative images must pass `alt=""` explicitly), and graceful nothing-render when the media is missing.
- Disks: `public` for imagery; `private` (local, non-public) for CV/documents. S3-compatible storage works purely through `FILESYSTEM_DISK`, `MEDIA_DISK`, and `PRIVATE_DISK` config.

## 8. Security

| Concern | Design |
|---|---|
| Rich HTML | `ContentSanitizer` (symfony/html-sanitizer) with an allowlist mirroring the RichEditor toolbar. Links are forced to `rel="noopener noreferrer"` when they have a target, and schemes are limited to `https` and `mailto`. Sanitised **on save** (model cast `SanitizedHtml`) **and at render** (`<x-rich-text :html="…">`, the only component allowed to use `{!! !!}`). A Pint/Arch test bans `{!!` everywhere except an allowlisted file list. |
| Embeds | Video: provider allowlist with ID extraction. Booking: domains come from the provider adapter. No editor-supplied `<script>` or `<iframe>`. |
| Authorization | A policy per model. Filament resources, actions, bulk actions, and relation managers call policies. Preview requires signature + auth + `view` permission. |
| Rate limits | `contact` (5/min, 20/day per IP; configurable), `cv` (60/min per IP), `preview` (30/min per user), Filament login (built-in). |
| Spam | `spatie/laravel-honeypot` (honeypot + min submit time). `CaptchaVerifier` contract with `NullVerifier`, `TurnstileVerifier`, `RecaptchaV3Verifier`; driver set in `SecuritySettings`, keys only in `.env`. |
| Headers | `SecurityHeaders` middleware: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN` + `frame-ancestors 'self'`, `Permissions-Policy` (camera/mic/geolocation off), HSTS in production. |
| CSP (ADR-015) | Public site: nonce-based (`Vite::useCspNonce()`), `script-src 'self' 'nonce-…'` + analytics and booking provider domains taken **dynamically** from settings. This uses the **Alpine.js CSP build**, so `unsafe-eval` is not needed. Admin panel: a separate, more permissive policy (Livewire/Alpine in Filament need `unsafe-eval`), scoped to the admin path. Feature tests assert that the booking provider domains are present in the CSP when booking is enabled. |
| Sessions | `secure`, `http_only`, `same_site=lax` in production; regenerated on login (Filament default). |
| Privacy | IPs HMAC-hashed; retention pruning; analytics payloads free of personal data; contact bodies never logged. |
| Admin discoverability | The admin path is excluded from the sitemap, `robots.txt`, and public HTML. There is no "Login" link on the public site. |
| Ops | `composer audit` and `npm audit` in Phase 7. `.env.example` lists keys only. `APP_DEBUG=false` is documented for production. |

## 9. Roles and permissions (ADR-008)

`spatie/laravel-permission` with **hand-written policies** and no Filament Shield. The matrix is fixed, small, and PRD-defined. Shield's generated per-resource permissions and UI would add moving parts without simplifying anything here.

- **Roles:** `super_admin`, `content_manager`, `editor` (`UserRole` enum).
- **Permissions** (seeded idempotently from `config/permissions.php`): `{content}.view|create|update|delete|publish` for pages, projects, experiences, skills, certifications, articles, recommendations, taxonomy; `media.upload`, `media.manage`; `navigation.manage`; `site.manage_cta`, `site.manage_booking`, `site.manage_cv`, `site.manage_general`, `site.manage_seo`, `site.manage_analytics`, `site.manage_security`; `redirects.manage`; `contact.view`, `contact.manage`; `users.manage`; `activity.view`; `maintenance.run`; `seo.structured_data_override`.
- **Super Admin is granted every permission explicitly. There is no `Gate::before` bypass.** This keeps invariant rules ("system pages can't be deleted", "can't publish without consent") binding on everyone.
- Editors create and update content and upload media, but cannot publish or delete. They save drafts, and a Content Manager publishes. A review queue is out of scope (documented).
- **Last Super Admin protection** in `UserPolicy` + `DemoteUser`/`DeleteUser` actions + a model observer.
- `php artisan portfolio:create-admin` creates the first super admin interactively (password prompt, validated). No default password is ever seeded.

## 10. Audit and editorial workflow

- `spatie/laravel-activitylog` 5: `LogsActivity` on content models (dirty attributes only; excludes `message`, `password`, tokens, and MFA secrets). Explicit activity entries from Actions: publish, unpublish, schedule, archive, restore, section reorder (before/after order), CV replacement, settings changes (changed keys + values, except secrets), and user/role changes.
- A read-only Activity Log resource for Super Admin.
- **Revisions (deliberately deferred):** no versioning system is built now. `PublishContent` stores a snapshot of the record + sections in the activity `properties` on each publish, which allows a "restore from publish snapshot" feature later without schema changes.

## 11. Frontend architecture

### 11.1 Stack and loading

- Blade components, Tailwind CSS 4 (tokens via `@theme` CSS custom properties), and Vite 8.
- `resources/js/app.js` (tiny core: Alpine CSP build, mobile nav, `track()` + delegated `data-track` listener, reduced-motion-aware reveal) plus **on-demand modules** loaded by `data-module` attributes through dynamic `import()`: `filters` (projects/insights), `carousel`, `booking`, `contact-form`, `case-study-metrics` (scroll depth / time on page), `share`.
- Progressive enhancement: filters work via the `?category=&tag=` query string server-side and are enhanced client-side with History API sync and no reload. Navigation works without JS (`<details>` fallback + `:target`). The contact form POSTs normally without JS. The booking embed has a `<noscript>` hosted link.
- The contact form uses **Alpine + fetch**, not Livewire, so the public site stays free of Livewire JS. That keeps it smaller and allows a strict CSP (ADR-016).

### 11.2 Design tokens (proposal; full table in `docs/design/DESIGN-SYSTEM.md`, Phase 3)

**Source palette (PRD §21.4 swatch image, embedded in the .docx):** `#081B2C`, `#102A43`, `#3C5A73`, `#E9E4D6`, `#C7BFAE`. These five values are canonical. The swatch has no Electric Blue, so the blue shades below are proposed (open question 9). Tokens marked *derived* are additions needed for states and surfaces.

| Token | Hex | Source | Role |
|---|---|---|---|
| `--color-navy-950` | `#081B2C` | PRD | Primary text, header/footer, Final CTA section |
| `--color-navy-900` | `#102A43` | PRD | Headings on ivory, hover surfaces on navy, secondary dark sections |
| `--color-steel-600` | `#3C5A73` | PRD | Muted text, meta lines, input borders, icons |
| `--color-ivory-200` | `#E9E4D6` | PRD | **Warm Ivory — page background** |
| `--color-stone-300` | `#C7BFAE` | PRD | Dividers, card borders (decorative), tag fills, muted text on navy |
| `--color-ivory-50` | `#F5F2EA` | derived | Raised card surface on the ivory page (gentle lift, not stark white) |
| `--color-blue-500` | `#2F6BFF` | proposed | Electric Blue: **non-text** accent only (highlights, focus ring on ivory) |
| `--color-blue-600` | `#1F55E0` | proposed | Primary button fill, links on ivory |
| `--color-blue-700` | `#1A46BD` | proposed | Hover/pressed |
| `--color-blue-300` | `#8FB0FF` | proposed | Links and focus ring on navy |
| `--color-error-700` / `success-700` / `warning-700` | `#B42318` / `#05603A` / `#93370D` | derived | States |

**Verified WCAG contrast** (computed 2026-10-03):

| Foreground / background | Ratio | Use | AA |
|---|---|---|---|
| navy-950 / ivory-200 | 13.74 | body text on page | ✓ |
| navy-950 / ivory-50 | 15.60 | text on cards | ✓ |
| navy-900 / ivory-200 | 11.53 | headings | ✓ |
| steel-600 / ivory-200 | 5.70 | muted text, input borders | ✓ |
| steel-600 / ivory-50 | 6.47 | muted text on cards | ✓ |
| navy-950 / stone-300 | 9.55 | text on tags | ✓ |
| ivory-200 / navy-950 | 13.74 | text on navy | ✓ |
| stone-300 / navy-950 | 9.55 | muted text on navy | ✓ |
| stone-300 / navy-900 | 8.01 | muted text on navy-900 | ✓ |
| blue-600 / ivory-200 | 4.81 | links on page | ✓ |
| blue-600 / ivory-50 | 5.47 | links on cards | ✓ |
| blue-700 / ivory-200 | 6.23 | link hover | ✓ |
| white / blue-600 | 6.12 | primary button label | ✓ |
| blue-300 / navy-950 | 8.16 | links and focus ring on navy | ✓ |
| blue-300 / navy-900 | 6.85 | links on navy-900 | ✓ |
| blue-500 / ivory-200 | 3.54 | **non-text only** (focus ring ≥ 3:1) | ✓ non-text; never used as text |
| error-700 / ivory-200 | 5.18 | error text | ✓ |
| success-700 / ivory-200 | 6.03 | success text (`#067647` measured 4.48, so it was darkened) | ✓ |
| warning-700 / ivory-200 | 5.92 | warning text | ✓ |
| stone-300 / ivory-200 | 1.44 | **decorative dividers only**; component boundaries (inputs, buttons) use steel-600 | n/a |

**Typography (proposal):** headings in **Source Serif 4** (variable, semibold, optical sizing) for editorial authority; body in **Inter** (variable) for readability. Both are OFL, self-hosted through `@fontsource-variable/*` and bundled by Vite (Latin subset), with `font-display: swap` and the critical weights preloaded. Measure: 65ch. Spacing scale: 4/8pt. Grid: 12 columns, `max-w` 1200px content / 720px prose. Breakpoints: 375, 768, 1024, 1440. Icons: Heroicons outline (one set, already in the dependency tree).

### 11.3 Components

As listed in MP §11.3: layout/navigation (including `nav.mobile-cta-bar`, which shows persistent CV + Contact below 1024px, and `sticky-contact`, which appears after 40% scroll on project/article detail pages), primitives, content cards, engagement components, states, and utilities. Each component takes view models/props only. Documented in `docs/design/DESIGN-SYSTEM.md`.

## 12. Contact, booking, CV

- **Contact:** `StoreContactSubmissionRequest` → `SubmitContactMessage`. The action runs SpamGuard (honeypot/timing/CAPTCHA) → persists → dispatches `ContactSubmissionReceived` → queued `OwnerNotificationMail` (+ an optional acknowledgement, off by default) → `AnalyticsTracker::record(contact_submit_success)`. Mail runs in queued listeners, so a mail failure never rolls back the stored submission (tested). Copy (success/error/privacy note, opportunity types) comes from `ContactSettings` with PRD defaults. JSON responses for the fetch path; redirect + flash + old input for the non-JS path.
- **Booking:** a `BookingProvider` enum (calendly, savvycal, cal_com, other) → `ProviderAdapter` (embed URL builder, script URL, CSP script/frame domains). `BookingPresenter` decides embed / link-out / hidden. **"Book a Call" CTAs are hidden centrally** by `CtaResolver` when booking is disabled. The embed loader lazy-loads on visibility or interaction, times out after ~8s, and listens for `error`, then shows the fallback panel (hosted link + email). Events: `booking_cta_click` (location) and `booking_widget_open`.
- **CV:** `CvRepository::current()` → `CvStreamer`. `/cv` → `inline; application/pdf`; `/cv/download` → `attachment; filename=<GeneralSettings cv_filename>`. Both record `cv_view`/`cv_download` with the `from` location (validated against the `CvLocation` enum), and `Cache-Control: private, no-store` so replacements show immediately. With no CV, buttons render the fallback ("CV temporarily unavailable — email me", copy from `CtaSettings`) and the endpoints render a friendly page (HTTP 404 with the fallback content, never a 500).

## 13. Analytics

- **Event catalogue:** the `AnalyticsEvent` enum is the single source. `php artisan portfolio:analytics-catalogue` exports `resources/js/analytics/events.generated.js`, and a test fails if the two drift.
- **Server:** an `AnalyticsTracker` contract. Drivers: `null`, `database` (first-party `analytics_events`, default for server events), `log`, `plausible` (Events API), and `ga4` (Measurement Protocol). The driver comes from `AnalyticsSettings` and is bound in a service provider.
- **Client:** `track(event, props)` is the **only** provider call site. Adapters: `null`, `plausible`, `umami`, `ga4`. The adapter is selected by `<meta name="analytics" …>` rendered from settings. Components only add `data-track` / `data-track-props`, and one delegated listener handles them.
- **Consent:** cookieless providers (Plausible/Umami) load without a banner. GA4 requires the consent banner before its script is loaded.
- **Coverage:** every §29 event, including location tags `cv_download_{header|hero|experience|contact|footer}`, `project_card_click` with `{project, source: homepage|listing|related}`, `case_study_view` + scroll 25/50/75/100 + time-on-page (toggle), `recommendation_carousel_advance`, `external_link_click`, and `cta_click` with the CTA key.

## 14. SEO

- `SeoResolver` precedence: entity `seo_meta` → owning page `seo_meta` → generated fallback (title/excerpt) → `SeoSettings` defaults. The title template is applied (`%s — {site name}`). `<x-seo.head>` renders title, description, canonical, robots, OG (with image dimensions), and Twitter card.
- `StructuredDataBuilder` emits JSON-LD from real data only: `WebSite` (all pages), `Person` (Home/About; `sameAs` from SocialSettings), `Article`, and `BreadcrumbList` (project/article detail). It never emits `Review`. `structured_data_override` is merged only when set by a Super Admin.
- **Sitemap:** `spatie/laravel-sitemap` writes `public/sitemap.xml` from live, indexable pages, projects, and articles with `lastmod`. It runs as a queued job on `ContentPublished` and nightly. It excludes noindex, drafts, `/404`, preview, and the admin path.
- **robots.txt:** controller-generated. In production it allows everything plus the sitemap reference; elsewhere it is `Disallow: /`.

## 15. Caching strategy

- **Store:** Redis with **tags** (`CACHE_STORE=redis`). The `ContentCache` wrapper falls back to **versioned keys** (a per-tag version counter) when the store doesn't support tags (database/file), so code is store-agnostic.
- **Cached:** resolved page section view models (`page:{id}`, plus the entity tags they reference, e.g. `projects`), navigation menus (`navigation`), settings (spatie settings cache), listing queries (`projects`, `articles`, …), and the redirect map (`redirects`).
- **Invalidation:** model observers and the `ContentPublished` / `ContentUnpublished` / `SettingsSaved` events flush exact tags on save, delete, restore, publish, and section save. Cache TTLs are capped at the next scheduled `published_at` (or the scheduler command flushes due content each minute).
- **No full-page response cache** (ADR-017). CSRF tokens on contact and nonce-based CSP make it unsafe without per-request rewriting. View-model caching plus `view:cache` achieves the performance target.
- **Production:** `config:cache`, `route:cache`, `view:cache`, `event:cache`, `icons:cache`, `filament:optimize`, `composer install --no-dev -o`, `npm run build`.

## 16. Testing strategy

- **Pest 5** on a dedicated MySQL schema `gf_portfolio_testing` (`RefreshDatabase`), for fidelity with production FKs, JSON, and unique-null behaviour (ADR-018).
- `Model::shouldBeStrict()` outside production, so lazy loading **throws** in tests.
- Factories for every model, with states (`published()`, `scheduled()`, `draft()`, `anonymised()`, `placeholder()`, `withConsent()`).
- Feature, unit, and architecture tests (Pest `arch()`: no `{!!` outside the allowlist, controllers don't use Eloquent directly, strict types everywhere in `app/`).
- Browser/a11y: Playwright + `@axe-core/playwright` (Node is available), covering the pages listed in MP §17.

## 17. Package list (to install in Phase 3 after dry runs)

Runtime: `filament/filament:^5.9`, `filament/spatie-laravel-media-library-plugin:^5.9`, `filament/spatie-laravel-settings-plugin:^5.9`, `spatie/laravel-medialibrary:^11.23`, `spatie/laravel-settings:^3.9`, `spatie/laravel-permission:^8.3`, `spatie/laravel-activitylog:^5.1`, `spatie/laravel-sitemap:^8.2`, `spatie/laravel-honeypot:^4.7`, `predis/predis` (unless phpredis is installed).
Already transitive (explicitly required because used directly): `symfony/html-sanitizer`, `blade-ui-kit/blade-heroicons`.
Dev: `pestphp/pest:^5`, `pestphp/pest-plugin-laravel`, `larastan/larastan:^3.12`, `laravel/pint` (skeleton).
npm: `alpinejs` + `@alpinejs/csp` build, `@fontsource-variable/inter`, `@fontsource-variable/source-serif-4`; dev `@playwright/test`, `@axe-core/playwright`.

## 18. Open questions for approval

1. **PHP 8.4 on PATH** (Discovery E1): may I `brew link --overwrite --force php@8.4` (changes your global `php`), or should I call the 8.4 binary explicitly for this project only?
2. **git:** may I run `git init -b main` and commit the docs as the first commit? Is there a remote (GitHub) to add?
3. **Local database:** may I create the `gf_portfolio` and `gf_portfolio_testing` schemas and a `gf_portfolio` MySQL user locally?
4. **Analytics provider default:** Plausible (recommended: cookieless, no banner), Umami, GA4, or none at launch?
5. **Booking provider:** Calendly, Cal.com, SavvyCal, or still undecided? (All are supported; this sets the seeded default.)
6. **Hosting target** (Forge/Ploi VPS, shared hosting, Laravel Cloud, other)? This affects queue workers and Redis availability.
7. **Fonts:** Source Serif 4 (headings) + Inter (body), or another pairing you prefer?
8. **`custom_embed` block:** omit as proposed (ADR-014)?
9. **Electric Blue:** the PRD swatch image has no blue. Is `#1F55E0` (links and buttons) / `#2F6BFF` (accent) acceptable, or do you have a specific hex?
