# Architecture Decision Records

Format: **Context → Decision → Consequences.** Status values: *Proposed* (awaiting Phase 2 approval), *Accepted*, *Superseded*.

---

## ADR-001 — Database-backed Laravel + Filament CMS instead of a headless CMS
**Status:** Accepted (2026-10-03)
**Context:** PRD §23.2 and §32 recommend a headless CMS or local content files and are framework-agnostic. The master prompt mandates Laravel + Filament with server-rendered Blade.
**Decision:** Build a database-backed CMS in Filament. Keep the PRD's *intent*: content decoupled from presentation, a single read-side data layer (`Queries/*`, `PageResolver`), structured and validated models, no redeploy for content changes, and static-like performance through caching.
**Consequences:** We own the admin UX and hosting of the CMS, including backups of the DB and media. In return the owner gets one integrated, permission-controlled tool and no third-party CMS cost.

## ADR-002 — Greenfield scaffold of Laravel 13
**Status:** Accepted (2026-10-03)
**Context:** Discovery found no Laravel application and no git repository (01-DISCOVERY §1).
**Decision:** Run `git init`, then scaffold `laravel/laravel` 13 into a scratch directory and move it into the repository root next to `docs/`. Skeleton defaults are kept unless a later ADR changes them.
**Consequences:** No backward-compatibility constraints. The welcome route is replaced by the CMS homepage route.

## ADR-003 — Version targets: PHP 8.4, Laravel 13, Filament 5
**Status:** Accepted (2026-10-03)
**Context:** The latest stable spatie packages (medialibrary 11.23, activitylog 5.1, sitemap 8.2) and `symfony/html-sanitizer` 8 require PHP ^8.4. Filament 5.9 supports Laravel 13. The default local CLI is PHP 8.1.
**Decision:** Target PHP 8.4, pinned through `config.platform.php` and `.php-version`. Use Laravel ^13, Filament ^5.9, and Livewire 4.
**Consequences:** Production hosting must provide PHP 8.4. Local commands must use the 8.4 binary.

## ADR-004 — Models stay in `app/Models`; domain logic in `app/Domain/*`
**Status:** Accepted (2026-10-03)
**Context:** MP §3.1 allows either. Filament resource generation, factories, and morph maps are most idiomatic with `App\Models`.
**Decision:** Keep Eloquent models in `app/Models`. Put Actions, Queries, Services, Enums, DTOs, Events, and Listeners in domain modules.
**Consequences:** Less friction with framework tooling, at the cost of a slight separation between each model and its domain module.

## ADR-005 — Settings via `spatie/laravel-settings` + Filament settings pages
**Status:** Accepted (2026-10-03)
**Context:** About nine typed settings groups are needed (MP §3.3), and there is no existing settings system.
**Decision:** One typed `Settings` class per group, each edited through a Filament `SettingsPage`. Secrets stay only in `.env`/config.
**Consequences:** Typed, cached settings with migrations for defaults. Changes are logged through a `SettingsSaved` listener.

## ADR-006 — Sections edited with a relationship-backed Repeater (not Builder + sync)
**Status:** Accepted (2026-10-03)
**Context:** Sections must persist to a shared morph table with add, remove, reorder, duplicate, hide, locked items, and transactional saves (MP §4.2).
**Decision:** Use `Repeater::make('sections')->relationship()->orderColumn('sort_order')`. A `block_type` select drives a dynamic `data` schema from `BlockRegistry`. Server-side validation runs in `SaveSections` before commit, inside the page's DB transaction.
**Consequences:** Native persistence, ordering, and cloning, with per-item deletion control. The UI is slightly less "visual block picker" than Builder, which is mitigated by grouped, searchable block select labels and icons. Exact Filament 5 APIs are checked against vendor source before implementation.

