# Building a theme

A theme is a directory under `public/themes/`. It is installed over the filesystem — there is no browser upload of theme PHP.

**Read this whole page before writing a view.** Six themes shipped with defects that this document now exists to prevent: empty feeds, dead forms, pages with no create form, and a search that never returned results. None of them errored. Every one returned HTTP 200 and rendered something quietly wrong.

---

## 1. The failure mode you must understand first

A theme view is a PHP file that receives a fixed set of variables. If you read a variable the core does not pass, PHP gives you `null` — **no warning, no error, no blank page**. Your loop iterates nothing, your form loses its token, your list renders empty.

This is what makes theme bugs expensive: the page looks fine to a smoke test and broken to a user.

```php
// The home view receives $communityFeed. It does NOT receive $questions.
$items = is_array($questions ?? null) ? $questions : [];   // silently always empty
$items = is_array($communityFeed ?? null) ? $communityFeed : [];  // correct
```

`php bin/inspect.php` catches exactly this. Run it after every edit.

---

## 2. Minimum structure

```
public/themes/my-theme/
├── theme.json              required — without it the theme does not load at all
├── my-theme.json           optional — the design token profile
├── assets/
│   └── css/
│       └── theme.css       must be declared in theme.json to load
└── views/                  optional — override only what you need
    ├── home.php
    ├── question.php
    ├── topics.php
    ├── topic.php
    ├── taxonomy.php
    └── search.php
```

The directory name must match `^[a-z0-9][a-z0-9_-]{0,63}$`. It is the theme's identity; the `name` field inside `theme.json` is ignored.

## 3. The manifest

```json
{
  "version": "1.0.0",
  "min_core": "0.7.1",
  "author": "Example",
  "description": "One sentence describing the look.",
  "directions": ["rtl", "ltr"],
  "assets": { "css": ["assets/css/theme.css"], "js": [] }
}
```

Four ways to make a theme silently vanish:

1. **No `theme.json`** — no overrides load.
2. **`directions` empty, missing, or not an array** — the manifest is discarded entirely.
3. **`directions` missing `rtl`** — every Arabic page renders core views. Your theme appears to "not work in Arabic" when it is actually not loaded at all.
4. **Stylesheet not listed in `assets.css`** — nothing is auto-discovered, so the theme renders unstyled.
5. **`min_core` set to a version the running Core does not satisfy.** This one is easy to trip on a pre-release: `version_compare()` ranks `0.9.0-rc1` **below** `0.9.0`, so a theme declaring `"min_core": "0.9.0"` is rejected by a `0.9.0-rc1` install. Either omit `min_core` entirely, or set it to the oldest version you actually support — not the version you happen to be running.

Full field reference: [Theme manifest](../reference/THEME-MANIFEST.md).

---

## 4. View data contracts

These are the variables each view actually receives. Assume nothing else exists.

### `home`

| Variable | Contents |
|---|---|
| `communityFeed` | The unified feed — questions **and** topics, newest activity first |
| `feedFilter` | `all`, `questions`, `discussions`, `unanswered` |
| `widgetsHtml` | Pre-rendered `homepage_main` widget region — **print it or the operator's widgets vanish** |
| `renderSection` | Callable: `$renderSection('home_sidebar')` |
| `currentUser` | Signed-in member, or null |
| `dbAvailable` | False when the feed query failed and the page degraded |
| `searchQuery`, `submittedQuestionId` | Legacy search term; non-zero right after a submission |
| `translator`, `localeContext` | Translation and direction |

**Feed item keys.** One card component can render both types:

