Files
sharper.ly/README.md
T
IllusionandClaude Opus 5 2e920a96e7 Land the third design drop, seed the CMS, and retire the figures band
Three pieces of one session's work. They are in one commit because home.tsx
and _page-kit/copy.ts carry hunks from all three and splitting them by hand
would have meant committing intermediate states of those two files that were
never built or served.

THE THIRD DESIGN DROP

Only Home.dc.html and SiteFooter.dc.html differ from the second drop; the
other nine page drafts are byte-identical to what already ships.

The home page stops opening on a launch announcement dated to one day in
June. New headline, lead and primary call to action ("See open calls"), and
the launch band becomes "A nationwide project, delivered locally" -
launchEyebrow/Heading/Lead renamed approach*, LaunchShots renamed
GalleryShots, since the band is no longer about an event. Its Arabic reuses
the About page's already-approved wording for the coverage line and the body
heading; the new lead and headline are ar-draft like the rest of the Arabic
home copy and still want client sign-off.

Five motion classes in _page-kit/motion.css, tokens only, each with its RTL
form where the property has no logical one: sh-wipe (the headline uncovered
from the inline start), sh-pan (the hero picture on a view timeline), sh-zoom
(a picture closing in inside its frame), sh-rule-top (an accent rule drawn
across a hovered tile) and sh-marquee (the logo wall). sh-wipe fills
`backwards`, not `both`, so the clip is gone at rest - inset(0) would shave a
descender or an Arabic mark off a tight heading line - and it is removed
outright under prefers-reduced-motion, because a clip does not fade a
headline, it hides it.

The marquee carries each logo twice and slides exactly half the track, so its
spacing is a margin on the tile rather than a gap on the row: with a row gap
the seam lands half a gap short. The second half is aria-hidden with no link,
so the wall is neither read out nor tabbed through twice; it pauses on hover
and focus (WCAG 2.2.2) and unwraps to a plain row under reduced motion. Below
eight partners the wall stays a grid, because a half-track narrower than its
frame drifts a short row across empty space instead of looping.

SEEDING THE CMS

`pnpm seed` writes content through the document service. It opens no port, so
it runs happily beside `pnpm dev:cms`, and every write is matched on a slug
and written over the same document, so a second run changes nothing but the
fields the seed owns.

src/seed/approved.ts is transcribed from docs/ in both languages and is safe
to publish: site settings, the three consortium members, the topic taxonomy,
the five activity streams with their implementing partners, the launch
article in full, and the four pages. What docs/ withholds is not written -
no statistics, no project email, no Facebook URL, no consortium addresses,
all of which the source writes as bracketed placeholders. Two fields are
judgement calls and say so at the line: activityStatus, which the schema
requires and the source never states, and the launch article's year, which is
derived - the source says "Tuesday, June 30" and within December 2025 to
December 2027 that is 2026 and no other year.

src/seed/demo.ts is invented and kept apart three ways: opt-in behind --demo,
refused outright when NODE_ENV=production, and tagged in the data itself with
a demo- slug prefix that shows in every URL and is how --demo --clean finds
them again, with nothing remembered between runs.

Two things the Strapi 5 document service does not do by itself, both of which
leave Arabic rows behind that the next run then collides with on the unique
slug: delete({ documentId }) with no locale removes the DEFAULT locale only,
not the document, and findMany({ locale }) answers for one locale, so a
document whose English half has gone is invisible to a search in English. The
first --clean left six Arabic orphans and failed the next run partway.
deleteMatching now pools documentIds across both locales and deletes with
locale: '*'.

Knex aborts its pending pool acquisitions on teardown, and each abort rejects
a promise nothing awaits, so it arrives as an unhandled rejection rather than
as an error a try/catch would see. scripts/seed.js absorbs exactly that, for
the length of the shutdown only: a seed that worked must not report itself as
a seed that failed.

THE FIGURES BAND, AND THE BAND THAT REPLACED IT

The third draft deletes the figures band, so shared.stat and blocks.stats-grid
are deleted with it, home-page.stats is dropped, and blocks.stats-grid comes
out of all six dynamic zones. On the web side that takes CmsStat, StatsRow,
CountUpValue, StatStrip and Card.Stat - the last two were already dead, but
they existed only to draw figures. No approved document carries a number, so
there is now nowhere for one to be typed in, which is the point.

The "nationwide project, delivered locally" band was half editable and half
not: its pictures came from an image-gallery block down the Blocks dynamic
zone, and its words came from bundled fallback copy with no CMS field at all.
The new page.band-intro component (eyebrow, heading, lead, images) on
home-page.approach puts the whole band in one place in the admin. The page
still falls back to the bundled copy when the fields are empty, and still
reads an image-gallery block after the component's own images, so a gallery
built in Blocks keeps rendering.

VERIFIED

pnpm typecheck and both builds pass. Both rule greps are clean: no raw hex or
tier-1 token in components/ or routes/, no physical-direction utility in
apps/web/app.

Against a seeded CMS on the built server: / redirects 302 with
Vary: Accept-Language, and all ten sections plus the detail routes return 200
in both locales with lang/dir correct in the first bytes. The home page draws
the marquee from twelve partners, the hero pan, the three hovered card rules
and the status pill, and carries no figures band.

The seed is idempotent - two identical --demo runs give identical counts,
balanced across locales - and a clean/re-seed cycle leaves nothing behind.
The approach band was proved editable end to end: heading, eyebrow, lead and
images changed through the CMS all came through on /en while /ar stayed on
its own Arabic, then the seeded copy was restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:16:53 +02:00