## ADR-007 — Media via `spatie/laravel-medialibrary` + official Filament plugin
**Status:** Accepted (2026-10-03)
**Context:** The system needs alt and caption properties, queued responsive conversions, and private storage for the CV, and must work with S3 through config alone.
**Decision:** Use medialibrary 11 with collections and rules centralised in `MediaRules`. The CV is a `Document` with media on a private disk, served by `/cv`.
**Consequences:** A mature conversion pipeline. The `media` table is polymorphic, which is justified because many owners share it.

## ADR-008 — `spatie/laravel-permission` with hand-written policies; no Filament Shield
**Status:** Accepted (2026-10-03)
**Context:** The role matrix is small and fixed by MP §9. Shield (4.3, Filament 5-compatible) generates per-resource permissions and its own UI.
**Decision:** Seed permissions from `config/permissions.php`. Write explicit policies. Super Admin receives every permission explicitly, with **no `Gate::before` bypass**, so invariants such as non-deletable system pages and consent-gated publishing bind everyone.
**Consequences:** More policy code to write, but every rule is explicit and testable. A simple Roles resource lets Super Admin adjust role permissions.

## ADR-009 — Shared typed `tags` with a polymorphic `taggables` pivot
**Status:** Accepted (2026-10-03)
**Context:** Tools appear on projects and experiences, and topics on articles (§10.1, §12.1, §16.1). Project categories are a curated, ordered taxonomy.
**Decision:** One `tags` table with `type` (`tool`, `topic`) and a morph pivot. Project categories and article categories get their own tables.
**Consequences:** A tool is created once and reused across projects and experiences. The polymorphism is justified because several owners share one concept.

## ADR-010 — Soft deletes only on recoverable content
**Status:** Accepted (2026-10-03)
**Decision:** Pages, projects, articles, experiences, recommendations, and certifications. Taxonomy, sections, navigation, and submissions are hard deleted (submissions are pruned by retention).
**Consequences:** Public queries must exclude trashed records, which the `live()` scope and route binding already do.

## ADR-011 — SEO metadata in a polymorphic `seo_meta` table
**Status:** Accepted (2026-10-03)
**Context:** Pages, projects, and articles need identical SEO fields.
**Decision:** A one-to-one morph table, eager-loaded on detail routes, with one shared `SeoFormSchema`.
**Consequences:** No column duplication across three tables, at the cost of one extra eager-loaded relation.

## ADR-012 — Experience responsibilities and achievements as ordered JSON lists
**Status:** Accepted (2026-10-03)
**Context:** These are owned, ordered, plain-text items that are never queried or shared.
**Decision:** Store them as JSON arrays edited with a simple Repeater/TagsInput.
**Consequences:** Simpler than child tables. If they ever need to be queried individually, a migration to child tables is straightforward.

## ADR-013 — SVG uploads disallowed
**Status:** Accepted (2026-10-03)
**Context:** SVG can carry scripts. Sanitising SVG reliably adds a dependency and risk, and logos work fine as PNG or WebP.
**Decision:** Accept raster images only, for every role.
**Consequences:** Logos must be uploaded as raster images. This can be revisited with a vetted SVG sanitiser later.

## ADR-014 — Omit the `custom_embed` block
**Status:** Accepted (2026-10-03)
**Context:** MP §4.3 allows omitting it with justification. The PRD needs video embeds and booking, both covered by allowlisted blocks.
**Decision:** Don't build it. The registry makes it a one-class addition later, as a super-admin-only, disabled-by-default block.
**Consequences:** Less XSS and CSP surface.

## ADR-015 — Nonce-based CSP with the Alpine.js CSP build on the public site
**Status:** Accepted (2026-10-03)
**Context:** The standard Alpine build needs `unsafe-eval`. The public site should have a strict CSP. Filament/Livewire in the admin need a more permissive policy.
**Decision:** The public site uses `@alpinejs/csp`, with components registered through `Alpine.data()`. Nonces come from `Vite::useCspNonce()`, and provider domains are added dynamically. The admin path gets a separate, documented, more permissive CSP.
**Consequences:** Alpine expressions in public Blade must stay simple (property and method references), with logic in JS modules. Feature tests assert the booking and analytics domains appear when they are configured.