| Key | Notes |
|---|---|
| `type` | `question` or `topic` — the only reliable discriminator |
| `id`, `slug`, `title`, `body` | `body` is **Markdown source**; excerpt it as text, never print it as HTML |
| `activity_at`, `created_at`, `updated_at` | |
| `category_name`, `category_slug` | Empty string when uncategorised |
| `author_name`, `author_username`, `author_avatar_url`, `author_user_id` | **Empty string for anonymous questions** — not null, not "Anonymous" |
| `tags` | List of `{name, slug}` |
| `views_count` | |
| question only | `votes_count`, `answers_count`, `best_answer_id` |
| topic only | `kind`, `is_pinned`, `is_locked`, `comments_count` |

### `question`

`question`, `answers`, `answersMeta`, `questionTags`, `comments`, `isFollowing`, `reportedItems`, `slug`, `currentUser`, `csrfToken`, `expandQuestionComments`, `notice`, `site`, `postingRules`, `currentPath`, `renderAd`, `renderBetweenAnswers`, `renderSection`, `translator`, `localeContext`

- `answersMeta` — `page`, `per_page`, `total`, `total_pages`. **`total` is the count across all pages**; use it for the heading, not `count($answers)`.
- `comments` — `['question' => [...], 'answers' => [answerId => [...]]]`
- `reportedItems` — `['question' => [...], 'answer' => [...], 'comment' => [...]]` of already-reported ids

### `topics`

`topics`, `topicsMeta`, `categories`, `languages`, `filters`, `mode`, `error`, `values`, `postingRules`, `currentUser`, `csrfToken`, `currentPath`, `site`, `translator`, `localeContext`

**`mode` is `listing` or `new`.** `/d/new` renders this same view with `mode = 'new'`. A theme that ignores it shows the listing — so the create form never exists and the page displays only its intro sentence. Four shipped themes had this bug.

### `topic`

`topic`, `comments`, `reportedItems`, `isFollowing`, `slug`, `notice`, `query`, `postingRules`, `currentUser`, `csrfToken`, `currentPath`, `site`, `translator`, `localeContext`

### `taxonomy`

`type` (`category` or `tag`), `taxonomy`, `questions`, `topics`, `isFollowing`, `currentUser`, `csrfToken`, `currentPath`, `site`, `translator`

### `search`

`results`, `options`, `dbAvailable`, `site`, `translator`, `localeContext`

**`results` is an envelope, not a list.** The items are at `$results['items']`, the query at `$results['filters']['q']`, the count at `$results['total']`. Reading `$results` as a list gives you nothing and the page reports "no results" forever. Four shipped themes had this bug.

```php
$items   = is_array($results['items'] ?? null) ? $results['items'] : [];
$query   = (string) ($results['filters']['q'] ?? '');
$total   = (int) ($results['total'] ?? 0);
```

---

## 5. What every override must keep

Dropping any of these produces a page that still returns 200 and is quietly broken.

| Keep | Or else |
|---|---|
| `csrfToken` in every form | Every write on the page fails |
| `answersMeta` pagination | Answers beyond page one are unreachable and canonical self-reference breaks |
| The answer composer (`/answers`) | Members cannot answer at all |
| `widgetsHtml` and `renderSection` | The operator's configured widgets disappear |
| The `mode === 'new'` branch in `topics` | `/d/new` has no create form |
| The `$results['items']` envelope in `search` | Search never returns results |
| Empty states | An empty feed, a locked topic, `dbAvailable === false` each need deliberate output |

---

## 6. Non-negotiable rules

**Escape everything.** A view is application code.

```php
<?= quaeriva_escape($item['title'] ?? '') ?>
```

**Never print `body` as HTML.** It is Markdown source. Render it with `quaeriva_rich_text($body, 'full')`, or excerpt it as escaped text.

```php
<?= quaeriva_rich_text($answer['body'] ?? '', 'full') ?>                       <!-- full content -->
<?= quaeriva_escape(mb_strimwidth(strip_tags($body), 0, 220, '…', 'UTF-8')) ?> <!-- card excerpt -->
```

**Build every link with the helpers.** Never hard-code a domain, a leading `/`, or `/admin` — the admin path and base path are both configurable.