1016 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SHARP-ER website
Bilingual (English / Arabic) website for **SHARP-ER** — *Shared Action on Rule of Law
Progress – Exit Route* — a European Union funded programme running nationwide in Libya
from December 2025 to December 2027.
SHARP-ER supports Libyan civil society organisations, local institutions and communities
working on justice, accountability and governance. It is implemented by a consortium of
**Acted** (lead), **United Efforts Consultancy (UEC)** and **Stichting Bureau Ensemble (SBE)**,
and continues the work of SHARP I and SHARP II (2019–2024).
The site publishes project information, events and registrations, calls and applications,
news from the project and its partner network, and a knowledge hub of resources and
publications — all of it in English and Arabic, with the Arabic side laid out right to left.
> EU visibility is a contractual requirement: the *Funded by the European Union* emblem is
> present on every page, and the three implementing partners are always shown together.
---
## Contents
- [The stack, and why](#the-stack-and-why)
- [Repository layout](#repository-layout)
- [Choosing a setup path](#choosing-a-setup-path)
- [Path A — manual setup](#path-a--manual-setup)
- [Path B — Docker](#path-b--docker)
- [Available scripts](#available-scripts)
- [Seeding content](#seeding-content)
- [Design tokens](#design-tokens)
- [Bilingual EN / AR](#bilingual-en--ar)
- [Adding a content type, end to end](#adding-a-content-type-end-to-end)
- [Fonts (licensed, not in this repository)](#fonts-licensed-not-in-this-repository)
- [Deployment and operations](#deployment-and-operations)
- [Troubleshooting](#troubleshooting)
- [Source documents](#source-documents)
---
## The stack, and why
| Layer | Choice | Why |
| --- | --- | --- |
| Front end | **React Router 8**, framework mode, `ssr: true` | The terms of reference require an SEO-friendly structure. Server-side rendering means every page — including the Arabic one — is delivered as complete HTML with the correct `<html lang>` and `<html dir>` in the first byte, plus canonical and `hreflang` tags a crawler can read without executing JavaScript. |
| UI | **React 19**, **TypeScript**, **Vite 8** | The framework's own toolchain. Typed loaders and route modules, so a content shape change is a compile error rather than a blank page. |
| Styling | **Tailwind CSS 4** over a **two-tier design-token** layer | Tier 1 holds raw values, tier 2 holds meanings; components only ever spend tier‑2 tokens. Re-branding is a one-line edit in one file. See [Design tokens](#design-tokens). |
| CMS | **Strapi 5.53** (TypeScript) | Self-hosted and handed over to UEC at the end of the assignment, as the ToR requires. Its i18n plugin models EN/AR as first-class locales of the same entry, and draft & publish gives editors a review step. |
| Database | **PostgreSQL 16** | Strapi's recommended production database; proper UTF‑8 and JSON support for bilingual content. |
| Runtime | **Node 22**, **pnpm 9** workspace | One lockfile, two applications, one `pnpm install`. |
The web application never talks to the database. It reads the Strapi REST API from its
**loaders on the server**, so the API token never reaches the browser, and every page falls
back to bundled draft copy if the CMS is unreachable — the site stays up when Strapi is down.
---
## Repository layout
```
sharp-er/
├── apps/
│ ├── web/ React Router 8 front end (SSR)
│ │ ├── app/
│ │ │ ├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, nav, footer
│ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the ten sections
│ │ │ ├── routes/ One module per page (+ shared _page-kit / _shared helpers)
│ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher
│ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css
│ │ │ ├── i18n/ Locale config, message catalogues (en.json / ar.json), formatters
│ │ │ └── lib/ strapi.ts (API client), seo.ts, env.server.ts, route-slugs.ts
│ │ ├── public/
│ │ │ ├── assets/ Logos, EU emblem, partner logos, icons
│ │ │ └── fonts/ Licensed woff2 files — NOT committed, see FONTS below
│ │ └── Dockerfile
│ │
│ └── cms/ Strapi 5 CMS
│ ├── config/ server, admin, database, middlewares (CORS), plugins (i18n, upload)
│ ├── src/
│ │ ├── api/ One folder per content type (schema, controller, routes, service)
│ │ ├── components/ Reusable field groups (hero, cta, seo, blocks/*)
│ │ └── bootstrap/ Boot-time reconciliation: locales + public permissions
│ ├── .env.example Every Strapi variable, documented
│ └── Dockerfile
│
├── docs/ SOURCE MATERIAL — read only, never edited
├── scripts/ setup-db.sh, generate-secrets.sh, check-prereqs.sh
├── docker-compose.yml postgres + cms + web
├── .env.example Root template: docker compose and the web server read this
├── CLAUDE.md Working brief for AI agent sessions
└── pnpm-workspace.yaml
```
---
## Choosing a setup path
Both paths give you the same three processes. Pick one; you do not need both.
| | **Path A — manual** | **Path B — Docker** |
| --- | --- | --- |
| You install | Node 22, pnpm 9, PostgreSQL 16 | Docker Engine + Compose v2 |
| Best for | day-to-day development (hot reload, fast rebuilds) | trying the project out, review builds, servers |
| Web | `http://localhost:5173` (dev server) | `http://localhost:3000` (production build) |
| CMS | `http://localhost:1337/admin` | `http://localhost:1337/admin` |
| Database | your local PostgreSQL, port 5432 | container, published on port **5433** |
| Time to first run | ~5 minutes | ~10 minutes (image build) |
Check your machine before you start:
```bash
./scripts/check-prereqs.sh # both paths
./scripts/check-prereqs.sh manual # or just one
./scripts/check-prereqs.sh docker
```
---
## Path A — manual setup
### 1. Prerequisites
| Tool | Version | Notes |
| --- | --- | --- |
| **Node.js** | **>= 22.22.0** | React Router 8 will not run on anything older — this is a hard floor, not a recommendation. `.nvmrc` pins **22.23.2**, the version this repository is developed on. Strapi 5 accepts Node 20 – 26, so 22 satisfies both. |
| **pnpm** | **9.x** (repo pins `pnpm@9.12.3`) | npm and yarn will not work: the workspace, the lockfile and the hoisted `node_modules` layout in `.npmrc` are all pnpm's. |
| **PostgreSQL** | **16** (server + client tools) | Client tools give you `psql`, `createdb` and `pg_isready`. |
| **Git** | any recent version | |
```bash
# Node — with nvm, the pinned version is one command
nvm install "$(cat .nvmrc)" && nvm use
# pnpm — corepack ships with Node
corepack enable && corepack prepare pnpm@9.12.3 --activate
# PostgreSQL 16 on Debian / Ubuntu
sudo apt install postgresql-16 postgresql-client-16
sudo systemctl enable --now postgresql
# macOS with Homebrew
brew install node@22 pnpm postgresql@16 && brew services start postgresql@16
```
Verify: `node -v` → `v22.22.0` or newer, `pnpm -v` → `9.x`, `psql --version` → `16.x`.
### 2. Install dependencies
```bash
git clone <repository-url> sharp-er
cd sharp-er
pnpm install # installs BOTH apps from the single root lockfile
```
Always run `pnpm install` from the repository root. `.npmrc` sets `node-linker=hoisted`
because Strapi's plugin resolution expects a flat `node_modules`; installing inside
`apps/cms` or `apps/web` on its own produces a tree neither application can boot from.
### 3. Create the database
The database is called `sharper_cms` and is owned by the role Strapi connects as
(`postgres` by default).
```bash
./scripts/setup-db.sh
```
The script is idempotent — it creates the database only if it is missing, never drops
anything, and finishes by proving it can connect and that the role may create tables.
It reads its defaults from `.env` and `apps/cms/.env`, and takes
`--name`, `--user`, `--password`, `--host`, `--port` overrides (`--help` for the details).
Doing it by hand is two commands:
```bash
createdb -h 127.0.0.1 -U postgres sharper_cms
psql -h 127.0.0.1 -U postgres -d sharper_cms -c "SELECT current_database(), version();"
```
<details>
<summary>If <code>psql</code> asks for a password you do not have (fresh Ubuntu install)</summary>
A newly installed PostgreSQL has a `postgres` role with no password set, reachable only
over the local socket. Set one, then use it as `DATABASE_PASSWORD` in `apps/cms/.env`:
```bash
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
```
For a shared or production machine, create a dedicated role instead:
```bash
sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-password' CREATEDB;"
./scripts/setup-db.sh --user sharper --password 'a-strong-password'
```
</details>
### 4. Environment files
Three files, with different jobs:
| File | Read by | Required in manual mode |
| --- | --- | --- |
| `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** |
| `apps/web/.env` | `pnpm --filter web start` (it loads its own `.env`) | no — but it is where `PORT` goes |
| `.env` (root) | `docker compose` only | no |
Each application reads its **own** `.env`, and the root one belongs to `docker compose`.
That split is not cosmetic: `PORT` and `HOST` mean something different to each server, and a
single shared value would put Strapi on the website's port.
```bash
# CMS: copy the fully documented template, then fill in the six secrets
cp apps/cms/.env.example apps/cms/.env
./scripts/generate-secrets.sh --write apps/cms/.env
# Set the database credentials you used in step 3
# DATABASE_CLIENT=postgres
# DATABASE_HOST=127.0.0.1
# DATABASE_PORT=5432
# DATABASE_NAME=sharper_cms
# DATABASE_USERNAME=postgres
# DATABASE_PASSWORD=...
```
`scripts/generate-secrets.sh` fills `APP_KEYS`, `API_TOKEN_SALT`, `ADMIN_JWT_SECRET`,
`JWT_SECRET`, `TRANSFER_TOKEN_SALT` and `ENCRYPTION_KEY` with 32 random bytes each. It never
overwrites a value that is already set unless you pass `--force`, so it is safe to re-run.
Without `--write` it just prints a block you can paste. By hand:
`openssl rand -base64 32`, once per key (`APP_KEYS` takes at least two, comma separated).
The web application needs nothing to start: `STRAPI_URL` already defaults to
`http://localhost:1337`, and with `SITE_URL` unset the canonical URL is derived from the
incoming request. Copy its template when you want to override those, or to pin the
production port:
```bash
cp apps/web/.env.example apps/web/.env # PORT, HOST, SITE_URL, STRAPI_URL, STRAPI_TOKEN
```
`pnpm --filter web start` loads that file. `pnpm dev:web` does not — the dev server is Vite
on 5173 — so export anything you need for development, or put it in the file for the
production server and pass it inline while developing.
### 5. Run both applications
```bash
pnpm dev # web + cms together, output interleaved and prefixed
```
or, in two terminals:
```bash
pnpm dev:cms # Strapi -> http://localhost:1337
pnpm dev:web # React Router -> http://localhost:5173
```
The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boot-time
reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on
every boot; it
- creates the `en` (default) and `ar` locales if they are missing, and
- grants the **Public** role `find` / `findOne` on the content types the site reads, and
`create` — and only `create` — on the three submission types (event registrations,
applications, contact messages), so nobody can read other people's personal data back out.
You never have to click through the Users & Permissions screens after adding a content type;
restart the CMS and the grant is there.
### 6. Create the first admin user
Strapi has no seeded administrator. On first run, open
**http://localhost:1337/admin**
and complete the registration form — the first account created becomes the super admin.
Or do it from the command line:
```bash
pnpm --filter cms exec strapi admin:create-user \
--firstname="Site" --lastname="Admin" \
--email="admin@example.org" --password="SomeStrongPassw0rd"
```
Passwords must be at least 8 characters and contain a number and an uppercase letter.
### 7. Confirm it works
| URL | Expected |
| --- | --- |
| `http://localhost:5173/` | 302 to `/en` or `/ar` depending on your `Accept-Language` |
| `http://localhost:5173/en` | English home page, `<html lang="en" dir="ltr">` |
| `http://localhost:5173/ar` | Arabic home page, `<html lang="ar" dir="rtl">`, mirrored layout |
| `http://localhost:1337/admin` | Strapi admin panel |
| `http://localhost:1337/api/home-page?locale=en` | JSON (or `404` until an entry is published) |
| `http://localhost:1337/_health` | `204 No Content` |
```bash
# The one check worth automating: is the SSR HTML right in the first byte?
curl -s http://localhost:5173/ar | head -c 200
```
Then run the type checker once, so you know the baseline is clean:
```bash
pnpm typecheck
```
---
## Path B — Docker
Three containers: **postgres** (database), **cms** (Strapi), **web** (React Router).
Nothing else is installed on your machine — no Node, no pnpm, no PostgreSQL.
### 1. Prerequisites
- **Docker Engine 24+** (or Docker Desktop) with the **Compose v2** plugin.
Check with `docker compose version`; the standalone `docker-compose` binary is *not*
supported — this file uses `depends_on.condition`, which is Compose v2 syntax.
- Roughly 3 GB of disk for the images and 2 GB of RAM while they build.
### 2. Configure
```bash
cp .env.example .env
./scripts/generate-secrets.sh --write .env
```
`docker compose` reads the `.env` file sitting next to `docker-compose.yml` and substitutes
every `${VARIABLE}` in it. The six Strapi secrets are declared as `${VAR:?...}`, so a missing
value stops the stack straight away with a readable message instead of a Strapi crash loop.
In Docker mode `apps/cms/.env` is **not used** — the container receives its environment from
`docker-compose.yml`. The root `.env` is the only file you edit.
### 3. Start
```bash
docker compose up -d --build # first run: builds both images, ~5-10 minutes
docker compose ps # STATUS column shows (healthy) when each is up
```
Start-up is ordered by health, not by luck:
```
postgres healthy -> cms healthy -> web
pg_isready GET /_health GET /en
```
`cms` waits for a database that is actually accepting connections (`pg_isready`), and `web`
waits for a CMS that answers `/_health`, so the very first page render already has content.
The health checks themselves are declared in `apps/cms/Dockerfile` and `apps/web/Dockerfile`,
which means `docker run` and any orchestrator inherit them too.
Give the CMS up to 90 seconds on its first boot: it creates its tables, then runs the
locale and permission reconciliation described in Path A, step 5.
```bash
docker compose logs -f # everything, following
docker compose logs -f cms # one service
docker compose logs --tail=50 web
```
### 4. Create the first admin user
Open **http://localhost:1337/admin** and register — the first account is the super admin.
From the command line:
```bash
docker compose exec cms pnpm exec strapi admin:create-user \
--firstname="Site" --lastname="Admin" \
--email="admin@example.org" --password="SomeStrongPassw0rd"
```
### 5. Confirm it works
| URL | Expected |
| --- | --- |
| `http://localhost:3000/` | 302 to `/en` or `/ar` |
| `http://localhost:3000/en` and `/ar` | English and Arabic home pages |
| `http://localhost:1337/admin` | Strapi admin panel |
| `psql -h 127.0.0.1 -p 5433 -U postgres sharper_cms` | a database prompt |
### What the compose file sets up
| Service | Image | Host port | Persists in | Health check |
| --- | --- | --- | --- | --- |
| `postgres` | `postgres:16-alpine` | **5433** → 5432 | volume `sharper_postgres-data` | `pg_isready` |
| `cms` | built from `apps/cms/Dockerfile` | 1337 | volume `sharper_cms-uploads` (Media Library) | `GET /_health` |
| `web` | built from `apps/web/Dockerfile` | 3000 | nothing — stateless | `GET /en` |
The database is published on **5433** on purpose, so it cannot collide with a PostgreSQL
already listening on your host's 5432. Inside the compose network the CMS still reaches it as
`postgres:5432`. Change the published port with `POSTGRES_PUBLISHED_PORT` in `.env`.
Both images build from the **repository root** as their context, because the pnpm lockfile
lives there. They are not size-optimised: the CMS image keeps its dev dependencies, because
Strapi's CLI resolves its TypeScript toolchain when it starts.
### Images from the CMS
Uploaded media is served by Strapi, and the web server turns a relative `/uploads/x.jpg`
into an absolute URL by prefixing `STRAPI_URL`. Inside Docker that value is `http://cms:1337`,
a hostname Docker resolves but your browser does not — so pictures uploaded through the Media
Library will not load until it can. One line in your hosts file fixes it, permanently:
```bash
echo "127.0.0.1 cms" | sudo tee -a /etc/hosts # Windows: C:\Windows\System32\drivers\etc\hosts
```
`cms` then resolves to `127.0.0.1`, where compose publishes the CMS on port 1337, so the one
URL works from the browser and from inside the compose network alike. Everything else —
pages, text, layout, and the site's own images in `apps/web/public/assets/` — works without
this step.
In production this problem disappears: the CMS has a real hostname
(`https://cms.sharper.ly`), and `STRAPI_URL` is set to it.
### Everyday commands
```bash
docker compose ps # status and health
docker compose restart cms # restart one service
docker compose exec cms sh # shell inside the CMS container
docker compose exec postgres psql -U postgres sharper_cms
docker compose up -d --build web # rebuild after changing app code
docker compose build --no-cache cms # rebuild ignoring the layer cache
```
Source code is **not** bind-mounted: the images contain a production build, so a code change
needs a rebuild. For day-to-day development use Path A, or run only the database in Docker
(`docker compose up -d postgres`) and the two applications with `pnpm dev`.
### Stopping and tearing down
| Command | Containers | Database + uploads |
| --- | --- | --- |
| `docker compose stop` | stopped, kept | kept |
| `docker compose down` | removed | **kept** (named volumes survive) |
| `docker compose down -v` | removed | **deleted — irreversible** |
| `docker compose down --rmi local` | removed, images too | kept |
`down -v` is how you start from an empty database — every entry, every uploaded file and
the admin account are gone. Take a dump first if you are unsure:
```bash
# Back up
docker compose exec -T postgres pg_dump -U postgres sharper_cms > backup-$(date +%F).sql
# Restore into a fresh stack
docker compose exec -T postgres psql -U postgres sharper_cms < backup-2026-01-31.sql
# Uploaded media
docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
tar czf /backup/uploads-$(date +%F).tar.gz -C /data .
```
---
## Available scripts
**Repository root** (`package.json`) — these fan out across the workspace:
| Command | Does |
| --- | --- |
| `pnpm dev` | runs `web` and `cms` in parallel, streaming both logs |
| `pnpm dev:web` | React Router dev server with HMR → `http://localhost:5173` |
| `pnpm dev:cms` | `strapi develop` (auto-reloads on schema changes) → `http://localhost:1337` |
| `pnpm build` | builds the CMS, then the web app (that order: the web build has no CMS dependency, but a broken CMS build should fail first) |
| `pnpm start` | runs both production builds |
| `pnpm seed` | writes the approved content into the CMS — see [Seeding content](#seeding-content) |
| `pnpm typecheck` | `tsc` in both applications, preceded in the web app by `react-router typegen` |
**`apps/web`** — run with `pnpm --filter web <script>`:
| Command | Does |
| --- | --- |
| `dev` | `react-router dev` — SSR dev server with HMR |
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
| `start` | the production server — `react-router-serve ./build/server/index.js`, started with `node --env-file-if-exists=.env` so `apps/web/.env` can set `PORT` |
| `typecheck` | regenerates `+types/*` route types, then `tsc` |
**`apps/cms`** — run with `pnpm --filter cms <script>`:
| Command | Does |
| --- | --- |
| `develop` / `dev` | `strapi develop` — the only mode in which the Content-Type Builder can write schemas |
| `build` | builds the admin panel and compiles the TypeScript server into `dist/` |
| `start` | `strapi start` — production mode; the Content-Type Builder is disabled |
| `seed` | writes the CMS content — `pnpm --filter cms seed -- --demo` to include the fixtures |
| `console` | a REPL with `strapi` in scope, for one-off data fixes |
| `strapi` | the CLI itself, e.g. `pnpm --filter cms exec strapi admin:create-user` |
| `upgrade` / `upgrade:dry` | `@strapi/upgrade` codemods for a minor/major version bump |
**`scripts/`** — repository helpers, all read-only unless you pass `--write`:
| Script | Does |
| --- | --- |
| `./scripts/check-prereqs.sh [all\|manual\|docker]` | verifies Node, pnpm, PostgreSQL, Docker and the env files; exits non-zero if something required is missing |
| `./scripts/setup-db.sh` | idempotent `createdb` + connection/permission verification (Path A, step 3) |
| `./scripts/generate-secrets.sh [--write FILE] [--force]` | fresh Strapi secrets; fills only empty/placeholder keys unless forced |
---
## Seeding content
A fresh database has an empty CMS, so every page renders its fallback copy and
every listing renders its empty state. `pnpm seed` fills it in. Both
applications can stay running while it does — it talks to PostgreSQL directly
and never opens a port of its own.
```bash
pnpm seed # the approved content
pnpm seed -- --demo # the same, plus fixtures to look at
pnpm seed -- --demo --clean # remove those fixtures again
```
> The `--` is pnpm's, not the seed's. Running it from `apps/cms` (`pnpm seed
> --demo`) needs no separator.
**It is safe to run twice.** Every write is matched on a slug and written over
the same document, so a second run changes nothing but the fields the seed
owns — your own edits to other fields survive. It deletes nothing except on
`--demo --clean`. It uploads nothing: where a page already has a picture, the
existing file is read back and written out again with the new copy.
### The approved set — `apps/cms/src/seed/approved.ts`
Transcribed from `docs/`, in both languages, and safe to publish: the site
settings, the three consortium members, the topic taxonomy, the five activity
streams, the launch article, and the About, What We Do, Contact and Home
pages.
What `docs/` does not supply is not written. There are no statistics anywhere
(the source marks every number "TBD — must not be published until confirmed"),
no project email, no Facebook URL and no consortium addresses, because the
source writes all of those as bracketed placeholders. Two fields are judgement
calls and say so at the line: every activity's `activityStatus`, which the
schema requires and the source never states, and the launch article's year,
which is derived — the source says "Tuesday, June 30", and within the project's
own December 2025 – December 2027 window that is 2026 and no other year.
### The demo set — `apps/cms/src/seed/demo.ts`
Invented content: twelve network organisations, six articles, six events, four
opportunities, six resources, six knowledge items, eight questions and five
regions, across the calendar and across every enum value, so the listings,
filters, detail pages and the home page's logo marquee have something in them.
It is kept apart from real content three ways:
1. **Opt-in.** `pnpm seed` never writes it.
2. **Refused in production.** With `NODE_ENV=production` the seed exits with an
error rather than writing a fixture, whatever flags it is given.
3. **Tagged in the data.** Every demo slug starts `demo-`, so it is visible in
every URL — `/en/news-and-updates/demo-…` — and `--demo --clean` finds them
again by that prefix alone, with nothing remembered between runs.
### After seeding
```bash
curl -s "http://localhost:1337/api/news-articles?locale=ar" | head -c 200
```
Then the home page at `http://localhost:5173/en` and `/ar`: the hero carries the
CMS headline, the newsroom band has cards, and — with `--demo` — the network
logo wall becomes the marquee, which needs at least eight partners before it
stops being a grid.
---
## Design tokens
Two tiers, so re-branding is a one-line edit in one file.
```
app/app.css entry: tailwindcss, then the three files below
app/styles/primitives.css TIER 1 raw values, no meaning (@theme)
app/styles/semantic.css TIER 2 roles that alias tier 1 (per surface)
app/styles/typography.css TIER 2 type roles, @font-face, EN/AR font switch
```
**The one rule: components may reference only tier-2 tokens.** Never a raw hex, never a
tier-1 primitive.
```tsx
<h2 className="heading-h2 text-[var(--text-primary)]" /> // yes
<h2 className="text-[#f8f9fa] text-[28px]" /> // no — hex and raw type
<h2 className="text-[var(--color-neutral-50)]" /> // no — tier-1 primitive
```
Because every component spends meanings rather than values, moving the brand green is four
lines in `primitives.css` (`--color-green-600` and its hover / active / disabled steps) and
no component changes at all.
The home page is **dark by default**, while the navigation and footer also have light
variants, so the palette is modelled as **surfaces, not a global theme**: `:root` *is* the
dark surface, and any element can flip its own subtree with `data-surface="light"`, which
re-resolves every tier-2 token and paints itself.
Brand colours are White `#ffffff`, Dark Grey `#1D1D1B`, Dark Turquoise `#008764` and
Red `#DD322F`, taken from the communication guideline in `docs/`.
Full reference — every token family, the Tailwind bridge, the surface model, the type roles
and the one deliberate deviation from Figma: **[`apps/web/app/styles/README.md`](apps/web/app/styles/README.md)**.
---
## Bilingual EN / AR
**URLs carry an explicit locale prefix on both languages.** There is no `?lang=` parameter
anywhere in the product.
```
/ 302, Vary: Accept-Language -> /en or /ar
/en, /en/about, ... English, ltr
/ar, /ar/about, ... Arabic, rtl
/EN/about 301 -> /en/about
```
The slug itself is always the Latin one (`/ar/knowledge-hub`, not a transliterated path), so
a URL survives a language switch and analytics can aggregate the two.
`<html lang>` and `<html dir>` are correct **in the first SSR byte** — they are attributes of
the server-rendered document, not something JavaScript sets afterwards. `root.tsx` also emits
a canonical link and `hreflang` alternates for `en`, `ar` and `x-default` on every page.
### Where the words live
| Content | Lives in | Edited by |
| --- | --- | --- |
| UI strings (navigation, buttons, filters, form labels, error messages) | `apps/web/app/i18n/messages/en.json` and `ar.json` — identical key sets, enforced by the `Messages` type | developers |
| Page and article content | Strapi, as an `en` and an `ar` version of the same entry | editors |
| Draft fallback copy | `apps/web/app/routes/_page-kit/copy.ts`, `_shared/page-copy.ts` — the approved prose used when the CMS is empty or unreachable | developers |
Read a string with the `useTranslation()` hook in a component, or `translate(locale, key)` in
a loader; keys are dotted paths (`t("pagination.page", { page: 2, pageCount: 7 })`) and are
typechecked against `en.json`, so a missing translation is a build error.
### Right-to-left
RTL is not a stylesheet; it is a consequence of using **CSS logical properties everywhere**.
Write `margin-inline-start`, `padding-inline`, `inset-inline-start`, `text-align: start`,
`border-inline-start` — and in Tailwind the logical utilities `ps-` `pe-` `ms-` `me-`
`start-` `end-`, never `pl-` `pr-` `ml-` `mr-` `left-` `right-`. The Arabic layout then
mirrors for free. `rtl:` and `ltr:` variants exist for the rare case that genuinely cannot be
expressed logically — an asymmetric icon, a background position.
Dates and numbers go through `~/i18n` (`formatDate`, `formatDateRange`, `formatNumber`,
`formatFileSize`), which uses `en-GB` and `ar-LY` with Western digits, matching the approved
Arabic copy.
### Adding a third locale
The types are exhaustive `Record<Locale, …>` maps, so after step 1 the compiler lists every
place that still has to be filled in.
1. `apps/web/app/components/ui/LanguageSwitcher/locale-path.ts` — add the code to `LOCALES`,
and an entry to `DIRECTION_BY_LOCALE`, `LOCALE_ENDONYM` and `LOCALE_SHORT_LABEL`.
2. `apps/web/app/i18n/config.ts` — add it to `INTL_LOCALE` and `OG_LOCALE`.
3. `apps/web/app/i18n/messages/<code>.json` — copy `en.json`, translate it, and register it in
`getMessages()`.
4. `apps/cms/config/plugins.ts` — add `{ code, name }` to the `locales` array and restart the
CMS; `src/bootstrap/locales.ts` creates it in the database on the next boot.
5. Nothing else: routing (`/:lang`), Accept-Language negotiation, the navigation, the footer
link columns and the `hreflang` alternates all derive from `SUPPORTED_LOCALES` and
`ROUTE_SLUGS`.
Note that the language switcher's UI is built for exactly two languages (it renders *the
other* one); a third locale needs it turned into a menu.
---
## Adding a content type, end to end
Worked example: a **Stories** collection, listed at `/en/stories` and `/ar/stories`.
### 1. Model it in Strapi
Run the CMS in development mode (`pnpm dev:cms`) — the Content-Type Builder only writes
schemas in `strapi develop`. Create the collection type, then in **Advanced settings** enable
**Internationalization** and **Draft & publish**.
Strapi writes the result to `apps/cms/src/api/story/` (`content-types/story/schema.json`,
plus a controller, routes and a service). **Commit that folder** — it is the schema, not a
local artefact. You can equally write the JSON by hand and restart.
Field conventions used across this project: `title` (string, required), `slug`
(uid from title, required), `summary` (text), `body` (blocks or rich text — both render),
`cover` (single media), `publicationDate` (date — the field everything sorts and filters on),
`topics` (relation to the shared `topics` taxonomy, so a tag means the same thing everywhere).
### 2. Restart the CMS, then check the API
`apps/cms/src/bootstrap/public-permissions.ts` grants the Public role `find` and `findOne`
on every `api::*` content type at boot, so there is nothing to click:
```bash
curl "http://localhost:1337/api/stories?locale=en&populate=cover"
```
A collection holding personal data is the exception — add its API name to
`PUBLIC_SUBMISSION_CONTENT_TYPES` in `apps/cms/src/bootstrap/constants.ts` and it gets
`create` only, never `find`.
### 3. Give it a URL
For a new top-level section, add the slug to `apps/web/app/lib/route-slugs.ts`
(`ROUTE_SLUGS`, and the right group in `ROUTE_SECTION_GROUPS`) and a label under
`nav.sections` in **both** `app/i18n/messages/en.json` and `ar.json`. The navigation, the
footer sitemap and `sectionPath()` all derive from those two, so nothing else needs editing.
Then register the module in `apps/web/app/routes.ts`. Section modules are picked up by their
conventional path (`SECTION_MODULES`); a detail route is one explicit line:
```ts
route("stories/:slug", "routes/stories.$slug.tsx", { id: "storyDetail" }),
```
Vite does not re-evaluate `routes.ts` when a new file appears — **restart `pnpm dev:web`**
after adding a route module.
### 4. Write the route module
`apps/web/app/routes/stories.tsx`. Fetch on the server, in the loader; the Strapi client is
server-only, and the API token never reaches the browser.
```tsx
import { buildMeta } from "~/lib/seo";
import { getMany } from "~/lib/strapi";
import { readRootSeo, requireLocale } from "~/routes/_shared/listing";
import type { LoaderFunctionArgs, MetaArgs } from "react-router";
interface Story {
documentId: string;
title: string;
slug: string;
summary?: string;
publicationDate?: string;
}
export async function loader({ params }: LoaderFunctionArgs) {
const locale = requireLocale(params.lang); // 404s on anything but en | ar
const { data } = await getMany<Story>("stories", {
locale,
sort: "publicationDate:desc",
populate: { cover: { fields: ["url", "alternativeText"] } },
pagination: { page: 1, pageSize: 12 },
});
return { locale, stories: data };
}
export function meta({ matches }: MetaArgs) {
const root = readRootSeo(matches); // { locale, pathname, siteUrl }
return buildMeta({ ...root, title: "Stories", description: "…" });
}
export default function Stories({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
return <main>{/* Card.Content, PageShell, … from the shared kit */}</main>;
}
```
For a detail page use `requireEntity(await getBySlug<Story>("stories", params.slug, { locale }))`
— `requireEntity` throws the 404 the root ErrorBoundary renders, localised.
Do **not** pass `alternates: true` to `buildMeta`: `root.tsx` already emits the canonical link
and the `hreflang` alternates for every route, and a leaf `meta` that repeats them duplicates
the tags. Use `handle.canonicalPath` to override the canonical instead.
Render with the existing kit rather than new markup: `~/components/ui` (Button, Input, Card,
…) and `app/routes/_shared/listing-ui` / `_page-kit/sections` (PageShell, PageHeader,
FilterBar, Pagination, EmptyState, RichText).
### 5. Verify
```bash
pnpm typecheck
```
Then open `/en/stories` and `/ar/stories`, confirm the Arabic page mirrors, and confirm an
unknown slug renders the localised 404 rather than a 500. If the CMS is stopped, the page
must still render its fallback copy.
---
## Fonts (licensed, not in this repository)
The brand faces are **Panton** for English and **Montserrat Arabic** for Arabic.
> **Note on the guideline.** The communication guideline PDF names the Arabic face
> "Montaser Arabic". That is a typo in the guideline — the correct face is **Montserrat
> Arabic**, confirmed with the client on 2026-09-11. Both files are supplied.
Both are **commercial typefaces licensed to the project, not to this repository** —
`.gitignore` excludes every `woff2` under `apps/web/public/fonts/`, and `.dockerignore` keeps
them out of built images. Do not commit them, and do not publish them to a CDN that serves
them outside the project's licence.
Drop the files into **`apps/web/public/fonts/`**, with exactly these names — matching is
case-sensitive in production:
| File | Family | Weight | Status |
| --- | --- | --- | --- |
| `Panton-Regular.woff2` | Panton | 400 | supplied |
| `Panton-Bold.woff2` | Panton | 700 | supplied |
| `Panton-SemiBold.woff2` | Panton | 500 | **missing** |
| `Montserrat-Arabic-Regular.woff2` | Montserrat Arabic | 400 | supplied |
| `Montserrat-Arabic-Medium.woff2` | Montserrat Arabic | 500–600 | supplied |
| `Montserrat-Arabic-Bold.woff2` | Montserrat Arabic | 700 | supplied |
Because a fresh clone has none of them, the stacks in `app/styles/primitives.css` degrade
politely and the site is fully usable without any of them:
```css
--font-en: "Panton", "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-ar: "Montserrat Arabic", "IBM Plex Sans Arabic", "Noto Sans Arabic", ui-sans-serif, system-ui, sans-serif;
```
The brand family is always **first** in the stack, so the moment a licensed file is dropped
in, it takes over — no code change, no rebuild of anything but the page. One current gap:
- **Panton SemiBold.** `label-md` asks for weight 500; with no SemiBold face declared, CSS
font matching resolves it to Panton Regular. When the cut arrives, add one `@font-face` for
`"Panton"` at `font-weight: 500 600` in `app/styles/typography.css`. Do not declare that
face before the file exists — a face that fails to load drops weight-500 text out of the
family altogether.
Under Docker, `docker-compose.yml` mounts `./apps/web/public/fonts` into the running web
container read-only, so a local container uses your licensed files without ever baking them
into an image. On a server, copy them into the deployed `build/client/fonts/` directory (or
mount them the same way) as part of the release, outside version control.
---
## Deployment and operations
### Building and running a release
```bash
pnpm install --frozen-lockfile
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
pnpm --filter cms start # strapi start :1337
pnpm --filter web start # react-router-serve :$PORT (see below)
```
Both are ordinary long-running Node processes. Run them under systemd, PM2 or the
containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates
TLS and forwards to 3000 and 1337.
#### Choosing the web server's port
`react-router-serve` binds `PORT` when it is set, and otherwise takes the first **free** port
from 3000 upwards — so a busy 3000 moves the site to 3001, 3002, … with no error, and the
proxy's upstream stops matching. Nothing in the repository defaults it for you.
`pnpm --filter web start` runs under `node --env-file-if-exists=.env`, so `apps/web/.env` is
enough:
```bash
echo 'PORT=8080' >> apps/web/.env
pnpm --filter web start # :8080
```
It has to be that file and not the root `.env`: Strapi reads a bare `PORT` too, so a shared
one would move the CMS onto the website's port. `apps/web/.env` is read by the web server
alone, exactly as `apps/cms/.env` is read by Strapi alone.
A real environment variable wins over the file, so `PORT=9090 pnpm --filter web start` and a
systemd unit's `Environment=PORT=8080` both override it. `HOST` works the same way — unset it
listens on every interface, `HOST=127.0.0.1` only accepts the reverse proxy on the same host.
Under Docker none of this applies: `PORT=3000` is set in the image and in
`docker-compose.yml`, `WEB_PORT` in the root `.env` is the **host** port the container is
published on (`WEB_PORT=8080` gives `8080:3000`), and `.dockerignore` keeps every `.env` out
of the build context.
Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly`), not
`http://127.0.0.1:8080` — canonical URLs, `hreflang` and JSON-LD are built from it.
### Production environment
| Variable | Where | Value |
| --- | --- | --- |
| `NODE_ENV` | both | `production` |
| `PORT` | web | the port `react-router-serve` binds, e.g. `8080`. Unset, it takes the first free port from 3000 upwards |
| `HOST` | web | optional; `127.0.0.1` to accept only the local reverse proxy, unset to listen on every interface |
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
| `CORS_ORIGINS` | cms | `https://sharper.ly,https://www.sharper.ly` — an empty list blocks every cross-origin browser call, which is the safe default |
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
| `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` |
Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the
CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step
by step (remember to add the bucket host to `img-src` / `media-src` in
`config/middlewares.ts`).
### Requirements from the terms of reference
The ToR (`docs/UEC_SHARPER_Website_TOR V3.docx`, sections 3.5 and 3.6) commits the service
provider to an operating standard, not only to a delivery. In summary:
| Requirement | How it is met, and what still has to be set up |
| --- | --- |
| **Secure hosting with SSL** | Terminate TLS at the proxy (Let's Encrypt or the host's certificate), redirect HTTP to HTTPS, and enable HSTS. Strapi must run with `IS_PROXIED=true` behind it. |
| **Regular backups** | Nightly `pg_dump` of `sharper_cms` **plus** the uploads directory — the database alone does not contain the media files. Keep them off the application server and test a restore before go-live. The commands are in [Stopping and tearing down](#stopping-and-tearing-down). |
| **Security checks and updates** | Watch Strapi and Node releases; `pnpm --filter cms upgrade:dry` previews a Strapi upgrade's codemods. Rotate `STRAPI_TOKEN` and the admin accounts at handover. |
| **Analytics (e.g. Google Analytics)** | **Not yet integrated.** The tag belongs in `apps/web/app/root.tsx`, in the document `<head>`, and must respect the site's privacy notice — collect no personal data through it and document what is collected. |
| **Monthly viewership report** | Comes from the analytics property once it exists: sessions, users, top pages, EN vs AR split (the locale is in the URL prefix, so `/en/*` and `/ar/*` aggregate cleanly), referrers, devices. |
| **Errors addressed within one day of being reported** | Keep a monitored inbox or tracker, and an uptime check on `https://sharper.ly/en` and `https://cms.sharper.ly/_health`. Both applications log to stdout; capture it with the process manager. |
| **Bi-weekly content support** | Editors work in the Strapi admin panel; no deployment is needed for content. Draft & publish gives a review step before anything goes live. |
| **Fast loading, mobile responsive, basic accessibility** | SSR HTML, no client-side data fetching on first paint, `font-display: swap`, responsive layouts and logical properties throughout. Re-check with Lighthouse on both `/en` and `/ar` before each release. |
| **SEO-friendly structure** | Server-rendered HTML, one canonical per page, `hreflang` for `en` / `ar` / `x-default`, JSON-LD (Organization, WebSite, Article, Event, BreadcrumbList), and `noindex` on filtered listing permutations. A `sitemap.xml` and `robots.txt` are **not yet built** — add them before launch. |
| **CMS handover to UEC** | The schema lives in `apps/cms/src/api/**` in version control, the admin panel is self-hosted, and `.env.example` documents every variable. Hand over: the repository, the server access, the database credentials and a super-admin account. |
---
## Troubleshooting
**`Unsupported engine` / the dev server exits immediately after `pnpm dev:web`.**
React Router 8 requires **Node >= 22.22.0** — this was the first thing to bite while
bootstrapping the project. Check with `node -v`; `nvm install "$(cat .nvmrc)" && nvm use`
fixes it. `pnpm install` warns but does not stop you, because `.npmrc` sets
`engine-strict=false` so that Strapi's narrower range does not block the workspace.
**Strapi exits with `Cannot find module 'pg'` or `knex: Required dependency pg not found`.**
`DATABASE_CLIENT=postgres` needs the `pg` driver. It is already a dependency of `apps/cms`,
so this means the install is incomplete or was run in the wrong place: run `pnpm install`
from the **repository root**, never inside `apps/cms`.
**`pnpm install` inside `apps/web` or `apps/cms` produces an app that will not boot.**
Same cause. `.npmrc` sets `node-linker=hoisted` for the whole workspace; a nested install
builds a different tree from the one Strapi's plugin resolution expects.
**`password authentication failed for user "postgres"`.**
A fresh PostgreSQL install has no password for that role. See the collapsed note in
[Path A, step 3](#3-create-the-database).
**`Missing app.keys` on CMS start-up.** The six secrets are not set. Run
`./scripts/generate-secrets.sh --write apps/cms/.env` (manual) or
`./scripts/generate-secrets.sh --write .env` (Docker).
**Port already in use.** 5432 usually means a PostgreSQL is already running — in Docker mode
the container is published on 5433 for exactly that reason (`POSTGRES_PUBLISHED_PORT`). For
1337, 3000 and 5173: `ss -ltnp | grep -E '1337|3000|5173'`.
**`WARN The field "pnpm.onlyBuiltDependencies" was found in apps/cms/package.json`.**
Harmless. pnpm only honours that field at the workspace root, where it is already declared.
**A new page 404s even though the route module exists.** `app/routes.ts` existence-checks the
section modules while Vite builds the route config, and Vite does not re-evaluate it when a
file appears. Restart `pnpm dev:web`.
**`Property 'data' does not exist` in a route's `meta`, or an Arabic page renders English
metadata.** In React Router 8 a meta match exposes `loaderData`, not `data`;
`matches[0].data` is `undefined` at runtime and a type error at compile time. Use
`readRootSeo(matches)` from `~/routes/_shared/listing`.
**Type errors about `./+types/...`.** Those files are generated. Run `pnpm typecheck`
(which runs `react-router typegen` first) or `pnpm --filter web exec react-router typegen`.
**The browser reports a CORS error calling the API.** Add the origin to `CORS_ORIGINS` in the
CMS environment and restart it. `http://localhost:5173` and `http://127.0.0.1:5173` are
always allowed outside production.
**Images uploaded in the CMS do not load under Docker.** Expected until `cms` resolves in
your browser — see [Images from the CMS](#images-from-the-cms).
**`docker compose` complains about `depends_on` / `condition`.** You are on the old
`docker-compose` v1 binary. Install the Compose v2 plugin and use `docker compose` (a space,
not a hyphen).
**The site renders in a different typeface than the design.** Expected on a fresh clone: the
licensed fonts are not in the repository. See [Fonts](#fonts-licensed-not-in-this-repository).
**Everything looks broken after a schema change in Strapi.** `strapi develop` writes schema
changes to `apps/cms/src/api/**` and migrates the database; if the two disagree, stop the
CMS, check `git status` in that folder, and restart. As a last resort in **development
only**, drop and recreate the database (`dropdb sharper_cms && ./scripts/setup-db.sh`, or
`docker compose down -v`) — this destroys all content.
---
## Source documents
`docs/` holds the material this site is built from. It is **read-only source material** —
never edit it, and never invent a figure that is not in it.
| File | What it is |
| --- | --- |
| `UEC_SHARPER_Website_TOR V3.docx` | Terms of reference: scope, functional and technical requirements, deliverables, timeline |
| `SHARPER_Website_Draft_Section_EN_AR.docx` | Approved draft copy for every section, English and Arabic |
| `SHARPER Home Page .docx` | Home page copy |
| `SHARP-ER_Website Recommendations.docx` | Structure and content recommendations |
| `Sharp Updated communication guidline_ (2).pdf` | Brand: colours, typefaces, EU visibility and partner-logo rules, tone of voice |
Tone, everywhere: professional, neutral, factual, inclusive and non-confrontational. The site
is not political, not advocacy and not campaign-style.
---
## Licence and credits
Funded by the European Union. Implemented by Acted, United Efforts Consultancy and
Stichting Bureau Ensemble. The views expressed on the site are those of the implementing
consortium and do not necessarily reflect those of the European Union.
Content, branding and the licensed typefaces belong to the project and its partners; they are
not covered by any open-source licence granted over this source code.