## ADR-016 — Contact form uses Alpine + fetch with a full POST fallback (not Livewire)
**Status:** Accepted (2026-10-03)
**Decision:** Keep Livewire off the public site. Use the same `ContactController@store` for JSON (fetch) and a normal POST (redirect with flash and old input).
**Consequences:** Smaller public JS and compatibility with the strict CSP. Both paths share one Form Request and one Action.

## ADR-017 — No full-page response cache
**Status:** Accepted (2026-10-03)
**Context:** CSRF tokens and per-request CSP nonces make cached HTML incorrect.
**Decision:** Cache view models, navigation, settings, and listing queries with tagged invalidation, and rely on `view:cache` and the route/config caches.
**Consequences:** Each request still renders Blade, which is cheap. This may be revisited behind a CDN with edge-side handling if needed.

## ADR-018 — Tests run on a dedicated MySQL schema
**Status:** Accepted (2026-10-03)
**Context:** Behaviour depends on MySQL specifics: unique-nullable flags, JSON, and FK actions. MySQL is available locally.
**Decision:** Use `gf_portfolio_testing` with `RefreshDatabase`.
**Consequences:** Slower than SQLite in memory, but faithful to production. CI will need a MySQL service.

## ADR-019 — PRD interpretations
**Status:** Accepted (2026-10-03)
- §20.1 "first viewed in a new **icon**" is read as "new **tab**" (matches MP §0.4).
- §6.1 "404 (/404)": unknown URLs get the CMS-driven 404 with status 404. `/404` also renders it with status 404 and is excluded from the sitemap.
- §6.1 Recommendations "integrated section … or /recommendations": recommendations appear as blocks. A `/recommendations` page can be created in the CMS with `recommendations_grid` when volume warrants it, and needs no code change.
- §9.1 "Secondary CTA: Download CV → triggers file download" is superseded by §20.1 (view first, download optional), per MP §0.4.

## ADR-020 — Revision history deferred
**Status:** Accepted (2026-10-03)
**Decision:** No versioning system now. The publish Action stores a snapshot of the record and its sections in the activity log properties.
**Consequences:** A later "restore snapshot" feature needs no schema change.

## ADR-021 — Editor workflow without a review queue
**Status:** Accepted (2026-10-03)
**Decision:** Editors save drafts. Content Managers and Super Admins publish. There is no submission or approval queue (out of scope for a single-owner site).
**Consequences:** Simple. A `review_requested_at` flag can be added later.

## ADR-022 — Defaults for the Phase 2 open questions
**Status:** Accepted (2026-10-03). The owner said "continue with Phase 3" without answering ARCHITECTURE §18, so conservative, reversible defaults were applied:
1. **PHP 8.4:** project-only. Commands run with `/opt/homebrew/opt/php@8.4/bin` first on PATH, and `.php-version` + `config.platform.php` pin 8.4. The global `php` (8.1) is untouched.
2. **git:** local repository on `main`, no remote. A remote can be added later.
3. **Database:** local schemas `gf_portfolio` and `gf_portfolio_testing` with a dedicated `gf_portfolio` user (not root).
4. **Analytics:** Plausible client/server adapters are the default provider, **disabled** until a site domain is configured. Server key events use the first-party `database` driver.
5. **Booking:** disabled by default; the provider enum defaults to Calendly with an empty URL. All "Book a Call" CTAs stay hidden until it's configured.
6. **Hosting:** generic Linux VPS assumptions (PHP-FPM, Supervisor, cron, Redis). Everything is configured through `.env`.
7. **Fonts:** Source Serif 4 (headings) + Inter (body), self-hosted.
8. **`custom_embed`:** omitted (ADR-014).
9. **Electric Blue:** `#1F55E0` for text/fills, `#2F6BFF` for non-text accents, `#1A46BD` for hover, `#8FB0FF` on navy. These live in CSS tokens, so they're a one-line change.