```php
<?= quaeriva_escape(quaeriva_url('c/' . rawurlencode($slug))) ?>   <!-- correct -->
href="/c/<?= $slug ?>"                                            <!-- breaks a subdirectory install -->
```

**Handle the anonymous author.** `author_name` is an empty string, not null.

**Logical properties only.** `margin-inline-start`, `padding-block-end`, `inset-inline-end`. `tests/Public/task51.php` fails the build on `left`/`right`.

**Touch targets and motion are shared rules.** The stylesheet raises controls to 2.75rem under `pointer: coarse` and caps animation under reduced motion. Do not shrink a control inside a `max-width` query, and do not re-declare a fixed `inline-size`/`block-size` on `.action-button` or `.icon-button` — a fixed box ignores the `min-*` that protects it.

**Consume tokens, never hard-code colour.**

```css
.my-card { background: var(--card-bg); border: 1px solid var(--card-border); }
```

**React to layout presets** through the body attributes the core emits:

```css
body[data-density="compact"] .my-card { padding-block: .75rem; }
body[data-sidebar-position="start"] .my-grid { /* … */ }
```

### Responsive tiers

Widths are expressed in `rem` so the layout reflows with the reader's browser font size instead of staying pinned to a pixel grid. A media query's `rem` is measured against the browser default, so the tiers are stable regardless of the operator's `--font-size-base` setting.

| Tier | Equivalent | Used for |
|---|---|---|
| 40rem | 640px | phone / phablet boundary |
| 45rem | 720px | profile and meta stacking |
| 56.25rem | 900px | sidebar collapse |
| 60rem | 960px | two-column threshold |
| 70rem | 1120px | header wrap |

Reuse an existing tier rather than inventing a value. Advertisement device-class queries stay in `px` on purpose: they classify the device for ad targeting, which is a product contract rather than a layout decision.

---

## 7. Recommended workflow

1. **Copy the closest core view** from `app/views/` into `public/themes/{slug}/views/`. Start from working code, not a blank file.
2. Restructure the markup. Keep every variable the core view read.
3. Style with tokens and logical properties.
4. Run the checks below.
5. Only then move to the next view.

Overriding one view at a time keeps a mistake attributable. Overriding six at once means a broken page and six suspects.

---

## 8. Verification

```bash
php bin/inspect.php
```

Exit 0 is the bar. It reports, per view, any variable the core never passes, missing CSRF, dropped pagination or widget regions, a missing `mode="new"` branch, a raw body echo, a hard-coded `/admin`, physical CSS properties, missing translation keys, contrast below 4.5:1 in either mode — and it renders every theme × page × locale for real.

```bash
php bin/run-all-tests.php
```

Manual checks before shipping:

- [ ] Arabic **and** English — confirm your views actually render in Arabic, not the fallback
- [ ] 360 / 768 / 1024 / 1440 px, no horizontal overflow
- [ ] Light **and** dark mode
- [ ] Sign in and post an answer, a comment, and a discussion from your theme
- [ ] `/d/new` shows the create form
- [ ] Search returns results
- [ ] The editor toolbar is usable at 360 px
- [ ] No external font, icon or script URL anywhere

---

## 9. What a theme must never do

- Fetch or execute remote code at runtime, including a font or icon CDN
- Hard-code `/admin`, a domain, or an installation folder
- Store or render raw user HTML
- Require a build step, Composer or npm
- Claim a direction in `directions` that it has not been tested in

## Related

[Theme manifest](../reference/THEME-MANIFEST.md) ·
[Template overrides](TEMPLATE-OVERRIDES.md) ·
[Appearance tokens](APPEARANCE-TOKENS.md) ·
[Theme packages](THEME-PACKAGES.md) ·
[Theme resolution](../07-internals/THEME-RESOLUTION.md) ·
[Theme design brief](THEME-DESIGN-BRIEF.md) ·
[Code inspection](../08-operations/CODE-INSPECTION.md)
