IllusionandClaude Opus 5 8464c24fb1 Match the home page to Figma frame 253:4799
The page resembled the design but had drifted in every band. Ported it
section by section against the measured frame.

Hero (253:4800)
- Add the SHARP wordmark above the headline. The frame paints "SHARP"
  only, not the full SHARP-ER lockup the navigation carries, so this is
  the SHARP mark on its own, as vector (extracted from the brand
  guideline artwork) at its true 273px rather than an upscaled PNG.
- Drop the leading project name from the headline in both locales: the
  wordmark above now carries it, as the frame composes it.
- Stop the lead paragraph rendering by default. The frame's subheading
  node is hidden, but the code fell back to the site description, so a
  paragraph always appeared.
- Fallback CTAs become "Discover SHARP-ER" / "Explore Our Work", 24px
  apart. Both the CMS and fallback paths now render through one HeroCta,
  so an editor-authored secondary CTA gets the designed white treatment
  instead of the dark-surface outline.
- The picture loses its radius and gains the frame's two flat washes and
  wide band.

Figures band
- 253:4835 is hidden="true" in the frame, so the band and its "figures
  still being confirmed" placeholder no longer render. It appears only
  once the CMS supplies real numbers.

Launch (361:10853)
- Heading renders at Display/Small instead of Heading/H2. SectionHeader
  gains `size` since display-sm was reachable only via titleAs="h1".
- The three-paragraph story becomes the frame's single centred lead with
  its four bold runs, which needed a ProseRun copy shape - a flat string
  cannot carry emphasis.
- The captions were Card.Content links with images and action labels;
  the frame draws plain centred Heading/H2 text.
- Add the three-up photo strip that bleeds past the page and clips.
- Remove the funding line, which the frame does not draw in this band.
  EU visibility still stands via the nav badge and footer statement.

Partners (393:14754)
- Heading moves from centred to start-aligned, up to Display/Small.
- Tiles move to the sunken surface (they were lighter than the page; the
  design has them darker), lose their border, 8px -> 4px radius, padding
  on the inline start only, logo 48 -> 70px, name to Body/Large Bold.

Footer
- Black ground via a new --surface-footer token, a top rule, links up to
  Body/Large Bold 18/28 at 24px spacing, and column headings to
  Body/Small Bold on --text-tertiary.
- Keeps the real IA from ROUTE_SLUGS rather than frame 140:7814's
  labels, which name six pages that do not exist.

Tokens
- New tier-1 steps for values the design uses and the ramps lacked
  (--color-black-a20, --color-green-200-a12, --opacity-50) and tier-2
  --overlay-picture-shade / --overlay-picture-tint for the hero wash.

copy.ts now names the Figma frame as the source for the home strings.
The Arabic launch lead is rebuilt from the approved Arabic column to
carry the English frame's meaning and is marked (ar-draft) pending
client sign-off - the file has no Arabic home frame to work from.

Verified: typecheck and build green, no raw hex or tier-1 token in
components/routes, no physical-direction utility, 141 i18n keys at
parity, both locales rendered and checked for RTL mirroring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 12:38:05 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00
2026-09-11 02:46:56 +02:00

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

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.
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:

./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
# 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

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).

./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:

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();"
If psql asks for a password you do not have (fresh Ubuntu install)

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:

sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"

For a shared or production machine, create a dedicated role instead:

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'

4. Environment files

Two files, with different jobs:

File Read by Required in manual mode
apps/cms/.env Strapi (it loads its own .env) yes
.env (root) docker compose, and optionally the web server no — the code defaults already point at localhost
# 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 reads STRAPI_URL, STRAPI_TOKEN and SITE_URL from its process environment. Nothing loads the root .env for you in manual mode, and you do not need it: STRAPI_URL already defaults to http://localhost:1337, and with SITE_URL unset the canonical URL is derived from the incoming request. To override them anyway:

cp .env.example .env
set -a; . ./.env; set +a     # export it into the current shell
pnpm dev

5. Run both applications

pnpm dev          # web + cms together, output interleaved and prefixed

or, in two terminals:

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:

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
# 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:

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

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

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.

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:

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:

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

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:

# 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 typecheck the web app's react-router typegen && tsc (the CMS has no typecheck script — its TypeScript is checked by strapi build)

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 react-router-serve ./build/server/index.js — the production server, port 3000
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
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

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.

<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.


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:

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:

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.

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

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:

--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

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  :3000

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.

Production environment

Variable Where Value
NODE_ENV both production
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.
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.

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.

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.

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.

S
Description
No description provided
Readme
5.5 MiB
Languages
TypeScript 90.1%
CSS 7.7%
Shell 1.5%
Dockerfile 0.5%
JavaScript 0.2%