---

## Phase 3 decisions

## ADR-023 — MFA enforcement narrowed to Super Admins through a custom middleware
**Status:** Accepted (2026-10-03)
**Context:** Filament 5 evaluates `multiFactorAuthentication(isRequired:)` once, at route registration, when no user is known, so a per-user closure silently never enforces anything. This was found by a failing test.
**Decision:** Mark MFA as required on the panel and swap the enforcing middleware for `RequireMultiFactorForSuperAdmins`, which only enforces for Super Admins. Other roles may opt in from their profile.
**Consequences:** Because the panel treats MFA as "required", users who opt in cannot disable MFA themselves afterwards; a Super Admin can reset it. This is acceptable for a small team.

## ADR-024 — Redirect middleware is global, not in the `web` group
**Status:** Accepted (2026-10-03)
**Context:** Route-group middleware only runs for matched routes. A retired URL has no route, so a `web`-group redirect check never ran and the request 404'd. This was found by a failing test.
**Decision:** Prepend `HandleRedirects` to the global middleware stack (GET/HEAD only).
**Consequences:** It runs one cached map lookup per request, including admin requests, which is negligible.

## ADR-025 — Last-Super-Admin guard lives in `User::delete()`
**Status:** Accepted (2026-10-03)
**Context:** spatie/permission's `HasRoles` detaches roles in its own `deleting` listener, which runs before listeners registered in `booted()`, so a `deleting` guard always saw "no roles".
**Decision:** Override `delete()` to check `UserPolicy::isLastSuperAdmin()` before any model events fire. The policy remains the primary gate.
**Consequences:** Mass deletes through the query builder bypass this, as they bypass all model events. Admin UI deletes go through models.

## ADR-026 — Settings with lists of records use an explicit pass-through cast
**Status:** Accepted (2026-10-03)
**Context:** spatie/laravel-settings resolves casts from `@var` docblocks and cannot handle arrays whose items are arrays (null inner cast) or `mixed`/multi-type unions.
**Decision:** `SocialSettings::links` and `BookingSettings::meeting_types` declare `PassthroughArrayCast`, which normalises every item value to `string|null`, so `@var array<int, array<string, string|null>>` is accurate for both the package and Larastan.
**Consequences:** Numeric values such as meeting duration are stored as numeric strings, and consumers cast them.

## ADR-027 — Admin keeps Filament's bundled (self-hosted) Inter font
**Status:** Accepted (2026-10-03)
**Context:** Filament 5 defaults to `LocalFontProvider` with Inter published under `public/fonts/filament`. Choosing a custom family would switch it to Bunny Fonts, a third-party CDN.
**Decision:** Don't set a custom admin font. The public site self-hosts Source Serif 4 and Inter via fontsource.
**Consequences:** The admin CSP needs no external font domains.

---

## Phase 4 decisions

## ADR-028 — Block images belong to the section, keyed by slot
**Status:** Accepted (2026-10-03)
**Context:** Block images need medialibrary conversions and required alt text, inside a relationship repeater whose items are `ContentSection` records.
**Decision:** `ContentSection` implements `HasMedia` (`block_images` collection). Each image field stores a slot name in `data`, plus its alt (and caption). The upload is filtered by the `slot` custom property. `BlockContext::image($slot)` resolves it.
**Consequences:** Media is eager-loaded with sections (no N+1). Deleting a section removes its images. The in-builder clone copies data only, while `DuplicateContent` copies images.

## ADR-029 — Section validation runs on dehydrated, normalised item state
**Status:** Accepted (2026-10-03)
**Context:** Filament 5's raw repeater state holds RichEditor documents as arrays and enum selects as enum instances, so validating raw Livewire state was wrong. Tests found both.
**Decision:** `GuardsContentSaves` collects each item's dehydrated state (`$item->getState(shouldCallHooksBefore: false)`), and `SaveSections` validates `$block->normalize($data)`, which is exactly what gets stored.
**Consequences:** Error keys still map to `data.sections.{itemKey}.data.{field}`, so messages appear on the right field.

## ADR-030 — Project galleries live in case-study gallery blocks
**Status:** Accepted (2026-10-03)
**Context:** A multi-file upload cannot carry per-image alt text, which the PRD requires (§26).
**Decision:** The project's own media is the card image (`thumbnail`) and logo. Screenshots and diagrams use `cs_gallery` / `cs_section` figures, which carry alt text and captions per image. The unused `gallery` collection was removed from Project.
**Consequences:** One way to add case-study imagery, always with alt text.

## ADR-031 — Draft preview links appear once public templates exist
**Status:** Accepted (2026-10-03)
**Context:** Preview renders the public templates, which are built in Phase 5.
**Decision:** `PreviewUrl` (signed, expiring) and the Preview actions are built now. They show only when the `preview` route is registered (`Route::has('preview')`), which happens in Phase 5.
**Consequences:** No dead buttons in the meantime. Tracked as a Phase 5 item, not marked complete.

## ADR-032 — Demo content identification
**Status:** Accepted (2026-10-03)
**Decision:** Records with an `is_demo` column are flagged. Taxonomy without the column (skills, tags, categories) is identified by bracketed names (`[Skill 1]`). PRD example category names (e.g. "Product", "Agile / Scrum") are kept as sensible defaults. `portfolio:purge-demo` deletes sections through models, so their images go too.

---

## Phase 5 decisions

## ADR-033 — Explicit cache unserialize allowlist
**Status:** Accepted (2026-10-03)
**Context:** Laravel 13 defaults `cache.serializable_classes` to `false` (no objects unserialized; gadget-chain hardening), so cached Eloquent collections and NavLinks came back as `__PHP_Incomplete_Class` and every page 500'd on Redis. Array-store tests didn't serialize, so they missed it.
**Decision:** Allowlist exactly the classes the content cache stores (our models, Eloquent/Support collections, Carbon, Pivot/MorphPivot, Media + MediaCollection, NavLink). Tests run the array store with `serialize=true` (`CACHE_ARRAY_SERIALIZE`), so a missing class fails a test.
**Consequences:** Protection stays for every other class. Caching a new object type requires adding its class here.

## ADR-034 — Mobile menu is a native `<details>` disclosure
**Status:** Accepted (2026-10-03)
**Decision:** The hamburger is `<details>/<summary>`, so it opens without JS and gets expanded state natively. Alpine (CSP build) adds Escape-to-close with focus return, focus into the menu, and close-on-navigate. The persistent mobile CTA bar carries CV and Contact (PRD §6.4).
**Consequences:** No focus trap is needed for a non-modal dropdown. It works with JS disabled.

## ADR-035 — Recommendations carousel is manual (no autoplay)
**Status:** Accepted (2026-10-03)
**Decision:** Previous/next buttons and a polite live region; nothing moves on its own, so no pause control is needed (WCAG 2.2.2). Without JS, all items are listed. A single recommendation renders as one card.

## ADR-036 — Social links are labelled text links with generic icons
**Status:** Accepted (2026-10-03)
**Context:** The one-icon-set rule (PRD §22) uses Heroicons, which has no brand logos.
**Decision:** Social links show the platform name with a consistent outline icon (envelope for email, external-link for others). `linkedin_click` and `social_click` are tagged by location.
**Consequences:** Accessible names are obvious. Brand logos can be added later as a single inline set if the owner wants them.

## ADR-037 — Phase 6 features render safe fallbacks until wired
**Status:** Accepted (2026-10-03)
**Decision:** Views check `Route::has()` for the contact POST and `/cv`. Until Phase 6, the contact block offers the direct email address, CV buttons render the "CV unavailable — email me" fallback, and booking renders meeting-type link-outs plus the fallback panel (never a blank space). Preview is live (ADR-031 resolved).

---

## Phase 6 decisions

## ADR-038 — Booking: iframe embeds for Calendly and Cal.com, readiness by provider postMessage
**Status:** Accepted (2026-10-03)
**Context:** PRD §18 wants an on-page calendar with a fallback that never leaves a blank space. Provider scripts would widen the CSP. A cross-origin iframe fires `load` even when its page failed, which a live check showed: a blocked or 404 calendar never triggered the fallback.
**Decision:** `BookingPresenter` embeds only hosts it recognises for the selected provider (calendly.com; cal.com/app.cal.com) as a lazy iframe, and adds exactly those frame origins to the CSP. Readiness is confirmed by the provider's own `postMessage`. Without it, the fallback (hosted page + email) appears below the frame after 8s, but the frame is never removed. SavvyCal and "other" providers link out. Without JS, the fallback is shown.
**Consequences:** The positive path needs a real booking link to verify (owner action). An invalid or blocked calendar always shows the fallback.

## ADR-039 — Spam is discarded silently; honeypot fields are mandatory
**Status:** Accepted (2026-10-03)
**Decision:** A filled honeypot, a submission faster than 3s, stripped honeypot fields (`honeypot_fields_required_for_all_forms=true`) or a failed CAPTCHA return the normal success response but store and send nothing, so bots learn nothing. The honeypot view is published and hidden with a CSS class, because the CSP forbids inline styles.
**Consequences:** A human submitting within 3 seconds would see "sent" without delivery. That is practically impossible for a real message (the minimum is 10 characters plus the required fields).

## ADR-040 — One contact endpoint, queued mail, analytics on attempt and success
**Status:** Accepted (2026-10-03)
**Decision:** `POST /contact` serves JSON (enhanced form) and redirect + flash + old input (no JS). The message is stored first, then `ContactSubmissionReceived` is handled by queued listeners: the owner notification (to recipients, or the public email) and an optional acknowledgement that is off by default. Domain listeners are registered explicitly, since only `app/Listeners` is auto-discovered. `contact_submit_attempt` and `contact_submit_success` are recorded server-side and in the browser.
**Consequences:** A mail outage only delays notifications; messages are never lost (tested).

## ADR-041 — Analytics: catalogue enum, first-party server events, cookieless client by default
**Status:** Accepted (2026-10-03)
**Decision:** `AnalyticsEvent` is the single catalogue. The browser copy is generated by `portfolio:analytics-catalogue` and checked by a test, and templates may only use catalogue names (also tested). The server driver defaults to the first-party `database` driver (dashboard), with optional Plausible/GA4 sent via a queued job. The client adapter (Plausible/Umami/GA4/none) is described by `<meta name="analytics">`, and provider scripts load with the CSP nonce from `<meta name="csp-nonce">`. GA4 loads only after consent (minimal accessible banner, choice in localStorage). The provider's script origin is added to the CSP automatically.
**Consequences:** Swapping providers is a settings change. No personal data is sent.

## ADR-042 — sitemap.xml stored on the local disk, regenerated by queued job
**Status:** Accepted (2026-10-03)
**Decision:** `SitemapGenerator` writes `storage/app/private/sitemap/sitemap.xml` (not `public/`, so the route controls it and nothing is served outside the app). It is regenerated by a unique queued job on `ContentPublished`/`ContentUnpublished`, nightly at 03:15, from Maintenance, or on demand if missing. `robots.txt` is dynamic: disallow-all outside production; in production, allow plus the sitemap reference. The admin path is never mentioned.

---

## Phase 7 decisions

## ADR-043 — No JavaScript framework on the public site (supersedes the Alpine part of ADR-015)
**Status:** Accepted (2026-10-03)
**Context:** By Phase 7 Alpine's only job was Escape handling on the `<details>` mobile menu, yet its CSP build was 72 KB of the core bundle and the main reason LCP missed "Good".
**Decision:** Remove Alpine. Every behaviour is a small vanilla module loaded by `[data-module]` (`mobile-nav`, `filters`, `carousel`, `booking`, `contact-form`, `case-study`, `consent`, `sticky-contact`). The nonce-based CSP from ADR-015 is unchanged, and with no framework there is nothing that could need `unsafe-eval`.
**Consequences:** Core JS is 4.6 KB. Each behaviour is a plain function with no framework idioms.

## ADR-044 — Browser tests on a disposable e2e environment
**Status:** Accepted (2026-10-03)
**Decision:** Playwright runs `php artisan serve --env=e2e` against `gf_portfolio_e2e`. Configuration lives in `.env.e2e` (git-ignored; it reuses the local DB user). `tests/Browser/global-setup.mjs` runs `migrate:fresh` and `BrowserTestSeeder`, which refuses to run outside `e2e` and adds test-only fixtures (recommendations, a 7-item capabilities grid, an unreachable booking provider). The development database is never touched.
**Consequences:** `npm run test:browser` is repeatable. CI needs MySQL and Chromium (`npx playwright install chromium`).

---

## ADR-045 — CI/CD with GitHub Actions: build once, rsync releases, atomic symlink switch
**Status:** Accepted (2026-10-03)
**Context:** The owner asked for CI/CD for development and production. Hosting is a generic VPS (ADR-022); server details aren't known yet.
**Decision:**
- **Branches:** `develop` deploys to the `development` environment, and `main` to `production` behind environment approval.
- **CI:** `ci.yml` (Pint, Larastan, audits, Pest on MySQL, Playwright + axe) runs on PRs and is a required prerequisite of every deploy.
- **Deploy:** `deploy.yml` builds once on the runner (vendor + assets, so the server needs only PHP), rsyncs to `releases/<run>-<sha>`, and runs `deploy/remote-deploy.sh` (shared `.env`/storage, migrate, seed essentials, caches, atomic `current` switch, queue restart). It then health-checks `/up` and rolls back automatically on failure.
- **Configuration:** server details live in per-environment secrets and variables. Deploys skip with a notice until they are configured.
**Consequences:** Zero-downtime deploys and instant rollbacks (5 releases kept). Migrations run before the switch, so they must stay backward-compatible with the running release (all current ones are additive).

## ADR-046 — Placeholder content seeder and reversible placeholder settings
**Status:** Accepted (2026-10-03)
**Context:** The owner wanted every page fully populated with placeholders for review. `DemoContentSeeder` left 18 blocks, all images, the CV, SEO and several settings empty.
**Decision:** `PlaceholderContentSeeder` runs `DemoContentSeeder` once, then fills the gaps under the same MP §16 rules: bracketed text, generated images labelled "[Placeholder]", example.com addresses, and no recommendations or verified metrics. Everything it adds can be found again: images carry `placeholder: true`, added system-page sections are labelled `Demo placeholder section`, and the CV version is `[Placeholder CV]`. `PlaceholderSettings::revert()` restores a setting only while it still holds the placeholder, so the owner's edits survive a purge.
**Consequences:** One command gives a fully populated review site, and `portfolio:purge-demo` removes it cleanly. `cs_quote` is the only block not seeded.

## ADR-047 — Owner-managed appearance: colour roles, dark mode, fonts and backgrounds
**Status:** Accepted (2026-10-03)
**Context:** The owner asked to manage fonts, colours (site, sections, buttons, cards) and backgrounds (colour or image), with light and dark mode. Components used palette utilities (`text-navy-950`), and one colour often did two jobs (body text and dark-section background), so overriding the palette could not work.
**Decision:**
- **Colour roles.** Components use roles only (`bg-surface`, `text-muted`…). Raw palette names were removed from the theme, and an architecture test forbids them in views. `.surface-inverse` re-points the roles inside dark sections.
- **Settings and output.** `ThemeSettings` holds light and dark palettes per role, the colour mode (light, dark or system), a visitor switch, the fonts and the site background. `ThemeStylesheet` emits validated values only (hex colours, catalogue keys, a restricted path, `<` stripped) as `:root` custom properties in a nonce'd `<style>`. The CSP is unchanged.
- **Accessibility.** Every rendered pair is listed in `ThemePalette::pairs()`, and saves below WCAG AA are refused with the failing pair named.
- **Fonts.** A curated catalogue of self-hosted variable fonts, one Vite entry each, loaded only when selected. No font CDN, and `font-src` stays `'self'`.
- **Section backgrounds.** Stored in `content_sections.appearance` (`SectionAppearance`) and validated for contrast. Image overlays have a computed minimum strength. The image is section media in slot `section-background`.
- **Permission.** `site.manage_appearance`, given to Super Admins and Content Managers.
**Consequences:** The owner can re-colour and re-type the whole site without a deploy, and cannot save an unreadable combination. The default look is unchanged (PRD palette, light only). Free-form custom font uploads are not supported: they would need licence checks and font-file validation. A possible follow-up.

## ADR-048 — Operations: automated backups, operator alerts, housekeeping, upload clean-up
**Status:** Accepted (2026-10-05)
**Context:** The 2026-10-05 system review found that backups were documented but not automated or ever restored, that errors went unnoticed (a single debug log, no alerting), that two tables grew without limit, and that replaced uploads were left on disk.
**Decision:**
- **Backups.** spatie/laravel-backup, scheduled nightly. It backs up the database plus the owner's files (images, CV versions), not the code, as an encrypted zip on every disk in `BACKUP_DISKS` (the server's `backups` disk plus an off-server disk in production). It also cleans up by retention and runs a daily health monitor. Dumps use a single transaction with `--set-gtid-purged=OFF --no-tablespaces`, so they need no global MySQL privileges: the default flush failed with a normal per-schema user. A restore was verified by row counts.
- **Alerts.** `OperationalAlerts` emails `ALERT_EMAIL` about reportable exceptions and failed jobs:
  - mail goes out synchronously, because the queue may be what is broken;
  - repeats are throttled per problem;
  - alerting can never itself fail a request;
  - no stack traces or request payloads are sent.

  Backup failures use the same address.
- **Housekeeping.** `activitylog:clean` and `queue:prune-failed` are scheduled. Production logs use the daily channel at warning level (documented).
- **Uploads.** Replaced or cleared `*_path` settings files are deleted after save. A section's background image is deleted when the section stops using it.
- **Uptime.** It must be monitored externally on `/up`. This is documented, since nothing on the server can report its own outage.
**Consequences:** Data loss and silent failure are covered without a third-party error service. An APM tool such as Sentry can still be added later behind the same report hook.

## ADR-049 — Deploying to cPanel hosting behind Cloudflare
**Status:** Accepted (2026-10-06)
**Context:** The hosting is a cPanel account (no root, no Supervisor, no Redis assumed), and the domain's DNS is on Cloudflare. The pipeline (ADR-045) assumed a VPS: the `php` binary on PATH, Supervisor for workers, and no proxy in front.
**Decision:**
- `PHP_BIN` (a GitHub variable passed to `remote-deploy.sh`) selects the PHP 8.4 CLI, e.g. cPanel's `ea-php84`.
- `QUEUE_WORKER_VIA_SCHEDULER` makes the per-minute scheduler run `queue:work --stop-when-empty --max-time=55` without overlapping, so one cron line runs everything.
- `TRUSTED_PROXIES` accepts IPs/CIDRs or `cloudflare` (Cloudflare's published ranges). `*` is ignored on purpose: the origin IP is public, so trusting everyone would let anyone spoof their address.
- Database-backed cache, sessions and queue where Redis is unavailable.
- The release layout and atomic symlink switch are unchanged; the subdomain's document root is `<base>/current/public`.
**Consequences:** The same pipeline deploys to a VPS or to cPanel by configuration alone. Queue latency on cPanel is up to a minute, which is fine for mail and image conversions. The Cloudflare ranges are a snapshot, so check them yearly.

