Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e930318f14
|
||
|
|
93b2fcb2ae
|
||
|
|
c0daacf1ba
|
||
|
|
2663d7eac6
|
||
|
|
9db3602dea
|
||
|
|
0d7db83fd2
|
||
|
|
801d99b745
|
||
|
|
70f6f92f00
|
||
|
|
2e920a96e7
|
||
|
|
2ed3a17914
|
||
|
|
e27e31bfa5
|
||
|
|
2c51c35fe8
|
||
|
|
19fab4a783
|
@@ -14,10 +14,12 @@ apps/cms/.strapi
|
|||||||
apps/cms/.tmp
|
apps/cms/.tmp
|
||||||
apps/cms/.cache
|
apps/cms/.cache
|
||||||
|
|
||||||
# Secrets. The containers receive their environment from docker compose.
|
# Secrets. The containers receive their environment from docker compose, so no
|
||||||
.env
|
# .env belongs in an image - including the per-application ones (apps/web/.env,
|
||||||
.env.*
|
# apps/cms/.env), which COPY apps/web / COPY apps/cms would otherwise pick up.
|
||||||
!.env.example
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
# Licensed fonts are mounted or copied in at deploy time, never baked into a
|
# Licensed fonts are mounted or copied in at deploy time, never baked into a
|
||||||
# published image. See the FONTS section of README.md.
|
# published image. See the FONTS section of README.md.
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
# WHO READS THIS FILE
|
# WHO READS THIS FILE
|
||||||
# * docker compose - every ${VARIABLE} in docker-compose.yml is read from
|
# * docker compose - every ${VARIABLE} in docker-compose.yml is read from
|
||||||
# the .env file sitting next to it (this one).
|
# the .env file sitting next to it (this one).
|
||||||
# * apps/web - the React Router server reads STRAPI_URL / STRAPI_TOKEN
|
# * apps/web - NOT this file. In manual mode the web app reads
|
||||||
# / SITE_URL from its process environment. In manual mode
|
# apps/web/.env (cp apps/web/.env.example apps/web/.env),
|
||||||
# nothing loads this file for you, so export it first:
|
# which is where PORT, HOST, SITE_URL, STRAPI_URL and
|
||||||
# set -a; . ./.env; set +a
|
# STRAPI_TOKEN belong. Under Docker the values below are
|
||||||
# The defaults below are also the code defaults, so a
|
# injected into the container instead. The defaults are
|
||||||
# plain `pnpm dev` works without exporting anything.
|
# also the code defaults, so a plain `pnpm dev` works
|
||||||
|
# with no .env at all.
|
||||||
# * apps/cms - Strapi does NOT read this file. In manual mode it reads
|
# * apps/cms - Strapi does NOT read this file. In manual mode it reads
|
||||||
# apps/cms/.env (cp apps/cms/.env.example apps/cms/.env).
|
# apps/cms/.env (cp apps/cms/.env.example apps/cms/.env).
|
||||||
# Under Docker the values below are injected into the
|
# Under Docker the values below are injected into the
|
||||||
@@ -63,12 +64,18 @@ ENCRYPTION_KEY=
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PORTS
|
# PORTS
|
||||||
# Host ports the two applications are published on in Docker mode, and the
|
# Host ports the two applications are published on in Docker mode. The ports
|
||||||
# ports they listen on in manual mode.
|
# they LISTEN on in manual mode live in each application's own .env
|
||||||
|
# (apps/web/.env, apps/cms/.env), because both servers read a bare PORT.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# React Router server (production build / Docker). `pnpm dev:web` uses 5173.
|
# Docker mode - host port the web container is published on; inside the
|
||||||
|
# container it always listens on 3000, so WEB_PORT=8080 means 8080:3000.
|
||||||
|
# Manual mode has its own PORT in apps/web/.env - never put a bare PORT in
|
||||||
|
# THIS file: both servers would read it and Strapi would land on the website's
|
||||||
|
# port. `pnpm dev:web` uses 5173 either way.
|
||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
|
|
||||||
# Strapi API + admin panel.
|
# Strapi API + admin panel.
|
||||||
CMS_PORT=1337
|
CMS_PORT=1337
|
||||||
|
|
||||||
@@ -79,7 +86,7 @@ CMS_PUBLIC_URL=http://localhost:1337
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# WEB APP
|
# WEB APP - Docker mode. In manual mode these live in apps/web/.env.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Public origin of the site. Used for canonical URLs, hreflang alternates and
|
# Public origin of the site. Used for canonical URLs, hreflang alternates and
|
||||||
@@ -87,9 +94,6 @@ CMS_PUBLIC_URL=http://localhost:1337
|
|||||||
# Leave it unset in local development and the request's own host is used.
|
# Leave it unset in local development and the request's own host is used.
|
||||||
SITE_URL=http://localhost:3000
|
SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Where the server-side loaders fetch content from, MANUAL MODE.
|
|
||||||
STRAPI_URL=http://localhost:1337
|
|
||||||
|
|
||||||
# Where they fetch from inside Docker, where "cms" is the compose service name.
|
# Where they fetch from inside Docker, where "cms" is the compose service name.
|
||||||
# NOTE: this origin is also prefixed onto uploaded media URLs, which the
|
# NOTE: this origin is also prefixed onto uploaded media URLs, which the
|
||||||
# browser has to be able to resolve too - see "Images from the CMS" in the
|
# browser has to be able to resolve too - see "Images from the CMS" in the
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ unreachable — the site must never 500 because Strapi is down.
|
|||||||
The site ships RTL Arabic; physical directions silently break it.
|
The site ships RTL Arabic; physical directions silently break it.
|
||||||
Check: `grep -rnE "\b(pl|pr|ml|mr|left|right)-[0-9]" apps/web/app` → nothing meaningful.
|
Check: `grep -rnE "\b(pl|pr|ml|mr|left|right)-[0-9]" apps/web/app` → nothing meaningful.
|
||||||
3. **Surfaces, not a theme.** `:root` *is* the dark surface; `data-surface="light"` flips a
|
3. **Surfaces, not a theme.** `:root` *is* the dark surface; `data-surface="light"` flips a
|
||||||
subtree and repaints itself. Do not add a global theme toggle or a `dark:` variant.
|
subtree and repaints itself. Never add a second theme system or a `dark:` variant. **The
|
||||||
|
one exception, accepted deliberately:** the nav's theme toggle flips *which* surface `<html>`
|
||||||
|
stands on (dark default ↔ light), persisted via the `sharper-theme` cookie read in the root
|
||||||
|
loader and set by the root action — SSR-rendered so the first byte and first paint always
|
||||||
|
match. The mechanism stays surfaces-only: the toggle just re-roots them. `--surface-nav`
|
||||||
|
must keep a value in BOTH surface blocks.
|
||||||
4. **Figma is READ ONLY.** Only `get_design_context`, `get_screenshot`, `get_metadata`,
|
4. **Figma is READ ONLY.** Only `get_design_context`, `get_screenshot`, `get_metadata`,
|
||||||
`get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
|
`get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
|
||||||
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
|
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
|
||||||
@@ -61,6 +66,8 @@ unreachable — the site must never 500 because Strapi is down.
|
|||||||
`skillNames: "resource:figma-design-to-code"`. File key `yvKHp3Ha2rQmv9kt9tCC7j`.
|
`skillNames: "resource:figma-design-to-code"`. File key `yvKHp3Ha2rQmv9kt9tCC7j`.
|
||||||
5. **Never invent statistics, dates, names or quotes.** If a number is not in `docs/` or in
|
5. **Never invent statistics, dates, names or quotes.** If a number is not in `docs/` or in
|
||||||
the CMS, it does not go on the page. Leave the slot empty or omit the component.
|
the CMS, it does not go on the page. Leave the slot empty or omit the component.
|
||||||
|
The same rule governs the seed: anything invented belongs in `src/seed/demo.ts`, never
|
||||||
|
in `approved.ts`, and `pnpm seed` without `--demo` must stay publishable.
|
||||||
6. **`docs/` is read-only source material.** Never edit, move or "clean up" those files.
|
6. **`docs/` is read-only source material.** Never edit, move or "clean up" those files.
|
||||||
7. **Both locales, always.** New UI text goes into `app/i18n/messages/en.json` *and*
|
7. **Both locales, always.** New UI text goes into `app/i18n/messages/en.json` *and*
|
||||||
`ar.json` with identical keys (the `Messages` type enforces it). A component that hard-codes
|
`ar.json` with identical keys (the `Messages` type enforces it). A component that hard-codes
|
||||||
@@ -86,6 +93,7 @@ unreachable — the site must never 500 because Strapi is down.
|
|||||||
| HTML shell, canonical, hreflang | `apps/web/app/root.tsx` |
|
| HTML shell, canonical, hreflang | `apps/web/app/root.tsx` |
|
||||||
| Content types | `apps/cms/src/api/<name>/content-types/<name>/schema.json` |
|
| Content types | `apps/cms/src/api/<name>/content-types/<name>/schema.json` |
|
||||||
| Boot-time locale + permission setup | `apps/cms/src/bootstrap/` |
|
| Boot-time locale + permission setup | `apps/cms/src/bootstrap/` |
|
||||||
|
| CMS seed content (`pnpm seed`) | `apps/cms/src/seed/` — `approved.ts` is from `docs/`; `demo.ts` is invented, opt-in behind `--demo`, every slug prefixed `demo-` |
|
||||||
| CORS, upload rules, i18n locales | `apps/cms/config/{middlewares.ts,plugins.ts}` |
|
| CORS, upload rules, i18n locales | `apps/cms/config/{middlewares.ts,plugins.ts}` |
|
||||||
|
|
||||||
## Conventions that bite
|
## Conventions that bite
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ publications — all of it in English and Arabic, with the Arabic side laid out
|
|||||||
- [Path A — manual setup](#path-a--manual-setup)
|
- [Path A — manual setup](#path-a--manual-setup)
|
||||||
- [Path B — Docker](#path-b--docker)
|
- [Path B — Docker](#path-b--docker)
|
||||||
- [Available scripts](#available-scripts)
|
- [Available scripts](#available-scripts)
|
||||||
|
- [Seeding content](#seeding-content)
|
||||||
- [Design tokens](#design-tokens)
|
- [Design tokens](#design-tokens)
|
||||||
- [Bilingual EN / AR](#bilingual-en--ar)
|
- [Bilingual EN / AR](#bilingual-en--ar)
|
||||||
- [Adding a content type, end to end](#adding-a-content-type-end-to-end)
|
- [Adding a content type, end to end](#adding-a-content-type-end-to-end)
|
||||||
@@ -195,12 +196,17 @@ sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-pass
|
|||||||
|
|
||||||
### 4. Environment files
|
### 4. Environment files
|
||||||
|
|
||||||
Two files, with different jobs:
|
Three files, with different jobs:
|
||||||
|
|
||||||
| File | Read by | Required in manual mode |
|
| File | Read by | Required in manual mode |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** |
|
| `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` |
|
| `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
|
```bash
|
||||||
# CMS: copy the fully documented template, then fill in the six secrets
|
# CMS: copy the fully documented template, then fill in the six secrets
|
||||||
@@ -222,17 +228,19 @@ overwrites a value that is already set unless you pass `--force`, so it is safe
|
|||||||
Without `--write` it just prints a block you can paste. By hand:
|
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).
|
`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
|
The web application needs nothing to start: `STRAPI_URL` already defaults to
|
||||||
environment**. Nothing loads the root `.env` for you in manual mode, and you do not need it:
|
`http://localhost:1337`, and with `SITE_URL` unset the canonical URL is derived from the
|
||||||
`STRAPI_URL` already defaults to `http://localhost:1337`, and with `SITE_URL` unset the
|
incoming request. Copy its template when you want to override those, or to pin the
|
||||||
canonical URL is derived from the incoming request. To override them anyway:
|
production port:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp apps/web/.env.example apps/web/.env # PORT, HOST, SITE_URL, STRAPI_URL, STRAPI_TOKEN
|
||||||
set -a; . ./.env; set +a # export it into the current shell
|
|
||||||
pnpm dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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
|
### 5. Run both applications
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -461,7 +469,8 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
|
|||||||
| `pnpm dev:cms` | `strapi develop` (auto-reloads on schema changes) → `http://localhost:1337` |
|
| `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 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 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`) |
|
| `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>`:
|
**`apps/web`** — run with `pnpm --filter web <script>`:
|
||||||
|
|
||||||
@@ -469,7 +478,7 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `dev` | `react-router dev` — SSR dev server with HMR |
|
| `dev` | `react-router dev` — SSR dev server with HMR |
|
||||||
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
|
| `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 |
|
| `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` |
|
| `typecheck` | regenerates `+types/*` route types, then `tsc` |
|
||||||
|
|
||||||
**`apps/cms`** — run with `pnpm --filter cms <script>`:
|
**`apps/cms`** — run with `pnpm --filter cms <script>`:
|
||||||
@@ -479,6 +488,7 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
|
|||||||
| `develop` / `dev` | `strapi develop` — the only mode in which the Content-Type Builder can write schemas |
|
| `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/` |
|
| `build` | builds the admin panel and compiles the TypeScript server into `dist/` |
|
||||||
| `start` | `strapi start` — production mode; the Content-Type Builder is disabled |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `upgrade` / `upgrade:dry` | `@strapi/upgrade` codemods for a minor/major version bump |
|
||||||
@@ -493,6 +503,73 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## Design tokens
|
||||||
|
|
||||||
Two tiers, so re-branding is a one-line edit in one file.
|
Two tiers, so re-branding is a one-line edit in one file.
|
||||||
@@ -766,18 +843,50 @@ pnpm install --frozen-lockfile
|
|||||||
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
|
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
|
||||||
|
|
||||||
pnpm --filter cms start # strapi start :1337
|
pnpm --filter cms start # strapi start :1337
|
||||||
pnpm --filter web start # react-router-serve :3000
|
pnpm --filter web start # react-router-serve :$PORT (see below)
|
||||||
```
|
```
|
||||||
|
|
||||||
Both are ordinary long-running Node processes. Run them under systemd, PM2 or the
|
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
|
containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates
|
||||||
TLS and forwards to 3000 and 1337.
|
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
|
### Production environment
|
||||||
|
|
||||||
| Variable | Where | Value |
|
| Variable | Where | Value |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `NODE_ENV` | both | `production` |
|
| `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 |
|
| `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_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 |
|
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ UPLOAD_MAX_FILE_SIZE=52428800
|
|||||||
# Cache-Control max-age (ms) for files served by the local upload provider.
|
# Cache-Control max-age (ms) for files served by the local upload provider.
|
||||||
UPLOAD_LOCAL_MAX_AGE=300000
|
UPLOAD_LOCAL_MAX_AGE=300000
|
||||||
|
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=your-username
|
||||||
|
SMTP_PASSWORD=your-password
|
||||||
|
DEFAULT_FROM=no-reply@yourdomain.com
|
||||||
|
DEFAULT_REPLY_TO=support@yourdomain.com
|
||||||
|
|
||||||
# Production only - switching the upload provider to S3. Uncomment these after
|
# Production only - switching the upload provider to S3. Uncomment these after
|
||||||
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
|
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
|
||||||
# config/plugins.ts (the switch is documented there step by step).
|
# config/plugins.ts (the switch is documented there step by step).
|
||||||
|
|||||||
@@ -138,6 +138,24 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
email: {
|
||||||
|
config: {
|
||||||
|
provider: "@strapi/provider-email-nodemailer",
|
||||||
|
providerOptions: {
|
||||||
|
host: env("SMTP_HOST", "mail.sharper.ly"),
|
||||||
|
port: env("SMTP_PORT", 587),
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: env("SMTP_USERNAME"),
|
||||||
|
pass: env("SMTP_PASSWORD"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
defaultFrom: "SHARP-ER <noreply@sharper.ly>",
|
||||||
|
defaultReplyTo: "United Efforts <info@unitedefforts.ngo>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"console": "strapi console",
|
"console": "strapi console",
|
||||||
"deploy": "strapi deploy",
|
"deploy": "strapi deploy",
|
||||||
"dev": "strapi develop",
|
"dev": "strapi develop",
|
||||||
|
"seed": "node --env-file-if-exists=.env ./scripts/seed.js",
|
||||||
"develop": "strapi develop",
|
"develop": "strapi develop",
|
||||||
"start": "strapi start",
|
"start": "strapi start",
|
||||||
"strapi": "strapi",
|
"strapi": "strapi",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"@strapi/database": "5.53.0",
|
"@strapi/database": "5.53.0",
|
||||||
"@strapi/plugin-cloud": "5.53.0",
|
"@strapi/plugin-cloud": "5.53.0",
|
||||||
"@strapi/plugin-users-permissions": "5.53.0",
|
"@strapi/plugin-users-permissions": "5.53.0",
|
||||||
|
"@strapi/provider-email-nodemailer": "^5.54.0",
|
||||||
"@strapi/strapi": "5.53.0",
|
"@strapi/strapi": "5.53.0",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SEED RUNNER
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Boots Strapi far enough to use the document service, runs src/seed, and
|
||||||
|
shuts it down again. It does NOT start a server: nothing listens on a port,
|
||||||
|
so it is safe to run while `pnpm dev:cms` is up - both talk to the same
|
||||||
|
PostgreSQL database.
|
||||||
|
|
||||||
|
`compileStrapi()` is Strapi's own TypeScript build step. It compiles
|
||||||
|
src/** (the seed included) into dist/, which is why this file is plain
|
||||||
|
CommonJS JavaScript and everything it calls is TypeScript.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pnpm seed
|
||||||
|
pnpm seed --demo
|
||||||
|
pnpm seed --demo --clean
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { compileStrapi, createStrapi } = require('@strapi/strapi');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const appContext = await compileStrapi();
|
||||||
|
const app = await createStrapi(appContext).load();
|
||||||
|
|
||||||
|
/* Required from dist, not from src: the file below is TypeScript, and what
|
||||||
|
`compileStrapi()` just produced is the JavaScript for it. */
|
||||||
|
const { runSeed, parseSeedOptions } = require('../dist/src/seed');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runSeed(app, parseSeedOptions(process.argv.slice(2)));
|
||||||
|
} finally {
|
||||||
|
await shutdown(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shut Strapi down without letting the shutdown fail the run.
|
||||||
|
*
|
||||||
|
* Knex tears its connection pool down by aborting every acquisition still
|
||||||
|
* pending on it, and each abort rejects a promise that nothing is waiting on -
|
||||||
|
* so it arrives as an unhandled rejection reading "aborted" rather than as an
|
||||||
|
* error this function could simply catch. Everything the seed wrote is
|
||||||
|
* committed long before this point, so those are absorbed for the length of
|
||||||
|
* the shutdown and nothing else is: a seed that worked must not report itself
|
||||||
|
* as a seed that failed, and a real error must still say so.
|
||||||
|
*/
|
||||||
|
async function shutdown(app) {
|
||||||
|
const absorbAbort = (reason) => {
|
||||||
|
const message = reason instanceof Error ? reason.message : String(reason);
|
||||||
|
if (message !== 'aborted') {
|
||||||
|
console.warn(`[seed] while shutting down: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('unhandledRejection', absorbAbort);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`[seed] Strapi did not shut down cleanly (${message}). The data is written.`);
|
||||||
|
} finally {
|
||||||
|
process.off('unhandledRejection', absorbAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
if (error instanceof Error && error.stack) console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -39,7 +39,6 @@
|
|||||||
"components": [
|
"components": [
|
||||||
"blocks.rich-text",
|
"blocks.rich-text",
|
||||||
"blocks.image-gallery",
|
"blocks.image-gallery",
|
||||||
"blocks.stats-grid",
|
|
||||||
"blocks.cta-banner",
|
"blocks.cta-banner",
|
||||||
"blocks.card-grid",
|
"blocks.card-grid",
|
||||||
"blocks.quote",
|
"blocks.quote",
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
"components": [
|
"components": [
|
||||||
"blocks.rich-text",
|
"blocks.rich-text",
|
||||||
"blocks.image-gallery",
|
"blocks.image-gallery",
|
||||||
"blocks.stats-grid",
|
|
||||||
"blocks.cta-banner",
|
"blocks.cta-banner",
|
||||||
"blocks.card-grid",
|
"blocks.card-grid",
|
||||||
"blocks.quote",
|
"blocks.quote",
|
||||||
|
|||||||
@@ -26,10 +26,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"approach": {
|
||||||
"type": "component",
|
"type": "component",
|
||||||
"repeatable": true,
|
"repeatable": false,
|
||||||
"component": "shared.stat",
|
"component": "page.band-intro",
|
||||||
"pluginOptions": {
|
"pluginOptions": {
|
||||||
"i18n": {
|
"i18n": {
|
||||||
"localized": true
|
"localized": true
|
||||||
@@ -41,7 +41,6 @@
|
|||||||
"components": [
|
"components": [
|
||||||
"blocks.rich-text",
|
"blocks.rich-text",
|
||||||
"blocks.image-gallery",
|
"blocks.image-gallery",
|
||||||
"blocks.stats-grid",
|
|
||||||
"blocks.cta-banner",
|
"blocks.cta-banner",
|
||||||
"blocks.card-grid",
|
"blocks.card-grid",
|
||||||
"blocks.quote",
|
"blocks.quote",
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
"components": [
|
"components": [
|
||||||
"blocks.rich-text",
|
"blocks.rich-text",
|
||||||
"blocks.image-gallery",
|
"blocks.image-gallery",
|
||||||
"blocks.stats-grid",
|
|
||||||
"blocks.cta-banner",
|
"blocks.cta-banner",
|
||||||
"blocks.card-grid",
|
"blocks.card-grid",
|
||||||
"blocks.quote",
|
"blocks.quote",
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
"components": [
|
"components": [
|
||||||
"blocks.rich-text",
|
"blocks.rich-text",
|
||||||
"blocks.image-gallery",
|
"blocks.image-gallery",
|
||||||
"blocks.stats-grid",
|
|
||||||
"blocks.cta-banner",
|
"blocks.cta-banner",
|
||||||
"blocks.card-grid",
|
"blocks.card-grid",
|
||||||
"blocks.quote",
|
"blocks.quote",
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
"components": [
|
"components": [
|
||||||
"blocks.rich-text",
|
"blocks.rich-text",
|
||||||
"blocks.image-gallery",
|
"blocks.image-gallery",
|
||||||
"blocks.stats-grid",
|
|
||||||
"blocks.cta-banner",
|
"blocks.cta-banner",
|
||||||
"blocks.card-grid",
|
"blocks.card-grid",
|
||||||
"blocks.quote",
|
"blocks.quote",
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"collectionName": "components_blocks_stats_grids",
|
|
||||||
"info": {
|
|
||||||
"displayName": "Stats Grid",
|
|
||||||
"icon": "chartBubble",
|
|
||||||
"description": "A grid of headline figures."
|
|
||||||
},
|
|
||||||
"options": {},
|
|
||||||
"attributes": {
|
|
||||||
"stats": {
|
|
||||||
"type": "component",
|
|
||||||
"repeatable": true,
|
|
||||||
"component": "shared.stat"
|
|
||||||
},
|
|
||||||
"note": {
|
|
||||||
"type": "text"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"collectionName": "components_page_band_intros",
|
||||||
|
"info": {
|
||||||
|
"displayName": "Band intro",
|
||||||
|
"icon": "layer",
|
||||||
|
"description": "The heading, lead and pictures of a page band, in one place so an editor is not hunting for half of it in the Blocks zone."
|
||||||
|
},
|
||||||
|
"options": {},
|
||||||
|
"attributes": {
|
||||||
|
"eyebrow": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"heading": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"lead": {
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
"images": {
|
||||||
|
"type": "media",
|
||||||
|
"multiple": true,
|
||||||
|
"required": false,
|
||||||
|
"allowedTypes": [
|
||||||
|
"images"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"collectionName": "components_shared_stats",
|
|
||||||
"info": {
|
|
||||||
"displayName": "Stat",
|
|
||||||
"icon": "chartBubble",
|
|
||||||
"description": "A single headline figure, e.g. 64 / CSO Partners."
|
|
||||||
},
|
|
||||||
"options": {},
|
|
||||||
"attributes": {
|
|
||||||
"value": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"label": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SEED - APPROVED CONTENT
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Everything in this file is transcribed from docs/, in both languages, and
|
||||||
|
nothing in it is invented. That is the whole point of keeping it apart from
|
||||||
|
demo.ts: this content is safe to publish.
|
||||||
|
|
||||||
|
SOURCES
|
||||||
|
docs/SHARPER_Website_Draft_Section_EN_AR.docx sections 1-9, EN and AR
|
||||||
|
columns, which is why no
|
||||||
|
Arabic string here is a
|
||||||
|
machine translation
|
||||||
|
docs/SHARPER Home Page .docx the launch article, EN/AR
|
||||||
|
docs/UEC_SHARPER_Website_TOR V3.docx the consortium and the
|
||||||
|
project's purpose
|
||||||
|
|
||||||
|
WHAT docs/ DOES NOT SUPPLY, AND IS THEREFORE NOT HERE
|
||||||
|
* Figures. Every number in the draft is bracketed and marked "TBD ... must
|
||||||
|
not be published until confirmed". There is no figures band on the site
|
||||||
|
and no `stats` component in the CMS - both were removed rather than left
|
||||||
|
as an empty invitation to fill one in.
|
||||||
|
* The project email. The draft writes it "[project email - e.g.
|
||||||
|
info@sharper.ly]", an example rather than an address, so
|
||||||
|
`contact-page.generalEmail` is left empty.
|
||||||
|
* The Facebook URL and the SHARP-legacy link. Both are bracketed
|
||||||
|
placeholders, and `shared.link` requires an href, so neither link is
|
||||||
|
written at all rather than written to nowhere.
|
||||||
|
* Consortium postal addresses - "[addresses/contacts TBD]".
|
||||||
|
* Any CSO partner, event, resource or question. The draft's examples are
|
||||||
|
all written "[CSO Name]", "[Date]", "[Report title]". Those live in
|
||||||
|
demo.ts and are opt-in.
|
||||||
|
|
||||||
|
THE TWO JUDGEMENT CALLS, BOTH FLAGGED AT THEIR FIELD
|
||||||
|
* `activityStatus` is required by the schema and the draft states no
|
||||||
|
status per activity stream.
|
||||||
|
* The launch article's year is derived, not given.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
import type { Localised, SeedData } from './upsert';
|
||||||
|
|
||||||
|
/** Slug -> content, for the collection types the approved set writes. */
|
||||||
|
export type SeedEntries = ReadonlyArray<{
|
||||||
|
readonly slug: string;
|
||||||
|
readonly data: Localised<SeedData>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
SITE SETTINGS
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const SITE_SETTINGS: Localised<SeedData> = {
|
||||||
|
en: {
|
||||||
|
siteName: 'SHARP-ER',
|
||||||
|
tagline: 'Shared Action on Rule of Law Progress – Exit Route',
|
||||||
|
euFundingStatement: 'Funded by the European Union',
|
||||||
|
implementedByLabel: 'Implemented by',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
siteName: 'SHARP-ER',
|
||||||
|
tagline: 'العمل المشترك لتعزيز سيادة القانون – مسار الخروج',
|
||||||
|
euFundingStatement: 'بتمويل من الاتحاد الأوروبي',
|
||||||
|
implementedByLabel: 'ينفّذه',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
THE CONSORTIUM
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Draft section 1 writes each member as "<name> - <role> - <deliverable>".
|
||||||
|
The Latin names are kept in the Arabic column exactly as the draft keeps
|
||||||
|
them. No website is seeded: the draft names none.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const CONSORTIUM: SeedEntries = [
|
||||||
|
{
|
||||||
|
slug: 'acted',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'Acted',
|
||||||
|
slug: 'acted',
|
||||||
|
partnerRole: 'consortium-lead',
|
||||||
|
description:
|
||||||
|
'Lead implementing partner — overall project management and civil society sub-projects.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'Acted',
|
||||||
|
slug: 'acted',
|
||||||
|
partnerRole: 'consortium-lead',
|
||||||
|
description: 'الشريك المنفّذ الرئيسي — الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'united-efforts-consultancy',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'United Efforts Consultancy (UEC)',
|
||||||
|
slug: 'united-efforts-consultancy',
|
||||||
|
partnerRole: 'consortium-partner',
|
||||||
|
description: 'Network strengthening, dialogue, and peer-learning.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'United Efforts Consultancy (UEC)',
|
||||||
|
slug: 'united-efforts-consultancy',
|
||||||
|
partnerRole: 'consortium-partner',
|
||||||
|
description: 'تعزيز الشبكة، والحوار، والتعلّم بين الأقران.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'stichting-bureau-ensemble',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'Stichting Bureau Ensemble (SBE)',
|
||||||
|
slug: 'stichting-bureau-ensemble',
|
||||||
|
partnerRole: 'consortium-partner',
|
||||||
|
description: 'Research, perception data, and knowledge publications.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'Stichting Bureau Ensemble (SBE)',
|
||||||
|
slug: 'stichting-bureau-ensemble',
|
||||||
|
partnerRole: 'consortium-partner',
|
||||||
|
description: 'البحث، وبيانات الاستطلاع، والمنشورات المعرفية.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Slugs, so later steps can wire relations without repeating the strings. */
|
||||||
|
export const PARTNER_SLUGS = {
|
||||||
|
acted: 'acted',
|
||||||
|
uec: 'united-efforts-consultancy',
|
||||||
|
sbe: 'stichting-bureau-ensemble',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
TOPICS
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The taxonomy the site filters by. Each name and description is one of the
|
||||||
|
draft's own area headings - the three pillars of section 1 and two of the
|
||||||
|
five activity streams of section 3 - so the vocabulary is the project's,
|
||||||
|
not one this seed made up.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const TOPICS: SeedEntries = [
|
||||||
|
{
|
||||||
|
slug: 'rule-of-law',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'Rule of Law',
|
||||||
|
slug: 'rule-of-law',
|
||||||
|
description:
|
||||||
|
'Supporting local institutions to improve access to justice and transparency.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'سيادة القانون',
|
||||||
|
slug: 'rule-of-law',
|
||||||
|
description: 'دعم المؤسسات المحلية لتحسين الوصول إلى العدالة وتعزيز الشفافية.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'civil-society',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'Civil Society',
|
||||||
|
slug: 'civil-society',
|
||||||
|
description:
|
||||||
|
'Building the capacity of CSOs to strengthen their role in oversight and accountability.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'المجتمع المدني',
|
||||||
|
slug: 'civil-society',
|
||||||
|
description: 'بناء قدرات منظمات المجتمع المدني لتعزيز دورها في الرقابة والمساءلة.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'local-governance',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'Local Governance',
|
||||||
|
slug: 'local-governance',
|
||||||
|
description:
|
||||||
|
'Enhancing collaboration between municipalities and local actors to improve service delivery.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'الحوكمة المحلية',
|
||||||
|
slug: 'local-governance',
|
||||||
|
description: 'تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'dialogue',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'Dialogue',
|
||||||
|
slug: 'dialogue',
|
||||||
|
description:
|
||||||
|
'SUQ dialogues and Civil Society Law Sessions that bring civic and government actors to the same table.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'الحوار',
|
||||||
|
slug: 'dialogue',
|
||||||
|
description: 'حوارات SUQ وجلسات قانون المجتمع المدني التي تجمع الفاعلين المدنيين والحكوميين حول طاولة واحدة.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'human-security',
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
name: 'Human Security',
|
||||||
|
slug: 'human-security',
|
||||||
|
description: 'Sub-projects designed to foster human security at the community level.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
name: 'الأمن البشري',
|
||||||
|
slug: 'human-security',
|
||||||
|
description: 'مشاريع فرعية مصمَّمة لتعزيز الأمن البشري على المستوى المجتمعي.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TOPIC_SLUGS = {
|
||||||
|
ruleOfLaw: 'rule-of-law',
|
||||||
|
civilSociety: 'civil-society',
|
||||||
|
localGovernance: 'local-governance',
|
||||||
|
dialogue: 'dialogue',
|
||||||
|
humanSecurity: 'human-security',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
ACTIVITIES - draft section 3
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Five streams, and the draft names all five along with the partner that
|
||||||
|
implements each. They map one-to-one onto the `activityStream` enum, which
|
||||||
|
is where that enum came from.
|
||||||
|
|
||||||
|
`activityStatus` is the one field with no source. The schema requires it and
|
||||||
|
the draft states no status per stream, so every activity is seeded
|
||||||
|
`ongoing`: the draft heads the section "here's where the project stands
|
||||||
|
today" and describes all five in the present tense. It is a default for
|
||||||
|
editors to correct, not a claim from docs/.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const activity = (
|
||||||
|
slug: string,
|
||||||
|
stream: string,
|
||||||
|
partners: readonly string[],
|
||||||
|
topics: readonly string[],
|
||||||
|
en: { title: string; summary: string },
|
||||||
|
ar: { title: string; summary: string }
|
||||||
|
) => ({
|
||||||
|
slug,
|
||||||
|
partners,
|
||||||
|
topics,
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
title: en.title,
|
||||||
|
slug,
|
||||||
|
summary: en.summary,
|
||||||
|
activityStream: stream,
|
||||||
|
activityStatus: 'ongoing',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
title: ar.title,
|
||||||
|
slug,
|
||||||
|
summary: ar.summary,
|
||||||
|
activityStream: stream,
|
||||||
|
activityStatus: 'ongoing',
|
||||||
|
},
|
||||||
|
} satisfies Localised<SeedData>,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ACTIVITIES = [
|
||||||
|
activity(
|
||||||
|
'the-sharp-network',
|
||||||
|
'sharp-network',
|
||||||
|
[PARTNER_SLUGS.uec],
|
||||||
|
[TOPIC_SLUGS.civilSociety],
|
||||||
|
{
|
||||||
|
title: 'The SHARP Network',
|
||||||
|
summary:
|
||||||
|
'Regional meetings, international events, and peer-to-peer learning that keep the civil society network connected.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'شبكة SHARP',
|
||||||
|
summary: 'اجتماعات إقليمية وفعاليات دولية وتعلّم بين الأقران تُبقي شبكة المجتمع المدني على تواصل.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
activity(
|
||||||
|
'cso-sub-projects-and-policy-to-rights',
|
||||||
|
'cso-subprojects-p2r',
|
||||||
|
[PARTNER_SLUGS.acted],
|
||||||
|
[TOPIC_SLUGS.civilSociety, TOPIC_SLUGS.ruleOfLaw],
|
||||||
|
{
|
||||||
|
title: 'CSO Sub-Projects & Policy-to-Rights',
|
||||||
|
summary:
|
||||||
|
'Collective sub-projects selected through open Calls for Expression of Interest, fostering cross-country cooperation, plus Policy-to-Rights (P2R) projects.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'المشاريع الفرعية لمنظمات المجتمع المدني ومشاريع P2R',
|
||||||
|
summary:
|
||||||
|
'مشاريع فرعية جماعية تُختار عبر دعوات مفتوحة للتعبير عن الاهتمام لتعزيز التعاون العابر للمناطق، إضافة إلى مشاريع “من السياسة إلى الحقوق”.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
activity(
|
||||||
|
'suq-dialogues-and-civil-society-law-sessions',
|
||||||
|
'suq-dialogues',
|
||||||
|
[PARTNER_SLUGS.uec, PARTNER_SLUGS.acted],
|
||||||
|
[TOPIC_SLUGS.dialogue, TOPIC_SLUGS.ruleOfLaw],
|
||||||
|
{
|
||||||
|
title: 'SUQ Dialogues & Civil Society Law Sessions',
|
||||||
|
summary:
|
||||||
|
'Search, Understand, Question (SUQ) dialogues driven by local needs, dialogue-facilitation training, and joint sessions with the Commission on Civil Society (CCS).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'حوارات SUQ وجلسات قانون المجتمع المدني',
|
||||||
|
summary:
|
||||||
|
'حوارات “ابحث، افهم، اسأل” (SUQ) مدفوعة باحتياجات محلية، وتدريب على تيسير الحوار، وجلسات مشتركة مع لجنة المجتمع المدني (CCS).',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
activity(
|
||||||
|
'human-security-initiatives',
|
||||||
|
'human-security',
|
||||||
|
[PARTNER_SLUGS.uec],
|
||||||
|
[TOPIC_SLUGS.humanSecurity],
|
||||||
|
{
|
||||||
|
title: 'Human Security Initiatives',
|
||||||
|
summary:
|
||||||
|
'16 individual sub-projects designed to foster human security at the community level.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'مبادرات الأمن البشري',
|
||||||
|
summary: '16 مشروعًا فرديًا فرعيًا مصمَّمًا لتعزيز الأمن البشري على المستوى المجتمعي.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
activity(
|
||||||
|
'research-and-perception-data',
|
||||||
|
'research',
|
||||||
|
[PARTNER_SLUGS.sbe],
|
||||||
|
[TOPIC_SLUGS.localGovernance],
|
||||||
|
{
|
||||||
|
title: 'Research & Perception Data',
|
||||||
|
summary: 'Nationwide perception-based data collection, analysis, and dissemination.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'البحث وبيانات الاستطلاع',
|
||||||
|
summary: 'جمع بيانات استطلاعية على المستوى الوطني وتحليلها ونشرها.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
THE LAUNCH ARTICLE - docs/SHARPER Home Page .docx
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The one piece of real news the project has handed over, written out in full
|
||||||
|
in both languages.
|
||||||
|
|
||||||
|
THE DATE: the document says "Tuesday, June 30" and gives no year. Within the
|
||||||
|
project's own window - December 2025 to December 2027 - 30 June falls on a
|
||||||
|
Tuesday in 2026 and in no other year, so 2026-06-30 is derived from the
|
||||||
|
source rather than chosen.
|
||||||
|
|
||||||
|
The source's closing line ("Visit the SHARP-ER website and follow our social
|
||||||
|
media channels...") is left out: it addresses a reader on social media and
|
||||||
|
asks them to come to this site, which is not something to print on it.
|
||||||
|
|
||||||
|
No `authorOrganisation`: the document attributes the article to no one, and
|
||||||
|
a byline is not a thing to guess at.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const LAUNCH_SLUG = 'sharp-er-project-officially-launches-in-libya';
|
||||||
|
|
||||||
|
export const LAUNCH_ARTICLE = {
|
||||||
|
slug: LAUNCH_SLUG,
|
||||||
|
topics: [TOPIC_SLUGS.civilSociety, TOPIC_SLUGS.ruleOfLaw],
|
||||||
|
data: {
|
||||||
|
en: {
|
||||||
|
title: 'SHARP-ER Project Officially Launches in Libya',
|
||||||
|
slug: LAUNCH_SLUG,
|
||||||
|
category: 'sharper-news',
|
||||||
|
publishDate: '2026-06-30',
|
||||||
|
featured: true,
|
||||||
|
excerpt:
|
||||||
|
'The launch brought together the European Union Delegation, three ministries, civil society organisations, partners and stakeholders from across Libya, and opened the Expression of Interest.',
|
||||||
|
body: [
|
||||||
|
'On Tuesday, June 30, the SHARP-ER project officially launched, bringing together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organizations, partners, and stakeholders from across Libya.',
|
||||||
|
'The event marked an important start for the project, including the launch of the SHARP-ER website and the opening of the Expression of Interest (EOI). Participants also learned more about the project’s objectives, upcoming opportunities, and activities focused on strengthening collaboration, partnerships, and local capacities.',
|
||||||
|
'Thank you to everyone who joined us and contributed to this milestone. We look forward to working together to support locally driven initiatives and create meaningful, lasting impact across Libya.',
|
||||||
|
'This project is funded by the European Union in Libya.',
|
||||||
|
].join('\n\n'),
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
title: 'انطلاق مشروع SHARP-ER رسميًا في ليبيا',
|
||||||
|
slug: LAUNCH_SLUG,
|
||||||
|
category: 'sharper-news',
|
||||||
|
publishDate: '2026-06-30',
|
||||||
|
featured: true,
|
||||||
|
excerpt:
|
||||||
|
'جمعت فعالية الإطلاق بعثة الاتحاد الأوروبي وثلاث وزارات ومنظمات المجتمع المدني والشركاء وأصحاب المصلحة من مختلف أنحاء ليبيا، وشهدت فتح باب التعبير عن الاهتمام.',
|
||||||
|
body: [
|
||||||
|
'انطلقت يوم الثلاثاء 30 يونيو رسميًا فعاليات مشروع SHARP-ER خلال حدث الإطلاق الذي جمع ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، إلى جانب منظمات المجتمع المدني والشركاء والجهات الفاعلة من مختلف أنحاء ليبيا.',
|
||||||
|
'وشكّل الحدث بداية مهمة للمشروع، حيث تم إطلاق الموقع الإلكتروني الرسمي، والإعلان عن فتح باب التعبير عن الاهتمام (EOI)، إلى جانب استعراض أهداف المشروع والفرص والأنشطة القادمة. وتركّز هذه الأنشطة على تعزيز التعاون والشراكات وبناء القدرات على المستوى المحلي.',
|
||||||
|
'نتوجه بالشكر إلى جميع المشاركين على حضورهم ومساهماتهم القيّمة. ونتطلع إلى العمل معًا خلال المرحلة المقبلة لدعم المبادرات المحلية وتحقيق أثر مستدام في مختلف أنحاء ليبيا.',
|
||||||
|
'المشروع ممول من الاتحاد الأوروبي في ليبيا.',
|
||||||
|
].join('\n\n'),
|
||||||
|
},
|
||||||
|
} satisfies Localised<SeedData>,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
PAGES
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The single types. Each hero, body and component list below is the draft's
|
||||||
|
own wording for that section; the home page's hero is the Claude Design
|
||||||
|
draft's, which is the same content the web app bundles as its fallback, so
|
||||||
|
the CMS and the fallback say the same thing rather than drifting apart.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const HOME_PAGE: Localised<SeedData> = {
|
||||||
|
en: {
|
||||||
|
hero: {
|
||||||
|
/* No `eyebrow`. The design draws a status pill reading "Now open -
|
||||||
|
Funding calls for Libyan organisations"; whether a call is open on any
|
||||||
|
given day is a fact about the project, not about the design, so the
|
||||||
|
pill stays unwritten until an editor writes it. */
|
||||||
|
headline: 'Advancing Justice and Accountability, Led by Libyan Communities',
|
||||||
|
subheading:
|
||||||
|
'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — works with civil society organisations, municipalities, and community leaders across Libya to strengthen access to justice, accountability, and local governance. Funded by the European Union, running to December 2027.',
|
||||||
|
},
|
||||||
|
/* The band under the hero. Seeded so an editor opening Home Page finds the
|
||||||
|
words already in a field they can change, rather than wondering where
|
||||||
|
the page is getting them from - the web app still falls back to the
|
||||||
|
bundled draft copy if this is ever emptied. `images` is left alone: the
|
||||||
|
seed uploads nothing. */
|
||||||
|
approach: {
|
||||||
|
eyebrow: 'Nationwide, Libya',
|
||||||
|
heading: 'A nationwide project, delivered locally',
|
||||||
|
lead: 'Local organisations lead the design and delivery of the work. SHARP-ER partners with Libyan civil society, municipalities, and community leaders — through funded sub-projects, training, and open dialogue between communities and the institutions that serve them.',
|
||||||
|
},
|
||||||
|
partnersHeadline: 'The consortium',
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route',
|
||||||
|
metaDescription:
|
||||||
|
'A nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
hero: {
|
||||||
|
headline: 'تعزيز العدالة والمساءلة بقيادة المجتمعات الليبية',
|
||||||
|
subheading:
|
||||||
|
'يعمل SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — مع منظمات المجتمع المدني والبلديات وقادة المجتمعات في مختلف أنحاء ليبيا على تعزيز الوصول إلى العدالة والمساءلة والحوكمة المحلية. بتمويل من الاتحاد الأوروبي، ويستمر حتى ديسمبر 2027.',
|
||||||
|
},
|
||||||
|
approach: {
|
||||||
|
eyebrow: 'على مستوى ليبيا بأكملها',
|
||||||
|
heading: 'مشروع وطني يُنفَّذ محليًا',
|
||||||
|
lead: 'تتولّى المنظمات المحلية تصميم العمل وتنفيذه. ويعمل SHARP-ER بالشراكة مع المجتمع المدني الليبي والبلديات وقادة المجتمعات، من خلال المشاريع الفرعية المموَّلة، والتدريب، والحوار المفتوح بين المجتمعات والمؤسسات التي تخدمها.',
|
||||||
|
},
|
||||||
|
partnersHeadline: 'الائتلاف المنفّذ',
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج',
|
||||||
|
metaDescription:
|
||||||
|
'مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات في ليبيا في تعزيز العدالة والمساءلة والحوكمة الرشيدة.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The About page. `consortium` is built in index.ts, where the partner
|
||||||
|
* documentIds are known; everything else is here.
|
||||||
|
*/
|
||||||
|
export const ABOUT_PAGE: Localised<SeedData> = {
|
||||||
|
en: {
|
||||||
|
hero: {
|
||||||
|
eyebrow: 'About the project',
|
||||||
|
headline: 'Strengthening the Rule of Law, Together',
|
||||||
|
subheading:
|
||||||
|
'SHARP-ER (Shared Action on Rule of Law Progress – Exit Route) is a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.',
|
||||||
|
},
|
||||||
|
body: [
|
||||||
|
'Running from December 2025 to December 2027, SHARP-ER is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union. The project works nationwide across Libya, partnering with civil society organisations, municipalities, and community leaders to strengthen access to justice, build the capacity of civil society to hold institutions accountable, and improve local governance and service delivery.',
|
||||||
|
].join('\n\n'),
|
||||||
|
pillars: [
|
||||||
|
{
|
||||||
|
title: 'Strengthening Rule of Law',
|
||||||
|
description:
|
||||||
|
'Supporting local institutions to improve access to justice and transparency.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Empowering Civil Society',
|
||||||
|
description:
|
||||||
|
'Building the capacity of CSOs to strengthen their role in oversight and accountability.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Supporting Local Governance',
|
||||||
|
description:
|
||||||
|
'Enhancing collaboration between municipalities and local actors to improve service delivery.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legacyHeadline: 'SHARP I and II (2019–2024)',
|
||||||
|
legacyBody:
|
||||||
|
'SHARP-ER builds on the foundation of SHARP I and II (2019–2024), carrying its network of civil society partners and its dialogue practice into a new phase focused on sustainability and exit.',
|
||||||
|
/* No `legacyLink`: the draft marks the target "[link: read about the SHARP
|
||||||
|
legacy]" and gives no URL, and `shared.link` requires an href. */
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'About SHARP-ER',
|
||||||
|
metaDescription:
|
||||||
|
'SHARP-ER is implemented by Acted with United Efforts Consultancy and Stichting Bureau Ensemble, and funded by the European Union, from December 2025 to December 2027.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
hero: {
|
||||||
|
eyebrow: 'عن المشروع',
|
||||||
|
headline: 'نحو تعزيز سيادة القانون، معًا',
|
||||||
|
subheading:
|
||||||
|
'SHARP-ER (العمل المشترك لتعزيز سيادة القانون – مسار الخروج) هو مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة.',
|
||||||
|
},
|
||||||
|
body: [
|
||||||
|
'يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي. يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.',
|
||||||
|
].join('\n\n'),
|
||||||
|
pillars: [
|
||||||
|
{
|
||||||
|
title: 'تعزيز سيادة القانون',
|
||||||
|
description: 'دعم المؤسسات المحلية لتحسين الوصول إلى العدالة وتعزيز الشفافية.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تمكين المجتمع المدني',
|
||||||
|
description: 'بناء قدرات منظمات المجتمع المدني لتعزيز دورها في الرقابة والمساءلة.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دعم الحوكمة المحلية',
|
||||||
|
description: 'تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legacyHeadline: 'مشروعا SHARP الأول والثاني (2019–2024)',
|
||||||
|
legacyBody:
|
||||||
|
'يبني SHARP-ER على الأساس الذي أرساه مشروعا SHARP الأول والثاني (2019–2024)، حاملًا معه شبكة الشركاء من المجتمع المدني وممارسة الحوار إلى مرحلة جديدة تركّز على الاستدامة ومسار الخروج.',
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'عن مشروع SHARP-ER',
|
||||||
|
metaDescription:
|
||||||
|
'يُنفَّذ SHARP-ER من قِبل Acted بالشراكة مع United Efforts Consultancy وStichting Bureau Ensemble، وبتمويل من الاتحاد الأوروبي، من ديسمبر 2025 إلى ديسمبر 2027.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The role chip and its line on the About page's consortium cards. */
|
||||||
|
export const CONSORTIUM_ROLES: Readonly<
|
||||||
|
Record<string, Localised<{ role: string; roleDescription: string }>>
|
||||||
|
> = {
|
||||||
|
[PARTNER_SLUGS.acted]: {
|
||||||
|
en: {
|
||||||
|
role: 'Lead implementing partner',
|
||||||
|
roleDescription: 'Overall project management and civil society sub-projects.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
role: 'الشريك المنفّذ الرئيسي',
|
||||||
|
roleDescription: 'الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[PARTNER_SLUGS.uec]: {
|
||||||
|
en: {
|
||||||
|
role: 'Consortium partner',
|
||||||
|
roleDescription: 'Network strengthening, dialogue, and peer-learning.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
role: 'شريك في الائتلاف',
|
||||||
|
roleDescription: 'تعزيز الشبكة، والحوار، والتعلّم بين الأقران.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[PARTNER_SLUGS.sbe]: {
|
||||||
|
en: {
|
||||||
|
role: 'Consortium partner',
|
||||||
|
roleDescription: 'Research, perception data, and knowledge publications.',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
role: 'شريك في الائتلاف',
|
||||||
|
roleDescription: 'البحث، وبيانات الاستطلاع، والمنشورات المعرفية.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WHAT_WE_DO_PAGE: Localised<SeedData> = {
|
||||||
|
en: {
|
||||||
|
hero: {
|
||||||
|
headline: 'A Participatory Approach to Justice and Governance',
|
||||||
|
subheading: 'We work directly with civil society, municipalities, and communities — not around them.',
|
||||||
|
},
|
||||||
|
intro: 'SHARP-ER is organised around four connected areas of work.',
|
||||||
|
workstreams: [
|
||||||
|
{
|
||||||
|
title: 'Capacity Building',
|
||||||
|
description:
|
||||||
|
'Training civil society organisations and local actors on governance, justice, and management, and equipping the SHARP network with peer-to-peer learning.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Institutional Support',
|
||||||
|
description:
|
||||||
|
'Strengthening the systems, procedures, and dialogue channels that connect local institutions with the people they serve.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Community Initiatives',
|
||||||
|
description:
|
||||||
|
'Funding and supporting CSO-led sub-projects, Policy-to-Rights (P2R) projects, and human-security initiatives designed and driven by local actors.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dialogue and Coordination',
|
||||||
|
description:
|
||||||
|
'Running SUQ (Search, Understand, Question) dialogues and Civil Society Law Sessions that bring civic and government actors to the same table.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
/* The four clauses of the draft's "Our approach rests on four
|
||||||
|
principles..." sentence, and nothing else: the design gives each tile an
|
||||||
|
explanatory line that appears in no approved document. */
|
||||||
|
principles: [
|
||||||
|
{ title: 'Working with local actors' },
|
||||||
|
{ title: 'Promoting sustainable — not one-off — solutions' },
|
||||||
|
{ title: 'Connecting civil society with institutions' },
|
||||||
|
{ title: 'Supporting gradual, realistic institutional reform' },
|
||||||
|
],
|
||||||
|
guidingValues: [
|
||||||
|
'Dialogue',
|
||||||
|
'Community Leadership',
|
||||||
|
'Learning',
|
||||||
|
'Inclusion',
|
||||||
|
'Responsibility & Accountability',
|
||||||
|
],
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'What We Do — SHARP-ER',
|
||||||
|
metaDescription:
|
||||||
|
'Capacity building, institutional support, community initiatives, and dialogue and coordination: the four connected areas of work behind SHARP-ER.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
hero: {
|
||||||
|
headline: 'نهج تشاركي في العدالة والحوكمة',
|
||||||
|
subheading: 'نعمل مباشرة مع المجتمع المدني والبلديات والمجتمعات المحلية، لا من حولها.',
|
||||||
|
},
|
||||||
|
intro: 'يقوم عمل SHARP-ER على أربعة محاور مترابطة.',
|
||||||
|
workstreams: [
|
||||||
|
{
|
||||||
|
title: 'بناء القدرات',
|
||||||
|
description:
|
||||||
|
'تدريب منظمات المجتمع المدني والجهات المحلية على الحوكمة والعدالة والإدارة، وتزويد شبكة SHARP بالتعلّم بين الأقران.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'الدعم المؤسسي',
|
||||||
|
description: 'تعزيز الأنظمة والإجراءات وقنوات الحوار التي تربط المؤسسات المحلية بالمجتمعات التي تخدمها.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'المبادرات المجتمعية',
|
||||||
|
description:
|
||||||
|
'تمويل ودعم مشاريع فرعية تقودها منظمات المجتمع المدني، ومشاريع “من السياسة إلى الحقوق” (P2R)، ومبادرات الأمن البشري التي يصممها الفاعلون المحليون ويقودونها.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'الحوار والتنسيق',
|
||||||
|
description:
|
||||||
|
'تنظيم حوارات SUQ (ابحث، افهم، اسأل) وجلسات قانون المجتمع المدني التي تجمع الفاعلين المدنيين والحكوميين حول طاولة واحدة.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
principles: [
|
||||||
|
{ title: 'العمل مع الفاعلين المحليين' },
|
||||||
|
{ title: 'تعزيز الحلول المستدامة لا الآنية' },
|
||||||
|
{ title: 'الربط بين المجتمع المدني والمؤسسات' },
|
||||||
|
{ title: 'دعم إصلاح مؤسسي تدريجي وواقعي' },
|
||||||
|
],
|
||||||
|
guidingValues: ['الحوار', 'القيادة المجتمعية', 'التعلّم', 'الشمول', 'المساءلة والمسؤولية'],
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'ماذا نفعل — SHARP-ER',
|
||||||
|
metaDescription:
|
||||||
|
'بناء القدرات، والدعم المؤسسي، والمبادرات المجتمعية، والحوار والتنسيق: المحاور الأربعة المترابطة التي يقوم عليها عمل SHARP-ER.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONTACT_PAGE: Localised<SeedData> = {
|
||||||
|
en: {
|
||||||
|
hero: {
|
||||||
|
headline: 'Get in Touch',
|
||||||
|
subheading: 'Questions about SHARP-ER, a partnership, or a specific activity? Reach out.',
|
||||||
|
},
|
||||||
|
intro: 'Questions about SHARP-ER, a partnership, or a specific activity? Reach out.',
|
||||||
|
websiteLabel: 'sharper.ly',
|
||||||
|
/* No `generalEmail`, no `socialLinks`, no `consortiumContacts`: the draft
|
||||||
|
writes all three as bracketed placeholders. */
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'Contact SHARP-ER',
|
||||||
|
metaDescription: 'Get in touch with the SHARP-ER consortium about the project, a partnership, or a specific activity.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
hero: {
|
||||||
|
headline: 'تواصل معنا',
|
||||||
|
subheading: 'لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.',
|
||||||
|
},
|
||||||
|
intro: 'لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.',
|
||||||
|
websiteLabel: 'sharper.ly',
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'تواصل مع SHARP-ER',
|
||||||
|
metaDescription: 'تواصل مع ائتلاف SHARP-ER بشأن المشروع، أو شراكة محتملة، أو نشاط معيّن.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,925 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SEED - DEMO FIXTURES
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
INVENTED CONTENT. Nothing in this file comes from docs/ and nothing in it is
|
||||||
|
true. It exists so the listings, filters, cards, empty states and the logo
|
||||||
|
marquee can be looked at with something in them, on a development machine.
|
||||||
|
|
||||||
|
HOW IT IS KEPT APART FROM REAL CONTENT
|
||||||
|
1. Opt-in. `pnpm seed` never writes it; only `pnpm seed --demo` does.
|
||||||
|
2. Refused in production. The seed will not write demo content when
|
||||||
|
NODE_ENV is production (see index.ts), whatever flags it is given.
|
||||||
|
3. Tagged in the data itself. Every slug starts `demo-`, so every demo
|
||||||
|
URL says so - /en/news-and-updates/demo-... - and `--demo --clean`
|
||||||
|
finds them again by that prefix alone, with nothing remembered between
|
||||||
|
runs. The FAQ type has no slug, so its demo entries are matched by
|
||||||
|
their exact questions instead.
|
||||||
|
|
||||||
|
The names are deliberately generic ("Coastal Justice Forum") rather than
|
||||||
|
plausible-sounding Libyan organisations: an invented name that could be
|
||||||
|
mistaken for a real partner is the thing to avoid. Cities are real places,
|
||||||
|
used only as locations.
|
||||||
|
|
||||||
|
DATES are computed from the moment the seed runs, so a freshly seeded site
|
||||||
|
always has events in the past, in progress and still to come.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
import type { Localised, SeedData } from './upsert';
|
||||||
|
|
||||||
|
/** Every demo slug starts with this, and that is how `--clean` finds them. */
|
||||||
|
export const DEMO_PREFIX = 'demo-';
|
||||||
|
|
||||||
|
export interface DemoEntry {
|
||||||
|
readonly slug: string;
|
||||||
|
readonly topics?: readonly string[];
|
||||||
|
readonly regions?: readonly string[];
|
||||||
|
readonly partners?: readonly string[];
|
||||||
|
readonly data: Localised<SeedData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** An ISO date `days` from the reference point, date only. */
|
||||||
|
const onDay = (from: Date, days: number): string =>
|
||||||
|
new Date(from.getTime() + days * DAY).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/** The same, with a time of day, for the datetime fields. */
|
||||||
|
const atHour = (from: Date, days: number, hour: number): string => {
|
||||||
|
const when = new Date(from.getTime() + days * DAY);
|
||||||
|
when.setUTCHours(hour, 0, 0, 0);
|
||||||
|
return when.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
REGIONS
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const DEMO_REGIONS: readonly DemoEntry[] = [
|
||||||
|
['tripoli', 'Tripoli', 'طرابلس'],
|
||||||
|
['benghazi', 'Benghazi', 'بنغازي'],
|
||||||
|
['misrata', 'Misrata', 'مصراتة'],
|
||||||
|
['sebha', 'Sebha', 'سبها'],
|
||||||
|
['zawiya', 'Zawiya', 'الزاوية'],
|
||||||
|
].map(([key, en, ar]) => {
|
||||||
|
const slug = `${DEMO_PREFIX}${key}`;
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
data: {
|
||||||
|
en: { name: en as string, slug },
|
||||||
|
ar: { name: ar as string, slug },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DEMO_REGION_SLUGS = DEMO_REGIONS.map((region) => region.slug);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
THE CIVIL SOCIETY NETWORK
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Twelve organisations, which is what the home page's logo wall needs to
|
||||||
|
become a marquee rather than a grid (it wants at least eight). None of them
|
||||||
|
has a logo: an invented organisation with an invented mark would be a step
|
||||||
|
too far, and the tile's no-logo state - its name set as text - is a state
|
||||||
|
the design draws anyway.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const csoPartner = (
|
||||||
|
key: string,
|
||||||
|
en: { name: string; description: string },
|
||||||
|
ar: { name: string; description: string },
|
||||||
|
role: 'cso-partner' | 'institution' = 'cso-partner'
|
||||||
|
): DemoEntry => {
|
||||||
|
const slug = `${DEMO_PREFIX}${key}`;
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
data: {
|
||||||
|
en: { name: en.name, slug, description: en.description, partnerRole: role, country: 'Libya' },
|
||||||
|
ar: { name: ar.name, slug, description: ar.description, partnerRole: role, country: 'ليبيا' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEMO_PARTNERS: readonly DemoEntry[] = [
|
||||||
|
csoPartner(
|
||||||
|
'coastal-justice-forum',
|
||||||
|
{ name: 'Coastal Justice Forum', description: 'Legal awareness sessions for residents of the coastal municipalities.' },
|
||||||
|
{ name: 'منتدى العدالة الساحلي', description: 'جلسات توعية قانونية لسكان البلديات الساحلية.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'southern-governance-network',
|
||||||
|
{ name: 'Southern Governance Network', description: 'Bringing municipal councils and residents together in the south.' },
|
||||||
|
{ name: 'شبكة الحوكمة الجنوبية', description: 'تقريب المجالس البلدية من السكان في الجنوب.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'open-municipality-initiative',
|
||||||
|
{ name: 'Open Municipality Initiative', description: 'Publishing municipal budgets and service plans in plain language.' },
|
||||||
|
{ name: 'مبادرة البلدية المفتوحة', description: 'نشر ميزانيات البلديات وخطط الخدمات بلغة مبسّطة.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'womens-participation-centre',
|
||||||
|
{ name: 'Women’s Participation Centre', description: 'Supporting women’s participation in local decision-making.' },
|
||||||
|
{ name: 'مركز المشاركة النسائية', description: 'دعم مشاركة النساء في صنع القرار المحلي.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'youth-civic-lab',
|
||||||
|
{ name: 'Youth Civic Lab', description: 'Civic education and volunteering programmes for young people.' },
|
||||||
|
{ name: 'مختبر الشباب المدني', description: 'برامج تربية مدنية وتطوّع للشباب.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'community-mediation-group',
|
||||||
|
{ name: 'Community Mediation Group', description: 'Mediation between neighbourhoods and local service providers.' },
|
||||||
|
{ name: 'مجموعة الوساطة المجتمعية', description: 'الوساطة بين الأحياء ومقدّمي الخدمات المحليين.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'legal-aid-collective',
|
||||||
|
{ name: 'Legal Aid Collective', description: 'Free first-line legal advice and referral.' },
|
||||||
|
{ name: 'تجمّع المساعدة القانونية', description: 'استشارات قانونية أولية مجانية وإحالة.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'transparency-watch-libya',
|
||||||
|
{ name: 'Transparency Watch', description: 'Monitoring public procurement at municipal level.' },
|
||||||
|
{ name: 'مرصد الشفافية', description: 'مراقبة المشتريات العامة على مستوى البلديات.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'desert-communities-association',
|
||||||
|
{ name: 'Desert Communities Association', description: 'Human-security initiatives in remote communities.' },
|
||||||
|
{ name: 'جمعية مجتمعات الصحراء', description: 'مبادرات الأمن البشري في المجتمعات النائية.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'civic-media-workshop',
|
||||||
|
{ name: 'Civic Media Workshop', description: 'Training citizen reporters to cover local governance.' },
|
||||||
|
{ name: 'ورشة الإعلام المدني', description: 'تدريب المراسلين المواطنين على تغطية الحوكمة المحلية.' }
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'municipal-council-of-tripoli-centre',
|
||||||
|
{ name: 'Municipal Council — Tripoli Centre', description: 'Local institution partnering on service-delivery dialogue.' },
|
||||||
|
{ name: 'المجلس البلدي — وسط طرابلس', description: 'مؤسسة محلية شريكة في حوار تقديم الخدمات.' },
|
||||||
|
'institution'
|
||||||
|
),
|
||||||
|
csoPartner(
|
||||||
|
'municipal-council-of-benghazi',
|
||||||
|
{ name: 'Municipal Council — Benghazi', description: 'Local institution partnering on transparency and planning.' },
|
||||||
|
{ name: 'المجلس البلدي — بنغازي', description: 'مؤسسة محلية شريكة في الشفافية والتخطيط.' },
|
||||||
|
'institution'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEMO_PARTNER_SLUGS = DEMO_PARTNERS.map((partner) => partner.slug);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
FAQs
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
No slug on this content type, so `--clean` matches these questions exactly.
|
||||||
|
Change a question here and the old one stops being cleanable - which is the
|
||||||
|
point: an edited question is an editor's question, not the seed's.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const faq = (order: number, en: [string, string], ar: [string, string]): Localised<SeedData> => ({
|
||||||
|
en: { question: en[0], answer: en[1], order },
|
||||||
|
ar: { question: ar[0], answer: ar[1], order },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DEMO_FAQS: readonly Localised<SeedData>[] = [
|
||||||
|
faq(
|
||||||
|
1,
|
||||||
|
['Who can apply to a SHARP-ER call?', 'Calls are open to civil society organisations registered in Libya. Each call lists its own eligibility criteria on its page.'],
|
||||||
|
['من يمكنه التقديم على دعوات SHARP-ER؟', 'الدعوات مفتوحة لمنظمات المجتمع المدني المسجّلة في ليبيا. وتُدرج كل دعوة شروط الأهلية الخاصة بها على صفحتها.']
|
||||||
|
),
|
||||||
|
faq(
|
||||||
|
2,
|
||||||
|
['In which languages can I apply?', 'Applications are accepted in Arabic and in English. Neither is preferred over the other.'],
|
||||||
|
['بأي لغة يمكنني التقديم؟', 'تُقبل الطلبات باللغتين العربية والإنجليزية، ولا تُفضَّل إحداهما على الأخرى.']
|
||||||
|
),
|
||||||
|
faq(
|
||||||
|
3,
|
||||||
|
['What is a SUQ dialogue?', 'SUQ stands for Search, Understand, Question. It is a facilitated session that brings residents together with the institutions that serve them.'],
|
||||||
|
['ما هو حوار SUQ؟', '«SUQ» اختصار لـ «ابحث، افهم، اسأل». وهي جلسة ميسَّرة تجمع السكان بالمؤسسات التي تخدمهم.']
|
||||||
|
),
|
||||||
|
faq(
|
||||||
|
4,
|
||||||
|
['Do I need to be a member of the SHARP network to take part?', 'No. Events and calls are open beyond the network, though some opportunities are reserved for member organisations and say so.'],
|
||||||
|
['هل يجب أن أكون عضوًا في شبكة SHARP للمشاركة؟', 'لا. الفعاليات والدعوات مفتوحة خارج الشبكة، وإن كانت بعض الفرص مخصّصة للمنظمات الأعضاء وتوضّح ذلك.']
|
||||||
|
),
|
||||||
|
faq(
|
||||||
|
5,
|
||||||
|
['Is there a cost to attend an event?', 'No. Every SHARP-ER event is free to attend.'],
|
||||||
|
['هل هناك رسوم لحضور الفعاليات؟', 'لا. حضور جميع فعاليات SHARP-ER مجاني.']
|
||||||
|
),
|
||||||
|
faq(
|
||||||
|
6,
|
||||||
|
['How is SHARP-ER funded?', 'SHARP-ER is funded by the European Union and implemented by a consortium of Acted, United Efforts Consultancy and Stichting Bureau Ensemble.'],
|
||||||
|
['كيف يُموَّل مشروع SHARP-ER؟', 'يُموَّل SHARP-ER من الاتحاد الأوروبي، وينفّذه ائتلاف يضم Acted وUnited Efforts Consultancy وStichting Bureau Ensemble.']
|
||||||
|
),
|
||||||
|
faq(
|
||||||
|
7,
|
||||||
|
['Can my organisation publish an update on this site?', 'Partner organisations in the network can submit updates, which appear in the newsroom under "From the Network" with the organisation credited.'],
|
||||||
|
['هل يمكن لمنظمتي نشر مستجدات على هذا الموقع؟', 'يمكن للمنظمات الشريكة في الشبكة إرسال مستجداتها، فتظهر في غرفة الأخبار ضمن «من الشبكة» منسوبة إلى المنظمة.']
|
||||||
|
),
|
||||||
|
faq(
|
||||||
|
8,
|
||||||
|
['Where can I find the project’s publications?', 'Research, policy articles, project reports and fact sheets are all in Resources and Publications, filterable by type, topic and year.'],
|
||||||
|
['أين أجد منشورات المشروع؟', 'تجد الأبحاث والمقالات السياساتية وتقارير المشروع وأوراق الحقائق في قسم الموارد والمنشورات، مع إمكانية التصفية حسب النوع والموضوع والسنة.']
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
NEWSROOM
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const demoNews = (now: Date): readonly DemoEntry[] => {
|
||||||
|
const article = (
|
||||||
|
key: string,
|
||||||
|
days: number,
|
||||||
|
category: 'sharper-news' | 'from-the-network',
|
||||||
|
authorSlug: string | undefined,
|
||||||
|
topics: readonly string[],
|
||||||
|
regions: readonly string[],
|
||||||
|
en: { title: string; excerpt: string; body: string },
|
||||||
|
ar: { title: string; excerpt: string; body: string }
|
||||||
|
): DemoEntry => {
|
||||||
|
const slug = `${DEMO_PREFIX}${key}`;
|
||||||
|
const shared = { slug, category, publishDate: onDay(now, days), featured: false };
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
topics,
|
||||||
|
regions,
|
||||||
|
...(authorSlug ? { partners: [authorSlug] } : {}),
|
||||||
|
data: {
|
||||||
|
en: { ...shared, title: en.title, excerpt: en.excerpt, body: en.body },
|
||||||
|
ar: { ...shared, title: ar.title, excerpt: ar.excerpt, body: ar.body },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
article(
|
||||||
|
'first-round-of-sub-projects-selected',
|
||||||
|
-7,
|
||||||
|
'sharper-news',
|
||||||
|
undefined,
|
||||||
|
['civil-society'],
|
||||||
|
[`${DEMO_PREFIX}tripoli`],
|
||||||
|
{
|
||||||
|
title: 'First round of community sub-projects selected',
|
||||||
|
excerpt: 'The selection panel has finished its review of the first Call for Expression of Interest.',
|
||||||
|
body: 'The selection panel has finished its review of the first Call for Expression of Interest. Successful organisations will be contacted directly, and a summary of the themes the round attracted will be published alongside the next call.\n\nThis is demo content and describes nothing that has happened.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'اختيار الجولة الأولى من المشاريع المجتمعية الفرعية',
|
||||||
|
excerpt: 'أنهت لجنة الاختيار مراجعتها لأول دعوة للتعبير عن الاهتمام.',
|
||||||
|
body: 'أنهت لجنة الاختيار مراجعتها لأول دعوة للتعبير عن الاهتمام. وسيتم التواصل مباشرة مع المنظمات المختارة، وسيُنشر ملخّص للمحاور التي استقطبتها الجولة مع الدعوة التالية.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
article(
|
||||||
|
'dialogue-facilitation-training-concludes',
|
||||||
|
-21,
|
||||||
|
'sharper-news',
|
||||||
|
undefined,
|
||||||
|
['dialogue'],
|
||||||
|
[`${DEMO_PREFIX}benghazi`],
|
||||||
|
{
|
||||||
|
title: 'Dialogue facilitation training concludes in Benghazi',
|
||||||
|
excerpt: 'Participants from eleven organisations completed the three-day facilitation course.',
|
||||||
|
body: 'Participants from eleven organisations completed the three-day facilitation course, which covers preparing a session, handling disagreement in the room, and following up on what was agreed.\n\nThis is demo content and describes nothing that has happened.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'اختتام التدريب على تيسير الحوار في بنغازي',
|
||||||
|
excerpt: 'أتمّ مشاركون من إحدى عشرة منظمة دورة التيسير التي استمرت ثلاثة أيام.',
|
||||||
|
body: 'أتمّ مشاركون من إحدى عشرة منظمة دورة التيسير التي استمرت ثلاثة أيام، والتي تتناول الإعداد للجلسة، وإدارة الاختلاف داخل القاعة، ومتابعة ما جرى الاتفاق عليه.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
article(
|
||||||
|
'perception-survey-fieldwork-begins',
|
||||||
|
-35,
|
||||||
|
'sharper-news',
|
||||||
|
undefined,
|
||||||
|
['local-governance'],
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
title: 'Nationwide perception survey enters fieldwork',
|
||||||
|
excerpt: 'Data collection has begun for the project’s first nationwide perception study.',
|
||||||
|
body: 'Data collection has begun for the project’s first nationwide perception study. Findings will be published in Resources and Publications once analysis is complete.\n\nThis is demo content and describes nothing that has happened.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'بدء العمل الميداني للدراسة الاستطلاعية الوطنية',
|
||||||
|
excerpt: 'انطلق جمع البيانات لأول دراسة استطلاعية وطنية ينفّذها المشروع.',
|
||||||
|
body: 'انطلق جمع البيانات لأول دراسة استطلاعية وطنية ينفّذها المشروع. وستُنشر النتائج في قسم الموارد والمنشورات فور اكتمال التحليل.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
article(
|
||||||
|
'coastal-justice-forum-completes-sub-project',
|
||||||
|
-14,
|
||||||
|
'from-the-network',
|
||||||
|
`${DEMO_PREFIX}coastal-justice-forum`,
|
||||||
|
['rule-of-law'],
|
||||||
|
[`${DEMO_PREFIX}zawiya`],
|
||||||
|
{
|
||||||
|
title: 'Coastal Justice Forum completes its first community sub-project',
|
||||||
|
excerpt: 'Six legal awareness sessions reached residents across four neighbourhoods.',
|
||||||
|
body: 'Six legal awareness sessions reached residents across four neighbourhoods, focusing on civil documentation and how to raise a complaint with the municipality.\n\nThis is demo content and describes nothing that has happened.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'منتدى العدالة الساحلي يُنجز أول مشروع مجتمعي فرعي',
|
||||||
|
excerpt: 'ست جلسات توعية قانونية وصلت إلى سكان أربعة أحياء.',
|
||||||
|
body: 'ست جلسات توعية قانونية وصلت إلى سكان أربعة أحياء، وركّزت على الوثائق المدنية وكيفية تقديم شكوى إلى البلدية.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
article(
|
||||||
|
'open-municipality-publishes-budget-guide',
|
||||||
|
-28,
|
||||||
|
'from-the-network',
|
||||||
|
`${DEMO_PREFIX}open-municipality-initiative`,
|
||||||
|
['local-governance'],
|
||||||
|
[`${DEMO_PREFIX}misrata`],
|
||||||
|
{
|
||||||
|
title: 'Open Municipality Initiative publishes a plain-language budget guide',
|
||||||
|
excerpt: 'The guide explains how a municipal budget is drawn up and where residents can comment on it.',
|
||||||
|
body: 'The guide explains how a municipal budget is drawn up, which decisions are taken when, and where residents can comment on it.\n\nThis is demo content and describes nothing that has happened.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'مبادرة البلدية المفتوحة تنشر دليلًا مبسّطًا للميزانية',
|
||||||
|
excerpt: 'يشرح الدليل كيفية إعداد ميزانية البلدية وأين يمكن للسكان إبداء الرأي فيها.',
|
||||||
|
body: 'يشرح الدليل كيفية إعداد ميزانية البلدية، وأي القرارات تُتّخذ ومتى، وأين يمكن للسكان إبداء الرأي فيها.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
article(
|
||||||
|
'youth-civic-lab-opens-volunteer-round',
|
||||||
|
-3,
|
||||||
|
'from-the-network',
|
||||||
|
`${DEMO_PREFIX}youth-civic-lab`,
|
||||||
|
['civil-society'],
|
||||||
|
[`${DEMO_PREFIX}sebha`],
|
||||||
|
{
|
||||||
|
title: 'Youth Civic Lab opens its next volunteering round',
|
||||||
|
excerpt: 'Applications are open to anyone aged 18 to 30 living in the south.',
|
||||||
|
body: 'Applications are open to anyone aged 18 to 30 living in the south. Volunteers are matched with a local organisation for three months.\n\nThis is demo content and describes nothing that has happened.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'مختبر الشباب المدني يفتح جولة التطوّع التالية',
|
||||||
|
excerpt: 'التقديم مفتوح لمن تتراوح أعمارهم بين 18 و30 عامًا من سكان الجنوب.',
|
||||||
|
body: 'التقديم مفتوح لمن تتراوح أعمارهم بين 18 و30 عامًا من سكان الجنوب. ويُربط كل متطوّع بمنظمة محلية لمدة ثلاثة أشهر.\n\nهذا محتوى تجريبي ولا يصف أي حدث وقع فعلًا.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
EVENTS
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Spread across the calendar on purpose: two already over, one running, three
|
||||||
|
still to come, so the listing's filters and every registration state have
|
||||||
|
something to show.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const demoEvents = (now: Date): readonly DemoEntry[] => {
|
||||||
|
const event = (
|
||||||
|
key: string,
|
||||||
|
days: number,
|
||||||
|
options: {
|
||||||
|
eventType: string;
|
||||||
|
format: 'in-person' | 'online' | 'hybrid';
|
||||||
|
registrationStatus: 'open' | 'closed' | 'full' | 'waitlist' | 'not-required';
|
||||||
|
capacity?: number;
|
||||||
|
region?: string;
|
||||||
|
topics?: readonly string[];
|
||||||
|
partners?: readonly string[];
|
||||||
|
},
|
||||||
|
en: { title: string; description: string; venue?: string },
|
||||||
|
ar: { title: string; description: string; venue?: string }
|
||||||
|
): DemoEntry => {
|
||||||
|
const slug = `${DEMO_PREFIX}${key}`;
|
||||||
|
const shared = {
|
||||||
|
slug,
|
||||||
|
eventType: options.eventType,
|
||||||
|
format: options.format,
|
||||||
|
registrationStatus: options.registrationStatus,
|
||||||
|
startDateTime: atHour(now, days, 9),
|
||||||
|
endDateTime: atHour(now, days, 14),
|
||||||
|
timezone: 'Africa/Tripoli',
|
||||||
|
...(options.capacity ? { capacity: options.capacity } : {}),
|
||||||
|
...(options.registrationStatus === 'open'
|
||||||
|
? { registrationDeadline: atHour(now, days - 2, 23) }
|
||||||
|
: {}),
|
||||||
|
waitlistEnabled: options.registrationStatus === 'waitlist',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
...(options.topics ? { topics: options.topics } : {}),
|
||||||
|
...(options.region ? { regions: [options.region] } : {}),
|
||||||
|
...(options.partners ? { partners: options.partners } : {}),
|
||||||
|
data: {
|
||||||
|
en: { ...shared, title: en.title, description: en.description, ...(en.venue ? { venue: en.venue } : {}) },
|
||||||
|
ar: { ...shared, title: ar.title, description: ar.description, ...(ar.venue ? { venue: ar.venue } : {}) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
event(
|
||||||
|
'suq-dialogue-tripoli',
|
||||||
|
21,
|
||||||
|
{
|
||||||
|
eventType: 'suq-dialogue',
|
||||||
|
format: 'in-person',
|
||||||
|
registrationStatus: 'open',
|
||||||
|
capacity: 60,
|
||||||
|
region: `${DEMO_PREFIX}tripoli`,
|
||||||
|
topics: ['dialogue', 'local-governance'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SUQ Dialogue — water and sanitation services',
|
||||||
|
description: 'Residents and the municipal services department sit down together over the winter service plan.',
|
||||||
|
venue: 'Tripoli',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'حوار SUQ — خدمات المياه والصرف الصحي',
|
||||||
|
description: 'يجلس السكان وإدارة الخدمات البلدية معًا لمناقشة خطة الخدمات الشتوية.',
|
||||||
|
venue: 'طرابلس',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
'facilitation-training-online',
|
||||||
|
35,
|
||||||
|
{
|
||||||
|
eventType: 'training',
|
||||||
|
format: 'online',
|
||||||
|
registrationStatus: 'open',
|
||||||
|
capacity: 25,
|
||||||
|
topics: ['dialogue'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dialogue facilitation training',
|
||||||
|
description: 'A three-session online course on preparing, running and following up a community dialogue.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تدريب على تيسير الحوار',
|
||||||
|
description: 'دورة عبر الإنترنت من ثلاث جلسات حول الإعداد للحوار المجتمعي وإدارته ومتابعته.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
'network-meeting-south',
|
||||||
|
49,
|
||||||
|
{
|
||||||
|
eventType: 'network-meeting',
|
||||||
|
format: 'hybrid',
|
||||||
|
registrationStatus: 'waitlist',
|
||||||
|
capacity: 40,
|
||||||
|
region: `${DEMO_PREFIX}sebha`,
|
||||||
|
topics: ['civil-society'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SHARP Network regional meeting — South',
|
||||||
|
description: 'Member organisations in the south meet to plan the next six months of joint work.',
|
||||||
|
venue: 'Sebha',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'اجتماع شبكة SHARP الإقليمي — الجنوب',
|
||||||
|
description: 'تجتمع المنظمات الأعضاء في الجنوب للتخطيط للأشهر الستة المقبلة من العمل المشترك.',
|
||||||
|
venue: 'سبها',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
'law-session-with-the-ccs',
|
||||||
|
-5,
|
||||||
|
{
|
||||||
|
eventType: 'law-session',
|
||||||
|
format: 'in-person',
|
||||||
|
registrationStatus: 'closed',
|
||||||
|
region: `${DEMO_PREFIX}benghazi`,
|
||||||
|
topics: ['rule-of-law', 'civil-society'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Civil Society Law Session with the CCS',
|
||||||
|
description: 'A joint session on registration procedures and reporting obligations for civil society organisations.',
|
||||||
|
venue: 'Benghazi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'جلسة قانون المجتمع المدني مع لجنة المجتمع المدني',
|
||||||
|
description: 'جلسة مشتركة حول إجراءات التسجيل والتزامات التقارير لمنظمات المجتمع المدني.',
|
||||||
|
venue: 'بنغازي',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
'suq-dialogue-misrata',
|
||||||
|
-30,
|
||||||
|
{
|
||||||
|
eventType: 'suq-dialogue',
|
||||||
|
format: 'in-person',
|
||||||
|
registrationStatus: 'closed',
|
||||||
|
region: `${DEMO_PREFIX}misrata`,
|
||||||
|
topics: ['dialogue'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SUQ Dialogue — street lighting and public safety',
|
||||||
|
description: 'A dialogue between neighbourhood representatives and the municipality on night-time safety.',
|
||||||
|
venue: 'Misrata',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'حوار SUQ — الإنارة العامة والسلامة',
|
||||||
|
description: 'حوار بين ممثلي الأحياء والبلدية حول السلامة الليلية.',
|
||||||
|
venue: 'مصراتة',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
'project-open-day',
|
||||||
|
7,
|
||||||
|
{
|
||||||
|
eventType: 'other',
|
||||||
|
format: 'online',
|
||||||
|
registrationStatus: 'not-required',
|
||||||
|
topics: ['civil-society'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SHARP-ER open session — how to apply',
|
||||||
|
description: 'An open walk-through of the application form and the selection criteria. No registration needed.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'جلسة SHARP-ER المفتوحة — كيف تتقدّم بطلب',
|
||||||
|
description: 'شرح مفتوح لنموذج التقديم ومعايير الاختيار. لا يلزم التسجيل.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
OPPORTUNITIES
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const demoOpportunities = (now: Date): readonly DemoEntry[] => {
|
||||||
|
const opportunity = (
|
||||||
|
key: string,
|
||||||
|
options: {
|
||||||
|
opportunityType: string;
|
||||||
|
opportunityStatus: 'upcoming' | 'open' | 'closed';
|
||||||
|
openDays: number;
|
||||||
|
deadlineDays: number;
|
||||||
|
topics?: readonly string[];
|
||||||
|
partners?: readonly string[];
|
||||||
|
},
|
||||||
|
en: { title: string; summary: string; eligibility: string; howToApply: string },
|
||||||
|
ar: { title: string; summary: string; eligibility: string; howToApply: string }
|
||||||
|
): DemoEntry => {
|
||||||
|
const slug = `${DEMO_PREFIX}${key}`;
|
||||||
|
const shared = {
|
||||||
|
slug,
|
||||||
|
opportunityType: options.opportunityType,
|
||||||
|
opportunityStatus: options.opportunityStatus,
|
||||||
|
openDate: onDay(now, options.openDays),
|
||||||
|
deadline: atHour(now, options.deadlineDays, 23),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
...(options.topics ? { topics: options.topics } : {}),
|
||||||
|
...(options.partners ? { partners: options.partners } : {}),
|
||||||
|
data: {
|
||||||
|
en: { ...shared, ...en },
|
||||||
|
ar: { ...shared, ...ar },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
opportunity(
|
||||||
|
'call-for-expression-of-interest-round-two',
|
||||||
|
{
|
||||||
|
opportunityType: 'call-eoi',
|
||||||
|
opportunityStatus: 'open',
|
||||||
|
openDays: -10,
|
||||||
|
deadlineDays: 25,
|
||||||
|
topics: ['civil-society', 'rule-of-law'],
|
||||||
|
partners: ['acted'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Call for Expression of Interest — community sub-projects, round two',
|
||||||
|
summary: 'Funding for civil society organisations to design and run a community sub-project over nine months.',
|
||||||
|
eligibility: 'Open to civil society organisations registered in Libya with at least one year of operations. Consortia of two or more organisations are welcome.',
|
||||||
|
howToApply: 'Complete the application form on this page. Applications may be submitted in Arabic or English. Late submissions are not considered.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'دعوة للتعبير عن الاهتمام — المشاريع المجتمعية الفرعية، الجولة الثانية',
|
||||||
|
summary: 'تمويل لمنظمات المجتمع المدني لتصميم وتنفيذ مشروع مجتمعي فرعي على مدى تسعة أشهر.',
|
||||||
|
eligibility: 'الدعوة مفتوحة لمنظمات المجتمع المدني المسجّلة في ليبيا ولديها سنة عمل واحدة على الأقل. ويُرحَّب بالتحالفات بين منظمتين أو أكثر.',
|
||||||
|
howToApply: 'يُرجى تعبئة نموذج التقديم في هذه الصفحة. ويمكن التقديم بالعربية أو الإنجليزية. ولا يُنظر في الطلبات المتأخرة.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
opportunity(
|
||||||
|
'join-the-sharp-network',
|
||||||
|
{
|
||||||
|
opportunityType: 'network-membership',
|
||||||
|
opportunityStatus: 'open',
|
||||||
|
openDays: -60,
|
||||||
|
deadlineDays: 90,
|
||||||
|
topics: ['civil-society'],
|
||||||
|
partners: ['united-efforts-consultancy'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Join the SHARP network',
|
||||||
|
summary: 'Become part of the civil society network behind SHARP-ER: regional meetings, peer learning, and joint events.',
|
||||||
|
eligibility: 'Open to civil society organisations working anywhere in Libya. There is no fee and no minimum size.',
|
||||||
|
howToApply: 'Send the membership form together with your registration certificate. Applications are reviewed on a rolling basis.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'انضم إلى شبكة SHARP',
|
||||||
|
summary: 'كن جزءًا من شبكة المجتمع المدني وراء SHARP-ER: اجتماعات إقليمية، وتعلّم بين الأقران، وفعاليات مشتركة.',
|
||||||
|
eligibility: 'مفتوح لمنظمات المجتمع المدني العاملة في أي مكان في ليبيا. لا توجد رسوم ولا حد أدنى للحجم.',
|
||||||
|
howToApply: 'أرسل نموذج العضوية مرفقًا بشهادة التسجيل. وتُراجَع الطلبات أولًا بأول.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
opportunity(
|
||||||
|
'training-monitoring-and-reporting',
|
||||||
|
{
|
||||||
|
opportunityType: 'training',
|
||||||
|
opportunityStatus: 'upcoming',
|
||||||
|
openDays: 20,
|
||||||
|
deadlineDays: 45,
|
||||||
|
topics: ['local-governance'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Training — monitoring and reporting for small grants',
|
||||||
|
summary: 'A short course for organisations managing a grant for the first time.',
|
||||||
|
eligibility: 'Reserved for organisations that hold, or have applied for, a SHARP-ER sub-project grant.',
|
||||||
|
howToApply: 'Applications open on the date shown above. Places are limited and allocated in order of application.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تدريب — المتابعة وإعداد التقارير للمنح الصغيرة',
|
||||||
|
summary: 'دورة قصيرة للمنظمات التي تدير منحة للمرة الأولى.',
|
||||||
|
eligibility: 'مخصّص للمنظمات الحاصلة على منحة مشروع فرعي من SHARP-ER أو المتقدّمة للحصول عليها.',
|
||||||
|
howToApply: 'يُفتح التقديم في التاريخ الموضّح أعلاه. المقاعد محدودة وتُخصَّص حسب أسبقية التقديم.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
opportunity(
|
||||||
|
'policy-to-rights-first-call',
|
||||||
|
{
|
||||||
|
opportunityType: 'grant',
|
||||||
|
opportunityStatus: 'closed',
|
||||||
|
openDays: -120,
|
||||||
|
deadlineDays: -40,
|
||||||
|
topics: ['rule-of-law'],
|
||||||
|
partners: ['acted'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Policy-to-Rights (P2R) — first call',
|
||||||
|
summary: 'Support for organisations turning a policy commitment into a concrete change residents can see.',
|
||||||
|
eligibility: 'This call is closed. It was open to organisations with previous experience of policy advocacy.',
|
||||||
|
howToApply: 'This call is closed. The next round will be announced on this page.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '«من السياسة إلى الحقوق» (P2R) — الدعوة الأولى',
|
||||||
|
summary: 'دعم للمنظمات التي تحوّل التزامًا سياساتيًا إلى تغيير ملموس يلمسه السكان.',
|
||||||
|
eligibility: 'هذه الدعوة مغلقة. وكانت مفتوحة للمنظمات ذات الخبرة السابقة في المناصرة السياساتية.',
|
||||||
|
howToApply: 'هذه الدعوة مغلقة. وسيُعلن عن الجولة التالية في هذه الصفحة.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
RESOURCES
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
No files are attached: the seed uploads nothing, so each entry is a record
|
||||||
|
with its metadata and the listing draws its format chip from that. The
|
||||||
|
detail page shows the entry without a download, which is the state an
|
||||||
|
editor sees before they attach the PDF.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const demoResources = (now: Date): readonly DemoEntry[] => {
|
||||||
|
const resource = (
|
||||||
|
key: string,
|
||||||
|
options: {
|
||||||
|
resourceType: string;
|
||||||
|
resourceLanguage: 'en' | 'ar' | 'bilingual';
|
||||||
|
days: number;
|
||||||
|
pageCount?: number;
|
||||||
|
topics?: readonly string[];
|
||||||
|
partners?: readonly string[];
|
||||||
|
featured?: boolean;
|
||||||
|
authors?: string;
|
||||||
|
},
|
||||||
|
en: { title: string; description: string },
|
||||||
|
ar: { title: string; description: string }
|
||||||
|
): DemoEntry => {
|
||||||
|
const slug = `${DEMO_PREFIX}${key}`;
|
||||||
|
const shared = {
|
||||||
|
slug,
|
||||||
|
resourceType: options.resourceType,
|
||||||
|
resourceLanguage: options.resourceLanguage,
|
||||||
|
publicationDate: onDay(now, options.days),
|
||||||
|
featured: options.featured ?? false,
|
||||||
|
...(options.pageCount ? { pageCount: options.pageCount } : {}),
|
||||||
|
...(options.authors ? { authors: options.authors } : {}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
...(options.topics ? { topics: options.topics } : {}),
|
||||||
|
...(options.partners ? { partners: options.partners } : {}),
|
||||||
|
data: {
|
||||||
|
en: { ...shared, ...en },
|
||||||
|
ar: { ...shared, ...ar },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
resource(
|
||||||
|
'nationwide-perception-study',
|
||||||
|
{
|
||||||
|
resourceType: 'research-perception',
|
||||||
|
resourceLanguage: 'bilingual',
|
||||||
|
days: -45,
|
||||||
|
pageCount: 64,
|
||||||
|
featured: true,
|
||||||
|
authors: 'Stichting Bureau Ensemble',
|
||||||
|
topics: ['local-governance', 'rule-of-law'],
|
||||||
|
partners: ['stichting-bureau-ensemble'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Nationwide perception study — baseline',
|
||||||
|
description: 'How residents across Libya describe their access to justice and their local services.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'الدراسة الاستطلاعية الوطنية — خط الأساس',
|
||||||
|
description: 'كيف يصف السكان في مختلف أنحاء ليبيا وصولهم إلى العدالة وخدماتهم المحلية.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
resource(
|
||||||
|
'sharp-er-fact-sheet',
|
||||||
|
{
|
||||||
|
resourceType: 'visibility-factsheet',
|
||||||
|
resourceLanguage: 'bilingual',
|
||||||
|
days: -90,
|
||||||
|
pageCount: 2,
|
||||||
|
topics: ['civil-society'],
|
||||||
|
},
|
||||||
|
{ title: 'SHARP-ER fact sheet', description: 'A two-page summary of the project, its partners and its areas of work.' },
|
||||||
|
{ title: 'ورقة حقائق SHARP-ER', description: 'ملخّص من صفحتين عن المشروع وشركائه ومجالات عمله.' }
|
||||||
|
),
|
||||||
|
resource(
|
||||||
|
'policy-article-municipal-transparency',
|
||||||
|
{
|
||||||
|
resourceType: 'policy-article',
|
||||||
|
resourceLanguage: 'en',
|
||||||
|
days: -20,
|
||||||
|
pageCount: 12,
|
||||||
|
authors: 'Transparency Watch',
|
||||||
|
topics: ['local-governance'],
|
||||||
|
partners: [`${DEMO_PREFIX}transparency-watch-libya`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'What municipal transparency costs, and what it saves',
|
||||||
|
description: 'A Policy-to-Rights article on publishing procurement decisions at municipal level.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ما تكلفة الشفافية البلدية وما الذي توفّره',
|
||||||
|
description: 'مقال ضمن سلسلة «من السياسة إلى الحقوق» حول نشر قرارات المشتريات على مستوى البلديات.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
resource(
|
||||||
|
'policy-article-legal-aid-access',
|
||||||
|
{
|
||||||
|
resourceType: 'policy-article',
|
||||||
|
resourceLanguage: 'ar',
|
||||||
|
days: -55,
|
||||||
|
pageCount: 9,
|
||||||
|
authors: 'Legal Aid Collective',
|
||||||
|
topics: ['rule-of-law'],
|
||||||
|
partners: [`${DEMO_PREFIX}legal-aid-collective`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'First-line legal advice: who asks, and for what',
|
||||||
|
description: 'What a year of free legal advice sessions says about the questions residents bring.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'الاستشارة القانونية الأولية: من يسأل، وعن ماذا',
|
||||||
|
description: 'ما الذي تكشفه سنة من جلسات الاستشارة القانونية المجانية عن أسئلة السكان.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
resource(
|
||||||
|
'project-report-first-half',
|
||||||
|
{
|
||||||
|
resourceType: 'project-report',
|
||||||
|
resourceLanguage: 'en',
|
||||||
|
days: -10,
|
||||||
|
pageCount: 28,
|
||||||
|
topics: ['civil-society'],
|
||||||
|
partners: ['acted'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Project report — first reporting period',
|
||||||
|
description: 'Activities, participation and lessons from the project’s first reporting period.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تقرير المشروع — فترة التقرير الأولى',
|
||||||
|
description: 'الأنشطة والمشاركة والدروس المستفادة من فترة التقرير الأولى للمشروع.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
resource(
|
||||||
|
'dialogue-practice-note',
|
||||||
|
{
|
||||||
|
resourceType: 'project-report',
|
||||||
|
resourceLanguage: 'bilingual',
|
||||||
|
days: -70,
|
||||||
|
pageCount: 16,
|
||||||
|
topics: ['dialogue'],
|
||||||
|
partners: ['united-efforts-consultancy'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Practice note — running a SUQ dialogue',
|
||||||
|
description: 'What has worked, and what has not, across the first rounds of community dialogue.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'مذكّرة عملية — كيف يُدار حوار SUQ',
|
||||||
|
description: 'ما الذي نجح وما الذي لم ينجح عبر الجولات الأولى من الحوار المجتمعي.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
KNOWLEDGE HUB
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const demoKnowledge = (now: Date): readonly DemoEntry[] => {
|
||||||
|
const item = (
|
||||||
|
key: string,
|
||||||
|
itemType: string,
|
||||||
|
days: number,
|
||||||
|
topics: readonly string[],
|
||||||
|
en: { title: string; summary: string },
|
||||||
|
ar: { title: string; summary: string }
|
||||||
|
): DemoEntry => {
|
||||||
|
const slug = `${DEMO_PREFIX}${key}`;
|
||||||
|
const shared = { slug, itemType, publicationDate: onDay(now, days) };
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
topics,
|
||||||
|
data: {
|
||||||
|
en: { ...shared, ...en },
|
||||||
|
ar: { ...shared, ...ar },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
item(
|
||||||
|
'module-governance-basics',
|
||||||
|
'training-module',
|
||||||
|
-80,
|
||||||
|
['local-governance'],
|
||||||
|
{ title: 'Training module — governance basics', summary: 'What a municipality does, who decides what, and where a resident fits in.' },
|
||||||
|
{ title: 'وحدة تدريبية — أساسيات الحوكمة', summary: 'ماذا تفعل البلدية، ومن يقرّر ماذا، وأين يقع دور الساكن.' }
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'module-facilitation-skills',
|
||||||
|
'training-module',
|
||||||
|
-60,
|
||||||
|
['dialogue'],
|
||||||
|
{ title: 'Training module — facilitation skills', summary: 'Preparing a session, keeping a room together, and closing on something concrete.' },
|
||||||
|
{ title: 'وحدة تدريبية — مهارات التيسير', summary: 'الإعداد للجلسة، وإبقاء القاعة متماسكة، والختام بنتيجة ملموسة.' }
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'toolkit-community-needs',
|
||||||
|
'toolkit',
|
||||||
|
-40,
|
||||||
|
['civil-society'],
|
||||||
|
{ title: 'Toolkit — identifying community needs', summary: 'A short set of tools for finding out what a neighbourhood actually wants changed.' },
|
||||||
|
{ title: 'دليل عملي — تحديد احتياجات المجتمع', summary: 'مجموعة أدوات موجزة لمعرفة ما يريد الحي تغييره فعلًا.' }
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'guide-law-session-registration',
|
||||||
|
'law-session-guide',
|
||||||
|
-30,
|
||||||
|
['rule-of-law'],
|
||||||
|
{ title: 'Law Session guide — registration and reporting', summary: 'Plain-language notes from the joint session with the Commission on Civil Society.' },
|
||||||
|
{ title: 'دليل جلسة قانونية — التسجيل وإعداد التقارير', summary: 'ملاحظات بلغة مبسّطة من الجلسة المشتركة مع لجنة المجتمع المدني.' }
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'case-study-water-dialogue',
|
||||||
|
'case-study',
|
||||||
|
-15,
|
||||||
|
['dialogue', 'local-governance'],
|
||||||
|
{ title: 'Case study — a dialogue that changed a service plan', summary: 'How one SUQ dialogue ended with a revised winter maintenance schedule.' },
|
||||||
|
{ title: 'دراسة حالة — حوار غيّر خطة خدمات', summary: 'كيف انتهى أحد حوارات SUQ بجدول صيانة شتوي معدَّل.' }
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
'case-study-first-grant',
|
||||||
|
'case-study',
|
||||||
|
-5,
|
||||||
|
['civil-society'],
|
||||||
|
{ title: 'Case study — managing a first grant', summary: 'What a small organisation learned in its first nine months of grant management.' },
|
||||||
|
{ title: 'دراسة حالة — إدارة أول منحة', summary: 'ما تعلّمته منظمة صغيرة في أول تسعة أشهر من إدارة منحة.' }
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home page fields the demo set overrides.
|
||||||
|
*
|
||||||
|
* The status pill is the one piece of approved-looking copy the demo writes,
|
||||||
|
* and it is true of the demo data: `demo-call-for-expression-of-interest-
|
||||||
|
* round-two` really is open on a demo-seeded site.
|
||||||
|
*/
|
||||||
|
export const DEMO_HOME_PAGE: Localised<SeedData> = {
|
||||||
|
en: { hero: { eyebrow: 'Now open · Funding calls for Libyan organisations' } },
|
||||||
|
ar: { hero: { eyebrow: 'مفتوح الآن · دعوات تمويل للمنظمات الليبية' } },
|
||||||
|
};
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SEED - orchestration
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Run it with `pnpm seed` from the repository root, or from apps/cms:
|
||||||
|
|
||||||
|
pnpm seed the approved content only (approved.ts)
|
||||||
|
pnpm seed --demo the same, plus the invented fixtures (demo.ts)
|
||||||
|
pnpm seed --demo --clean remove the invented fixtures, leave the rest
|
||||||
|
|
||||||
|
IDEMPOTENT. Every write goes through `upsert`, matched on a slug (or, for
|
||||||
|
the single types, on there being only one document), so a second run writes
|
||||||
|
the same fields over the same documents. It never deletes anything except on
|
||||||
|
`--demo --clean`, and then only what carries the demo slug prefix.
|
||||||
|
|
||||||
|
ORDER MATTERS. Partners and topics are written first because the activities,
|
||||||
|
the articles and the About page's consortium cards all point at them by
|
||||||
|
documentId, and a relation can only be set once its target exists.
|
||||||
|
|
||||||
|
WHAT IT DOES NOT TOUCH
|
||||||
|
* Media. The seed uploads nothing. Where a page already has a picture -
|
||||||
|
the home hero, most of all - the existing file is read back and written
|
||||||
|
out again with the new copy, because a component is replaced wholesale
|
||||||
|
on write and anything left out of the payload is gone.
|
||||||
|
* Submissions. `application`, `event-registration` and `contact-submission`
|
||||||
|
hold personal data. Nothing writes to them here, not even in demo mode.
|
||||||
|
* Locales and permissions. Those are reconciled at boot by src/bootstrap.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ABOUT_PAGE,
|
||||||
|
ACTIVITIES,
|
||||||
|
CONSORTIUM,
|
||||||
|
CONSORTIUM_ROLES,
|
||||||
|
CONTACT_PAGE,
|
||||||
|
HOME_PAGE,
|
||||||
|
LAUNCH_ARTICLE,
|
||||||
|
SITE_SETTINGS,
|
||||||
|
TOPICS,
|
||||||
|
WHAT_WE_DO_PAGE,
|
||||||
|
type SeedEntries,
|
||||||
|
} from './approved';
|
||||||
|
import {
|
||||||
|
DEMO_FAQS,
|
||||||
|
DEMO_HOME_PAGE,
|
||||||
|
DEMO_PARTNERS,
|
||||||
|
DEMO_PARTNER_SLUGS,
|
||||||
|
DEMO_PREFIX,
|
||||||
|
DEMO_REGIONS,
|
||||||
|
demoEvents,
|
||||||
|
demoKnowledge,
|
||||||
|
demoNews,
|
||||||
|
demoOpportunities,
|
||||||
|
demoResources,
|
||||||
|
type DemoEntry,
|
||||||
|
} from './demo';
|
||||||
|
import {
|
||||||
|
deleteBySlugPrefix,
|
||||||
|
deleteWhere,
|
||||||
|
mediaId,
|
||||||
|
readExisting,
|
||||||
|
upsert,
|
||||||
|
type Localised,
|
||||||
|
type SeedData,
|
||||||
|
type SeedUid,
|
||||||
|
} from './upsert';
|
||||||
|
|
||||||
|
export interface SeedOptions {
|
||||||
|
/** Write the invented fixtures as well as the approved content. */
|
||||||
|
readonly demo: boolean;
|
||||||
|
/** With `demo`, remove those fixtures instead of writing them. */
|
||||||
|
readonly clean: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UID = {
|
||||||
|
siteSettings: 'api::site-settings.site-settings',
|
||||||
|
homePage: 'api::home-page.home-page',
|
||||||
|
aboutPage: 'api::about-page.about-page',
|
||||||
|
whatWeDoPage: 'api::what-we-do-page.what-we-do-page',
|
||||||
|
contactPage: 'api::contact-page.contact-page',
|
||||||
|
partner: 'api::partner.partner',
|
||||||
|
topic: 'api::topic.topic',
|
||||||
|
region: 'api::region.region',
|
||||||
|
activity: 'api::activity.activity',
|
||||||
|
news: 'api::news-article.news-article',
|
||||||
|
event: 'api::event.event',
|
||||||
|
opportunity: 'api::opportunity.opportunity',
|
||||||
|
resource: 'api::resource.resource',
|
||||||
|
knowledge: 'api::knowledge-item.knowledge-item',
|
||||||
|
faq: 'api::faq.faq',
|
||||||
|
} as const satisfies Record<string, SeedUid>;
|
||||||
|
|
||||||
|
/** Every slugged type the demo set writes to, in the order `--clean` sweeps. */
|
||||||
|
const DEMO_SLUGGED_TYPES: readonly SeedUid[] = [
|
||||||
|
UID.knowledge,
|
||||||
|
UID.resource,
|
||||||
|
UID.opportunity,
|
||||||
|
UID.event,
|
||||||
|
UID.news,
|
||||||
|
UID.partner,
|
||||||
|
UID.region,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** slug -> documentId, built as each type is written. */
|
||||||
|
type SlugIndex = Map<string, string>;
|
||||||
|
|
||||||
|
const log = (strapi: Core.Strapi, message: string): void => {
|
||||||
|
strapi.log.info(`[seed] ${message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Write a list of slugged entries and return the slug -> documentId index. */
|
||||||
|
async function upsertAll(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
uid: SeedUid,
|
||||||
|
entries: ReadonlyArray<{ slug: string; data: Localised<SeedData> }>,
|
||||||
|
into: SlugIndex = new Map()
|
||||||
|
): Promise<SlugIndex> {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { documentId } = await upsert(strapi, uid, entry.data, {
|
||||||
|
slug: { $eq: entry.slug },
|
||||||
|
});
|
||||||
|
into.set(entry.slug, documentId);
|
||||||
|
}
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a list of slugs into the documentIds a relation field wants. */
|
||||||
|
const relate = (index: SlugIndex, slugs: readonly string[] | undefined): string[] =>
|
||||||
|
(slugs ?? []).map((slug) => index.get(slug)).filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add relation fields to both locales of an entry.
|
||||||
|
*
|
||||||
|
* Relations are held per locale in Strapi, so a translation that is written
|
||||||
|
* without them comes back with an empty list - which is how a page ends up
|
||||||
|
* with its topics in English and none in Arabic.
|
||||||
|
*/
|
||||||
|
function withRelations(
|
||||||
|
data: Localised<SeedData>,
|
||||||
|
relations: SeedData
|
||||||
|
): Localised<SeedData> {
|
||||||
|
return {
|
||||||
|
en: { ...data.en, ...relations },
|
||||||
|
ar: { ...data.ar, ...relations },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
THE APPROVED SET
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function seedApproved(strapi: Core.Strapi): Promise<{
|
||||||
|
partners: SlugIndex;
|
||||||
|
topics: SlugIndex;
|
||||||
|
launchArticleId: string;
|
||||||
|
}> {
|
||||||
|
await upsert(strapi, UID.siteSettings, SITE_SETTINGS);
|
||||||
|
log(strapi, 'site settings');
|
||||||
|
|
||||||
|
const partners = await upsertAll(strapi, UID.partner, CONSORTIUM);
|
||||||
|
log(strapi, `${partners.size} consortium partners`);
|
||||||
|
|
||||||
|
const topics = await upsertAll(strapi, UID.topic, TOPICS as SeedEntries);
|
||||||
|
log(strapi, `${topics.size} topics`);
|
||||||
|
|
||||||
|
for (const activity of ACTIVITIES) {
|
||||||
|
await upsert(
|
||||||
|
strapi,
|
||||||
|
UID.activity,
|
||||||
|
withRelations(activity.data, {
|
||||||
|
implementingPartners: relate(partners, activity.partners),
|
||||||
|
topics: relate(topics, activity.topics),
|
||||||
|
}),
|
||||||
|
{ slug: { $eq: activity.slug } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(strapi, `${ACTIVITIES.length} activities`);
|
||||||
|
|
||||||
|
const launch = await upsert(
|
||||||
|
strapi,
|
||||||
|
UID.news,
|
||||||
|
withRelations(LAUNCH_ARTICLE.data, { topics: relate(topics, LAUNCH_ARTICLE.topics) }),
|
||||||
|
{ slug: { $eq: LAUNCH_ARTICLE.slug } }
|
||||||
|
);
|
||||||
|
log(strapi, 'the launch article');
|
||||||
|
|
||||||
|
/* The home hero already carries an uploaded picture on this database; read
|
||||||
|
it back so writing the new copy does not drop it. */
|
||||||
|
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
|
||||||
|
const heroMedia = mediaId(existingHome?.hero, 'media');
|
||||||
|
|
||||||
|
await upsert(strapi, UID.homePage, {
|
||||||
|
en: {
|
||||||
|
...HOME_PAGE.en,
|
||||||
|
hero: { ...(HOME_PAGE.en.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
|
||||||
|
featuredNews: [launch.documentId],
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
...HOME_PAGE.ar,
|
||||||
|
hero: { ...(HOME_PAGE.ar.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
|
||||||
|
featuredNews: [launch.documentId],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log(strapi, 'home page');
|
||||||
|
|
||||||
|
/* The About page's consortium cards are components that point at the partner
|
||||||
|
documents, so they can only be built now that the partners exist. */
|
||||||
|
const consortiumFor = (locale: 'en' | 'ar'): SeedData[] =>
|
||||||
|
CONSORTIUM.map((member) => ({
|
||||||
|
partner: partners.get(member.slug),
|
||||||
|
...CONSORTIUM_ROLES[member.slug]![locale],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await upsert(strapi, UID.aboutPage, {
|
||||||
|
en: { ...ABOUT_PAGE.en, consortium: consortiumFor('en') },
|
||||||
|
ar: { ...ABOUT_PAGE.ar, consortium: consortiumFor('ar') },
|
||||||
|
});
|
||||||
|
log(strapi, 'about page');
|
||||||
|
|
||||||
|
await upsert(strapi, UID.whatWeDoPage, WHAT_WE_DO_PAGE);
|
||||||
|
log(strapi, 'what we do page');
|
||||||
|
|
||||||
|
await upsert(strapi, UID.contactPage, CONTACT_PAGE);
|
||||||
|
log(strapi, 'contact page');
|
||||||
|
|
||||||
|
return { partners, topics, launchArticleId: launch.documentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
THE DEMO SET
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function seedDemo(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
context: { partners: SlugIndex; topics: SlugIndex; launchArticleId: string }
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const regions = await upsertAll(strapi, UID.region, DEMO_REGIONS);
|
||||||
|
log(strapi, `${regions.size} demo regions`);
|
||||||
|
|
||||||
|
/* Written into the same index as the consortium: a resource or an article
|
||||||
|
can credit either a consortium member or a network organisation, and the
|
||||||
|
lookup should not have to know which. */
|
||||||
|
const partners = await upsertAll(strapi, UID.partner, DEMO_PARTNERS, context.partners);
|
||||||
|
log(strapi, `${DEMO_PARTNERS.length} demo network partners`);
|
||||||
|
|
||||||
|
for (const item of DEMO_FAQS) {
|
||||||
|
await upsert(strapi, UID.faq, item, { question: { $eq: item.en.question } });
|
||||||
|
}
|
||||||
|
log(strapi, `${DEMO_FAQS.length} demo FAQs`);
|
||||||
|
|
||||||
|
/* `entry.partners` is the author on a news article (a single relation) and
|
||||||
|
the implementing/publishing organisations everywhere else. */
|
||||||
|
const news = demoNews(now);
|
||||||
|
const newsIndex: SlugIndex = new Map();
|
||||||
|
for (const entry of news) {
|
||||||
|
const author = relate(partners, entry.partners)[0];
|
||||||
|
const { documentId } = await upsert(
|
||||||
|
strapi,
|
||||||
|
UID.news,
|
||||||
|
withRelations(entry.data, {
|
||||||
|
topics: relate(context.topics, entry.topics),
|
||||||
|
regions: relate(regions, entry.regions),
|
||||||
|
...(author ? { authorOrganisation: author } : {}),
|
||||||
|
}),
|
||||||
|
{ slug: { $eq: entry.slug } }
|
||||||
|
);
|
||||||
|
newsIndex.set(entry.slug, documentId);
|
||||||
|
}
|
||||||
|
log(strapi, `${news.length} demo articles`);
|
||||||
|
|
||||||
|
const events = demoEvents(now);
|
||||||
|
for (const entry of events) {
|
||||||
|
await upsert(
|
||||||
|
strapi,
|
||||||
|
UID.event,
|
||||||
|
withRelations(entry.data, {
|
||||||
|
topics: relate(context.topics, entry.topics),
|
||||||
|
/* `region` is a single relation on this type, not a list. */
|
||||||
|
...(entry.regions?.length ? { region: relate(regions, entry.regions)[0] } : {}),
|
||||||
|
implementingPartners: relate(partners, entry.partners),
|
||||||
|
}),
|
||||||
|
{ slug: { $eq: entry.slug } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(strapi, `${events.length} demo events`);
|
||||||
|
|
||||||
|
const opportunities = demoOpportunities(now);
|
||||||
|
for (const entry of opportunities) {
|
||||||
|
await upsert(
|
||||||
|
strapi,
|
||||||
|
UID.opportunity,
|
||||||
|
withRelations(entry.data, {
|
||||||
|
topics: relate(context.topics, entry.topics),
|
||||||
|
implementingPartners: relate(partners, entry.partners),
|
||||||
|
}),
|
||||||
|
{ slug: { $eq: entry.slug } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(strapi, `${opportunities.length} demo opportunities`);
|
||||||
|
|
||||||
|
const resources = demoResources(now);
|
||||||
|
for (const entry of resources) {
|
||||||
|
await upsert(
|
||||||
|
strapi,
|
||||||
|
UID.resource,
|
||||||
|
withRelations(entry.data, {
|
||||||
|
topics: relate(context.topics, entry.topics),
|
||||||
|
partnerOrganisations: relate(partners, entry.partners),
|
||||||
|
}),
|
||||||
|
{ slug: { $eq: entry.slug } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(strapi, `${resources.length} demo resources`);
|
||||||
|
|
||||||
|
const knowledge = demoKnowledge(now);
|
||||||
|
for (const entry of knowledge) {
|
||||||
|
await upsert(
|
||||||
|
strapi,
|
||||||
|
UID.knowledge,
|
||||||
|
withRelations(entry.data, { topics: relate(context.topics, entry.topics) }),
|
||||||
|
{ slug: { $eq: entry.slug } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(strapi, `${knowledge.length} demo knowledge items`);
|
||||||
|
|
||||||
|
/* Finally the home page, which the demo set only adds to: the status pill,
|
||||||
|
the network logo wall (twelve organisations, which is what turns the wall
|
||||||
|
into the design's marquee), and two network stories beside the real launch
|
||||||
|
article. The hero is rewritten in full, media included, because writing a
|
||||||
|
component replaces it. */
|
||||||
|
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
|
||||||
|
const heroMedia = mediaId(existingHome?.hero, 'media');
|
||||||
|
|
||||||
|
const featured = [
|
||||||
|
context.launchArticleId,
|
||||||
|
newsIndex.get(`${DEMO_PREFIX}first-round-of-sub-projects-selected`),
|
||||||
|
newsIndex.get(`${DEMO_PREFIX}coastal-justice-forum-completes-sub-project`),
|
||||||
|
].filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
const homeFor = (locale: 'en' | 'ar'): SeedData => ({
|
||||||
|
...HOME_PAGE[locale],
|
||||||
|
hero: {
|
||||||
|
...(HOME_PAGE[locale].hero as SeedData),
|
||||||
|
...(DEMO_HOME_PAGE[locale].hero as SeedData),
|
||||||
|
...(heroMedia ? { media: heroMedia } : {}),
|
||||||
|
},
|
||||||
|
featuredNews: featured,
|
||||||
|
partners: relate(partners, DEMO_PARTNER_SLUGS),
|
||||||
|
});
|
||||||
|
|
||||||
|
await upsert(strapi, UID.homePage, { en: homeFor('en'), ar: homeFor('ar') });
|
||||||
|
log(strapi, 'home page, with the demo network wall');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
REMOVING THE DEMO SET
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function cleanDemo(strapi: Core.Strapi): Promise<void> {
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
for (const uid of DEMO_SLUGGED_TYPES) {
|
||||||
|
removed += await deleteBySlugPrefix(strapi, uid, DEMO_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Both languages: the filter runs once per locale, and an Arabic question
|
||||||
|
never matches an English one. */
|
||||||
|
removed += await deleteWhere(strapi, UID.faq, {
|
||||||
|
question: {
|
||||||
|
$in: DEMO_FAQS.flatMap((item) => [item.en.question, item.ar.question]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* The home page is approved content with demo fields written onto it, so it
|
||||||
|
is put back rather than deleted. Deleting the partners above already
|
||||||
|
emptied the wall; this takes the status pill off again. */
|
||||||
|
const existingHome = await readExisting(strapi, UID.homePage, ['hero.media']);
|
||||||
|
const heroMedia = mediaId(existingHome?.hero, 'media');
|
||||||
|
|
||||||
|
await upsert(strapi, UID.homePage, {
|
||||||
|
en: {
|
||||||
|
...HOME_PAGE.en,
|
||||||
|
hero: { ...(HOME_PAGE.en.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
...HOME_PAGE.ar,
|
||||||
|
hero: { ...(HOME_PAGE.ar.hero as SeedData), ...(heroMedia ? { media: heroMedia } : {}) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log(strapi, `removed ${removed} demo entries and reset the home page`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
ENTRY POINT
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function runSeed(strapi: Core.Strapi, options: SeedOptions): Promise<void> {
|
||||||
|
/* The guard that makes demo content safe to have in the repository at all:
|
||||||
|
no flag combination writes invented entries to a production database. */
|
||||||
|
if (options.demo && process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error(
|
||||||
|
'[seed] refusing to touch demo content with NODE_ENV=production. ' +
|
||||||
|
'Run `pnpm seed` without --demo to write the approved content only.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.clean && !options.demo) {
|
||||||
|
throw new Error(
|
||||||
|
'[seed] --clean removes demo content and nothing else, so it needs --demo. ' +
|
||||||
|
'Approved content is never deleted by the seed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.clean) {
|
||||||
|
await cleanDemo(strapi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await seedApproved(strapi);
|
||||||
|
|
||||||
|
if (options.demo) {
|
||||||
|
log(strapi, 'writing DEMO fixtures - invented content, every slug prefixed "demo-"');
|
||||||
|
await seedDemo(strapi, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(strapi, 'done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the options off a process argument list. */
|
||||||
|
export function parseSeedOptions(argv: readonly string[]): SeedOptions {
|
||||||
|
return {
|
||||||
|
demo: argv.includes('--demo'),
|
||||||
|
clean: argv.includes('--clean'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { DemoEntry };
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SEED - idempotent document helpers
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Every write in the seed goes through here, so the whole seed can be run
|
||||||
|
again over a database it has already written to and change nothing but the
|
||||||
|
fields it owns. Nothing is ever deleted except demo entries, and only on an
|
||||||
|
explicit `--demo --clean`.
|
||||||
|
|
||||||
|
THE SHAPE OF A WRITE
|
||||||
|
A seed entry is always a pair - the English document and its Arabic
|
||||||
|
translation - because every localised content type on this site ships in
|
||||||
|
both languages (CLAUDE.md rule 7). `upsert` writes the English document
|
||||||
|
first, because that is the one carrying the identity (the slug), then
|
||||||
|
writes the Arabic locale onto the SAME document, which is how Strapi 5
|
||||||
|
models a translation: one `documentId`, one row per locale.
|
||||||
|
|
||||||
|
DRAFT AND PUBLISH
|
||||||
|
Content is written as a draft and then published explicitly. Publishing
|
||||||
|
is a separate call rather than `status: 'published'` on the write, so a
|
||||||
|
half-built entry is never live for the moment between the two locales.
|
||||||
|
|
||||||
|
Two content types - `topic` and `region` - are pure taxonomy and have
|
||||||
|
draft & publish switched off, and a document service for such a type has
|
||||||
|
no `publish` method and no `status` to filter on at all. `usesDraft`
|
||||||
|
below reads that off the schema rather than keeping a list, so a type that
|
||||||
|
gains or loses the setting needs no change here.
|
||||||
|
|
||||||
|
TYPES
|
||||||
|
`strapi.documents(uid)` is generated per content type, so a helper that
|
||||||
|
takes the uid as a parameter cannot be typed against one schema. The
|
||||||
|
single cast below is the seam: past it, the seed's own data is checked by
|
||||||
|
the content files' own types, and Strapi validates the payload at runtime
|
||||||
|
against the real schema - a wrong field name fails the seed loudly.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
|
/** The two locales this site ships. */
|
||||||
|
export const LOCALES = ['en', 'ar'] as const;
|
||||||
|
|
||||||
|
export type SeedLocale = (typeof LOCALES)[number];
|
||||||
|
|
||||||
|
/** One piece of content, written once per locale. */
|
||||||
|
export type Localised<T> = Readonly<Record<SeedLocale, T>>;
|
||||||
|
|
||||||
|
/** A field payload - checked by Strapi against the real schema at runtime. */
|
||||||
|
export type SeedData = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Any content type uid `strapi.documents()` accepts. */
|
||||||
|
export type SeedUid = Parameters<Core.Strapi['documents']>[0];
|
||||||
|
|
||||||
|
interface SeedDocument {
|
||||||
|
documentId: string;
|
||||||
|
[field: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document service, minus the per-uid generics.
|
||||||
|
*
|
||||||
|
* See the TYPES note above: this is the one place the seed steps outside
|
||||||
|
* Strapi's generated types, and it is deliberate.
|
||||||
|
*/
|
||||||
|
interface LooseDocumentService {
|
||||||
|
findFirst(params: SeedData): Promise<SeedDocument | null>;
|
||||||
|
findMany(params: SeedData): Promise<SeedDocument[]>;
|
||||||
|
create(params: SeedData): Promise<SeedDocument>;
|
||||||
|
update(params: SeedData): Promise<SeedDocument | null>;
|
||||||
|
delete(params: SeedData): Promise<unknown>;
|
||||||
|
publish(params: SeedData): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = (strapi: Core.Strapi, uid: SeedUid): LooseDocumentService =>
|
||||||
|
strapi.documents(uid) as unknown as LooseDocumentService;
|
||||||
|
|
||||||
|
/** Whether this content type has draft & publish, and so a `publish` call. */
|
||||||
|
const usesDraft = (strapi: Core.Strapi, uid: SeedUid): boolean =>
|
||||||
|
Boolean(strapi.contentType(uid).options?.draftAndPublish);
|
||||||
|
|
||||||
|
/** `status: 'draft'`, but only where a status exists to ask about. */
|
||||||
|
const draftFilter = (strapi: Core.Strapi, uid: SeedUid): SeedData =>
|
||||||
|
usesDraft(strapi, uid) ? { status: 'draft' } : {};
|
||||||
|
|
||||||
|
/** What a seed step did, for the run summary. */
|
||||||
|
export interface SeedResult {
|
||||||
|
documentId: string;
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write both locales of a document and publish it.
|
||||||
|
*
|
||||||
|
* `find` identifies the entry across runs - a slug for a collection type,
|
||||||
|
* nothing at all for a single type, which has only ever one document. When it
|
||||||
|
* matches nothing the document is created; when it matches, the same fields
|
||||||
|
* are written over the existing one, so an editor's OTHER changes survive a
|
||||||
|
* re-run and the fields the seed owns go back to the seeded value.
|
||||||
|
*/
|
||||||
|
export async function upsert(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
uid: SeedUid,
|
||||||
|
data: Localised<SeedData>,
|
||||||
|
find?: SeedData
|
||||||
|
): Promise<SeedResult> {
|
||||||
|
const service = documents(strapi, uid);
|
||||||
|
|
||||||
|
/* Looked up as a draft: a document that exists but has never been published
|
||||||
|
is still the same document, and creating a second one beside it is how a
|
||||||
|
seed ends up with duplicates. */
|
||||||
|
const existing = await service.findFirst({
|
||||||
|
...(find ? { filters: find } : {}),
|
||||||
|
locale: 'en',
|
||||||
|
...draftFilter(strapi, uid),
|
||||||
|
});
|
||||||
|
|
||||||
|
let documentId: string;
|
||||||
|
let created = false;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
documentId = existing.documentId;
|
||||||
|
await service.update({ documentId, locale: 'en', data: data.en });
|
||||||
|
} else {
|
||||||
|
const document = await service.create({ locale: 'en', data: data.en });
|
||||||
|
documentId = document.documentId;
|
||||||
|
created = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An update against a locale the document does not have yet creates that
|
||||||
|
translation - there is no separate "add a locale" call. */
|
||||||
|
await service.update({ documentId, locale: 'ar', data: data.ar });
|
||||||
|
|
||||||
|
if (usesDraft(strapi, uid)) {
|
||||||
|
await service.publish({ documentId, locale: '*' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { documentId, created };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the documents a filter matches, in every locale.
|
||||||
|
*
|
||||||
|
* TWO THINGS THE 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:
|
||||||
|
*
|
||||||
|
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
|
||||||
|
* only, not the document. It takes `locale: '*'` to remove the lot.
|
||||||
|
* 2. `findMany({ locale })` answers for one locale, so a document whose
|
||||||
|
* English half has already gone is invisible to a search in English.
|
||||||
|
* Both locales are searched and the documentIds pooled, which also
|
||||||
|
* sweeps up orphans an earlier, less careful run left.
|
||||||
|
*/
|
||||||
|
async function deleteMatching(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
uid: SeedUid,
|
||||||
|
filters: SeedData
|
||||||
|
): Promise<number> {
|
||||||
|
const service = documents(strapi, uid);
|
||||||
|
const documentIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
const matches = await service.findMany({
|
||||||
|
filters,
|
||||||
|
locale,
|
||||||
|
...draftFilter(strapi, uid),
|
||||||
|
pagination: { limit: -1 },
|
||||||
|
});
|
||||||
|
for (const match of matches) documentIds.add(match.documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const documentId of documentIds) {
|
||||||
|
await service.delete({ documentId, locale: '*' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentIds.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every document of a type whose slug starts with the given prefix.
|
||||||
|
*
|
||||||
|
* This is how `--demo --clean` finds what it planted: the prefix is on the
|
||||||
|
* slug itself, so nothing has to be remembered between runs and an entry an
|
||||||
|
* editor has since renamed out of the prefix is deliberately left alone.
|
||||||
|
*/
|
||||||
|
export async function deleteBySlugPrefix(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
uid: SeedUid,
|
||||||
|
prefix: string
|
||||||
|
): Promise<number> {
|
||||||
|
return deleteMatching(strapi, uid, { slug: { $startsWith: prefix } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete documents matched by an arbitrary filter.
|
||||||
|
*
|
||||||
|
* The FAQ content type has no slug to prefix, so its demo entries are found by
|
||||||
|
* their exact questions instead - in both languages, because the filter is
|
||||||
|
* matched per locale. Anything the seed did not write is left untouched,
|
||||||
|
* because the filter is the seeded list itself.
|
||||||
|
*/
|
||||||
|
export async function deleteWhere(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
uid: SeedUid,
|
||||||
|
filters: SeedData
|
||||||
|
): Promise<number> {
|
||||||
|
return deleteMatching(strapi, uid, filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one field off a single type before overwriting it.
|
||||||
|
*
|
||||||
|
* Used for the media an editor has already uploaded: the seed owns the words
|
||||||
|
* on a page, not the pictures, so it reads the current hero image back and
|
||||||
|
* writes it out again with the new copy rather than dropping it. A component
|
||||||
|
* is replaced wholesale on write, so anything inside it that is not in the
|
||||||
|
* payload is gone.
|
||||||
|
*/
|
||||||
|
export async function readExisting(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
uid: SeedUid,
|
||||||
|
populate: string[],
|
||||||
|
find?: SeedData
|
||||||
|
): Promise<SeedDocument | null> {
|
||||||
|
return documents(strapi, uid).findFirst({
|
||||||
|
...(find ? { filters: find } : {}),
|
||||||
|
locale: 'en',
|
||||||
|
...draftFilter(strapi, uid),
|
||||||
|
populate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id of a media entry nested in a populated component, if there is one. */
|
||||||
|
export function mediaId(component: unknown, field: string): number | undefined {
|
||||||
|
if (!component || typeof component !== 'object') return undefined;
|
||||||
|
const media = (component as Record<string, unknown>)[field];
|
||||||
|
if (!media || typeof media !== 'object') return undefined;
|
||||||
|
const id = (media as Record<string, unknown>).id;
|
||||||
|
return typeof id === 'number' ? id : undefined;
|
||||||
|
}
|
||||||
@@ -96,19 +96,6 @@ export interface BlocksRichText extends Struct.ComponentSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlocksStatsGrid extends Struct.ComponentSchema {
|
|
||||||
collectionName: 'components_blocks_stats_grids';
|
|
||||||
info: {
|
|
||||||
description: 'A grid of headline figures.';
|
|
||||||
displayName: 'Stats Grid';
|
|
||||||
icon: 'chartBubble';
|
|
||||||
};
|
|
||||||
attributes: {
|
|
||||||
note: Schema.Attribute.Text;
|
|
||||||
stats: Schema.Attribute.Component<'shared.stat', true>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlocksVideoEmbed extends Struct.ComponentSchema {
|
export interface BlocksVideoEmbed extends Struct.ComponentSchema {
|
||||||
collectionName: 'components_blocks_video_embeds';
|
collectionName: 'components_blocks_video_embeds';
|
||||||
info: {
|
info: {
|
||||||
@@ -150,6 +137,21 @@ export interface NavigationNavGroup extends Struct.ComponentSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PageBandIntro extends Struct.ComponentSchema {
|
||||||
|
collectionName: 'components_page_band_intros';
|
||||||
|
info: {
|
||||||
|
description: 'The heading, lead and pictures of a page band, in one place so an editor is not hunting for half of it in the Blocks zone.';
|
||||||
|
displayName: 'Band intro';
|
||||||
|
icon: 'layer';
|
||||||
|
};
|
||||||
|
attributes: {
|
||||||
|
eyebrow: Schema.Attribute.String;
|
||||||
|
heading: Schema.Attribute.String;
|
||||||
|
images: Schema.Attribute.Media<'images', true>;
|
||||||
|
lead: Schema.Attribute.Text;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageConsortiumMember extends Struct.ComponentSchema {
|
export interface PageConsortiumMember extends Struct.ComponentSchema {
|
||||||
collectionName: 'components_page_consortium_members';
|
collectionName: 'components_page_consortium_members';
|
||||||
info: {
|
info: {
|
||||||
@@ -297,19 +299,6 @@ export interface SharedSeo extends Struct.ComponentSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharedStat extends Struct.ComponentSchema {
|
|
||||||
collectionName: 'components_shared_stats';
|
|
||||||
info: {
|
|
||||||
description: 'A single headline figure, e.g. 64 / CSO Partners.';
|
|
||||||
displayName: 'Stat';
|
|
||||||
icon: 'chartBubble';
|
|
||||||
};
|
|
||||||
attributes: {
|
|
||||||
label: Schema.Attribute.String & Schema.Attribute.Required;
|
|
||||||
value: Schema.Attribute.String & Schema.Attribute.Required;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SharedTitledText extends Struct.ComponentSchema {
|
export interface SharedTitledText extends Struct.ComponentSchema {
|
||||||
collectionName: 'components_shared_titled_texts';
|
collectionName: 'components_shared_titled_texts';
|
||||||
info: {
|
info: {
|
||||||
@@ -333,10 +322,10 @@ declare module '@strapi/strapi' {
|
|||||||
'blocks.partner-logos': BlocksPartnerLogos;
|
'blocks.partner-logos': BlocksPartnerLogos;
|
||||||
'blocks.quote': BlocksQuote;
|
'blocks.quote': BlocksQuote;
|
||||||
'blocks.rich-text': BlocksRichText;
|
'blocks.rich-text': BlocksRichText;
|
||||||
'blocks.stats-grid': BlocksStatsGrid;
|
|
||||||
'blocks.video-embed': BlocksVideoEmbed;
|
'blocks.video-embed': BlocksVideoEmbed;
|
||||||
'navigation.footer-column': NavigationFooterColumn;
|
'navigation.footer-column': NavigationFooterColumn;
|
||||||
'navigation.nav-group': NavigationNavGroup;
|
'navigation.nav-group': NavigationNavGroup;
|
||||||
|
'page.band-intro': PageBandIntro;
|
||||||
'page.consortium-member': PageConsortiumMember;
|
'page.consortium-member': PageConsortiumMember;
|
||||||
'page.workstream': PageWorkstream;
|
'page.workstream': PageWorkstream;
|
||||||
'shared.accordion-item': SharedAccordionItem;
|
'shared.accordion-item': SharedAccordionItem;
|
||||||
@@ -346,7 +335,6 @@ declare module '@strapi/strapi' {
|
|||||||
'shared.hero': SharedHero;
|
'shared.hero': SharedHero;
|
||||||
'shared.link': SharedLink;
|
'shared.link': SharedLink;
|
||||||
'shared.seo': SharedSeo;
|
'shared.seo': SharedSeo;
|
||||||
'shared.stat': SharedStat;
|
|
||||||
'shared.titled-text': SharedTitledText;
|
'shared.titled-text': SharedTitledText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,7 +464,6 @@ export interface ApiAboutPageAboutPage extends Struct.SingleTypeSchema {
|
|||||||
[
|
[
|
||||||
'blocks.rich-text',
|
'blocks.rich-text',
|
||||||
'blocks.image-gallery',
|
'blocks.image-gallery',
|
||||||
'blocks.stats-grid',
|
|
||||||
'blocks.cta-banner',
|
'blocks.cta-banner',
|
||||||
'blocks.card-grid',
|
'blocks.card-grid',
|
||||||
'blocks.quote',
|
'blocks.quote',
|
||||||
@@ -588,7 +587,6 @@ export interface ApiActivityActivity extends Struct.CollectionTypeSchema {
|
|||||||
[
|
[
|
||||||
'blocks.rich-text',
|
'blocks.rich-text',
|
||||||
'blocks.image-gallery',
|
'blocks.image-gallery',
|
||||||
'blocks.stats-grid',
|
|
||||||
'blocks.cta-banner',
|
'blocks.cta-banner',
|
||||||
'blocks.card-grid',
|
'blocks.card-grid',
|
||||||
'blocks.quote',
|
'blocks.quote',
|
||||||
@@ -1165,11 +1163,16 @@ export interface ApiHomePageHomePage extends Struct.SingleTypeSchema {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
attributes: {
|
attributes: {
|
||||||
|
approach: Schema.Attribute.Component<'page.band-intro', false> &
|
||||||
|
Schema.Attribute.SetPluginOptions<{
|
||||||
|
i18n: {
|
||||||
|
localized: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
blocks: Schema.Attribute.DynamicZone<
|
blocks: Schema.Attribute.DynamicZone<
|
||||||
[
|
[
|
||||||
'blocks.rich-text',
|
'blocks.rich-text',
|
||||||
'blocks.image-gallery',
|
'blocks.image-gallery',
|
||||||
'blocks.stats-grid',
|
|
||||||
'blocks.cta-banner',
|
'blocks.cta-banner',
|
||||||
'blocks.card-grid',
|
'blocks.card-grid',
|
||||||
'blocks.quote',
|
'blocks.quote',
|
||||||
@@ -1216,12 +1219,6 @@ export interface ApiHomePageHomePage extends Struct.SingleTypeSchema {
|
|||||||
localized: true;
|
localized: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
stats: Schema.Attribute.Component<'shared.stat', true> &
|
|
||||||
Schema.Attribute.SetPluginOptions<{
|
|
||||||
i18n: {
|
|
||||||
localized: true;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
updatedAt: Schema.Attribute.DateTime;
|
updatedAt: Schema.Attribute.DateTime;
|
||||||
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
|
||||||
Schema.Attribute.Private;
|
Schema.Attribute.Private;
|
||||||
@@ -1256,7 +1253,6 @@ export interface ApiKnowledgeItemKnowledgeItem
|
|||||||
[
|
[
|
||||||
'blocks.rich-text',
|
'blocks.rich-text',
|
||||||
'blocks.image-gallery',
|
'blocks.image-gallery',
|
||||||
'blocks.stats-grid',
|
|
||||||
'blocks.cta-banner',
|
'blocks.cta-banner',
|
||||||
'blocks.card-grid',
|
'blocks.card-grid',
|
||||||
'blocks.quote',
|
'blocks.quote',
|
||||||
@@ -1389,7 +1385,6 @@ export interface ApiNewsArticleNewsArticle extends Struct.CollectionTypeSchema {
|
|||||||
[
|
[
|
||||||
'blocks.rich-text',
|
'blocks.rich-text',
|
||||||
'blocks.image-gallery',
|
'blocks.image-gallery',
|
||||||
'blocks.stats-grid',
|
|
||||||
'blocks.cta-banner',
|
'blocks.cta-banner',
|
||||||
'blocks.card-grid',
|
'blocks.card-grid',
|
||||||
'blocks.quote',
|
'blocks.quote',
|
||||||
@@ -2134,7 +2129,6 @@ export interface ApiWhatWeDoPageWhatWeDoPage extends Struct.SingleTypeSchema {
|
|||||||
[
|
[
|
||||||
'blocks.rich-text',
|
'blocks.rich-text',
|
||||||
'blocks.image-gallery',
|
'blocks.image-gallery',
|
||||||
'blocks.stats-grid',
|
|
||||||
'blocks.cta-banner',
|
'blocks.cta-banner',
|
||||||
'blocks.card-grid',
|
'blocks.card-grid',
|
||||||
'blocks.quote',
|
'blocks.quote',
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SHARP-ER web (React Router 8) - environment template
|
||||||
|
#
|
||||||
|
# Copy to .env and fill in real values: cp .env.example .env
|
||||||
|
# Never commit .env.
|
||||||
|
#
|
||||||
|
# `pnpm --filter web start` loads THIS file (apps/web/.env), the same way the
|
||||||
|
# CMS loads apps/cms/.env. It is per-application on purpose: PORT and HOST mean
|
||||||
|
# something different to each server, and a single shared value would move
|
||||||
|
# Strapi onto the website's port. The repository-root .env is docker compose's
|
||||||
|
# file and is not read here.
|
||||||
|
#
|
||||||
|
# A real environment variable always wins over this file, so a systemd unit,
|
||||||
|
# a container or `PORT=9090 pnpm --filter web start` overrides any line below.
|
||||||
|
#
|
||||||
|
# `pnpm dev:web` does NOT read this file - the dev server is Vite on 5173, and
|
||||||
|
# it only exposes variables prefixed VITE_, by inlining them into the CLIENT
|
||||||
|
# bundle. Never use that prefix here: STRAPI_TOKEN below is a server secret.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# --- Server ----------------------------------------------------------------
|
||||||
|
|
||||||
|
# Port react-router-serve binds. Leave it unset and the server takes the first
|
||||||
|
# FREE port from 3000 upwards - 3001, 3002, ... once 3000 is busy - which a
|
||||||
|
# reverse proxy cannot be pointed at. `pnpm dev:web` ignores this and uses 5173.
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Interface to bind. Unset means every interface. Set it to 127.0.0.1 when a
|
||||||
|
# reverse proxy on the same host should be the only way in.
|
||||||
|
# HOST=127.0.0.1
|
||||||
|
|
||||||
|
# --- Content ---------------------------------------------------------------
|
||||||
|
|
||||||
|
# Public origin of the site. Canonical URLs, hreflang alternates and JSON-LD
|
||||||
|
# are built from it, so it must match how visitors actually reach the site -
|
||||||
|
# https://sharper.ly in production, never the internal http://127.0.0.1:3000.
|
||||||
|
# Leave it unset in local development and the request's own host is used.
|
||||||
|
SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Where the server-side loaders fetch content from. This origin is also
|
||||||
|
# prefixed onto uploaded media URLs, which the browser has to resolve too -
|
||||||
|
# see "Images from the CMS" in README.md.
|
||||||
|
STRAPI_URL=http://localhost:1337
|
||||||
|
|
||||||
|
# Optional Strapi API token. Only needed to read draft or otherwise protected
|
||||||
|
# content; published content is public. Settings -> API Tokens in the admin.
|
||||||
|
STRAPI_TOKEN=
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
* "Ghost / Large / Default" is the one cell in the matrix with no padding
|
* "Ghost / Large / Default" is the one cell in the matrix with no padding
|
||||||
while its own Hover and Disabled cells have it - an authoring slip that
|
while its own Hover and Disabled cells have it - an authoring slip that
|
||||||
would make the button jump on hover. Padding is kept on all ghost states.
|
would make the button jump on hover. Padding is kept on all ghost states.
|
||||||
|
* Ghost is repainted to the Claude Design drafts rather than to the Figma
|
||||||
|
frame: text-only throughout, no hover fill, no lift, widening gap. See
|
||||||
|
the block below and the ghost tokens in semantic.css.
|
||||||
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
|
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
|
||||||
Figma's 40% opacity, so the page never shows through a disabled control.
|
Figma's 40% opacity, so the page never shows through a disabled control.
|
||||||
`--control-opacity-disabled` still holds the 0.4 if that is ever wanted.
|
`--control-opacity-disabled` still holds the 0.4 if that is ever wanted.
|
||||||
@@ -190,11 +193,28 @@
|
|||||||
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
|
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Figma gives the ghost md/lg cells radius.md while every other cell uses
|
/* GHOST IS A TEXT LINK, NOT A BUTTON.
|
||||||
radius.sm - reproduced rather than normalised. */
|
The drafts draw "All updates ->" and "What we do ->" as plain text that
|
||||||
.sh-btn[data-variant="ghost"][data-size="md"],
|
turns accent green while the label-to-arrow gap opens from 8px to 12px.
|
||||||
.sh-btn[data-variant="ghost"][data-size="lg"] {
|
So the ghost variant opts out of the 3px lift the filled controls take -
|
||||||
border-radius: var(--radius-control-lg);
|
nothing that has no box should appear to rise off the page - and animates
|
||||||
|
its gap instead. (The old Figma-quirk override that gave ghost md/lg
|
||||||
|
radius.md is gone with --radius-control-lg; every control is 4px.) */
|
||||||
|
.sh-btn[data-variant="ghost"] .sh-btn__content {
|
||||||
|
transition:
|
||||||
|
opacity var(--motion-duration-fast) var(--motion-ease-standard),
|
||||||
|
gap var(--motion-duration-base) var(--motion-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.sh-btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-btn[data-variant="ghost"]:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading])
|
||||||
|
.sh-btn__content {
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------
|
/* ---------------------------------------------------------------------
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export function CardCTA({
|
|||||||
data-surface="dark"
|
data-surface="dark"
|
||||||
className={cx(
|
className={cx(
|
||||||
"sh-card relative flex flex-col gap-lg rounded-surface p-10",
|
"sh-card relative flex flex-col gap-lg rounded-surface p-10",
|
||||||
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-popover)]",
|
/* The drafts rest nothing on a shadow - a panel is a fill and an edge,
|
||||||
|
and the black is spent on what floats. --elevation-card is that
|
||||||
|
resting step: none on the dark surface, soft on the light one. */
|
||||||
|
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-card)]",
|
||||||
"sm:flex-row sm:items-center",
|
"sm:flex-row sm:items-center",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function CardContent({
|
|||||||
light on both surfaces - text-on-accent is the only always-white
|
light on both surfaces - text-on-accent is the only always-white
|
||||||
role, text-primary would go dark on the light surface. */}
|
role, text-primary would go dark on the light surface. */}
|
||||||
{badge ? (
|
{badge ? (
|
||||||
<span className="absolute start-md top-md rounded-control-lg bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]">
|
<span className="absolute start-md top-md rounded-chip bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]">
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
STYLING RULE for this whole directory:
|
STYLING RULE for this whole directory:
|
||||||
* colour and radius come from the tier-2 Tailwind bridge
|
* colour and radius come from the tier-2 Tailwind bridge
|
||||||
(bg-surface-raised, text-text-accent, border-border-subtle,
|
(bg-surface-raised, text-text-accent, border-border-subtle,
|
||||||
rounded-surface, ...) - never a hex, never a tier-1 primitive;
|
rounded-card, ...) - never a hex, never a tier-1 primitive;
|
||||||
* tokens without a bridge utility are spent as arbitrary values,
|
* tokens without a bridge utility are spent as arbitrary values,
|
||||||
e.g. shadow-[var(--elevation-card)];
|
e.g. shadow-[var(--elevation-card)];
|
||||||
* spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma
|
* spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma
|
||||||
@@ -89,7 +89,6 @@ const CARD_ICON_SRC: Record<CardIconName, string> = {
|
|||||||
"arrow-up": "url('/assets/icons/arrow-up.svg')",
|
"arrow-up": "url('/assets/icons/arrow-up.svg')",
|
||||||
linkedin: "url('/assets/icons/linkedin.svg')",
|
linkedin: "url('/assets/icons/linkedin.svg')",
|
||||||
mail: "url('/assets/icons/mail.svg')",
|
mail: "url('/assets/icons/mail.svg')",
|
||||||
globe: "url('/assets/icons/globe.svg')",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CardIconProps {
|
export interface CardIconProps {
|
||||||
@@ -143,8 +142,10 @@ export interface CardShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The surface every card shares: raised fill, hairline border, 12px corner
|
* The surface every card shares: raised fill, hairline border and the 4px
|
||||||
* (Figma radius/lg) and the card elevation.
|
* corner the design drafts draw. `--elevation-card` is the drafts' resting
|
||||||
|
* shadow, which on the dark surface is none at all - the hairline does the
|
||||||
|
* separating, and the black is kept for the hover state in card.css.
|
||||||
*/
|
*/
|
||||||
export function CardShell({
|
export function CardShell({
|
||||||
as = "article",
|
as = "article",
|
||||||
@@ -160,7 +161,7 @@ export function CardShell({
|
|||||||
"sh-card",
|
"sh-card",
|
||||||
linked && "sh-card--linked",
|
linked && "sh-card--linked",
|
||||||
"relative flex bg-[var(--surface-card)] text-text-primary",
|
"relative flex bg-[var(--surface-card)] text-text-primary",
|
||||||
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
|
"rounded-card border border-border-subtle shadow-[var(--elevation-card)]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Card.Stat - Figma 25:792 (260 x 166)
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
The figure card used by the home page stats strip: a large accent number,
|
|
||||||
an optional trend chip, the label it counts, a rule and a caption. Never a
|
|
||||||
link - a number is not a destination.
|
|
||||||
|
|
||||||
USAGE
|
|
||||||
-----
|
|
||||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Card.Stat
|
|
||||||
as="li"
|
|
||||||
value="2,450"
|
|
||||||
label="Participants reached"
|
|
||||||
caption="Cumulative regional outreach, updated each quarter."
|
|
||||||
trend={{ label: "12%", direction: "up", description: "up 12% on last quarter" }}
|
|
||||||
/>
|
|
||||||
<Card.Stat as="li" value="18" label="Municipalities engaged" />
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
// Arabic - Eastern Arabic numerals belong in the value string itself.
|
|
||||||
<Card.Stat value="٢٬٤٥٠" label="عدد المشاركين" />
|
|
||||||
========================================================================= */
|
|
||||||
|
|
||||||
import { CardIcon, CardShell, cx } from "./CardShell";
|
|
||||||
import type { CardElement } from "./types";
|
|
||||||
|
|
||||||
export interface CardStatTrend {
|
|
||||||
/** Visible text, e.g. "12%". */
|
|
||||||
label: string;
|
|
||||||
/** Up is the accent fill, down the danger fill. Defaults to up. */
|
|
||||||
direction?: "up" | "down";
|
|
||||||
/**
|
|
||||||
* Spoken instead of the bare figure, e.g. "up 12% on last quarter". The
|
|
||||||
* arrow is decorative, so without this a screen reader hears only "12%".
|
|
||||||
*/
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CardStatProps {
|
|
||||||
/** Pre-formatted for the locale - the card never formats a number itself. */
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
caption?: string;
|
|
||||||
trend?: CardStatTrend;
|
|
||||||
as?: CardElement;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardStat({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
caption,
|
|
||||||
trend,
|
|
||||||
as = "article",
|
|
||||||
className,
|
|
||||||
}: CardStatProps) {
|
|
||||||
const isDown = trend?.direction === "down";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardShell
|
|
||||||
as={as}
|
|
||||||
className={cx("flex-col items-start gap-md p-lg", className)}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-between gap-sm">
|
|
||||||
<p className="display-sm tabular-nums text-text-accent">{value}</p>
|
|
||||||
|
|
||||||
{trend ? (
|
|
||||||
/* Figma paints the chip label in text/accent on a background/accent
|
|
||||||
fill - green on green, invisible. Kept as the accent fill with the
|
|
||||||
always-white on-accent label, and the danger fill when the figure
|
|
||||||
is falling. */
|
|
||||||
<span
|
|
||||||
className={cx(
|
|
||||||
"inline-flex shrink-0 items-center gap-2xs rounded-control-lg px-xs py-2xs",
|
|
||||||
"label-md text-text-on-accent",
|
|
||||||
isDown ? "bg-surface-danger" : "bg-surface-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardIcon
|
|
||||||
name="arrow-up"
|
|
||||||
size={10}
|
|
||||||
className={isDown ? "rotate-180" : undefined}
|
|
||||||
/>
|
|
||||||
{trend.description ? (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true">{trend.label}</span>
|
|
||||||
<span className="sr-only">{trend.description}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
trend.label
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="body-md-bold text-text-primary">{label}</p>
|
|
||||||
|
|
||||||
<div aria-hidden="true" className="w-full border-t border-border-subtle" />
|
|
||||||
|
|
||||||
{caption ? (
|
|
||||||
<p className="body-sm text-text-secondary">{caption}</p>
|
|
||||||
) : null}
|
|
||||||
</CardShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
socials={[
|
socials={[
|
||||||
{ kind: "linkedin", href: "https://www.linkedin.com/in/example" },
|
{ kind: "linkedin", href: "https://www.linkedin.com/in/example" },
|
||||||
{ kind: "mail", href: "mailto:helen.vance@example.org", label: "Email" },
|
{ kind: "mail", href: "mailto:helen.vance@example.org", label: "Email" },
|
||||||
{ kind: "website", href: "https://example.org", label: "Website" },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -30,14 +29,14 @@
|
|||||||
import { CardLink, CardMedia, CardShell, CardIcon, cx } from "./CardShell";
|
import { CardLink, CardMedia, CardShell, CardIcon, cx } from "./CardShell";
|
||||||
import type { CardElement, CardIconName, CardImage, CardTitleElement } from "./types";
|
import type { CardElement, CardIconName, CardImage, CardTitleElement } from "./types";
|
||||||
|
|
||||||
export type CardTeamSocialKind = "linkedin" | "mail" | "website";
|
export type CardTeamSocialKind = "linkedin" | "mail";
|
||||||
|
|
||||||
export interface CardTeamSocial {
|
export interface CardTeamSocial {
|
||||||
kind: CardTeamSocialKind;
|
kind: CardTeamSocialKind;
|
||||||
href: string;
|
href: string;
|
||||||
/**
|
/**
|
||||||
* Accessible name of the link. Defaults to the platform name; pass a
|
* Accessible name of the link. Defaults to the platform name; pass a
|
||||||
* translated string on Arabic pages ("البريد الإلكتروني", "الموقع").
|
* translated string on Arabic pages ("البريد الإلكتروني").
|
||||||
*/
|
*/
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
@@ -59,13 +58,11 @@ export interface CardTeamProps {
|
|||||||
const SOCIAL_ICON: Record<CardTeamSocialKind, CardIconName> = {
|
const SOCIAL_ICON: Record<CardTeamSocialKind, CardIconName> = {
|
||||||
linkedin: "linkedin",
|
linkedin: "linkedin",
|
||||||
mail: "mail",
|
mail: "mail",
|
||||||
website: "globe",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SOCIAL_LABEL: Record<CardTeamSocialKind, string> = {
|
const SOCIAL_LABEL: Record<CardTeamSocialKind, string> = {
|
||||||
linkedin: "LinkedIn",
|
linkedin: "LinkedIn",
|
||||||
mail: "Email",
|
mail: "Email",
|
||||||
website: "Website",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CardTeam({
|
export function CardTeam({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
CARD - mechanics that cannot be expressed as a utility
|
CARD - mechanics that cannot be expressed as a utility
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Everything token-driven lives in the TSX as a tier-2 utility
|
Everything token-driven lives in the TSX as a tier-2 utility
|
||||||
(bg-surface-raised, text-text-accent, rounded-surface, ...). This file only
|
(bg-surface-raised, text-text-accent, rounded-card, ...). This file only
|
||||||
holds the three things a utility cannot do:
|
holds the three things a utility cannot do:
|
||||||
|
|
||||||
1. paint an exported SVG asset in the current token colour (mask),
|
1. paint an exported SVG asset in the current token colour (mask),
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
component rather than by app/app.css, so it is not scoped by anything else.
|
component rather than by app/app.css, so it is not scoped by anything else.
|
||||||
|
|
||||||
Everything lives in @layer components so a Tailwind utility passed through
|
Everything lives in @layer components so a Tailwind utility passed through
|
||||||
`className` (`rounded-surface`, `text-text-secondary`, ...) still wins.
|
`className` (`rounded-card`, `text-text-secondary`, ...) still wins.
|
||||||
The layer statement is a defensive no-op when app.css has already declared
|
The layer statement is a defensive no-op when app.css has already declared
|
||||||
the same order, and makes this file correct on its own if it ever loads first.
|
the same order, and makes this file correct on its own if it ever loads first.
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
@@ -88,17 +88,29 @@
|
|||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
HOVER / FOCUS AFFORDANCES
|
HOVER / FOCUS AFFORDANCES
|
||||||
------------------------------------------------------------------------ */
|
------------------------------------------------------------------------ */
|
||||||
|
/* The drafts hover a card three ways at once: the hairline turns accent
|
||||||
|
green, the card rises, and a deep black lands under it. The lift is
|
||||||
|
pointer-only - a touch "hover" would leave the card raised after the tap -
|
||||||
|
and it is dropped entirely under prefers-reduced-motion below, because a
|
||||||
|
transform still lands instantly once app.css has flattened the duration. */
|
||||||
.sh-card--linked {
|
.sh-card--linked {
|
||||||
transition:
|
transition:
|
||||||
box-shadow var(--motion-duration-base) var(--motion-ease-standard),
|
box-shadow var(--motion-duration-base) var(--motion-ease-standard),
|
||||||
border-color var(--motion-duration-base) var(--motion-ease-standard);
|
border-color var(--motion-duration-base) var(--motion-ease-standard),
|
||||||
|
transform var(--motion-duration-base) var(--motion-ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sh-card--linked:hover {
|
.sh-card--linked:hover {
|
||||||
border-color: var(--border-default);
|
border-color: var(--border-accent);
|
||||||
box-shadow: var(--elevation-popover);
|
box-shadow: var(--elevation-popover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.sh-card--linked:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sh-card--linked:hover .sh-card-title,
|
.sh-card--linked:hover .sh-card-title,
|
||||||
.sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title {
|
.sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -116,6 +128,7 @@
|
|||||||
/* app.css already neutralises transition durations under reduced motion, but a
|
/* app.css already neutralises transition durations under reduced motion, but a
|
||||||
transform still lands instantly - drop it entirely. */
|
transform still lands instantly - drop it entirely. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sh-card--linked:hover,
|
||||||
.sh-card--linked:hover .sh-card-media img {
|
.sh-card--linked:hover .sh-card-media img {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
CARD - Figma frame 25:809
|
CARD - Figma frame 25:809
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Five card types that share one surface, one link mechanism and one icon
|
Four card types that share one surface, one link mechanism and one icon
|
||||||
strategy. Import the namespace for readable markup:
|
strategy. Import the namespace for readable markup:
|
||||||
|
|
||||||
import { Card } from "~/components/ui/Card";
|
import { Card } from "~/components/ui/Card";
|
||||||
<Card.Content … /> <Card.Team … /> <Card.Horizontal … />
|
<Card.Content … /> <Card.Team … />
|
||||||
<Card.Stat … /> <Card.CTA … />
|
<Card.Horizontal … /> <Card.CTA … />
|
||||||
|
|
||||||
or the components directly when you want the narrowest import:
|
or the components directly when you want the narrowest import:
|
||||||
|
|
||||||
import { CardStat } from "~/components/ui/Card";
|
import { CardTeam } from "~/components/ui/Card";
|
||||||
|
|
||||||
|
The fifth, Card.Stat, is gone along with the rest of the project's figures
|
||||||
|
machinery: no approved document carries a number, so nothing drew it.
|
||||||
|
|
||||||
Every card is correct on both surfaces without a prop: colour comes only
|
Every card is correct on both surfaces without a prop: colour comes only
|
||||||
from tier-2 roles, which [data-surface="dark"] and [data-surface="light"]
|
from tier-2 roles, which [data-surface="dark"] and [data-surface="light"]
|
||||||
@@ -20,7 +23,6 @@
|
|||||||
export { CardContent } from "./CardContent";
|
export { CardContent } from "./CardContent";
|
||||||
export { CardTeam } from "./CardTeam";
|
export { CardTeam } from "./CardTeam";
|
||||||
export { CardHorizontal } from "./CardHorizontal";
|
export { CardHorizontal } from "./CardHorizontal";
|
||||||
export { CardStat } from "./CardStat";
|
|
||||||
export { CardCTA } from "./CardCTA";
|
export { CardCTA } from "./CardCTA";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -39,7 +41,6 @@ export type {
|
|||||||
CardTeamSocialKind,
|
CardTeamSocialKind,
|
||||||
} from "./CardTeam";
|
} from "./CardTeam";
|
||||||
export type { CardHorizontalProps } from "./CardHorizontal";
|
export type { CardHorizontalProps } from "./CardHorizontal";
|
||||||
export type { CardStatProps, CardStatTrend } from "./CardStat";
|
|
||||||
export type { CardCTAProps, CardCTAAction } from "./CardCTA";
|
export type { CardCTAProps, CardCTAAction } from "./CardCTA";
|
||||||
export type {
|
export type {
|
||||||
CardShellProps,
|
CardShellProps,
|
||||||
@@ -58,14 +59,12 @@ export type {
|
|||||||
import { CardContent } from "./CardContent";
|
import { CardContent } from "./CardContent";
|
||||||
import { CardTeam } from "./CardTeam";
|
import { CardTeam } from "./CardTeam";
|
||||||
import { CardHorizontal } from "./CardHorizontal";
|
import { CardHorizontal } from "./CardHorizontal";
|
||||||
import { CardStat } from "./CardStat";
|
|
||||||
import { CardCTA } from "./CardCTA";
|
import { CardCTA } from "./CardCTA";
|
||||||
|
|
||||||
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.Stat, Card.CTA. */
|
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.CTA. */
|
||||||
export const Card = {
|
export const Card = {
|
||||||
Content: CardContent,
|
Content: CardContent,
|
||||||
Team: CardTeam,
|
Team: CardTeam,
|
||||||
Horizontal: CardHorizontal,
|
Horizontal: CardHorizontal,
|
||||||
Stat: CardStat,
|
|
||||||
CTA: CardCTA,
|
CTA: CardCTA,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -33,10 +33,9 @@ export interface CardLinkTarget {
|
|||||||
rel?: string;
|
rel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The five icons the card family uses, each an asset exported from Figma. */
|
/** The icons the card family uses, each an asset exported from Figma. */
|
||||||
export type CardIconName =
|
export type CardIconName =
|
||||||
| "arrow-right"
|
| "arrow-right"
|
||||||
| "arrow-up"
|
| "arrow-up"
|
||||||
| "linkedin"
|
| "linkedin"
|
||||||
| "mail"
|
| "mail";
|
||||||
| "globe";
|
|
||||||
|
|||||||
@@ -287,7 +287,9 @@ function SocialButton({ link }: { link: FooterSocialLink }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FooterSocialIcon platform={link.platform} className="h-[17px] w-[17px]" />
|
<FooterSocialIcon platform={link.platform} className="h-[17px] w-[17px]" />
|
||||||
<span className="body-sm-bold">{link.label}</span>
|
{/* The label never wraps under its own icon: "Follow us on Facebook" is
|
||||||
|
one line at every width, as the draft has it. */}
|
||||||
|
<span className="body-sm-bold whitespace-nowrap">{link.label}</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
background-color: var(--field-bg);
|
background-color: var(--field-bg);
|
||||||
color: var(--field-fg);
|
color: var(--field-fg);
|
||||||
border: var(--control-border-width) solid var(--field-border);
|
border: var(--control-border-width) solid var(--field-border);
|
||||||
border-radius: var(--radius-control-lg);
|
border-radius: var(--radius-control);
|
||||||
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
|
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
|
||||||
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
|
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type FocusEvent as ReactFocusEvent,
|
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
|
||||||
} from "react";
|
|
||||||
import { Link } from "react-router";
|
|
||||||
|
|
||||||
import "./LanguageSwitcher.css";
|
|
||||||
import {
|
|
||||||
buildLocalizedHref,
|
|
||||||
getDirection,
|
|
||||||
LOCALE_ENDONYM,
|
|
||||||
LOCALES,
|
|
||||||
type Locale,
|
|
||||||
} from "./locale-path";
|
|
||||||
|
|
||||||
/* Reinforces the global :focus-visible rule in app.css with the same tier-2
|
|
||||||
focus tokens, so the ring is visible even if a caller overrides `outline`. */
|
|
||||||
const FOCUS_RING =
|
|
||||||
"focus-visible:outline-[color:var(--focus-ring-color)] " +
|
|
||||||
"focus-visible:outline-[length:var(--focus-ring-width)] " +
|
|
||||||
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
|
|
||||||
|
|
||||||
const TRANSITION =
|
|
||||||
"transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
|
|
||||||
|
|
||||||
type OptionPosition = "first" | "last";
|
|
||||||
|
|
||||||
export interface LanguageMenuProps {
|
|
||||||
/** The locale the reader is currently on. */
|
|
||||||
locale: Locale;
|
|
||||||
/** Current pathname, e.g. "/en/news-and-updates". */
|
|
||||||
pathname: string;
|
|
||||||
/** Query string to carry across the switch. Usually `location.search`. */
|
|
||||||
search?: string;
|
|
||||||
/** Fragment to carry across the switch. Usually `location.hash`. */
|
|
||||||
hash?: string;
|
|
||||||
/**
|
|
||||||
* Accessible name of the globe, in the reader's language ("Language"). It is
|
|
||||||
* required because the globe carries no text of its own.
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/** Navigate with a full document request instead of a client transition. */
|
|
||||||
reloadDocument?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The navigation's language control: the globe (figma 478:16381) as a button
|
|
||||||
* that opens a short list of the site's languages, each linking to the page the
|
|
||||||
* reader is on under that locale, with the query string and fragment kept.
|
|
||||||
*
|
|
||||||
* Not drawn in Figma - the design shows the globe alone - so the list is built
|
|
||||||
* from the tier-2 surface, border and elevation tokens a popover uses, and
|
|
||||||
* hangs from the globe's inline-end edge so it opens inward in both directions.
|
|
||||||
*
|
|
||||||
* A disclosure rather than an ARIA menu: the entries are ordinary links and are
|
|
||||||
* announced and followed as links. Each carries its own lang/hrefLang and its
|
|
||||||
* label its own dir, so a screen reader switches voice for "العربية". The
|
|
||||||
* current language is marked with aria-current and set in bold, so the state is
|
|
||||||
* not carried by colour alone. ArrowDown/ArrowUp open the list and move through it, Escape closes it
|
|
||||||
* and returns focus to the globe, and an outside press or focus leaving closes
|
|
||||||
* it. Focus is never trapped.
|
|
||||||
*/
|
|
||||||
export function LanguageMenu({
|
|
||||||
locale,
|
|
||||||
pathname,
|
|
||||||
search,
|
|
||||||
hash,
|
|
||||||
label,
|
|
||||||
reloadDocument = false,
|
|
||||||
className,
|
|
||||||
}: LanguageMenuProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const pendingFocus = useRef<OptionPosition | null>(null);
|
|
||||||
|
|
||||||
const rawId = useId();
|
|
||||||
const listId = `sharper-language-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
||||||
|
|
||||||
const options = useCallback(
|
|
||||||
(): HTMLElement[] =>
|
|
||||||
Array.from(
|
|
||||||
rootRef.current?.querySelectorAll<HTMLElement>("[data-language-option]") ??
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const focusOption = useCallback(
|
|
||||||
(position: OptionPosition) => {
|
|
||||||
const all = options();
|
|
||||||
(position === "first" ? all[0] : all[all.length - 1])?.focus();
|
|
||||||
},
|
|
||||||
[options],
|
|
||||||
);
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
|
||||||
pendingFocus.current = null;
|
|
||||||
setOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* Outside press. pointerdown rather than click so the list is already gone
|
|
||||||
by the time the click lands on whatever is underneath. */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
|
||||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
|
||||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
/* A keyboard request to open and step into the list can only be honoured
|
|
||||||
once the list has rendered. */
|
|
||||||
useEffect(() => {
|
|
||||||
const position = pendingFocus.current;
|
|
||||||
if (!open || position === null) return;
|
|
||||||
pendingFocus.current = null;
|
|
||||||
focusOption(position);
|
|
||||||
}, [open, focusOption]);
|
|
||||||
|
|
||||||
const openAndFocus = (position: OptionPosition) => {
|
|
||||||
if (open) {
|
|
||||||
focusOption(position);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingFocus.current = position;
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleButtonKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
openAndFocus("first");
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
openAndFocus("last");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOptionKeyDown = (event: ReactKeyboardEvent<HTMLAnchorElement>) => {
|
|
||||||
const all = options();
|
|
||||||
const index = all.indexOf(event.currentTarget);
|
|
||||||
const moveTo = (to: number) => all[(to + all.length) % all.length]?.focus();
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case "ArrowDown":
|
|
||||||
event.preventDefault();
|
|
||||||
moveTo(index + 1);
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
event.preventDefault();
|
|
||||||
moveTo(index - 1);
|
|
||||||
break;
|
|
||||||
case "Home":
|
|
||||||
event.preventDefault();
|
|
||||||
moveTo(0);
|
|
||||||
break;
|
|
||||||
case "End":
|
|
||||||
event.preventDefault();
|
|
||||||
moveTo(all.length - 1);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (event.key !== "Escape" || !open) return;
|
|
||||||
event.preventDefault();
|
|
||||||
close();
|
|
||||||
buttonRef.current?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
/* React's onBlur is focusout, so it fires for descendants too. */
|
|
||||||
const handleBlur = (event: ReactFocusEvent<HTMLDivElement>) => {
|
|
||||||
const next = event.relatedTarget as Node | null;
|
|
||||||
if (!next || !rootRef.current?.contains(next)) close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={rootRef}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
className={["relative", className].filter(Boolean).join(" ")}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={listId}
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
onClick={() => (open ? close() : setOpen(true))}
|
|
||||||
onKeyDown={handleButtonKeyDown}
|
|
||||||
className={[
|
|
||||||
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
|
|
||||||
"text-[var(--text-primary)] hover:text-[var(--text-accent)]",
|
|
||||||
"aria-expanded:text-[var(--text-accent)]",
|
|
||||||
TRANSITION,
|
|
||||||
FOCUS_RING,
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="sh-language-globe" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul
|
|
||||||
id={listId}
|
|
||||||
hidden={!open}
|
|
||||||
className={[
|
|
||||||
"absolute end-0 top-full z-[var(--layer-dropdown)] mt-sm",
|
|
||||||
"flex min-w-[10rem] flex-col gap-2xs p-2xs",
|
|
||||||
"rounded-control border border-[var(--border-subtle)]",
|
|
||||||
"bg-[var(--surface-raised)] shadow-[var(--elevation-popover)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{LOCALES.map((option) => {
|
|
||||||
const current = option === locale;
|
|
||||||
return (
|
|
||||||
<li key={option}>
|
|
||||||
<Link
|
|
||||||
to={buildLocalizedHref(pathname, option, { search, hash })}
|
|
||||||
reloadDocument={reloadDocument}
|
|
||||||
hrefLang={option}
|
|
||||||
lang={option}
|
|
||||||
aria-current={current ? "true" : undefined}
|
|
||||||
data-language-option={option}
|
|
||||||
onClick={close}
|
|
||||||
onKeyDown={handleOptionKeyDown}
|
|
||||||
className={[
|
|
||||||
current ? "body-md-bold" : "body-md",
|
|
||||||
"block rounded-control px-sm py-xs text-start no-underline",
|
|
||||||
"text-[var(--text-primary)] hover:bg-[var(--surface-muted)]",
|
|
||||||
"aria-[current=true]:text-[var(--text-accent)]",
|
|
||||||
TRANSITION,
|
|
||||||
FOCUS_RING,
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{/* The label carries its own direction; the row does not, so
|
|
||||||
both names line up on the page's inline-start edge instead
|
|
||||||
of each hugging the side its own script reads from. */}
|
|
||||||
<span dir={getDirection(option)}>{LOCALE_ENDONYM[option]}</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LanguageMenu;
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
USAGE
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
// In the navigation bar, wired from the router's location:
|
|
||||||
const { pathname, search, hash } = useLocation();
|
|
||||||
<LanguageMenu
|
|
||||||
label={strings.languageMenuLabel}
|
|
||||||
locale={locale}
|
|
||||||
pathname={pathname}
|
|
||||||
search={search}
|
|
||||||
hash={hash}
|
|
||||||
/>
|
|
||||||
------------------------------------------------------------------------ */
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
LANGUAGE SWITCHER - figma `globe` 478:16381 (navigation 20:236)
|
|
||||||
----------------------------------------------------------------------------
|
|
||||||
The navigation now carries the language control as a bare 24x24 globe in the
|
|
||||||
Actions slot, replacing the "AR" / "EN" pill that used to sit next to the EU
|
|
||||||
badge. The exported asset fills its path with a hard-coded #1D1D1B, which
|
|
||||||
would be invisible on the dark surface, so it is applied as a mask over
|
|
||||||
`currentColor` - the same technique as `.sh-arrow-icon` and `.sh-card-icon`.
|
|
||||||
That keeps the exact exported geometry while the colour comes from whichever
|
|
||||||
semantic token the bar has in force.
|
|
||||||
|
|
||||||
GEOMETRY, straight from Figma: a 24x24 box (`--control-icon-size`) with the
|
|
||||||
glyph filling it edge to edge. The globe is radially symmetric, so unlike the
|
|
||||||
arrow it needs no RTL mirroring.
|
|
||||||
========================================================================= */
|
|
||||||
|
|
||||||
@layer theme, base, components, utilities;
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.sh-language-globe {
|
|
||||||
display: block;
|
|
||||||
flex: none;
|
|
||||||
inline-size: var(--control-icon-size);
|
|
||||||
block-size: var(--control-icon-size);
|
|
||||||
background-color: currentColor;
|
|
||||||
-webkit-mask-image: url("/assets/icons/globe-solid.svg");
|
|
||||||
mask-image: url("/assets/icons/globe-solid.svg");
|
|
||||||
-webkit-mask-size: 100% 100%;
|
|
||||||
mask-size: 100% 100%;
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
-webkit-mask-position: center;
|
|
||||||
mask-position: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,7 @@ export interface LanguageSwitcherProps {
|
|||||||
* same page under that locale, preserving the rest of the path.
|
* same page under that locale, preserving the rest of the path.
|
||||||
*
|
*
|
||||||
* The mobile drawer uses this, where a spelled-out target is clearer than an
|
* The mobile drawer uses this, where a spelled-out target is clearer than an
|
||||||
* icon. The desktop bar uses <LanguageMenu />, the globe with a dropdown.
|
* icon.
|
||||||
*/
|
*/
|
||||||
export function LanguageSwitcher({
|
export function LanguageSwitcher({
|
||||||
locale,
|
locale,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import "./LanguageSwitcher.css";
|
|
||||||
import {
|
import {
|
||||||
getCounterpartLocale,
|
getCounterpartLocale,
|
||||||
getCounterpartPath,
|
getCounterpartPath,
|
||||||
@@ -34,15 +33,11 @@ export interface LanguageToggleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Both languages, side by side: the globe, the current locale in an accent
|
* Both languages, side by side: the current locale in an accent chip, and the
|
||||||
* chip, and the other locale as a link to the same page under that locale -
|
* other locale as a link to the same page under that locale.
|
||||||
* the redesign's nav control, replacing the globe-plus-dropdown on the desktop
|
|
||||||
* bar.
|
|
||||||
*
|
*
|
||||||
* One tap to switch and no state to open, which is why the draft prefers it;
|
* One tap to switch and no state to open, which is why the draft prefers it;
|
||||||
* the drawer still uses <LanguageSwitcher /> and <LanguageMenu /> remains for
|
* the drawer still uses <LanguageSwitcher />.
|
||||||
* anywhere a list of more than two languages is needed.
|
|
||||||
*
|
|
||||||
* The current language is marked with `aria-current`, not by colour alone. The
|
* The current language is marked with `aria-current`, not by colour alone. The
|
||||||
* link carries its own lang/hrefLang/dir and is named with the target
|
* link carries its own lang/hrefLang/dir and is named with the target
|
||||||
* language's endonym ("العربية"), so a screen reader switches voice for it
|
* language's endonym ("العربية"), so a screen reader switches voice for it
|
||||||
@@ -71,8 +66,6 @@ export function LanguageToggle({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="sh-language-globe" />
|
|
||||||
|
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<span
|
<span
|
||||||
aria-current="true"
|
aria-current="true"
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ export type {
|
|||||||
LanguageSwitcherProps,
|
LanguageSwitcherProps,
|
||||||
LanguageSwitcherSize,
|
LanguageSwitcherSize,
|
||||||
} from "./LanguageSwitcher";
|
} from "./LanguageSwitcher";
|
||||||
export { LanguageMenu } from "./LanguageMenu";
|
|
||||||
export type { LanguageMenuProps } from "./LanguageMenu";
|
|
||||||
export { LanguageToggle } from "./LanguageToggle";
|
export { LanguageToggle } from "./LanguageToggle";
|
||||||
export type { LanguageToggleProps } from "./LanguageToggle";
|
export type { LanguageToggleProps } from "./LanguageToggle";
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,43 @@ import { Link } from "react-router";
|
|||||||
import { cx, FOCUS_RING } from "./styles";
|
import { cx, FOCUS_RING } from "./styles";
|
||||||
import type { NavSurface } from "./types";
|
import type { NavSurface } from "./types";
|
||||||
|
|
||||||
/* Exported from Figma logo/sharp-er (317:5571) and logo/sharp-er-dark
|
/** One half of the animated brand lockup, drawn as its own SVG file. */
|
||||||
(317:7812). Intrinsic 334x46; the navigation draws it at 100px wide, which
|
interface BrandMark {
|
||||||
is the designed size in every navigation frame (desktop and mobile). */
|
src: string;
|
||||||
const LOGO_SRC: Record<NavSurface, string> = {
|
/** Intrinsic size of the file, for the aspect ratio the browser reserves. */
|
||||||
light: "/assets/sharp-er-logo.png",
|
width: number;
|
||||||
dark: "/assets/sharp-er-logo-dark.png",
|
height: number;
|
||||||
};
|
/** The mark's own drawn width, so the pair flexes to one scale. */
|
||||||
|
drawnWidth: number;
|
||||||
|
}
|
||||||
|
|
||||||
const LOGO_INTRINSIC_WIDTH = 334;
|
/**
|
||||||
const LOGO_INTRINSIC_HEIGHT = 46;
|
* The SHARP-ER lockup, split into its two drawn pieces so the "-ER" can slide
|
||||||
|
* out from behind the P and fade in once the page has settled (Navigation.css).
|
||||||
|
* Each surface ships its own export of the two pieces: white on the dark
|
||||||
|
* surface, the two-tone ink on the light surface. Both exports of a piece are
|
||||||
|
* the exact same box (218x46 and 112x46), so the pair shares one scale; each
|
||||||
|
* mark takes the share of the row its box gives it, and the suffix rides flush
|
||||||
|
* against the P's right edge.
|
||||||
|
*/
|
||||||
|
const BRAND_LOCKUP: Record<
|
||||||
|
NavSurface,
|
||||||
|
{ base: BrandMark; suffix: BrandMark }
|
||||||
|
> = {
|
||||||
|
light: {
|
||||||
|
base: { src: "/assets/sharp-wordmark.svg", width: 218, height: 46, drawnWidth: 218 },
|
||||||
|
suffix: { src: "/assets/sharp-er-suffix.svg", width: 112, height: 46, drawnWidth: 112 },
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
base: { src: "/assets/sharp-wordmark-inverse.svg", width: 218, height: 46, drawnWidth: 218 },
|
||||||
|
suffix: { src: "/assets/sharp-er-suffix-inverse.svg", width: 112, height: 46, drawnWidth: 112 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export interface BrandProps {
|
export interface BrandProps {
|
||||||
/** Locale-prefixed home path, e.g. "/en". */
|
/** Locale-prefixed home path, e.g. "/en". */
|
||||||
href: string;
|
href: string;
|
||||||
/** Accessible name of the link; the image itself is decorative. */
|
/** Accessible name of the link; the images are decorative. */
|
||||||
label: string;
|
label: string;
|
||||||
surface: NavSurface;
|
surface: NavSurface;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,6 +47,8 @@ export interface BrandProps {
|
|||||||
|
|
||||||
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
|
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
|
||||||
export function Brand({ href, label, surface, className }: BrandProps) {
|
export function Brand({ href, label, surface, className }: BrandProps) {
|
||||||
|
const { base, suffix } = BRAND_LOCKUP[surface];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
@@ -35,16 +59,39 @@ export function Brand({ href, label, surface, className }: BrandProps) {
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* `dir="ltr"` because a Latin wordmark never mirrors; its position on
|
||||||
|
the bar still follows the page's direction. */}
|
||||||
|
<span dir="ltr" className="flex w-[100px] items-start">
|
||||||
<img
|
<img
|
||||||
src={LOGO_SRC[surface]}
|
src={base.src}
|
||||||
alt=""
|
alt=""
|
||||||
width={LOGO_INTRINSIC_WIDTH}
|
width={base.width}
|
||||||
height={LOGO_INTRINSIC_HEIGHT}
|
height={base.height}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className="block h-auto w-[100px]"
|
style={{ flex: `${base.drawnWidth} 1 0%` }}
|
||||||
|
className="block h-auto min-w-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* The clipping box starts at the P's right edge, so the mark inside
|
||||||
|
it can slide out from behind the P (Navigation.css). */}
|
||||||
|
<span
|
||||||
|
style={{ flex: `${suffix.drawnWidth} 1 0%` }}
|
||||||
|
className="sh-brand-suffix block min-w-0"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={suffix.src}
|
||||||
|
alt=""
|
||||||
|
width={suffix.width}
|
||||||
|
height={suffix.height}
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
className="sh-brand-suffix__mark block h-auto w-full"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,14 @@ function MenuArrow() {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/* Mirrors with the page: the arrow points at the reading edge. */
|
/* Mirrors with the page: the arrow points at the reading edge. It only
|
||||||
className="shrink-0 rtl:-scale-x-100"
|
appears when the reader hovers or focus-es the link, so a row of
|
||||||
|
always-visible arrows does not announce every entry at once. */
|
||||||
|
className={cx(
|
||||||
|
"shrink-0 rtl:-scale-x-100 opacity-0",
|
||||||
|
"transition-opacity duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
|
||||||
|
"group-hover:opacity-100 group-focus-visible:opacity-100",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<path d="M5 12h13M12.5 6.5L18 12l-5.5 5.5" />
|
<path d="M5 12h13M12.5 6.5L18 12l-5.5 5.5" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -436,10 +442,14 @@ export function DesktopNav({
|
|||||||
className={cx(
|
className={cx(
|
||||||
"body-sm-bold flex cursor-pointer items-center gap-2xs",
|
"body-sm-bold flex cursor-pointer items-center gap-2xs",
|
||||||
"whitespace-nowrap pb-[2px] text-start",
|
"whitespace-nowrap pb-[2px] text-start",
|
||||||
"text-[var(--text-primary)]",
|
/* The drafts rest a nav label in text/secondary and
|
||||||
"hover:text-[var(--text-accent)]",
|
brighten it to primary under the pointer; the section
|
||||||
"aria-expanded:text-[var(--text-accent)]",
|
the reader is in stays primary and is marked by the
|
||||||
"data-[current=true]:text-[var(--text-accent)]",
|
accent rule below, not by green text. */
|
||||||
|
"text-[var(--text-secondary)]",
|
||||||
|
"hover:text-[var(--text-primary)]",
|
||||||
|
"aria-expanded:text-[var(--text-primary)]",
|
||||||
|
"data-[current=true]:text-[var(--text-primary)]",
|
||||||
HOVER_TRANSITION,
|
HOVER_TRANSITION,
|
||||||
FOCUS_RING,
|
FOCUS_RING,
|
||||||
)}
|
)}
|
||||||
@@ -485,7 +495,7 @@ export function DesktopNav({
|
|||||||
}
|
}
|
||||||
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
|
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"body-sm-bold flex items-center justify-between gap-md",
|
"group body-sm-bold flex items-center justify-between gap-md",
|
||||||
"rounded-control px-sm py-xs no-underline",
|
"rounded-control px-sm py-xs no-underline",
|
||||||
"text-[var(--text-primary)]",
|
"text-[var(--text-primary)]",
|
||||||
"hover:bg-[var(--surface-page)] hover:text-[var(--text-accent)]",
|
"hover:bg-[var(--surface-page)] hover:text-[var(--text-accent)]",
|
||||||
@@ -512,9 +522,10 @@ export function DesktopNav({
|
|||||||
onFocus={closeGroup}
|
onFocus={closeGroup}
|
||||||
className={cx(
|
className={cx(
|
||||||
"body-sm-bold whitespace-nowrap pb-[2px] no-underline",
|
"body-sm-bold whitespace-nowrap pb-[2px] no-underline",
|
||||||
"text-[var(--text-primary)]",
|
/* Same rest/hover/current treatment as a group trigger. */
|
||||||
"hover:text-[var(--text-accent)]",
|
"text-[var(--text-secondary)]",
|
||||||
"aria-[current=page]:text-[var(--text-accent)]",
|
"hover:text-[var(--text-primary)]",
|
||||||
|
"aria-[current=page]:text-[var(--text-primary)]",
|
||||||
HOVER_TRANSITION,
|
HOVER_TRANSITION,
|
||||||
FOCUS_RING,
|
FOCUS_RING,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { EuBadge } from "../EuBadge";
|
import { EuBadge } from "../EuBadge";
|
||||||
@@ -28,6 +29,18 @@ const FOCUSABLE_SELECTOR = [
|
|||||||
'[tabindex]:not([tabindex="-1"])',
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
].join(", ");
|
].join(", ");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hamburger and its close twin. The glyph is the drafts' 24px box, which is
|
||||||
|
* well under the 44x44 WCAG 2.5.5 target size, so the control is a 44px square
|
||||||
|
* around it. The negative inline-end margin gives back the 10px of padding that
|
||||||
|
* would otherwise push the glyph off the page gutter the wordmark opposite it
|
||||||
|
* lines up with - logical, so it mirrors in Arabic.
|
||||||
|
*/
|
||||||
|
const TAP_TARGET = cx(
|
||||||
|
"-me-[0.625rem] inline-flex size-11 shrink-0 cursor-pointer",
|
||||||
|
"items-center justify-center rounded-control",
|
||||||
|
);
|
||||||
|
|
||||||
function focusablesIn(root: HTMLElement): HTMLElement[] {
|
function focusablesIn(root: HTMLElement): HTMLElement[] {
|
||||||
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||||
(element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0,
|
(element) => !element.hasAttribute("hidden") && element.getClientRects().length > 0,
|
||||||
@@ -55,6 +68,23 @@ export interface MobileNavProps {
|
|||||||
* `inert` (so assistive technology cannot browse the page under an aria-modal
|
* `inert` (so assistive technology cannot browse the page under an aria-modal
|
||||||
* dialog either), the page behind cannot scroll, Escape closes it, and focus
|
* dialog either), the page behind cannot scroll, Escape closes it, and focus
|
||||||
* returns to the hamburger on close.
|
* returns to the hamburger on close.
|
||||||
|
*
|
||||||
|
* THE DRAWER IS PORTALLED TO <body>, AND HAS TO BE.
|
||||||
|
* The bar it hangs off paints the drafts' translucent ground with
|
||||||
|
* `backdrop-filter: blur(14px)`, and a filtered element becomes the containing
|
||||||
|
* block for every `position: fixed` descendant (CSS Filter Effects 1 s.6.1).
|
||||||
|
* Rendered in place, `fixed inset-0` therefore resolved against the 64px bar
|
||||||
|
* rather than the viewport: the panel painted a 64px strip and its menu spilled
|
||||||
|
* over the page with no background behind it. The header also carries
|
||||||
|
* `z-index: var(--layer-header)`, which is a stacking context, so an
|
||||||
|
* `aria-modal` dialog nested inside it could never rise above anything painted
|
||||||
|
* above the bar either. A portal fixes both at once, and it is where a modal
|
||||||
|
* dialog belongs regardless.
|
||||||
|
*
|
||||||
|
* The portal is client-only, so the panel joins the DOM on mount. Nothing is
|
||||||
|
* lost: the trigger is a button with an onClick, so without JavaScript the
|
||||||
|
* drawer could not be opened anyway, and every link in it is already in the
|
||||||
|
* server-rendered DesktopNav markup.
|
||||||
*/
|
*/
|
||||||
export function MobileNav({
|
export function MobileNav({
|
||||||
items,
|
items,
|
||||||
@@ -69,6 +99,9 @@ export function MobileNav({
|
|||||||
className,
|
className,
|
||||||
}: MobileNavProps) {
|
}: MobileNavProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
/* The portal target only exists in a browser, and the first client render has
|
||||||
|
to match the server's, so the panel is mounted one commit later. */
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
const drawerRef = useRef<HTMLDivElement>(null);
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -81,6 +114,8 @@ export function MobileNav({
|
|||||||
|
|
||||||
const close = useCallback(() => setOpen(false), []);
|
const close = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
/* Focus in on open, and back to the hamburger on close. */
|
/* Focus in on open, and back to the hamburger on close. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -94,11 +129,11 @@ export function MobileNav({
|
|||||||
|
|
||||||
/* The drawer is aria-modal, so the rest of the page has to be inert as well -
|
/* The drawer is aria-modal, so the rest of the page has to be inert as well -
|
||||||
otherwise a screen reader still browses the header, main and footer behind
|
otherwise a screen reader still browses the header, main and footer behind
|
||||||
it. Rather than naming those landmarks (the drawer is itself rendered
|
it. Rather than naming those landmarks, every ancestor up to <body> has its
|
||||||
inside the header, so inerting the header would inert the drawer), every
|
OTHER children inerted; from the portal that is simply body's other
|
||||||
ancestor up to <body> has its OTHER children inerted. `inert` is set as an
|
children, which is exactly the header, main and footer. `inert` is set as
|
||||||
attribute so no DOM-lib typing is needed, and an element that was already
|
an attribute so no DOM-lib typing is needed, and an element that was
|
||||||
inert is skipped so the restore never clears someone else's flag.
|
already inert is skipped so the restore never clears someone else's flag.
|
||||||
|
|
||||||
This runs after the focus effect above on purpose: focus is already inside
|
This runs after the focus effect above on purpose: focus is already inside
|
||||||
the drawer by the time the element it came from goes inert. */
|
the drawer by the time the element it came from goes inert. */
|
||||||
@@ -215,24 +250,7 @@ export function MobileNav({
|
|||||||
FOCUS_RING,
|
FOCUS_RING,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const panel = (
|
||||||
<>
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={drawerId}
|
|
||||||
aria-label={open ? strings.closeMenu : strings.openMenu}
|
|
||||||
onClick={() => setOpen((value) => !value)}
|
|
||||||
className={cx(
|
|
||||||
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control xl:hidden",
|
|
||||||
FOCUS_RING,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MenuBars />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id={drawerId}
|
id={drawerId}
|
||||||
ref={drawerRef}
|
ref={drawerRef}
|
||||||
@@ -256,10 +274,7 @@ export function MobileNav({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={strings.closeMenu}
|
aria-label={strings.closeMenu}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className={cx(
|
className={cx(TAP_TARGET, FOCUS_RING)}
|
||||||
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
|
|
||||||
FOCUS_RING,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<MenuBars open />
|
<MenuBars open />
|
||||||
</button>
|
</button>
|
||||||
@@ -341,6 +356,23 @@ export function MobileNav({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={drawerId}
|
||||||
|
aria-label={open ? strings.closeMenu : strings.openMenu}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className={cx(TAP_TARGET, "xl:hidden", FOCUS_RING, className)}
|
||||||
|
>
|
||||||
|
<MenuBars />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mounted ? createPortal(panel, document.body) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,54 @@
|
|||||||
animation: sh-nav-menu-in var(--motion-duration-base)
|
animation: sh-nav-menu-in var(--motion-duration-base)
|
||||||
var(--motion-ease-entrance) both;
|
var(--motion-ease-entrance) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The "-ER" after "SHARP" in the brand, sliding out from behind the P and
|
||||||
|
fading in once the page has settled. The same reveal the home hero used to
|
||||||
|
run on its wordmark, now owned by the navigation. The suffix sits in its
|
||||||
|
own box whose inline-start edge is the P's right edge, and that box clips;
|
||||||
|
the mark starts a full box-width back - entirely beside "SHARP", so
|
||||||
|
nothing overlaps it - and travels forward to rest, with the opacity
|
||||||
|
reaching 1 before the travel ends, so the glyphs read as solid for the
|
||||||
|
last, slowest part of the decelerating slide. The translate is physical
|
||||||
|
(translateX) on purpose: the lockup row is pinned to dir="ltr" because a
|
||||||
|
Latin wordmark never mirrors, so the P is always left of the suffix, in
|
||||||
|
Arabic too. Pure CSS, so it runs from the server-rendered HTML without
|
||||||
|
waiting for hydration, and the `both` fill keeps the suffix hidden through
|
||||||
|
the delay instead of flashing in first. It replays whenever the navigation
|
||||||
|
mounts again, e.g. on a client-side navigation back to a page. Under
|
||||||
|
prefers-reduced-motion the animation is removed outright - app.css only
|
||||||
|
collapses the duration and would leave the delay holding the suffix
|
||||||
|
hidden - so the full name is simply there from the start. */
|
||||||
|
.sh-brand-suffix {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-brand-suffix__mark {
|
||||||
|
animation: sh-brand-suffix-in var(--motion-duration-reveal)
|
||||||
|
var(--motion-ease-entrance) var(--motion-delay-reveal) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sh-brand-suffix__mark {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sh-brand-suffix-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sh-nav-menu-in {
|
@keyframes sh-nav-menu-in {
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
type Locale,
|
type Locale,
|
||||||
} from "../LanguageSwitcher";
|
} from "../LanguageSwitcher";
|
||||||
|
|
||||||
|
import "./Navigation.css";
|
||||||
|
|
||||||
import { Brand } from "./Brand";
|
import { Brand } from "./Brand";
|
||||||
import { DesktopNav } from "./DesktopNav";
|
import { DesktopNav } from "./DesktopNav";
|
||||||
import { MobileNav } from "./MobileNav";
|
import { MobileNav } from "./MobileNav";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
|
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
|
||||||
import { cx } from "./styles";
|
import { cx } from "./styles";
|
||||||
import type { NavItem, NavigationStrings, NavSurface } from "./types";
|
import type { NavItem, NavigationStrings, NavSurface } from "./types";
|
||||||
@@ -44,18 +47,20 @@ export interface NavigationProps {
|
|||||||
* The SHARP-ER site navigation - Figma frame 20:236.
|
* The SHARP-ER site navigation - Figma frame 20:236.
|
||||||
*
|
*
|
||||||
* Three slots, spread across the bar: the wordmark at the inline start, the
|
* Three slots, spread across the bar: the wordmark at the inline start, the
|
||||||
* mega-menu groups in the middle, and the language globe at the inline end.
|
* mega-menu groups in the middle, and the language control at the inline end.
|
||||||
* Everything is laid out with logical properties, so the Arabic bar mirrors
|
* Everything is laid out with logical properties, so the Arabic bar mirrors
|
||||||
* (logo right, globe left) from `<html dir="rtl">` alone - there is no separate
|
* (logo right, language control left) from `<html dir="rtl">` alone - there is
|
||||||
* Arabic component, which is what variants 334:526 and 334:764 show.
|
* no separate Arabic component, which is what variants 334:526 and 334:764
|
||||||
|
* show.
|
||||||
*
|
*
|
||||||
* Below the xl breakpoint the bar collapses to wordmark + hamburger (20:220)
|
* Below the xl breakpoint the bar collapses to wordmark + hamburger (20:220)
|
||||||
* and the whole tree moves into a full-screen drawer.
|
* and the whole tree moves into a full-screen drawer.
|
||||||
*
|
*
|
||||||
* EU VISIBILITY: the updated design has no funding badge in the bar - the
|
* EU VISIBILITY: the updated design has no funding badge in the bar - the
|
||||||
* Actions slot holds the language globe alone. The mandatory "Funded by the
|
* Actions slot holds the language control and the theme toggle. The mandatory
|
||||||
* European Union" lockup is carried by <Footer />, which renders on every page,
|
* "Funded by the European Union" lockup is carried by <Footer />, which
|
||||||
* and by the mobile drawer; it is not dropped from the site.
|
* renders on every page, and by the mobile drawer; it is not dropped from the
|
||||||
|
* site.
|
||||||
*/
|
*/
|
||||||
export function Navigation({
|
export function Navigation({
|
||||||
locale = "en",
|
locale = "en",
|
||||||
@@ -120,16 +125,16 @@ export function Navigation({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-md">
|
<div className="flex items-center gap-md">
|
||||||
{/* The globe is desktop-only: the mobile bar (20:220) is wordmark +
|
{/* The language control is desktop-only: the mobile bar (20:220) is
|
||||||
hamburger, and the drawer carries the switcher instead. Visibility
|
wordmark + hamburger, and the drawer carries the switcher instead.
|
||||||
lives on this wrapper rather than on the child, because two
|
Visibility lives on this wrapper rather than on the child, because
|
||||||
display utilities on one element are resolved by the order
|
two display utilities on one element are resolved by the order
|
||||||
Tailwind emits them, not by class-attribute order. */}
|
Tailwind emits them, not by class-attribute order. */}
|
||||||
{showLanguageSwitcher && (
|
{showLanguageSwitcher && (
|
||||||
/* Figma 555:3848 sets a 20px rule between the links and the
|
/* Figma 555:3848 sets a 20px rule between the links and the
|
||||||
actions, then 24px to the globe. It is decorative, so it is
|
actions, then 24px to the language cluster. It is decorative, so
|
||||||
hidden from assistive technology and drawn with a border token
|
it is hidden from assistive technology and drawn with a border
|
||||||
rather than a rule element. */
|
token rather than a rule element. */
|
||||||
<div className="hidden items-center gap-lg xl:flex">
|
<div className="hidden items-center gap-lg xl:flex">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -148,6 +153,11 @@ export function Navigation({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The theme control sits with the other actions at every breakpoint:
|
||||||
|
after the (desktop-only) language cluster on wide screens, beside
|
||||||
|
the hamburger on a compact bar. */}
|
||||||
|
<ThemeToggle locale={locale} />
|
||||||
|
|
||||||
<MobileNav
|
<MobileNav
|
||||||
items={navItems}
|
items={navItems}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useFetcher, useRouteLoaderData } from "react-router";
|
||||||
|
|
||||||
|
import { createTranslator } from "~/i18n/t";
|
||||||
|
import type { AppTheme } from "~/lib/theme";
|
||||||
|
|
||||||
|
import type { Locale } from "../LanguageSwitcher";
|
||||||
|
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
|
||||||
|
|
||||||
|
/** The document surface the root loader currently paints on <html>. */
|
||||||
|
interface RootLoaderData {
|
||||||
|
theme?: AppTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal sun, for "switch to light". Decorative - the label calls it. */
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal crescent moon, for "switch to dark". Decorative. */
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeToggleProps {
|
||||||
|
/** Reading locale, for the accessible label. */
|
||||||
|
locale: Locale;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site's one global theme control: sun in dark mode, moon in light.
|
||||||
|
*
|
||||||
|
* It renders the target of the next click - the label and the icon describe
|
||||||
|
* what a press DOES ("Switch to light theme"), never the current state - and
|
||||||
|
* posts that target to the root action at `/`. The action persists the
|
||||||
|
* `sharper-theme` cookie, the loader re-runs and the shell repaints
|
||||||
|
* `data-surface` on <html>, so the whole tier-2 palette re-resolves. Because
|
||||||
|
* the current theme is read off the root loader (which the server fed from
|
||||||
|
* the cookie), the SSR markup and the first client render always agree.
|
||||||
|
*/
|
||||||
|
export function ThemeToggle({ locale, className }: ThemeToggleProps) {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const rootData = useRouteLoaderData<RootLoaderData>("root");
|
||||||
|
const t = useMemo(() => createTranslator(locale), [locale]);
|
||||||
|
|
||||||
|
const theme: AppTheme = rootData?.theme ?? "dark";
|
||||||
|
const targetTheme: AppTheme = theme === "light" ? "dark" : "light";
|
||||||
|
const label =
|
||||||
|
targetTheme === "light" ? t("nav.themeToLight") : t("nav.themeToDark");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fetcher.Form action="/" method="post">
|
||||||
|
<input type="hidden" name="theme" value={targetTheme} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={cx(
|
||||||
|
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center",
|
||||||
|
"rounded-control text-[var(--text-secondary)]",
|
||||||
|
"hover:text-[var(--text-primary)]",
|
||||||
|
HOVER_TRANSITION,
|
||||||
|
FOCUS_RING,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{targetTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
</fetcher.Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
USAGE
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
// In the nav actions cluster, between the language control and the
|
||||||
|
// hamburger. Reads the theme from the root loader on its own.
|
||||||
|
<ThemeToggle locale={locale} />
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
@@ -41,7 +41,7 @@ export interface NavigationStrings {
|
|||||||
closeMenu: string;
|
closeMenu: string;
|
||||||
/** Accessible name of the logo link. */
|
/** Accessible name of the logo link. */
|
||||||
homeLinkLabel: string;
|
homeLinkLabel: string;
|
||||||
/** Accessible name of the language globe, which carries no text of its own. */
|
/** Accessible name of the in-bar language control, which carries no text of its own. */
|
||||||
languageMenuLabel: string;
|
languageMenuLabel: string;
|
||||||
/** The mandatory EU funding statement. */
|
/** The mandatory EU funding statement. */
|
||||||
euStatement: string;
|
euStatement: string;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"closeMenu": "إغلاق القائمة",
|
"closeMenu": "إغلاق القائمة",
|
||||||
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
|
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
|
||||||
"languageMenu": "اللغة",
|
"languageMenu": "اللغة",
|
||||||
|
"themeToLight": "التبديل إلى المظهر الفاتح",
|
||||||
|
"themeToDark": "التبديل إلى المظهر الداكن",
|
||||||
"skipToContent": "تخطَّ إلى المحتوى",
|
"skipToContent": "تخطَّ إلى المحتوى",
|
||||||
"sections": {
|
"sections": {
|
||||||
"home": "الرئيسية",
|
"home": "الرئيسية",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"homeLinkLabel": "SHARP-ER home",
|
"homeLinkLabel": "SHARP-ER home",
|
||||||
"languageMenu": "Language",
|
"languageMenu": "Language",
|
||||||
|
"themeToLight": "Switch to light theme",
|
||||||
|
"themeToDark": "Switch to dark theme",
|
||||||
"skipToContent": "Skip to content",
|
"skipToContent": "Skip to content",
|
||||||
"sections": {
|
"sections": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SITE THEME
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The one global theme decision the site makes. The whole tier-2 palette is
|
||||||
|
still per-surface (see app/styles/semantic.css); this simply lets a reader
|
||||||
|
flip WHICH surface the document stands on, `:root` dark by default. The
|
||||||
|
preference travels as the `sharper-theme` cookie so the server renders the
|
||||||
|
same surface the next page will paint - no flash-of-wrong-theme, no
|
||||||
|
hydration mismatch.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
export type AppTheme = "dark" | "light";
|
||||||
|
|
||||||
|
export const THEME_COOKIE = "sharper-theme";
|
||||||
|
|
||||||
|
/** Default when no cookie is present. `:root` is the dark surface. */
|
||||||
|
export const DEFAULT_THEME: AppTheme = "dark";
|
||||||
|
|
||||||
|
/** Parse the cookie header into a theme. Anything unknown resolves to dark. */
|
||||||
|
export function readTheme(cookieHeader: string | null): AppTheme {
|
||||||
|
if (!cookieHeader) return DEFAULT_THEME;
|
||||||
|
const pair = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.startsWith(`${THEME_COOKIE}=`));
|
||||||
|
const value = pair?.split("=")[1];
|
||||||
|
return value === "light" ? "light" : value === "dark" ? "dark" : DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Set-Cookie value that persists a theme choice for a year. */
|
||||||
|
export function themeCookie(theme: AppTheme): string {
|
||||||
|
return `${THEME_COOKIE}=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
`Layout` renders the whole document, which is why <html lang> and <html dir>
|
`Layout` renders the whole document, which is why <html lang> and <html dir>
|
||||||
are correct in the FIRST SSR byte: the locale is read from the URL, not from
|
are correct in the FIRST SSR byte: the locale is read from the URL, not from
|
||||||
a cookie, a context provider or an effect, so the server and the first
|
a cookie, a context provider or an effect, so the server and the first
|
||||||
client render cannot disagree.
|
client render cannot disagree. The theme surface is the one deliberate
|
||||||
|
exception to "no cookies" - it is a cookie read in the loader, but because
|
||||||
|
the loader is what the server renders, the first byte still matches.
|
||||||
|
|
||||||
The header and the footer live here rather than in the ":lang" layout so the
|
The header and the footer live here rather than in the ":lang" layout so the
|
||||||
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
|
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
|
||||||
@@ -43,6 +45,7 @@ import {
|
|||||||
} from "~/lib/route-handle";
|
} from "~/lib/route-handle";
|
||||||
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
|
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
|
||||||
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
|
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
|
||||||
|
import { DEFAULT_THEME, readTheme, themeCookie, type AppTheme } from "~/lib/theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last-resort origin for the canonical and hreflang links. The root loader
|
* Last-resort origin for the canonical and hreflang links. The root loader
|
||||||
@@ -80,9 +83,31 @@ export function loader({ request }: Route.LoaderArgs) {
|
|||||||
dir: dirFor(locale),
|
dir: dirFor(locale),
|
||||||
pathname,
|
pathname,
|
||||||
siteUrl: getSiteUrl(request),
|
siteUrl: getSiteUrl(request),
|
||||||
|
/* Which surface the document stands on. Read from the theme cookie so the
|
||||||
|
server paints the same surface the reader asked for - the browser never
|
||||||
|
guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
|
||||||
|
`:root` is dark, so the absence of a cookie is a dark first visit. */
|
||||||
|
theme: readTheme(request.headers.get("Cookie")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root action is the theme toggle's destination (the nav posts here at
|
||||||
|
* `/`). It validates the requested theme and persists it with Set-Cookie; the
|
||||||
|
* loader re-runs and the shell repaints the `data-surface` on <html>.
|
||||||
|
*/
|
||||||
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const requested = formData.get("theme");
|
||||||
|
const theme: AppTheme =
|
||||||
|
requested === "light" ? "light" : requested === "dark" ? "dark" : DEFAULT_THEME;
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: { "Set-Cookie": themeCookie(theme) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function pathnameLocale(pathname: string): Locale {
|
function pathnameLocale(pathname: string): Locale {
|
||||||
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
|
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
|
||||||
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
|
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
|
||||||
@@ -126,13 +151,19 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
const navItems = useMemo(() => buildNavItems(locale), [locale]);
|
const navItems = useMemo(() => buildNavItems(locale), [locale]);
|
||||||
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
|
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
|
||||||
|
|
||||||
const navSurface = useRouteHandleValue(readNavSurface) ?? "dark";
|
/* The theme cookie decides which surface the DOCUMENT stands on. The header
|
||||||
const footerSurface = useRouteHandleValue(readFooterSurface) ?? "dark";
|
and footer follow it, unless the route's handle forces one (`navSurface`
|
||||||
|
/ `footerSurface`) - so a page can still draw a light nav on a dark
|
||||||
|
surface in either theme. */
|
||||||
|
const rootData = useRouteLoaderData<typeof loader>("root");
|
||||||
|
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
|
||||||
|
|
||||||
|
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
|
||||||
|
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
|
||||||
|
|
||||||
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
|
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
|
||||||
`meta` replaces its parents' wholesale, so a page that writes its own
|
`meta` replaces its parents' wholesale, so a page that writes its own
|
||||||
would silently drop them. `handle.canonicalPath` is the escape hatch. */
|
would silently drop them. `handle.canonicalPath` is the escape hatch. */
|
||||||
const rootData = useRouteLoaderData<typeof loader>("root");
|
|
||||||
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
|
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
|
||||||
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
|
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
|
||||||
/* A detail route emits its own pair from the counterpart's real Strapi slug
|
/* A detail route emits its own pair from the counterpart's real Strapi slug
|
||||||
@@ -142,7 +173,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
|
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} dir={dir} data-surface="dark">
|
<html lang={locale} dir={dir} data-surface={theme}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ export function ConsentCheckbox({
|
|||||||
required
|
required
|
||||||
aria-invalid={error ? true : undefined}
|
aria-invalid={error ? true : undefined}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
className="mt-2xs size-md shrink-0 rounded-[var(--radius-control)] border border-border-strong accent-[var(--interactive-primary-bg)]"
|
className="mt-2xs size-md shrink-0 rounded-chip border border-border-strong accent-[var(--interactive-primary-bg)]"
|
||||||
/>
|
/>
|
||||||
<label htmlFor={name} className="body-sm text-text-primary">
|
<label htmlFor={name} className="body-sm text-text-primary">
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
panel right after it. Deliberately NOT an ARIA accordion widget with arrow
|
panel right after it. Deliberately NOT an ARIA accordion widget with arrow
|
||||||
key roving: there is one tab stop per question, which is what a keyboard
|
key roving: there is one tab stop per question, which is what a keyboard
|
||||||
user expects from a list of links-like controls, and it matches the
|
user expects from a list of links-like controls, and it matches the
|
||||||
disclosure the navigation already ships (DesktopNav, LanguageMenu).
|
disclosure the navigation already ships (DesktopNav, the language switcher).
|
||||||
|
|
||||||
The answer is CMS richtext (markdown), so it renders through <RichText />
|
The answer is CMS richtext (markdown), so it renders through <RichText />
|
||||||
rather than as a raw string.
|
rather than as a raw string.
|
||||||
@@ -42,7 +42,11 @@ export interface AccordionProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The +/- mark. The upright bar collapses as the question opens. */
|
/**
|
||||||
|
* The +/- mark. The upright bar collapses as the question opens, and the whole
|
||||||
|
* mark turns half a revolution with it - the design draft's own gesture, which
|
||||||
|
* carries the change of state even where the thin bar is hard to follow.
|
||||||
|
*/
|
||||||
function ToggleMark({ open }: { open: boolean }) {
|
function ToggleMark({ open }: { open: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -50,8 +54,8 @@ function ToggleMark({ open }: { open: boolean }) {
|
|||||||
className={cx(
|
className={cx(
|
||||||
"flex size-8 shrink-0 items-center justify-center rounded-pill",
|
"flex size-8 shrink-0 items-center justify-center rounded-pill",
|
||||||
"border border-[var(--border-default)] text-[var(--text-accent)]",
|
"border border-[var(--border-default)] text-[var(--text-accent)]",
|
||||||
"transition-colors duration-[var(--motion-duration-base)]",
|
"transition duration-[var(--motion-duration-base)]",
|
||||||
open && "border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
|
open && "rotate-180 border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
|||||||
@@ -38,11 +38,19 @@ export interface CmsHero {
|
|||||||
readonly ctas?: readonly CmsCta[] | null;
|
readonly ctas?: readonly CmsCta[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** shared.stat - `value` is authored as a string so "48%" and "1,583" survive. */
|
/**
|
||||||
export interface CmsStat {
|
* page.band-intro - a page band's own heading, lead and pictures.
|
||||||
|
*
|
||||||
|
* It exists so a band is edited in one place. The alternative - a headline in
|
||||||
|
* one field and its pictures somewhere down the Blocks zone - is how an editor
|
||||||
|
* ends up unable to find half of a section.
|
||||||
|
*/
|
||||||
|
export interface CmsBandIntro {
|
||||||
readonly id?: number;
|
readonly id?: number;
|
||||||
readonly value: string;
|
readonly eyebrow?: string | null;
|
||||||
readonly label: string;
|
readonly heading?: string | null;
|
||||||
|
readonly lead?: string | null;
|
||||||
|
readonly images?: readonly StrapiMedia[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** shared.titled-text - pillars and principles. */
|
/** shared.titled-text - pillars and principles. */
|
||||||
@@ -175,7 +183,8 @@ export interface CmsFaq extends StrapiEntity {
|
|||||||
/** api::home-page.home-page */
|
/** api::home-page.home-page */
|
||||||
export interface CmsHomePage extends StrapiEntity {
|
export interface CmsHomePage extends StrapiEntity {
|
||||||
readonly hero?: CmsHero | null;
|
readonly hero?: CmsHero | null;
|
||||||
readonly stats?: readonly CmsStat[] | null;
|
/** The band under the hero - "A nationwide project, delivered locally". */
|
||||||
|
readonly approach?: CmsBandIntro | null;
|
||||||
readonly blocks?: readonly CmsBlock[] | null;
|
readonly blocks?: readonly CmsBlock[] | null;
|
||||||
/** Curated newsroom picks; the page falls back to the latest articles. */
|
/** Curated newsroom picks; the page falls back to the latest articles. */
|
||||||
readonly featuredNews?: readonly CmsNewsArticle[] | null;
|
readonly featuredNews?: readonly CmsNewsArticle[] | null;
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
PAGE COPY - EN / AR
|
PAGE COPY - EN / AR
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
SOURCE
|
SOURCE
|
||||||
Home page Figma frame 253:4799 "Home-en" is the source of truth.
|
Home page The Claude Design draft "Home page redesign with news
|
||||||
The headline (I253:4800;228:2609), the launch heading
|
and FAQs" (third drop) is the source of truth. Its
|
||||||
(I361:10853;361:11147), the one-paragraph launch lead
|
headline, lead, primary call to action and the
|
||||||
with its four bold runs (I361:10853;349:8838) and the
|
"nationwide project, delivered locally" band are
|
||||||
three captions are transcribed from that frame, NOT
|
transcribed from that draft, NOT from docs/SHARPER Home
|
||||||
from docs/SHARPER Home Page .docx - the design condenses
|
Page .docx - the design condenses the draft, and the
|
||||||
the draft, and the draft itself is headed "Placeholder
|
draft itself is headed "Placeholder text for design
|
||||||
text for design purposes - not final copy".
|
purposes - not final copy". Every claim in it is
|
||||||
|
nonetheless in docs/: the consortium, the partner
|
||||||
|
groups it works with, the EU funding and the December
|
||||||
|
2027 end date.
|
||||||
Everything else docs/SHARPER_Website_Draft_Section_EN_AR.docx
|
Everything else docs/SHARPER_Website_Draft_Section_EN_AR.docx
|
||||||
(sections 1, 2 and 9), which carries both columns, so
|
(sections 1, 2 and 9), which carries both columns, so
|
||||||
no string below is a machine translation of the other
|
no string below is a machine translation of the other
|
||||||
@@ -32,8 +35,9 @@
|
|||||||
Figures. The draft marks every number "TBD" and says they must not be
|
Figures. The draft marks every number "TBD" and says they must not be
|
||||||
published until confirmed, so there is no statistic anywhere in this file.
|
published until confirmed, so there is no statistic anywhere in this file.
|
||||||
Figma hides the home page's figures band outright (frame 253:4835 is
|
Figma hides the home page's figures band outright (frame 253:4835 is
|
||||||
hidden="true"), so the band renders only once the CMS `stats` component
|
hidden="true") and the third design drop deletes it, so the band is gone
|
||||||
supplies real numbers, and nothing stands in for it until then.
|
from the page and the `stats` component is gone from the CMS. There is
|
||||||
|
nowhere left for a figure to be typed in, which is the point.
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
import type { Locale } from "~/i18n";
|
import type { Locale } from "~/i18n";
|
||||||
@@ -69,17 +73,13 @@ export interface HomeCopy {
|
|||||||
readonly heroCtaPrimary: string;
|
readonly heroCtaPrimary: string;
|
||||||
readonly heroCtaSecondary: string;
|
readonly heroCtaSecondary: string;
|
||||||
/**
|
/**
|
||||||
* The figures band. Bundled so the band is never empty, and overridden by
|
* The band under the hero picture: what the project is and who delivers it.
|
||||||
* `home-page.stats` the moment an editor writes one. Every figure here is
|
* It replaces the launch-event band of the previous drop, which had dated
|
||||||
* verifiable: three consortium members and the Dec 2025 - Dec 2027 run come
|
* the whole home page to a single day in June.
|
||||||
* from the project brief, the four areas of work are the What We Do
|
|
||||||
* workstreams, and the sixteen human security sub-projects are the approved
|
|
||||||
* EN/AR draft's own wording.
|
|
||||||
*/
|
*/
|
||||||
readonly stats: readonly { readonly value: string; readonly label: string }[];
|
readonly approachEyebrow: string;
|
||||||
readonly launchEyebrow: string;
|
readonly approachHeading: string;
|
||||||
readonly launchHeading: string;
|
readonly approachLead: string;
|
||||||
readonly launchLead: string;
|
|
||||||
readonly workHeading: string;
|
readonly workHeading: string;
|
||||||
readonly workLinkLabel: string;
|
readonly workLinkLabel: string;
|
||||||
readonly newsEyebrow: string;
|
readonly newsEyebrow: string;
|
||||||
@@ -194,21 +194,17 @@ export interface PageCopy {
|
|||||||
|
|
||||||
const EN: PageCopy = {
|
const EN: PageCopy = {
|
||||||
home: {
|
home: {
|
||||||
heroHeadline: "SHARP-ER Project Officially Launches in Libya",
|
heroHeadline: "Advancing Justice and Accountability, Led by Libyan Communities",
|
||||||
heroLead:
|
heroLead:
|
||||||
"Shared Action on Rule of Law Progress — Exit Route: a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
|
"SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — works with civil society organisations, municipalities, and community leaders across Libya to strengthen access to justice, accountability, and local governance. Funded by the European Union, running to December 2027.",
|
||||||
heroCtaPrimary: "Expression of Interest",
|
heroCtaPrimary: "See open calls",
|
||||||
heroCtaSecondary: "About the project",
|
heroCtaSecondary: "About the project",
|
||||||
stats: [
|
approachEyebrow: "Nationwide, Libya",
|
||||||
{ value: "3", label: "Consortium partners" },
|
/* The same sentence the About page heads its body with - one project, one
|
||||||
{ value: "4", label: "Areas of work" },
|
description of it. */
|
||||||
{ value: "16", label: "Human security sub-projects" },
|
approachHeading: "A nationwide project, delivered locally",
|
||||||
{ value: "25 mo", label: "Dec 2025 – Dec 2027" },
|
approachLead:
|
||||||
],
|
"Local organisations lead the design and delivery of the work. SHARP-ER partners with Libyan civil society, municipalities, and community leaders — through funded sub-projects, training, and open dialogue between communities and the institutions that serve them.",
|
||||||
launchEyebrow: "30 June · Launch event",
|
|
||||||
launchHeading: "An important start for the project",
|
|
||||||
launchLead:
|
|
||||||
"The launch brought together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organisations, partners, and stakeholders from across Libya. The event marked the opening of the Expression of Interest and the launch of this website.",
|
|
||||||
workHeading: "Three areas of change",
|
workHeading: "Three areas of change",
|
||||||
workLinkLabel: "What we do",
|
workLinkLabel: "What we do",
|
||||||
newsEyebrow: "Newsroom",
|
newsEyebrow: "Newsroom",
|
||||||
@@ -383,23 +379,17 @@ const EN: PageCopy = {
|
|||||||
|
|
||||||
const AR: PageCopy = {
|
const AR: PageCopy = {
|
||||||
home: {
|
home: {
|
||||||
heroHeadline: "انطلاق مشروع SHARP-ER رسميًا في ليبيا",
|
heroHeadline: "تعزيز العدالة والمساءلة بقيادة المجتمعات الليبية",
|
||||||
heroLead:
|
heroLead:
|
||||||
"العمل المشترك لتعزيز سيادة القانون – مسار الخروج: مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات في ليبيا في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
|
"يعمل SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — مع منظمات المجتمع المدني والبلديات وقادة المجتمعات في مختلف أنحاء ليبيا على تعزيز الوصول إلى العدالة والمساءلة والحوكمة المحلية. بتمويل من الاتحاد الأوروبي، ويستمر حتى ديسمبر 2027.",
|
||||||
heroCtaPrimary: "الدعوة للتعبير عن الاهتمام",
|
heroCtaPrimary: "اطّلع على الدعوات المفتوحة",
|
||||||
heroCtaSecondary: "عن المشروع",
|
heroCtaSecondary: "عن المشروع",
|
||||||
/* Western digits, as the rest of the Arabic draft writes its numbers
|
/* The same two lines the About page uses for its coverage fact and its
|
||||||
("ديسمبر 2025", "SHARP I and II (2019–2024)"). */
|
body heading, so the Arabic reads identically on both pages. */
|
||||||
stats: [
|
approachEyebrow: "على مستوى ليبيا بأكملها",
|
||||||
{ value: "3", label: "شركاء الائتلاف" },
|
approachHeading: "مشروع وطني يُنفَّذ محليًا",
|
||||||
{ value: "4", label: "مجالات العمل" },
|
approachLead:
|
||||||
{ value: "16", label: "مشروعًا فرعيًا للأمن الإنساني" },
|
"تتولّى المنظمات المحلية تصميم العمل وتنفيذه. ويعمل SHARP-ER بالشراكة مع المجتمع المدني الليبي والبلديات وقادة المجتمعات، من خلال المشاريع الفرعية المموَّلة، والتدريب، والحوار المفتوح بين المجتمعات والمؤسسات التي تخدمها.",
|
||||||
{ value: "25 شهرًا", label: "ديسمبر 2025 – ديسمبر 2027" },
|
|
||||||
],
|
|
||||||
launchEyebrow: "30 يونيو · فعالية الإطلاق",
|
|
||||||
launchHeading: "بداية مهمة للمشروع",
|
|
||||||
launchLead:
|
|
||||||
"جمعت فعالية الإطلاق ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، ومنظمات المجتمع المدني، والشركاء، وأصحاب المصلحة من مختلف أنحاء ليبيا. وشهد الحدث افتتاح الدعوة للتعبير عن الاهتمام وإطلاق هذا الموقع الإلكتروني.",
|
|
||||||
workHeading: "ثلاثة مجالات للتغيير",
|
workHeading: "ثلاثة مجالات للتغيير",
|
||||||
workLinkLabel: "ماذا نفعل",
|
workLinkLabel: "ماذا نفعل",
|
||||||
newsEyebrow: "غرفة الأخبار",
|
newsEyebrow: "غرفة الأخبار",
|
||||||
|
|||||||
@@ -23,11 +23,12 @@
|
|||||||
scroll block is additionally gated on `prefers-reduced-motion: no-preference`
|
scroll block is additionally gated on `prefers-reduced-motion: no-preference`
|
||||||
so a reduced-motion visitor never gets a timeline-driven opacity ramp at all.
|
so a reduced-motion visitor never gets a timeline-driven opacity ramp at all.
|
||||||
|
|
||||||
DIRECTION: `transform-origin` and `linear-gradient()` have no logical
|
DIRECTION: `transform-origin`, `linear-gradient()`, `clip-path: inset()` and
|
||||||
keywords, so the progress bar and the brand stripe carry an explicit RTL
|
`translateX()` have no logical keywords, so the progress bar, the brand
|
||||||
override. These are the only physical directions in the file, and they exist
|
stripe, the headline wipe, the hover rule and the logo marquee each carry an
|
||||||
because the property itself has no logical form - every box edge elsewhere is
|
explicit RTL override. These are the only physical directions in the file,
|
||||||
still ps-/pe-/start-/end-.
|
and they exist because the property itself has no logical form - every box
|
||||||
|
edge elsewhere is still ps-/pe-/start-/end-.
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
@layer theme, base, components, utilities;
|
@layer theme, base, components, utilities;
|
||||||
@@ -59,6 +60,33 @@
|
|||||||
--sh-rise-delay: calc(var(--motion-duration-fast) * 3);
|
--sh-rise-delay: calc(var(--motion-duration-fast) * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The home headline. The third design drop stops lifting it and uncovers it
|
||||||
|
instead, from the inline start, so the words are read into place rather
|
||||||
|
than slid in. It reads the SAME `--sh-rise-delay` as the cascade above, so
|
||||||
|
`.sh-rise--3` &c. stagger the wipe alongside everything else. */
|
||||||
|
.sh-wipe {
|
||||||
|
animation: sh-wipe var(--motion-duration-reveal) var(--motion-ease-entrance)
|
||||||
|
var(--sh-rise-delay, 0ms) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .sh-wipe {
|
||||||
|
animation-name: sh-wipe-rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `backwards`, not `both`: the clip has to be GONE once the wipe is over.
|
||||||
|
`inset(0)` at rest would keep clipping to the border box, which shaves a
|
||||||
|
descender or an Arabic mark off a tight heading line. */
|
||||||
|
|
||||||
|
/* app.css shortens every animation but leaves its delay alone, which for a
|
||||||
|
clip means the headline is not merely faded but absent until the delay has
|
||||||
|
passed. Removing the animation outright puts the words on screen from the
|
||||||
|
first paint, which is what a reduced-motion reader asked for. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sh-wipe {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* The brand stripe under the hero picture, drawn on from the inline start. */
|
/* The brand stripe under the hero picture, drawn on from the inline start. */
|
||||||
.sh-bar-grow {
|
.sh-bar-grow {
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
@@ -93,6 +121,69 @@
|
|||||||
infinite;
|
infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The network logo wall, drifting past. The track carries the tiles TWICE,
|
||||||
|
so moving it exactly half its own width lands on an identical frame and
|
||||||
|
the loop never shows a seam; 42.5x the reveal duration is the draft's 34s
|
||||||
|
at the shipped token.
|
||||||
|
|
||||||
|
`width` is set here rather than with a `w-max` utility on purpose: the
|
||||||
|
reduced-motion block below has to be able to take it back, and a utility
|
||||||
|
sits in a later layer than this one.
|
||||||
|
|
||||||
|
It stops under the pointer and whenever anything inside it takes focus, so
|
||||||
|
a reader can hold the movement still to look at - or tab to - a logo
|
||||||
|
(WCAG 2.2.2). Under reduced motion it stops being a track at all: the row
|
||||||
|
simply wraps and the duplicate half is removed, so nothing moves and no
|
||||||
|
logo is shown twice. */
|
||||||
|
.sh-marquee {
|
||||||
|
width: max-content;
|
||||||
|
animation: sh-marquee calc(var(--motion-duration-reveal) * 42.5) linear
|
||||||
|
infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL reads the other way, so the drift does too. */
|
||||||
|
[dir="rtl"] .sh-marquee {
|
||||||
|
animation-name: sh-marquee-rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-marquee:hover,
|
||||||
|
.sh-marquee:focus-within {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sh-marquee {
|
||||||
|
width: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-marquee__clone {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The wall fades out at both ends instead of being cut off mid-tile. `black`
|
||||||
|
and `transparent` here are alpha, not colour - a mask reads the channel
|
||||||
|
only - which is why this is not a surface token. The ramp is symmetric, so
|
||||||
|
it needs no RTL form. */
|
||||||
|
.sh-marquee-mask {
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
black 8%,
|
||||||
|
black 92%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
black 8%,
|
||||||
|
black 92%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
INTERACTION
|
INTERACTION
|
||||||
---------------------------------------------------------------------- */
|
---------------------------------------------------------------------- */
|
||||||
@@ -112,6 +203,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A picture pushing in a little while the frame around it is hovered. The
|
||||||
|
frame clips, so the picture grows into its own box and not over the page.
|
||||||
|
Paired with .sh-lift the tile rises and its picture closes in, which is
|
||||||
|
how the draft hovers the gallery. */
|
||||||
|
.sh-zoom {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-zoom img {
|
||||||
|
transition: transform var(--motion-duration-slow)
|
||||||
|
var(--motion-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.sh-zoom:hover img {
|
||||||
|
transform: scale(1.09);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An accent rule drawn across the top of a tile under the pointer, growing
|
||||||
|
from the inline start. It is a pseudo-element so the tile's own markup
|
||||||
|
stays a heading and a paragraph. */
|
||||||
|
.sh-rule-top {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sh-rule-top::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
block-size: 3px;
|
||||||
|
background: var(--surface-accent);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: transform var(--motion-duration-slow)
|
||||||
|
var(--motion-ease-entrance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .sh-rule-top::before {
|
||||||
|
transform-origin: right center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.sh-rule-top:hover::before {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* An FAQ answer opening under its question. */
|
/* An FAQ answer opening under its question. */
|
||||||
.sh-answer {
|
.sh-answer {
|
||||||
animation: sh-answer var(--motion-duration-base) var(--motion-ease-entrance)
|
animation: sh-answer var(--motion-duration-base) var(--motion-ease-entrance)
|
||||||
@@ -134,6 +275,16 @@
|
|||||||
animation-name: sh-reveal-in;
|
animation-name: sh-reveal-in;
|
||||||
animation-range: entry 0% entry 80%;
|
animation-range: entry 0% entry 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The hero picture drifting as the page scrolls past it. It is scaled up
|
||||||
|
before it travels, so the drift can never uncover an edge of the
|
||||||
|
frame. Chromium-only, like every timeline here: elsewhere the picture
|
||||||
|
simply sits still, which is the state the design falls back to. */
|
||||||
|
.sh-pan {
|
||||||
|
animation: sh-pan linear both;
|
||||||
|
animation-timeline: view();
|
||||||
|
animation-range: entry 100% exit 0%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (animation-timeline: scroll()) {
|
@supports (animation-timeline: scroll()) {
|
||||||
@@ -236,6 +387,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes sh-wipe {
|
||||||
|
from {
|
||||||
|
opacity: 0.4;
|
||||||
|
clip-path: inset(0 100% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sh-wipe-rtl {
|
||||||
|
from {
|
||||||
|
opacity: 0.4;
|
||||||
|
clip-path: inset(0 0 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sh-marquee {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sh-marquee-rtl {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sh-pan {
|
||||||
|
from {
|
||||||
|
transform: translateY(-5%) scale(1.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(5%) scale(1.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes sh-answer {
|
@keyframes sh-answer {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -393,6 +393,12 @@ export function PageHero({
|
|||||||
const rise = (step?: 2 | 3 | 4 | 5) =>
|
const rise = (step?: 2 | 3 | 4 | 5) =>
|
||||||
reveal ? cx("sh-rise", step && `sh-rise--${step}`) : undefined;
|
reveal ? cx("sh-rise", step && `sh-rise--${step}`) : undefined;
|
||||||
|
|
||||||
|
/* The same cascade, uncovering the words instead of lifting them - the
|
||||||
|
headline treatment of the home draft. `.sh-wipe` reads the very step
|
||||||
|
classes `rise` sets, so both share one stagger. */
|
||||||
|
const wipe = (step?: 2 | 3 | 4 | 5) =>
|
||||||
|
reveal ? cx("sh-wipe", step && `sh-rise--${step}`) : undefined;
|
||||||
|
|
||||||
const washes = ambient ?? [];
|
const washes = ambient ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -436,6 +442,7 @@ export function PageHero({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Container className="flex flex-col gap-2xl">
|
<Container className="flex flex-col gap-2xl">
|
||||||
|
<div className="flex flex-col gap-lg">
|
||||||
{funderBadge || wordmark ? (
|
{funderBadge || wordmark ? (
|
||||||
/* Figma groups the two (478:16183): the badge, then 24px, then the
|
/* Figma groups the two (478:16183): the badge, then 24px, then the
|
||||||
lockup. The group as a whole keeps the 48px the Container's gap
|
lockup. The group as a whole keeps the 48px the Container's gap
|
||||||
@@ -452,12 +459,27 @@ export function PageHero({
|
|||||||
{leadWithActions ? (
|
{leadWithActions ? (
|
||||||
<>
|
<>
|
||||||
{/* Headline across the top ... */}
|
{/* Headline across the top ... */}
|
||||||
<div className={cx("flex max-w-[43.75rem] flex-col gap-sm", rise(3))}>
|
<div className="flex max-w-[43.75rem] flex-col gap-sm">
|
||||||
{eyebrow ? (
|
{eyebrow ? (
|
||||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
<p
|
||||||
|
className={cx(
|
||||||
|
"label-md uppercase text-[var(--text-accent)]",
|
||||||
|
rise(3),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
{/* The wipe is on the heading itself, not on the column, so the
|
||||||
|
eyebrow above it still arrives with the rest of the cascade
|
||||||
|
rather than being uncovered along with the words. */}
|
||||||
|
<h1
|
||||||
|
className={cx(
|
||||||
|
"heading-h1 text-[var(--text-primary)] md:display-sm",
|
||||||
|
wipe(3),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -529,6 +551,7 @@ export function PageHero({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{media ? (
|
{media ? (
|
||||||
mediaTreatment === "banner" ? (
|
mediaTreatment === "banner" ? (
|
||||||
@@ -539,13 +562,18 @@ export function PageHero({
|
|||||||
mint tint over a shade, both painted on the photograph. */
|
mint tint over a shade, both painted on the photograph. */
|
||||||
<div className={cx("w-full", rise(5))}>
|
<div className={cx("w-full", rise(5))}>
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
|
{/* `sh-pan` drifts the picture inside the band as the page
|
||||||
|
scrolls past it. It is scaled up inside the frame first,
|
||||||
|
so the drift never uncovers an edge; the washes above are
|
||||||
|
positioned against the frame, not the picture, so they do
|
||||||
|
not travel with it. */}
|
||||||
<img
|
<img
|
||||||
src={media.src}
|
src={media.src}
|
||||||
alt={media.alt}
|
alt={media.alt}
|
||||||
{...(media.width ? { width: media.width } : {})}
|
{...(media.width ? { width: media.width } : {})}
|
||||||
{...(media.height ? { height: media.height } : {})}
|
{...(media.height ? { height: media.height } : {})}
|
||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
className="aspect-[16/9] w-full object-cover md:aspect-[1244/415]"
|
className="sh-pan aspect-[16/9] w-full object-cover md:aspect-[1244/415]"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -563,27 +591,24 @@ export function PageHero({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The colour band (478:16360): three blocks flush under the
|
{/* The colour band (478:16360, redrawn in the drafts' home
|
||||||
picture, 6px tall. Figma lays out 390 / 495 / 363 inside a
|
hero): three blocks flush under the picture, 6px tall, split
|
||||||
1244-wide clipping frame, so the last block loses 4px and the
|
390 : 495 : 363 and carried as flex-grow ratios so the split
|
||||||
split that is actually drawn is 390 : 495 : 359 - carried here
|
holds at every width instead of only at the drafts' 1244.
|
||||||
as flex-grow ratios so it holds at every width instead of only
|
|
||||||
at 1244. The three fills are `background/accent`,
|
The fills are `background/accent`, `background/inverse` and
|
||||||
`background/inverse` and `background/danger`, which is why the
|
`background/danger` - the drafts paint the middle block
|
||||||
middle block reads white on the dark hero and near-black on a
|
#F8F9FA, so the stripe reads green / white / red on the dark
|
||||||
light surface.
|
hero and flips to green / near-black / red on a light
|
||||||
|
surface.
|
||||||
|
|
||||||
Purely decorative, so it is hidden from assistive technology;
|
Purely decorative, so it is hidden from assistive technology;
|
||||||
as an ordinary flex row it mirrors in Arabic along with the
|
as an ordinary flex row it mirrors in Arabic along with the
|
||||||
rest of the page. */}
|
rest of the page. */}
|
||||||
<div aria-hidden="true" className="sh-bar-grow flex h-[6px] w-full">
|
<div aria-hidden="true" className="sh-bar-grow flex h-[6px] w-full">
|
||||||
<span className="basis-0 grow-[390] bg-[var(--surface-accent)]" />
|
<span className="basis-0 grow-[390] bg-[var(--surface-accent)]" />
|
||||||
{/* The middle block is the raised dark, not the inverse:
|
<span className="basis-0 grow-[495] bg-[#20201E]" />
|
||||||
the redesign drops pure white from the page entirely, so
|
<span className="basis-0 grow-[363] bg-[var(--surface-danger)]" />
|
||||||
the stripe reads green / dark / red on the near-black
|
|
||||||
band instead of green / white / red. */}
|
|
||||||
<span className="basis-0 grow-[495] bg-[var(--surface-raised)]" />
|
|
||||||
<span className="basis-0 grow-[359] bg-[var(--surface-danger)]" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -750,39 +775,3 @@ export function ValueChips({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatStripProps {
|
|
||||||
stats: readonly { value: string; label: string }[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The figures band. `value` is authored in the CMS as a string, already
|
|
||||||
* formatted for its locale, because the site must never invent or re-derive a
|
|
||||||
* project figure - see the note the pages render when this list is empty.
|
|
||||||
*/
|
|
||||||
export function StatStrip({ stats, className }: StatStripProps) {
|
|
||||||
if (stats.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<dl
|
|
||||||
className={cx(
|
|
||||||
"grid grid-cols-2 gap-lg border-y border-[var(--border-subtle)] py-xl md:grid-cols-4",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{stats.map((stat) => (
|
|
||||||
/* Source order is term-then-description, as the spec requires; the
|
|
||||||
reversed column paints the figure above its label without repeating
|
|
||||||
the label for a screen reader. */
|
|
||||||
<div
|
|
||||||
key={`${stat.value}-${stat.label}`}
|
|
||||||
className="flex flex-col-reverse gap-2xs text-start"
|
|
||||||
>
|
|
||||||
<dt className="body-sm text-[var(--text-secondary)]">{stat.label}</dt>
|
|
||||||
<dd className="heading-h1 text-[var(--text-accent)]">{stat.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -792,7 +792,7 @@ export function FacetGroup({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cx(
|
className={cx(
|
||||||
"mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center",
|
"mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center",
|
||||||
"rounded-[var(--radius-control)] border",
|
"rounded-chip border",
|
||||||
option.isCurrent
|
option.isCurrent
|
||||||
? "border-border-accent"
|
? "border-border-accent"
|
||||||
: "border-border-default",
|
: "border-border-default",
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ function StreamDisclosure({
|
|||||||
|
|
||||||
<h3 className="flex-1 heading-h3">{copy.title}</h3>
|
<h3 className="flex-1 heading-h3">{copy.title}</h3>
|
||||||
|
|
||||||
<span className="hidden shrink-0 rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
|
<span className="hidden shrink-0 rounded-chip bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
|
||||||
{copy.partners}
|
{copy.partners}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) {
|
|||||||
to={entry.href}
|
to={entry.href}
|
||||||
prefetch="intent"
|
prefetch="intent"
|
||||||
className={[
|
className={[
|
||||||
"rounded-[var(--radius-control)] border-s-2 border-s-[var(--border-accent)]",
|
"rounded-chip border-s-2 border-s-[var(--border-accent)]",
|
||||||
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
|
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
|
||||||
"transition-colors duration-[var(--motion-duration-fast)]",
|
"transition-colors duration-[var(--motion-duration-fast)]",
|
||||||
"ease-[var(--motion-ease-standard)] hover:text-text-accent",
|
"ease-[var(--motion-ease-standard)] hover:text-text-accent",
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
HOME - /en and /ar
|
HOME - /en and /ar
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
The redesigned home page (Claude Design "Home", second drop), carried onto
|
The redesigned home page (Claude Design "Home page redesign with news and
|
||||||
the project's own design system. Six bands, alternating the two dark
|
FAQs", third drop), carried onto the project's own design system. Six bands,
|
||||||
surfaces the way the draft alternates #0B0B0A and #1D1D1B:
|
alternating the two dark surfaces the way the draft alternates #0B0B0A and
|
||||||
|
#1D1D1B:
|
||||||
|
|
||||||
Hero EU badge, an optional status pill, the headline, the lead,
|
Hero EU badge, an optional status pill, the headline, the lead,
|
||||||
the CTA pair, the wide picture, the brand stripe and the
|
the CTA pair, the wide picture and the brand stripe. Two
|
||||||
figures row. Two slow washes drift behind it.
|
slow washes drift behind it.
|
||||||
Launch "An important start for the project": the launch lead and
|
Approach "A nationwide project, delivered locally": what the project
|
||||||
three pictures from the CMS gallery.
|
does, and up to three pictures. Heading, lead and pictures
|
||||||
|
are all `home-page.approach`, so the band is edited in one
|
||||||
|
place rather than half in a field and half in Blocks.
|
||||||
Work "Three areas of change" - the three approved pillars, with
|
Work "Three areas of change" - the three approved pillars, with
|
||||||
a link through to What We Do.
|
a link through to What We Do.
|
||||||
Newsroom three cards from the newsroom, newest first.
|
Newsroom three cards from the newsroom, newest first.
|
||||||
@@ -20,26 +23,27 @@
|
|||||||
--------------------------------------------
|
--------------------------------------------
|
||||||
* The draft's FAQ column promises a reply "within five working days". That
|
* The draft's FAQ column promises a reply "within five working days". That
|
||||||
commitment appears in no approved document, so it is not published
|
commitment appears in no approved document, so it is not published
|
||||||
(CLAUDE.md rule 5). Everything else in the draft is verifiable: the four
|
(CLAUDE.md rule 5). Everything else in the draft is verifiable against
|
||||||
figures, the ministries at the launch and the three pillars are all in
|
docs/ or the bundled draft copy.
|
||||||
docs/ or in the bundled draft copy.
|
* The figures band the second drop carried is gone, as it is from the third
|
||||||
* Every list - news, questions, partner logos, launch pictures - is CMS
|
draft, and so is the `home-page.stats` component behind it: no approved
|
||||||
|
document carries a number, so there was never anything to put in it.
|
||||||
|
* Every list - news, questions, partner logos, band pictures - is CMS
|
||||||
data, never the draft's sample entries. Each band shows its localised
|
data, never the draft's sample entries. Each band shows its localised
|
||||||
empty state instead of inventing content.
|
empty state instead of inventing content.
|
||||||
* The draft's own navigation and footer are ignored: the site ships
|
* The draft's own navigation and footer are ignored: the site ships
|
||||||
<Navigation /> and <Footer /> from the app shell.
|
<Navigation /> and <Footer /> from the app shell.
|
||||||
|
|
||||||
MOTION: every animation in the draft is here, as classes from
|
MOTION: every animation in the draft is here, as classes from
|
||||||
_page-kit/motion.css, plus the figures counting up on arrival. The one
|
_page-kit/motion.css - the headline wipe, the hero picture's scroll-driven
|
||||||
effect deliberately dropped is the coloured glow behind a hovered button or
|
drift, the gallery and card hovers, the logo marquee. The one effect
|
||||||
card.
|
deliberately dropped is the coloured glow behind a hovered button or card.
|
||||||
|
|
||||||
There is no Arabic frame, so /ar is this same DOM under `dir="rtl"`, which
|
There is no Arabic frame, so /ar is this same DOM under `dir="rtl"`, which
|
||||||
is why every inline edge below is logical (ps-/pe-/start-/end-).
|
is why every inline edge below is logical (ps-/pe-/start-/end-).
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useState } from "react";
|
import { Link, useRouteLoaderData } from "react-router";
|
||||||
import { Link } from "react-router";
|
|
||||||
|
|
||||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||||
import { Card } from "~/components/ui/Card";
|
import { Card } from "~/components/ui/Card";
|
||||||
@@ -57,6 +61,7 @@ import {
|
|||||||
buildWebSiteJsonLd,
|
buildWebSiteJsonLd,
|
||||||
} from "~/lib/seo";
|
} from "~/lib/seo";
|
||||||
import { safeHref } from "~/lib/safe-href";
|
import { safeHref } from "~/lib/safe-href";
|
||||||
|
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
|
||||||
|
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
import { Accordion } from "./_page-kit/accordion";
|
import { Accordion } from "./_page-kit/accordion";
|
||||||
@@ -74,11 +79,11 @@ import {
|
|||||||
toImage,
|
toImage,
|
||||||
toPartner,
|
toPartner,
|
||||||
type ResolvedImage,
|
type ResolvedImage,
|
||||||
|
type ResolvedPartner,
|
||||||
} from "./_page-kit/cms.server";
|
} from "./_page-kit/cms.server";
|
||||||
import { pageCopy } from "./_page-kit/copy";
|
import { pageCopy } from "./_page-kit/copy";
|
||||||
import { routeLocale } from "./_page-kit/locale";
|
import { routeLocale } from "./_page-kit/locale";
|
||||||
import {
|
import {
|
||||||
Container,
|
|
||||||
cx,
|
cx,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
PageHero,
|
PageHero,
|
||||||
@@ -90,12 +95,21 @@ import "./_page-kit/motion.css";
|
|||||||
|
|
||||||
/** The network logo wall - a wide but finite grid. */
|
/** The network logo wall - a wide but finite grid. */
|
||||||
const PARTNER_PAGE_SIZE = 36;
|
const PARTNER_PAGE_SIZE = 36;
|
||||||
|
/**
|
||||||
|
* Below this many partners the wall stays a grid.
|
||||||
|
*
|
||||||
|
* The marquee loops by carrying the tiles twice and sliding exactly half the
|
||||||
|
* track, which only reads as a loop while the half is wider than the frame it
|
||||||
|
* slides through. Eight 178px tiles clear a desktop container; fewer would
|
||||||
|
* drift a short row across an empty band, so the grid keeps them still.
|
||||||
|
*/
|
||||||
|
const PARTNER_MARQUEE_MIN = 8;
|
||||||
/** The draft's newsroom band is a straight three-up row. */
|
/** The draft's newsroom band is a straight three-up row. */
|
||||||
const NEWS_PAGE_SIZE = 3;
|
const NEWS_PAGE_SIZE = 3;
|
||||||
/** More than a dozen questions is a page of its own, not a home page band. */
|
/** More than a dozen questions is a page of its own, not a home page band. */
|
||||||
const FAQ_PAGE_SIZE = 12;
|
const FAQ_PAGE_SIZE = 12;
|
||||||
/** The draft's launch band shows three pictures. */
|
/** The draft's approach band shows three pictures. */
|
||||||
const LAUNCH_SHOTS = 3;
|
const GALLERY_SHOTS = 3;
|
||||||
|
|
||||||
/** A newsroom card, already localised and safe to serialise to the client. */
|
/** A newsroom card, already localised and safe to serialise to the client. */
|
||||||
interface NewsCard {
|
interface NewsCard {
|
||||||
@@ -131,7 +145,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||||||
locale,
|
locale,
|
||||||
populate: {
|
populate: {
|
||||||
hero: { populate: { media: true, ctas: true } },
|
hero: { populate: { media: true, ctas: true } },
|
||||||
stats: true,
|
approach: { populate: { images: true } },
|
||||||
// Strapi 5 populates a dynamic zone per component with `on`.
|
// Strapi 5 populates a dynamic zone per component with `on`.
|
||||||
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
|
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
|
||||||
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
|
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
|
||||||
@@ -174,9 +188,15 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||||||
pagination: { pageSize: FAQ_PAGE_SIZE },
|
pagination: { pageSize: FAQ_PAGE_SIZE },
|
||||||
});
|
});
|
||||||
|
|
||||||
const gallery = list(page?.blocks)
|
/* The band's own pictures come first. An `image-gallery` block is still
|
||||||
|
read after them, so a gallery an editor built in the Blocks zone before
|
||||||
|
the band had a media field of its own keeps rendering. */
|
||||||
|
const gallery = [
|
||||||
|
...list(page?.approach?.images),
|
||||||
|
...list(page?.blocks)
|
||||||
.filter(isImageGalleryBlock)
|
.filter(isImageGalleryBlock)
|
||||||
.flatMap((block) => list(block.images))
|
.flatMap((block) => list(block.images)),
|
||||||
|
]
|
||||||
.map((image) => toImage(image, "medium"))
|
.map((image) => toImage(image, "medium"))
|
||||||
.filter((image): image is ResolvedImage => image !== undefined);
|
.filter((image): image is ResolvedImage => image !== undefined);
|
||||||
|
|
||||||
@@ -193,12 +213,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||||||
variant: cta.variant ?? "primary",
|
variant: cta.variant ?? "primary",
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
/* Editor-owned figures win; the bundled four stand in until then. */
|
approach: {
|
||||||
stats: list(page?.stats).map((stat) => ({
|
eyebrow: page?.approach?.eyebrow ?? null,
|
||||||
value: stat.value,
|
heading: page?.approach?.heading ?? null,
|
||||||
label: stat.label,
|
lead: page?.approach?.lead ?? null,
|
||||||
})),
|
},
|
||||||
gallery: gallery.slice(0, LAUNCH_SHOTS),
|
gallery: gallery.slice(0, GALLERY_SHOTS),
|
||||||
partnersHeadline: page?.partnersHeadline ?? null,
|
partnersHeadline: page?.partnersHeadline ?? null,
|
||||||
partners: partners.map(toPartner),
|
partners: partners.map(toPartner),
|
||||||
news: news.slice(0, NEWS_PAGE_SIZE).map((article) => toNewsCard(article, locale)),
|
news: news.slice(0, NEWS_PAGE_SIZE).map((article) => toNewsCard(article, locale)),
|
||||||
@@ -291,93 +311,17 @@ function StatusPill({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* `useLayoutEffect` would warn during SSR, and the count only exists in a
|
|
||||||
browser, so the effect is picked per environment. */
|
|
||||||
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A figure that counts up to its value on arrival, as the draft does.
|
* The approach band's three pictures. The CMS alt text doubles as the visible
|
||||||
*
|
|
||||||
* The server renders the FINAL value, so a reader without JavaScript - and a
|
|
||||||
* search engine - sees the real figure. In a browser the count is reset before
|
|
||||||
* the first paint (layout effect, not a plain effect), so the number never
|
|
||||||
* flashes its end state first. A value the CMS wrote as words rather than
|
|
||||||
* digits is simply printed.
|
|
||||||
*/
|
|
||||||
function CountUpValue({ value }: { value: string }) {
|
|
||||||
const parsed = /^(\d[\d,]*)([\s\S]*)$/.exec(value);
|
|
||||||
const target = parsed ? Number(parsed[1]!.replace(/,/g, "")) : null;
|
|
||||||
const suffix = parsed ? parsed[2]! : "";
|
|
||||||
const [shown, setShown] = useState<number | null>(target);
|
|
||||||
|
|
||||||
useBrowserLayoutEffect(() => {
|
|
||||||
if (target === null) return;
|
|
||||||
/* app.css collapses animation, but this one is script-driven, so the
|
|
||||||
preference has to be honoured here as well. */
|
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
||||||
|
|
||||||
let frame = 0;
|
|
||||||
const started = performance.now();
|
|
||||||
const duration = 1500;
|
|
||||||
|
|
||||||
setShown(0);
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const progress = Math.min(1, (now - started) / duration);
|
|
||||||
/* Ease out cubic - the draft's own curve. */
|
|
||||||
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))));
|
|
||||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
|
|
||||||
frame = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(frame);
|
|
||||||
}, [target]);
|
|
||||||
|
|
||||||
if (target === null) return <>{value}</>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{shown}
|
|
||||||
{suffix}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The figures row that closes the hero: four cells divided by hairlines. The
|
|
||||||
* grid's own background paints the 1px gaps, which is how the draft draws the
|
|
||||||
* dividers without a border on each cell.
|
|
||||||
*/
|
|
||||||
function StatsRow({ stats }: { stats: readonly { value: string; label: string }[] }) {
|
|
||||||
if (stats.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<dl className="grid grid-cols-1 gap-px border-y border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{stats.map((stat) => (
|
|
||||||
/* Source order is term-then-description, as the spec requires; the
|
|
||||||
reversed column paints the figure above its label without repeating
|
|
||||||
the label for a screen reader. */
|
|
||||||
<div
|
|
||||||
key={`${stat.value}-${stat.label}`}
|
|
||||||
className="flex flex-col-reverse gap-2xs bg-[var(--surface-sunken)] px-lg py-xl text-start transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
|
|
||||||
>
|
|
||||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">{stat.label}</dt>
|
|
||||||
<dd className="heading-h1 tabular-nums text-[var(--text-primary)]">
|
|
||||||
<CountUpValue value={stat.value} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The launch band's three pictures. The CMS alt text doubles as the visible
|
|
||||||
* caption - so the picture is described once, in the caption, and the <img>
|
* caption - so the picture is described once, in the caption, and the <img>
|
||||||
* itself is marked decorative rather than having a screen reader read the same
|
* itself is marked decorative rather than having a screen reader read the same
|
||||||
* sentence twice.
|
* sentence twice.
|
||||||
|
*
|
||||||
|
* The frame and the picture hover apart, as the third draft has them: the
|
||||||
|
* frame lifts and takes the accent hairline (`sh-lift`) while the picture
|
||||||
|
* closes in inside it (`sh-zoom`, which also does the clipping).
|
||||||
*/
|
*/
|
||||||
function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||||
if (images.length === 0) return null;
|
if (images.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -385,12 +329,14 @@ function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
|||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
<li key={image.src} className="sh-reveal">
|
<li key={image.src} className="sh-reveal">
|
||||||
<figure className="flex flex-col gap-xs">
|
<figure className="flex flex-col gap-xs">
|
||||||
|
<div className="sh-lift sh-zoom rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]">
|
||||||
<img
|
<img
|
||||||
src={image.src}
|
src={image.src}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="sh-lift h-[15rem] w-full rounded-control border border-[var(--border-subtle)] object-cover hover:border-[var(--border-accent)]"
|
className="h-[15rem] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{image.alt ? (
|
{image.alt ? (
|
||||||
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
|
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
|
||||||
{image.alt}
|
{image.alt}
|
||||||
@@ -403,6 +349,74 @@ function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tile on the civil society logo wall.
|
||||||
|
*
|
||||||
|
* `decorative` renders the second half of the marquee track: the same tile,
|
||||||
|
* hidden from assistive technology, with no link and no alt text, so the loop
|
||||||
|
* repeats visually without repeating the wall for a screen reader or adding a
|
||||||
|
* second tab stop per partner.
|
||||||
|
*/
|
||||||
|
function PartnerTile({
|
||||||
|
partner,
|
||||||
|
fixedWidth = false,
|
||||||
|
decorative = false,
|
||||||
|
}: {
|
||||||
|
partner: ResolvedPartner;
|
||||||
|
fixedWidth?: boolean;
|
||||||
|
decorative?: boolean;
|
||||||
|
}) {
|
||||||
|
const href = decorative ? undefined : safeHref(partner.website);
|
||||||
|
|
||||||
|
/* The logo wall is a wall of equal tiles; the name is the tile's text when
|
||||||
|
there is no logo to draw, and always its accessible name. */
|
||||||
|
const inner = partner.logo ? (
|
||||||
|
<img
|
||||||
|
src={partner.logo.src}
|
||||||
|
alt={decorative ? "" : partner.name}
|
||||||
|
loading="lazy"
|
||||||
|
className="max-h-[3.5rem] w-auto max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="body-sm-bold text-center text-[var(--text-secondary)]">
|
||||||
|
{partner.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
{...(decorative ? { "aria-hidden": true } : {})}
|
||||||
|
className={cx(
|
||||||
|
"flex h-[5.5rem] items-center justify-center",
|
||||||
|
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md",
|
||||||
|
"transition-colors duration-[var(--motion-duration-base)]",
|
||||||
|
"hover:border-[var(--border-accent)]",
|
||||||
|
/* A still grid reveals tile by tile; a travelling track must not, or
|
||||||
|
each tile would ramp its own opacity while it moves.
|
||||||
|
|
||||||
|
The track's spacing is a margin on the tile rather than a `gap` on
|
||||||
|
the row: the loop slides exactly half the track, so each half has to
|
||||||
|
carry its own trailing space or the seam lands half a gap short. */
|
||||||
|
fixedWidth ? "me-md w-[11.125rem] shrink-0" : "sh-reveal sh-reveal--in",
|
||||||
|
decorative && "sh-marquee__clone",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{href ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
inner
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The "see the whole section" link the draft puts opposite a band heading. */
|
/** The "see the whole section" link the draft puts opposite a band heading. */
|
||||||
function BandLink({ to, label }: { to: string; label: string }) {
|
function BandLink({ to, label }: { to: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -417,16 +431,19 @@ function BandLink({ to, label }: { to: string; label: string }) {
|
|||||||
------------------------------------------------------------------------ */
|
------------------------------------------------------------------------ */
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
const { locale, hero, stats, gallery, partners, news, faqs } = loaderData;
|
const { locale, hero, approach, gallery, partners, news, faqs } = loaderData;
|
||||||
const t = createTranslator(locale);
|
const t = createTranslator(locale);
|
||||||
const copy = pageCopy(locale).home;
|
const copy = pageCopy(locale).home;
|
||||||
|
/* The hero badge sits on the page's own surface, so it follows the theme
|
||||||
|
exactly as the footer's badge does: same root cookie, same emblem. */
|
||||||
|
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
|
||||||
|
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
|
||||||
/* The three pillars are the About page's approved copy - one source, so the
|
/* The three pillars are the About page's approved copy - one source, so the
|
||||||
two pages can never drift apart. */
|
two pages can never drift apart. */
|
||||||
const pillars = pageCopy(locale).about.pillars;
|
const pillars = pageCopy(locale).about.pillars;
|
||||||
|
|
||||||
const headline = hero.headline ?? copy.heroHeadline;
|
const headline = hero.headline ?? copy.heroHeadline;
|
||||||
const lead = hero.subheading ?? copy.heroLead;
|
const lead = hero.subheading ?? copy.heroLead;
|
||||||
const figures = stats.length > 0 ? stats : copy.stats;
|
|
||||||
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
|
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
|
||||||
|
|
||||||
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
|
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
|
||||||
@@ -462,7 +479,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PageHero
|
<PageHero
|
||||||
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-2xl)]"
|
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-md)]"
|
||||||
title={headline}
|
title={headline}
|
||||||
lead={lead}
|
lead={lead}
|
||||||
leadWithActions
|
leadWithActions
|
||||||
@@ -482,16 +499,17 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
]}
|
]}
|
||||||
reveal
|
reveal
|
||||||
/* EU visibility sits at the top of the hero (the draft's badge). The
|
/* EU visibility sits at the top of the hero (the draft's badge). The
|
||||||
emblem is the dark-surface export because the hero sits on the
|
emblem matches the page surface the hero stands on, which the theme
|
||||||
page's own dark surface. `emblemAlt` is left at its empty default:
|
cookie decides - dark emblem in dark mode, light emblem in light
|
||||||
the statement is real text beside the emblem, so naming the image
|
mode, like the footer's badge. `emblemAlt` is left at its empty
|
||||||
would read the attribution out twice. */
|
default: the statement is real text beside the emblem, so naming the
|
||||||
|
image would read the attribution out twice. */
|
||||||
funderBadge={
|
funderBadge={
|
||||||
<EuBadge
|
<EuBadge
|
||||||
size="sm"
|
size="sm"
|
||||||
statement={t("site.euStatement")}
|
statement={t("site.euStatement")}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
surface="dark"
|
surface={theme}
|
||||||
className="self-start"
|
className="self-start"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -500,28 +518,29 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
actions={heroCtas.map((cta) => (
|
actions={heroCtas.map((cta) => (
|
||||||
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
||||||
))}
|
))}
|
||||||
below={<StatsRow stats={figures} />}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* THE LAUNCH */}
|
{/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */}
|
||||||
<PageSection
|
<PageSection
|
||||||
aria-labelledby="home-launch-heading"
|
aria-labelledby="home-approach-heading"
|
||||||
band
|
band
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3xl">
|
<div className="flex flex-col gap-3xl">
|
||||||
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
|
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
id="home-launch-heading"
|
id="home-approach-heading"
|
||||||
eyebrow={copy.launchEyebrow}
|
eyebrow={approach.eyebrow ?? copy.approachEyebrow}
|
||||||
title={copy.launchHeading}
|
title={approach.heading ?? copy.approachHeading}
|
||||||
size="display"
|
size="display"
|
||||||
align="center"
|
align="center"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="body-lg text-[var(--text-secondary)]">{copy.launchLead}</p>
|
<p className="body-lg text-[var(--text-secondary)]">
|
||||||
|
{approach.lead ?? copy.approachLead}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LaunchShots images={gallery} />
|
<GalleryShots images={gallery} />
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
@@ -545,7 +564,9 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
{pillars.map((pillar) => (
|
{pillars.map((pillar) => (
|
||||||
<li
|
<li
|
||||||
key={pillar.title}
|
key={pillar.title}
|
||||||
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
/* `sh-rule-top` draws the draft's accent rule across the top
|
||||||
|
of the tile as the pointer arrives. */
|
||||||
|
className="sh-lift sh-reveal sh-reveal--in sh-rule-top flex h-full flex-col gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
||||||
>
|
>
|
||||||
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
|
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
|
||||||
<p className="body-md text-[var(--text-secondary)]">
|
<p className="body-md text-[var(--text-secondary)]">
|
||||||
@@ -664,7 +685,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
key={member.name}
|
key={member.name}
|
||||||
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
|
||||||
>
|
>
|
||||||
<span className="rounded-control bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
|
<span className="rounded-chip bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
|
||||||
{member.role}
|
{member.role}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3>
|
<h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3>
|
||||||
@@ -680,59 +701,35 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
{copy.networkLabel}
|
{copy.networkLabel}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{partners.length > 0 ? (
|
{partners.length === 0 ? (
|
||||||
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{partners.map((partner) => {
|
|
||||||
const href = safeHref(partner.website);
|
|
||||||
|
|
||||||
/* The logo wall is a grid of equal tiles; the name is the
|
|
||||||
tile's text when there is no logo to draw, and always its
|
|
||||||
accessible name. */
|
|
||||||
const inner = (
|
|
||||||
<>
|
|
||||||
{partner.logo ? (
|
|
||||||
<img
|
|
||||||
src={partner.logo.src}
|
|
||||||
alt={partner.name}
|
|
||||||
loading="lazy"
|
|
||||||
className="max-h-[3.5rem] w-auto max-w-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="body-sm-bold text-center text-[var(--text-secondary)]">
|
|
||||||
{partner.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={partner.id}
|
|
||||||
className={cx(
|
|
||||||
"sh-reveal sh-reveal--in flex h-[5.5rem] items-center justify-center",
|
|
||||||
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md",
|
|
||||||
"transition-colors duration-[var(--motion-duration-base)]",
|
|
||||||
"hover:border-[var(--border-accent)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{href ? (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
inner
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<EmptyState message={copy.partnersEmpty} />
|
<EmptyState message={copy.partnersEmpty} />
|
||||||
|
) : partners.length >= PARTNER_MARQUEE_MIN ? (
|
||||||
|
/* The wall drifts, as the third draft draws it. The track holds
|
||||||
|
the tiles twice so the loop is seamless; the mask fades it out
|
||||||
|
at both ends instead of cutting a tile in half. Both halves
|
||||||
|
come from one list, so the wall can never show a partner the
|
||||||
|
CMS does not hold. */
|
||||||
|
<div className="sh-marquee-mask relative overflow-hidden">
|
||||||
|
<ul className="sh-marquee flex list-none">
|
||||||
|
{partners.map((partner) => (
|
||||||
|
<PartnerTile key={partner.id} partner={partner} fixedWidth />
|
||||||
|
))}
|
||||||
|
{partners.map((partner) => (
|
||||||
|
<PartnerTile
|
||||||
|
key={`repeat-${partner.id}`}
|
||||||
|
partner={partner}
|
||||||
|
fixedWidth
|
||||||
|
decorative
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{partners.map((partner) => (
|
||||||
|
<PartnerTile key={partner.id} partner={partner} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ function NewsMetaRow({ entry }: { entry: NewsListItem }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-sm">
|
<div className="flex flex-wrap items-center gap-sm">
|
||||||
{entry.tag ? (
|
{entry.tag ? (
|
||||||
<span className="rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent">
|
<span className="rounded-chip bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent">
|
||||||
{entry.tag}
|
{entry.tag}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ function ResourceRow({
|
|||||||
{entry.fileKind ? (
|
{entry.fileKind ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="flex h-14 w-11 shrink-0 items-center justify-center rounded-[var(--radius-control)] border border-border-default bg-surface-sunken label-md text-text-accent"
|
className="flex h-14 w-11 shrink-0 items-center justify-center rounded-chip border border-border-default bg-surface-sunken label-md text-text-accent"
|
||||||
>
|
>
|
||||||
{entry.fileKind}
|
{entry.fileKind}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ A Tailwind 4 `@theme static` block. Raw ramps only, named by value:
|
|||||||
`--font-weight-regular..black`
|
`--font-weight-regular..black`
|
||||||
* space `--spacing-2xs..5xl` - radius `--radius-none..full` -
|
* space `--spacing-2xs..5xl` - radius `--radius-none..full` -
|
||||||
border width `--border-width-sm..xl` - opacity `--opacity-0..100`
|
border width `--border-width-sm..xl` - opacity `--opacity-0..100`
|
||||||
* shadow `--shadow-xs..xl` - duration `--duration-instant..slower` -
|
* shadow `--shadow-xs..xl` plus the dark-ground steps `--shadow-dark-lg|xl` -
|
||||||
|
duration `--duration-instant..slower` -
|
||||||
easing `--ease-standard | decelerate | accelerate | emphasized`
|
easing `--ease-standard | decelerate | accelerate | emphasized`
|
||||||
* z ladder `--z-0..70` - containers `--container-prose | content | wide`
|
* z ladder `--z-0..70` - containers `--container-prose | content | wide`
|
||||||
|
|
||||||
@@ -90,6 +91,25 @@ Plain CSS custom properties whose value is always `var(--<primitive>)`. Families
|
|||||||
`--focus-ring-*` `--overlay-*` `--radius-*` `--control-*` `--layout-*`
|
`--focus-ring-*` `--overlay-*` `--radius-*` `--control-*` `--layout-*`
|
||||||
`--elevation-*` `--motion-*` `--layer-*` `--type-body-base-*`
|
`--elevation-*` `--motion-*` `--layer-*` `--type-body-base-*`
|
||||||
|
|
||||||
|
Two families are worth knowing by heart, because both were retuned to the
|
||||||
|
Claude Design drafts rather than to the older Figma Button frame:
|
||||||
|
|
||||||
|
* **Shape.** The drafts draw three corners, not five. `--radius-chip` (2px) for
|
||||||
|
a tag, thumbnail, flag or checkbox; `--radius-control` / `--radius-card` /
|
||||||
|
`--radius-surface` (all 4px) for a button, field, menu, card or panel;
|
||||||
|
`--radius-pill` for a filter chip or status pill. Nothing in the drafts is
|
||||||
|
8px or 12px, which is why the old `--radius-control-lg` is gone. The tier-1
|
||||||
|
ramp still carries `--radius-md` / `--radius-lg` - only the meanings moved.
|
||||||
|
* **Elevation.** A shadow is a fraction of the ground it falls on, so
|
||||||
|
`--elevation-card`, `--elevation-popover` and `--elevation-modal` are
|
||||||
|
declared **per surface**. On the dark surface a resting card has no shadow at
|
||||||
|
all (the drafts separate with a hairline) and the deep black - tier 1's
|
||||||
|
`--shadow-dark-lg` / `--shadow-dark-xl`, the drafts' own
|
||||||
|
`0 20px 44px rgba(0,0,0,.5)` and `0 24px 48px rgba(0,0,0,.6)` - is spent on
|
||||||
|
what actually floats: a card under the pointer, the nav dropdown. The light
|
||||||
|
surface keeps the soft ramp. Only `--elevation-flat` and `--elevation-raised`
|
||||||
|
are surface-independent.
|
||||||
|
|
||||||
Interactive tokens are fully regular - for each of the five Figma button
|
Interactive tokens are fully regular - for each of the five Figma button
|
||||||
variants (`primary`, `secondary`, `dark`, `danger`, `ghost`) there is
|
variants (`primary`, `secondary`, `dark`, `danger`, `ghost`) there is
|
||||||
|
|
||||||
@@ -124,6 +144,18 @@ Because both surface blocks define the *same* token names, a component written
|
|||||||
against `--text-primary` is automatically correct on either one - which is why
|
against `--text-primary` is automatically correct on either one - which is why
|
||||||
components must never reach past a role to a primitive.
|
components must never reach past a role to a primitive.
|
||||||
|
|
||||||
|
**The one global exception, accepted deliberately - the theme toggle.** The
|
||||||
|
nav's sun/moon control (Navigation/ThemeToggle.tsx) lets a reader re-root the
|
||||||
|
surface the whole document stands on: the root action persists the
|
||||||
|
`sharper-theme` cookie, the root loader returns it and `<html
|
||||||
|
data-surface={theme}>` follows, so a light-theme visit stands on
|
||||||
|
`[data-surface="light"]` and every token re-resolves. The mechanism is still
|
||||||
|
surfaces-only - the toggle never repaints anything itself, it just chooses
|
||||||
|
which pole `:root` behaviour applies to - and the header/footer follow the
|
||||||
|
theme unless a route's `handle` forces a surface. `--surface-nav` (the
|
||||||
|
translucent bar) must stay defined in BOTH surface blocks, or a light-theme
|
||||||
|
nav resolves against the dark block.
|
||||||
|
|
||||||
Utility variants `surface-dark:` and `surface-light:` exist for the rare case a
|
Utility variants `surface-dark:` and `surface-light:` exist for the rare case a
|
||||||
utility (not a token) has to differ, and `rtl:` / `ltr:` for direction. Prefer
|
utility (not a token) has to differ, and `rtl:` / `ltr:` for direction. Prefer
|
||||||
CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `start-`, `end-`) so RTL
|
CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `start-`, `end-`) so RTL
|
||||||
@@ -163,12 +195,14 @@ Filenames are case-sensitive in production - match the table exactly.
|
|||||||
|
|
||||||
## Known deviation from Figma
|
## Known deviation from Figma
|
||||||
|
|
||||||
The published Ghost button hover paints `background/accent` (`#008764`) and keeps
|
**Ghost is a text link, not a button.** The published Figma Ghost cell fills on
|
||||||
`base/turquoise-dark` (`#006b4f`) as the label - 1.40:1, which fails WCAG AA.
|
hover (`background/accent` behind a `base/turquoise-dark` label - 1.40:1, a
|
||||||
`--interactive-ghost-fg-hover` is therefore `--color-white` on **both** surfaces
|
WCAG AA failure). The Claude Design drafts never fill a ghost control: every
|
||||||
(4.52:1, passes AA for body text). The two lines are marked `(a11y)` in
|
"All updates ->" / "What we do ->" is plain text in `--text-primary` that turns
|
||||||
`semantic.css`; revert them to `var(--color-green-700)` for pixel-exact parity
|
accent green under the pointer while the label-to-arrow gap opens from 8px to
|
||||||
if the designer prefers the failure.
|
12px. The drafts win, so the ghost tokens carry no fill in any state, the
|
||||||
|
variant opts out of the 3px lift in `Button.css`, and the old a11y workaround
|
||||||
|
is no longer needed.
|
||||||
|
|
||||||
## Contrast audit
|
## Contrast audit
|
||||||
|
|
||||||
@@ -179,25 +213,26 @@ bold) and for non-text UI boundaries (1.4.11).
|
|||||||
|
|
||||||
### Dark surface (`:root`, `[data-surface="dark"]`)
|
### Dark surface (`:root`, `[data-surface="dark"]`)
|
||||||
|
|
||||||
| text token | page `#1d1d1b` | raised `#2e2e2c` | sunken `#0b0b0a` | muted (white 8%) | accent-subtle (green 24%) | danger-subtle (red 24%) |
|
| text token | page `#1d1d1b` | raised `#141413` | sunken `#0b0b0a` | muted (white 8%) | accent-subtle (green 24%) | danger-subtle (red 24%) |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `--text-primary` neutral-50 | 16.02 | 12.91 | 18.68 | 12.71 | 12.40 | 12.86 |
|
| `--text-primary` neutral-50 | 16.02 | 17.49 | 18.68 | 12.71 | 12.40 | 12.86 |
|
||||||
| `--text-secondary` neutral-300 | 9.46 | 7.63 | 11.04 | 7.51 | 7.32 | 7.60 |
|
| `--text-secondary` neutral-300 | 9.46 | 10.33 | 11.04 | 7.51 | 7.32 | 7.60 |
|
||||||
| `--text-tertiary` neutral-400 | 5.56 | 4.48 | 6.48 | 4.41 | 4.30 | 4.46 |
|
| `--text-tertiary` neutral-400 | 5.56 | 6.07 | 6.48 | 4.41 | 4.30 | 4.46 |
|
||||||
| `--text-accent` / `--text-link` green-400 | 6.02 | 4.85 | 7.02 | 4.78 | 4.66 | 4.83 |
|
| `--text-accent` / `--text-link` green-400 | 6.02 | 6.57 | 7.02 | 4.78 | 4.66 | 4.83 |
|
||||||
| `--text-link-hover` green-300 | 8.56 | 6.90 | 9.99 | 6.80 | 6.63 | 6.87 |
|
| `--text-link-hover` green-300 | 8.56 | 9.35 | 9.99 | 6.80 | 6.63 | 6.87 |
|
||||||
| `--text-danger` red-400 | 5.39 | 4.34 | 6.29 | 4.28 | 4.17 | 4.33 |
|
| `--text-danger` red-400 | 5.39 | 5.89 | 6.29 | 4.28 | 4.17 | 4.33 |
|
||||||
| `--text-disabled` neutral-500 | 2.36 | 1.91 | 2.76 | 1.88 | 1.83 | 1.90 |
|
| `--text-disabled` neutral-500 | 2.36 | 2.58 | 2.76 | 1.88 | 1.83 | 1.90 |
|
||||||
|
|
||||||
`--text-primary`, `--text-secondary` and `--text-link-hover` pass AA everywhere.
|
`--text-primary`, `--text-secondary` and `--text-link-hover` pass AA everywhere.
|
||||||
Everything below them is only cleared against `--surface-page` and
|
Everything below them is only cleared against `--surface-page` and
|
||||||
`--surface-sunken`, which is where the design actually uses them:
|
`--surface-sunken`, which is where the design actually uses them:
|
||||||
|
|
||||||
|
* `--surface-raised` is the drafts' `#141413`, DARKER than the page, so every
|
||||||
|
role gains contrast on it rather than losing it - the whole column clears AA.
|
||||||
* `--text-tertiary`, `--text-accent`, `--text-link` and `--text-danger` land
|
* `--text-tertiary`, `--text-accent`, `--text-link` and `--text-danger` land
|
||||||
between 4.17 and 4.85 on `--surface-raised` and on the three translucent
|
between 4.17 and 4.78 on the three translucent fills - AA for large text,
|
||||||
fills - AA for large text, **not** for body text. Card bodies use
|
**not** for body text. Card bodies use `--text-secondary`; keep
|
||||||
`--text-secondary`; keep tertiary/accent/danger body copy on the page or
|
tertiary/accent/danger body copy on an opaque surface, or raise it a step.
|
||||||
sunken surface, or raise the text a step.
|
|
||||||
* `--text-disabled` is 2.36:1 by design. WCAG 1.4.3 exempts the label of a
|
* `--text-disabled` is 2.36:1 by design. WCAG 1.4.3 exempts the label of a
|
||||||
disabled control, so this is deliberate and must not be repointed - it is
|
disabled control, so this is deliberate and must not be repointed - it is
|
||||||
the only token allowed below 3:1.
|
the only token allowed below 3:1.
|
||||||
@@ -250,16 +285,18 @@ Everything below them is only cleared against `--surface-page` and
|
|||||||
| dark white / `bg-hover` black | 21.00 |
|
| dark white / `bg-hover` black | 21.00 |
|
||||||
| dark `fg-disabled` neutral-300 / `bg-disabled` neutral-600 (dark surface) | 5.39 |
|
| dark `fg-disabled` neutral-300 / `bg-disabled` neutral-600 (dark surface) | 5.39 |
|
||||||
| dark `fg-disabled` neutral-200 / `bg-disabled` neutral-500 (light surface) | 4.91 |
|
| dark `fg-disabled` neutral-200 / `bg-disabled` neutral-500 (light surface) | 4.91 |
|
||||||
| **ghost `fg-hover` white / `bg-hover` green-600** (was green-900, 2.62) | **4.52** |
|
| ghost `fg` neutral-50 / dark page (no fill in any state) | 16.02 |
|
||||||
| ghost white / `bg-active` green-700 | 6.53 |
|
| ghost `fg-hover` green-400 / dark page | 6.02 |
|
||||||
| ghost `fg` green-400 / dark page | 6.02 |
|
| ghost `fg` neutral-800 / light page | 16.88 |
|
||||||
| ghost `fg` green-600 / light page | 4.52 |
|
| ghost `fg-hover` green-700 / light page | 6.53 |
|
||||||
|
| **ghost `fg-disabled` neutral-500 / dark page** | **2.36** |
|
||||||
|
| **ghost `fg-disabled` neutral-400 / light page** | **3.04** |
|
||||||
| secondary `fg` neutral-50 / dark page | 16.02 |
|
| secondary `fg` neutral-50 / dark page | 16.02 |
|
||||||
| secondary `fg` neutral-800 / light page | 16.88 |
|
| secondary `fg` neutral-800 / light page | 16.88 |
|
||||||
| **secondary `fg-disabled` neutral-500 / dark page** | **2.36** |
|
| **secondary `fg-disabled` neutral-500 / dark page** | **2.36** |
|
||||||
| **secondary `fg-disabled` neutral-400 / light page** | **3.04** |
|
| **secondary `fg-disabled` neutral-400 / light page** | **3.04** |
|
||||||
|
|
||||||
The four bolded disabled rows are the WCAG 1.4.3 exemption again - a disabled
|
The bolded disabled rows are the WCAG 1.4.3 exemption again - a disabled
|
||||||
control has no contrast minimum. Every enabled pair clears 4.5:1.
|
control has no contrast minimum. Every enabled pair clears 4.5:1.
|
||||||
|
|
||||||
### Fields and boundaries
|
### Fields and boundaries
|
||||||
|
|||||||
@@ -210,12 +210,25 @@
|
|||||||
|
|
||||||
/* --------------------------------------------------------------------
|
/* --------------------------------------------------------------------
|
||||||
SHADOW ramp
|
SHADOW ramp
|
||||||
|
|
||||||
|
Two ramps, because a shadow is a fraction of the ground it falls on.
|
||||||
|
The soft steps are tuned for a white page; on the drafts' near-black
|
||||||
|
ground a 12-28% black is simply invisible, so the deep steps below carry
|
||||||
|
the two shadows the drafts actually draw:
|
||||||
|
card hover 0 20px 44px rgba(0,0,0,0.5)
|
||||||
|
nav dropdown 0 24px 48px rgba(0,0,0,0.6)
|
||||||
|
`semantic.css` picks the ramp per surface via --elevation-*.
|
||||||
-------------------------------------------------------------------- */
|
-------------------------------------------------------------------- */
|
||||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.06);
|
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.06);
|
||||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.08);
|
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.08);
|
||||||
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.12), 0 2px 4px -2px rgb(0 0 0 / 0.08);
|
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 0.12), 0 2px 4px -2px rgb(0 0 0 / 0.08);
|
||||||
--shadow-lg: 0 12px 28px -6px rgb(0 0 0 / 0.18), 0 4px 8px -4px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 12px 28px -6px rgb(0 0 0 / 0.18), 0 4px 8px -4px rgb(0 0 0 / 0.1);
|
||||||
--shadow-xl: 0 24px 48px -12px rgb(0 0 0 / 0.28);
|
--shadow-xl: 0 24px 48px -12px rgb(0 0 0 / 0.28);
|
||||||
|
|
||||||
|
/* [drafts] the deep steps, for a shadow falling on the dark ground */
|
||||||
|
--shadow-dark-lg: 0 20px 44px rgb(0 0 0 / 0.5);
|
||||||
|
--shadow-dark-xl: 0 24px 48px rgb(0 0 0 / 0.6);
|
||||||
|
|
||||||
--shadow-none: 0 0 #0000;
|
--shadow-none: 0 0 #0000;
|
||||||
|
|
||||||
/* --------------------------------------------------------------------
|
/* --------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,6 +17,13 @@
|
|||||||
:root and [data-surface="dark"] share one block; [data-surface="light"]
|
:root and [data-surface="dark"] share one block; [data-surface="light"]
|
||||||
comes afterwards so it wins on the same element, and proximity makes the
|
comes afterwards so it wins on the same element, and proximity makes the
|
||||||
nearest data-surface ancestor win for any subtree.
|
nearest data-surface ancestor win for any subtree.
|
||||||
|
|
||||||
|
THEME TOGGLE (the declared exception to "no global theme"). The nav's
|
||||||
|
theme control flips WHICH surface the DOCUMENT stands on: the root action
|
||||||
|
persists `sharper-theme`, the root loader returns it, and <html
|
||||||
|
data-surface> follows - so light mode means the whole site stands on
|
||||||
|
[data-surface="light"] and every token re-resolves, including the nav and
|
||||||
|
footer, which follow the theme unless a route's handle forces a surface.
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -121,19 +128,18 @@
|
|||||||
--interactive-danger-border-disabled: var(--color-transparent);
|
--interactive-danger-border-disabled: var(--color-transparent);
|
||||||
|
|
||||||
/* --- interactive: ghost (text-only) -------------------------------------
|
/* --- interactive: ghost (text-only) -------------------------------------
|
||||||
DEVIATION FROM FIGMA (a11y). The published component paints the hover
|
The drafts never fill a ghost control. Every "All updates ->" / "What we
|
||||||
background/accent (#008764) and keeps base/turquoise-dark (#006b4f) as
|
do ->" link is plain text in --text-primary that turns accent green under
|
||||||
the label - 1.40:1, far below WCAG AA. Both surfaces therefore use white
|
the pointer while the label-to-arrow gap opens; the fill the old Figma
|
||||||
on the accent fill, which is 4.52:1 and passes AA for body text. The two
|
Button frame painted on hover (background/accent behind a green label,
|
||||||
lines marked (a11y) are the only ones that differ from Figma; reverting
|
1.40:1) is gone, and with it the a11y deviation it forced. */
|
||||||
them to var(--color-green-700) restores pixel parity and the failure. */
|
|
||||||
--interactive-ghost-bg: var(--color-transparent);
|
--interactive-ghost-bg: var(--color-transparent);
|
||||||
--interactive-ghost-bg-hover: var(--color-green-600);
|
--interactive-ghost-bg-hover: var(--color-transparent);
|
||||||
--interactive-ghost-bg-active: var(--color-green-700);
|
--interactive-ghost-bg-active: var(--color-transparent);
|
||||||
--interactive-ghost-bg-disabled: var(--color-transparent);
|
--interactive-ghost-bg-disabled: var(--color-transparent);
|
||||||
--interactive-ghost-fg: var(--color-green-400);
|
--interactive-ghost-fg: var(--color-neutral-50);
|
||||||
--interactive-ghost-fg-hover: var(--color-white); /* (a11y) 4.52:1 on green-600; figma: green-700 = 1.40:1 */
|
--interactive-ghost-fg-hover: var(--color-green-400); /* 6.02:1 on the page ground */
|
||||||
--interactive-ghost-fg-disabled: var(--color-green-400);
|
--interactive-ghost-fg-disabled: var(--color-neutral-500);
|
||||||
--interactive-ghost-border: var(--color-transparent);
|
--interactive-ghost-border: var(--color-transparent);
|
||||||
--interactive-ghost-border-hover: var(--color-transparent);
|
--interactive-ghost-border-hover: var(--color-transparent);
|
||||||
--interactive-ghost-border-disabled: var(--color-transparent);
|
--interactive-ghost-border-disabled: var(--color-transparent);
|
||||||
@@ -165,6 +171,14 @@
|
|||||||
they carry the same value on either surface. */
|
they carry the same value on either surface. */
|
||||||
--overlay-picture-shade: var(--color-black-a20);
|
--overlay-picture-shade: var(--color-black-a20);
|
||||||
--overlay-picture-tint: var(--color-green-200-a12);
|
--overlay-picture-tint: var(--color-green-200-a12);
|
||||||
|
|
||||||
|
/* --- elevation ----------------------------------------------------------
|
||||||
|
The drafts give a resting card a hairline and no shadow at all, and keep
|
||||||
|
the black for what genuinely floats: a card under the pointer and the
|
||||||
|
navigation dropdown. */
|
||||||
|
--elevation-card: var(--shadow-none);
|
||||||
|
--elevation-popover: var(--shadow-dark-lg);
|
||||||
|
--elevation-modal: var(--shadow-dark-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -186,6 +200,9 @@
|
|||||||
--surface-danger-subtle: var(--color-red-100);
|
--surface-danger-subtle: var(--color-red-100);
|
||||||
--surface-inverse: var(--color-neutral-900); /* figma background/inverse #0b0b0a */
|
--surface-inverse: var(--color-neutral-900); /* figma background/inverse #0b0b0a */
|
||||||
--surface-footer: var(--color-white); /* figma 140:7812, the light footer */
|
--surface-footer: var(--color-white); /* figma 140:7812, the light footer */
|
||||||
|
/* The light bar floats the same way the dark one does - the ground colour at
|
||||||
|
90% with a blur behind it, so content shows through as it scrolls under. */
|
||||||
|
--surface-nav: color-mix(in srgb, var(--color-white) 90%, transparent);
|
||||||
--surface-selection: var(--color-green-200);
|
--surface-selection: var(--color-green-200);
|
||||||
|
|
||||||
/* --- text ------------------------------------------------------------- */
|
/* --- text ------------------------------------------------------------- */
|
||||||
@@ -262,14 +279,14 @@
|
|||||||
--interactive-danger-border-hover: var(--color-transparent);
|
--interactive-danger-border-hover: var(--color-transparent);
|
||||||
--interactive-danger-border-disabled: var(--color-transparent);
|
--interactive-danger-border-disabled: var(--color-transparent);
|
||||||
|
|
||||||
/* --- interactive: ghost (text-only) - see the a11y note above ---------- */
|
/* --- interactive: ghost (text-only) - see the note above --------------- */
|
||||||
--interactive-ghost-bg: var(--color-transparent);
|
--interactive-ghost-bg: var(--color-transparent);
|
||||||
--interactive-ghost-bg-hover: var(--color-green-600);
|
--interactive-ghost-bg-hover: var(--color-transparent);
|
||||||
--interactive-ghost-bg-active: var(--color-green-700);
|
--interactive-ghost-bg-active: var(--color-transparent);
|
||||||
--interactive-ghost-bg-disabled: var(--color-transparent);
|
--interactive-ghost-bg-disabled: var(--color-transparent);
|
||||||
--interactive-ghost-fg: var(--color-green-600);
|
--interactive-ghost-fg: var(--color-neutral-800);
|
||||||
--interactive-ghost-fg-hover: var(--color-white); /* (a11y) 4.52:1 on green-600; figma: green-700 = 1.40:1 */
|
--interactive-ghost-fg-hover: var(--color-green-700); /* 6.53:1 on white */
|
||||||
--interactive-ghost-fg-disabled: var(--color-green-600);
|
--interactive-ghost-fg-disabled: var(--color-neutral-400);
|
||||||
--interactive-ghost-border: var(--color-transparent);
|
--interactive-ghost-border: var(--color-transparent);
|
||||||
--interactive-ghost-border-hover: var(--color-transparent);
|
--interactive-ghost-border-hover: var(--color-transparent);
|
||||||
--interactive-ghost-border-disabled: var(--color-transparent);
|
--interactive-ghost-border-disabled: var(--color-transparent);
|
||||||
@@ -299,6 +316,11 @@
|
|||||||
they carry the same value on either surface. */
|
they carry the same value on either surface. */
|
||||||
--overlay-picture-shade: var(--color-black-a20);
|
--overlay-picture-shade: var(--color-black-a20);
|
||||||
--overlay-picture-tint: var(--color-green-200-a12);
|
--overlay-picture-tint: var(--color-green-200-a12);
|
||||||
|
|
||||||
|
/* --- elevation - the soft ramp; the deep black belongs on the dark ground */
|
||||||
|
--elevation-card: var(--shadow-md);
|
||||||
|
--elevation-popover: var(--shadow-lg);
|
||||||
|
--elevation-modal: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -308,7 +330,7 @@
|
|||||||
------------------------------------------------------------------------ */
|
------------------------------------------------------------------------ */
|
||||||
:root {
|
:root {
|
||||||
/* --- shape -------------------------------------------------------------
|
/* --- shape -------------------------------------------------------------
|
||||||
--radius-control / --radius-control-lg / --radius-card / --radius-surface
|
--radius-chip / --radius-control / --radius-card / --radius-surface
|
||||||
/ --radius-pill are declared in the @theme block near the bottom of this
|
/ --radius-pill are declared in the @theme block near the bottom of this
|
||||||
file so that they also generate rounded-* utilities. They are tier-2
|
file so that they also generate rounded-* utilities. They are tier-2
|
||||||
tokens like everything else here. */
|
tokens like everything else here. */
|
||||||
@@ -348,12 +370,13 @@
|
|||||||
--layout-content-max: var(--container-content);
|
--layout-content-max: var(--container-content);
|
||||||
--layout-prose-max: var(--container-prose);
|
--layout-prose-max: var(--container-prose);
|
||||||
|
|
||||||
/* --- elevation ---------------------------------------------------------- */
|
/* --- elevation ----------------------------------------------------------
|
||||||
|
Only the two flattest steps are surface-independent. A shadow is a
|
||||||
|
fraction of the ground it falls on, so --elevation-card / -popover /
|
||||||
|
-modal are declared inside each surface block above: the dark surface
|
||||||
|
takes the drafts' deep black, the light one keeps the soft ramp. */
|
||||||
--elevation-flat: var(--shadow-none);
|
--elevation-flat: var(--shadow-none);
|
||||||
--elevation-raised: var(--shadow-sm);
|
--elevation-raised: var(--shadow-sm);
|
||||||
--elevation-card: var(--shadow-md);
|
|
||||||
--elevation-popover: var(--shadow-lg);
|
|
||||||
--elevation-modal: var(--shadow-xl);
|
|
||||||
|
|
||||||
/* --- focus ring geometry ------------------------------------------------- */
|
/* --- focus ring geometry ------------------------------------------------- */
|
||||||
--focus-ring-width: var(--border-width-lg);
|
--focus-ring-width: var(--border-width-lg);
|
||||||
@@ -430,7 +453,6 @@
|
|||||||
--color-interactive-dark-hover: var(--interactive-dark-bg-hover);
|
--color-interactive-dark-hover: var(--interactive-dark-bg-hover);
|
||||||
--color-interactive-danger: var(--interactive-danger-bg);
|
--color-interactive-danger: var(--interactive-danger-bg);
|
||||||
--color-interactive-danger-hover: var(--interactive-danger-bg-hover);
|
--color-interactive-danger-hover: var(--interactive-danger-bg-hover);
|
||||||
--color-interactive-ghost-hover: var(--interactive-ghost-bg-hover);
|
|
||||||
|
|
||||||
--color-field-bg: var(--field-bg);
|
--color-field-bg: var(--field-bg);
|
||||||
--color-field-border: var(--field-border);
|
--color-field-border: var(--field-border);
|
||||||
@@ -443,13 +465,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Shape semantics that also need to exist as Tailwind utilities
|
/* Shape semantics that also need to exist as Tailwind utilities
|
||||||
(rounded-control / rounded-card / rounded-surface). They alias tier 1
|
(rounded-chip / rounded-control / rounded-card / rounded-surface). They
|
||||||
directly - never a tier-2 name, which would self-reference. */
|
alias tier 1 directly - never a tier-2 name, which would self-reference.
|
||||||
|
|
||||||
|
THE DRAFTS DRAW THREE CORNERS, NOT FIVE. Every panel, card, button, input
|
||||||
|
and menu in the Claude Design drafts is 4px; every tag, thumbnail, flag and
|
||||||
|
checkbox is 2px; filter chips and the status pill are fully round. Nothing
|
||||||
|
in the drafts is 8px or 12px, so `--radius-card` and `--radius-surface` come
|
||||||
|
down to radius/sm and the old `--radius-control-lg` (radius/md, a quirk of
|
||||||
|
the Figma Button frame's ghost cells) is gone. The tier-1 ramp is untouched:
|
||||||
|
only the meanings moved. */
|
||||||
@theme static {
|
@theme static {
|
||||||
--radius-control: var(--radius-sm);
|
--radius-chip: var(--radius-xs); /* 2px - tags, thumbnails, checkboxes */
|
||||||
--radius-control-lg: var(--radius-md);
|
--radius-control: var(--radius-sm); /* 4px - buttons, fields, menu items */
|
||||||
--radius-card: var(--radius-md);
|
--radius-card: var(--radius-sm); /* 4px */
|
||||||
--radius-surface: var(--radius-lg);
|
--radius-surface: var(--radius-sm); /* 4px */
|
||||||
--radius-pill: var(--radius-full);
|
--radius-pill: var(--radius-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "react-router build",
|
"build": "react-router build",
|
||||||
"dev": "react-router dev --host",
|
"dev": "react-router dev --host",
|
||||||
"start": "react-router-serve ./build/server/index.js",
|
"start": "node --env-file-if-exists=.env ../../node_modules/.bin/react-router-serve ./build/server/index.js",
|
||||||
"typecheck": "react-router typegen && tsc"
|
"typecheck": "react-router typegen && tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g id="globe" clip-path="url(#clip0_0_123)">
|
|
||||||
<path id="Vector" d="M12 0C9.62663 0 7.30655 0.703788 5.33316 2.02236C3.35977 3.34094 1.8217 5.21509 0.913451 7.4078C0.00519943 9.60051 -0.232441 12.0133 0.230582 14.3411C0.693605 16.6689 1.83649 18.8071 3.51472 20.4853C5.19295 22.1635 7.33115 23.3064 9.65892 23.7694C11.9867 24.2324 14.3995 23.9948 16.5922 23.0866C18.7849 22.1783 20.6591 20.6402 21.9776 18.6668C23.2962 16.6935 24 14.3734 24 12C23.9966 8.81846 22.7312 5.76821 20.4815 3.51852C18.2318 1.26883 15.1815 0.00344108 12 0ZM15.222 7H8.77801C9.62677 5.32427 10.7119 3.77916 12 2.412C13.2886 3.77877 14.3738 5.32396 15.222 7ZM16.022 9C16.3294 9.97068 16.4898 10.9819 16.498 12C16.4898 13.0181 16.3294 14.0293 16.022 15H7.97601C7.66866 14.0293 7.50821 13.0181 7.50001 12C7.50821 10.9819 7.66866 9.97068 7.97601 9H16.022ZM9.40001 2.356C8.24766 3.76578 7.29659 5.3287 6.57401 7H3.35301C4.67886 4.71643 6.85166 3.04775 9.40001 2.356ZM2.00001 12C1.99991 10.9824 2.15538 9.97067 2.46101 9H5.90001C5.64076 9.97915 5.50636 10.9871 5.50001 12C5.50636 13.0129 5.64076 14.0209 5.90001 15H2.46101C2.15538 14.0293 1.99991 13.0176 2.00001 12ZM3.35301 17H6.57401C7.29659 18.6713 8.24766 20.2342 9.40001 21.644C6.85166 20.9522 4.67886 19.2836 3.35301 17ZM8.77801 17H15.222C14.3732 18.6757 13.2882 20.2208 12 21.588C10.7114 20.2212 9.62625 18.676 8.77801 17ZM14.605 21.644C15.7556 20.2339 16.705 18.671 17.426 17H20.647C19.3223 19.2825 17.1515 20.9511 14.605 21.644ZM22 12C22.0001 13.0176 21.8446 14.0293 21.539 15H18.1C18.3592 14.0209 18.4936 13.0129 18.5 12C18.4936 10.9871 18.3592 9.97915 18.1 9H21.537C21.8433 9.97056 21.9995 10.9823 22 12ZM17.426 7C16.705 5.32899 15.7556 3.76609 14.605 2.356C17.1515 3.04893 19.3223 4.71747 20.647 7H17.426Z" fill="#1D1D1B"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_0_123">
|
|
||||||
<rect width="24" height="24" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,10 +0,0 @@
|
|||||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g id="globe" clip-path="url(#clip0_0_6)">
|
|
||||||
<path id="Vector" d="M12.8338 7C12.8338 10.2219 10.2219 12.8338 7 12.8338M12.8338 7C12.8338 3.77808 10.2219 1.1662 7 1.1662M12.8338 7H1.1662M7 12.8338C3.77808 12.8338 1.1662 10.2219 1.1662 7M7 12.8338C5.50202 11.2609 4.66648 9.17208 4.66648 7C4.66648 4.82793 5.50202 2.73908 7 1.1662M7 12.8338C8.49798 11.2609 9.33352 9.17208 9.33352 7C9.33352 4.82793 8.49798 2.73908 7 1.1662M7 1.1662C3.77808 1.1662 1.1662 3.77808 1.1662 7" stroke="#008764" stroke-linecap="round"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_0_6">
|
|
||||||
<rect width="14" height="14" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 786 B |
@@ -1 +1,22 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="116.87" height="46" viewBox="217.133 0 116.867 46"><!-- "-ER" of the SHARP-ER lockup: dash, E and R from Figma logo/sharp-er (317:5571), in its 334x46 units. The viewBox starts at the P's right edge so this sits flush after sharp-wordmark-inverse.svg; glyphs are lowered 0.66 units onto the SHARP baseline. --><g fill="#FFFFFF" transform="translate(0 0.655)"><rect x="222.411" y="18.612" width="25.518" height="7.181"/><path transform="translate(254.297 0.680)" d="M0 0H31.5681L32.2058 12.6272H31.0579C29.2722 8.33311 27.4015 5.39953 25.4458 3.82644C23.4901 2.21083 20.6203 1.40303 16.8363 1.40303H14.6042V21.1092H16.7725C18.7708 21.1092 20.4501 20.3652 21.8107 18.8771C23.1712 17.3465 24.1278 15.3058 24.6805 12.7548H25.6371V31.6319H24.553C23.7877 28.4432 22.746 26.1686 21.428 24.8081C20.1526 23.4475 18.3882 22.7673 16.1348 22.7673H14.6042V43.2387H17.0276C20.9391 43.2387 24.2554 42.0695 26.9764 39.7312C29.7399 37.3928 31.6319 34.3954 32.6523 30.739H33.9277L33.4175 44.6418H0V43.2387H3.58728V1.40303H0V0Z"/><path transform="translate(291.515 0.106)" d="M15.3596 2.32247C15.8358 1.61254 16.8895 1.25758 18.5207 1.25758C21.3575 1.25758 23.3535 2.17034 24.5186 3.98572C25.6837 5.80109 26.2714 8.35682 26.2714 11.6529C26.2714 13.5697 26.0282 15.3141 25.552 16.8759C25.0758 18.4377 24.1944 19.6953 22.9279 20.6284C21.6615 21.5614 19.7871 22.0381 17.3251 22.0381H14.6402V6.64286C14.6402 4.47252 14.8834 3.03239 15.3596 2.32247ZM41.5296 40.8206C41.1142 41.277 40.7292 41.6218 40.3746 41.8551C40.02 42.0883 39.5742 42.2202 39.0271 42.2608C38.2368 42.2608 37.629 41.9261 37.2135 41.2567C36.7982 40.5874 36.403 39.4211 36.0281 37.7477L34.5894 31.2975C34.0423 28.7114 32.8671 26.7743 31.0535 25.4761C29.2399 24.178 26.4639 23.3768 22.705 23.0421C24.3362 22.8596 25.8053 22.5756 27.0819 22.1902C29.5034 21.46 31.4081 20.4762 32.8063 19.2491C34.2045 18.0219 35.1872 16.6832 35.7749 15.2431C36.3625 13.8029 36.6563 12.373 36.6563 10.9531C36.6563 7.48463 35.2176 4.8072 32.3402 2.91069C29.4629 1.01418 25.1265 0.0608505 19.3312 0.0608505C18.5409 0.0608505 17.2035 0.0811341 15.3292 0.121701C13.4548 0.162268 11.5096 0.182552 9.51363 0.182552C7.76084 0.182552 5.99795 0.162268 4.22489 0.121701C2.45186 0.0811341 1.04357 0.0405669 0 0V1.25758C1.29686 1.29815 2.27962 1.46042 2.96857 1.7241C3.65753 1.98779 4.11345 2.49488 4.34648 3.22508C4.57949 3.95529 4.69095 5.09117 4.69095 6.63272V37.6868C4.69095 39.1878 4.57949 40.3135 4.34648 41.064C4.11345 41.8145 3.66766 42.3216 3.00909 42.565C2.35056 42.8084 1.33738 42.9808 0 43.062V44.3094C1.04357 44.2283 2.45186 44.1776 4.22489 44.1573C5.99795 44.137 7.88241 44.1268 9.88849 44.1268C11.6413 44.1268 13.3637 44.137 15.0759 44.1573C16.7881 44.1776 18.2066 44.2283 19.3312 44.3094V43.0721C17.9938 42.991 17.0009 42.8186 16.3322 42.5752C15.6635 42.3318 15.2177 41.8247 14.9847 41.0742C14.7517 40.3237 14.6402 39.1979 14.6402 37.697V23.2956H17.9533H18.703C19.9999 23.2956 21.0029 23.681 21.7324 24.4518C22.4619 25.2226 23.0292 26.2672 23.4548 27.5856C23.8803 28.904 24.2248 30.4355 24.5186 32.19L25.6432 38.4475C26.0991 41.0336 27.0616 42.8084 28.5206 43.7617C29.9796 44.7151 31.8336 45.2019 34.093 45.2019C35.9268 45.2019 37.4871 44.9179 38.784 44.3601C40.0808 43.8023 41.2865 42.8693 42.4111 41.5711L41.5296 40.8206Z"/></g></svg>
|
<svg width="112" height="46" viewBox="0 0 112 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_654_4402" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="68" y="0" width="44" height="46">
|
||||||
|
<path d="M68.917 0H111.588V45.3429H68.917V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_654_4402)">
|
||||||
|
<mask id="mask1_654_4402" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="69" y="0" width="43" height="46">
|
||||||
|
<path d="M84.4732 2.42832C84.9494 1.7184 86.0031 1.36344 87.6343 1.36344C90.4712 1.36344 92.4671 2.2762 93.6322 4.09157C94.7974 5.90695 95.385 8.46268 95.385 11.7588C95.385 13.6755 95.1418 15.4199 94.6657 16.9818C94.1895 18.5436 93.308 19.8012 92.0416 20.7342C90.7751 21.6673 88.9008 22.1439 86.4388 22.1439H83.7539V6.74872C83.7539 4.57838 83.997 3.13825 84.4732 2.42832ZM110.643 40.9265C110.228 41.3829 109.843 41.7277 109.488 41.9609C109.134 42.1942 108.688 42.326 108.141 42.3666C107.35 42.3666 106.743 42.0319 106.327 41.3626C105.912 40.6932 105.517 39.5269 105.142 37.8535L103.703 31.4034C103.156 28.8172 101.981 26.8801 100.167 25.582C98.3536 24.2838 95.5775 23.4826 91.8187 23.148C93.4499 22.9654 94.919 22.6814 96.1955 22.2961C98.617 21.5658 100.522 20.5821 101.92 19.3549C103.318 18.1278 104.301 16.7891 104.889 15.3489C105.476 13.9088 105.77 12.4788 105.77 11.059C105.77 7.59048 104.331 4.91306 101.454 3.01655C98.5765 1.12003 94.2401 0.166708 88.4448 0.166708C87.6546 0.166708 86.3172 0.186992 84.4428 0.227559C82.5685 0.268126 80.6232 0.28841 78.6273 0.28841C76.8745 0.28841 75.1116 0.268126 73.3385 0.227559C71.5655 0.186992 70.1572 0.146425 69.1136 0.105858V1.36344C70.4105 1.404 71.3933 1.56627 72.0822 1.82996C72.7712 2.09364 73.2271 2.60073 73.4601 3.33094C73.6931 4.06115 73.8046 5.19703 73.8046 6.73858V37.7927C73.8046 39.2937 73.6931 40.4194 73.4601 41.1699C73.2271 41.9204 72.7813 42.4275 72.1227 42.6709C71.4642 42.9143 70.451 43.0867 69.1136 43.1678V44.4152C70.1572 44.3341 71.5655 44.2834 73.3385 44.2631C75.1116 44.2428 76.9961 44.2327 79.0021 44.2327C80.7549 44.2327 82.4773 44.2428 84.1896 44.2631C85.9018 44.2834 87.3202 44.3341 88.4448 44.4152V43.178C87.1075 43.0968 86.1146 42.9244 85.4459 42.681C84.7772 42.4376 84.3314 41.9305 84.0984 41.18C83.8653 40.4295 83.7539 39.3038 83.7539 37.8028V23.4015H87.0669H87.8167C89.1135 23.4015 90.1166 23.7869 90.846 24.5577C91.5755 25.3284 92.1429 26.373 92.5684 27.6915C92.994 29.0099 93.3384 30.5413 93.6322 32.2958L94.7569 38.5533C95.2128 41.1395 96.1753 42.9143 97.6343 43.8676C99.0932 44.8209 100.947 45.3077 103.207 45.3077C105.04 45.3077 106.601 45.0238 107.898 44.466C109.194 43.9082 110.4 42.9751 111.525 41.677L110.643 40.9265Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask1_654_4402)">
|
||||||
|
<path d="M-71.3516 -5.69522H156.01V50.381H-71.3516V-5.69522Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#clip0_654_4402)">
|
||||||
|
<path d="M31.887 0.679123H63.4551L64.0929 13.3064H62.9449C61.1593 9.01223 59.2885 6.07865 57.3328 4.50556C55.3771 2.88995 52.5073 2.08215 48.7233 2.08215H46.4913V21.7883H48.6596C50.6578 21.7883 52.3372 21.0443 53.6977 19.5562C55.0582 18.0256 56.0149 15.9849 56.5675 13.4339H57.5241V32.311H56.44C55.6747 29.1223 54.633 26.8477 53.3151 25.4872C52.0396 24.1267 50.2752 23.4464 48.0218 23.4464H46.4913V43.9179H48.9147C52.8262 43.9179 56.1424 42.7486 58.8634 40.4103C61.627 38.0719 63.5189 35.0746 64.5393 31.4182H65.8148L65.3046 45.3209H31.887V43.9179H35.4743V2.08215H31.887V0.679123Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<rect y="18.6099" width="25.5096" height="7.18178" fill="white"/>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_654_4402">
|
||||||
|
<rect width="35.8728" height="44.6418" fill="white" transform="translate(30.2927 0.679123)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg width="112" height="46" viewBox="0 0 112 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_652_4234" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="68" y="0" width="44" height="46">
|
||||||
|
<path d="M68.9169 0H111.588V45.3429H68.9169V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_652_4234)">
|
||||||
|
<mask id="mask1_652_4234" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="69" y="0" width="43" height="46">
|
||||||
|
<path d="M84.4732 2.42832C84.9494 1.7184 86.0031 1.36344 87.6343 1.36344C90.4711 1.36344 92.4671 2.2762 93.6322 4.09157C94.7973 5.90695 95.385 8.46268 95.385 11.7588C95.385 13.6755 95.1418 15.4199 94.6656 16.9818C94.1894 18.5436 93.308 19.8012 92.0415 20.7342C90.7751 21.6673 88.9007 22.1439 86.4387 22.1439H83.7538V6.74872C83.7538 4.57838 83.997 3.13825 84.4732 2.42832ZM110.643 40.9265C110.228 41.3829 109.843 41.7277 109.488 41.9609C109.134 42.1942 108.688 42.326 108.141 42.3666C107.35 42.3666 106.743 42.0319 106.327 41.3626C105.912 40.6932 105.517 39.5269 105.142 37.8535L103.703 31.4034C103.156 28.8172 101.981 26.8801 100.167 25.582C98.3536 24.2838 95.5775 23.4826 91.8186 23.148C93.4499 22.9654 94.9189 22.6814 96.1955 22.2961C98.617 21.5658 100.522 20.5821 101.92 19.3549C103.318 18.1278 104.301 16.7891 104.888 15.3489C105.476 13.9088 105.77 12.4788 105.77 11.059C105.77 7.59048 104.331 4.91306 101.454 3.01655C98.5765 1.12003 94.2401 0.166708 88.4448 0.166708C87.6545 0.166708 86.3172 0.186992 84.4428 0.227559C82.5685 0.268126 80.6232 0.28841 78.6272 0.28841C76.8745 0.28841 75.1116 0.268126 73.3385 0.227559C71.5655 0.186992 70.1572 0.146425 69.1136 0.105858V1.36344C70.4105 1.404 71.3932 1.56627 72.0822 1.82996C72.7712 2.09364 73.2271 2.60073 73.4601 3.33094C73.6931 4.06115 73.8046 5.19703 73.8046 6.73858V37.7927C73.8046 39.2937 73.6931 40.4194 73.4601 41.1699C73.2271 41.9204 72.7813 42.4275 72.1227 42.6709C71.4642 42.9143 70.451 43.0867 69.1136 43.1678V44.4152C70.1572 44.3341 71.5655 44.2834 73.3385 44.2631C75.1116 44.2428 76.996 44.2327 79.0021 44.2327C80.7549 44.2327 82.4773 44.2428 84.1895 44.2631C85.9018 44.2834 87.3202 44.3341 88.4448 44.4152V43.178C87.1074 43.0968 86.1145 42.9244 85.4458 42.681C84.7772 42.4376 84.3313 41.9305 84.0983 41.18C83.8653 40.4295 83.7538 39.3038 83.7538 37.8028V23.4015H87.0669H87.8166C89.1135 23.4015 90.1165 23.7869 90.846 24.5577C91.5755 25.3284 92.1428 26.373 92.5684 27.6915C92.9939 29.0099 93.3384 30.5413 93.6322 32.2958L94.7568 38.5533C95.2127 41.1395 96.1753 42.9143 97.6342 43.8676C99.0932 44.8209 100.947 45.3077 103.207 45.3077C105.04 45.3077 106.601 45.0238 107.898 44.466C109.194 43.9082 110.4 42.9751 111.525 41.677L110.643 40.9265Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask1_652_4234)">
|
||||||
|
<path d="M-71.3516 -5.69522H156.01V50.381H-71.3516V-5.69522Z" fill="#1D1D1B"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g clip-path="url(#clip0_652_4234)">
|
||||||
|
<path d="M31.887 0.679123H63.4551L64.0928 13.3064H62.9449C61.1592 9.01223 59.2885 6.07865 57.3328 4.50556C55.3771 2.88995 52.5073 2.08215 48.7233 2.08215H46.4912V21.7883H48.6595C50.6578 21.7883 52.3371 21.0443 53.6977 19.5562C55.0582 18.0256 56.0148 15.9849 56.5675 13.4339H57.5241V32.311H56.44C55.6747 29.1223 54.633 26.8477 53.315 25.4872C52.0396 24.1267 50.2752 23.4464 48.0218 23.4464H46.4912V43.9179H48.9146C52.8261 43.9179 56.1424 42.7486 58.8634 40.4103C61.6269 38.0719 63.5189 35.0746 64.5392 31.4182H65.8147L65.3045 45.3209H31.887V43.9179H35.4743V2.08215H31.887V0.679123Z" fill="#1D1D1B"/>
|
||||||
|
</g>
|
||||||
|
<rect y="18.6099" width="25.5096" height="7.18178" fill="#1D1D1B"/>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_652_4234">
|
||||||
|
<rect width="35.8728" height="44.6418" fill="white" transform="translate(30.2926 0.679123)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -1,285 +1,93 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<svg width="218" height="46" viewBox="0 0 218 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<svg
|
<mask id="mask0_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="135" y="0" width="43" height="46">
|
||||||
width="272.0"
|
<path d="M135.235 0.657146H177.907V46H135.235V0.657146Z" fill="white"/>
|
||||||
height="56.8"
|
</mask>
|
||||||
viewBox="209.6 153.6 272.0 56.8"
|
<g mask="url(#mask0_654_5095)">
|
||||||
version="1.1"
|
<mask id="mask1_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="135" y="0" width="43" height="46">
|
||||||
id="svg906"
|
<path d="M150.792 3.08547C151.268 2.37555 152.322 2.02058 153.953 2.02058C156.79 2.02058 158.786 2.93334 159.951 4.74872C161.116 6.5641 161.704 9.11982 161.704 12.4159C161.704 14.3327 161.46 16.0771 160.984 17.6389C160.508 19.2007 159.627 20.4583 158.36 21.3914C157.094 22.3244 155.219 22.8011 152.757 22.8011H150.072V7.40586C150.072 5.23552 150.316 3.79539 150.792 3.08547ZM176.962 41.5836C176.546 42.04 176.161 42.3848 175.807 42.6181C175.452 42.8513 175.006 42.9832 174.459 43.0238C173.669 43.0238 173.061 42.6891 172.646 42.0197C172.23 41.3504 171.835 40.1841 171.46 38.5107L170.022 32.0605C169.474 29.4744 168.299 27.5373 166.486 26.2391C164.672 24.941 161.896 24.1398 158.137 23.8051C159.768 23.6226 161.237 23.3386 162.514 22.9532C164.935 22.223 166.84 21.2392 168.238 20.0121C169.637 18.7849 170.619 17.4462 171.207 16.0061C171.795 14.566 172.088 13.136 172.088 11.7161C172.088 8.24763 170.65 5.5702 167.772 3.67369C164.895 1.77718 160.559 0.823855 154.763 0.823855C153.973 0.823855 152.636 0.844138 150.761 0.884705C148.887 0.925272 146.942 0.945556 144.946 0.945556C143.193 0.945556 141.43 0.925272 139.657 0.884705C137.884 0.844138 136.476 0.803571 135.432 0.763004V2.02058C136.729 2.06115 137.712 2.22342 138.401 2.48711C139.09 2.75079 139.546 3.25788 139.779 3.98809C140.012 4.71829 140.123 5.85417 140.123 7.39572V38.4498C140.123 39.9508 140.012 41.0765 139.779 41.827C139.546 42.5775 139.1 43.0846 138.441 43.328C137.783 43.5714 136.77 43.7438 135.432 43.825V45.0724C136.476 44.9913 137.884 44.9406 139.657 44.9203C141.43 44.9 143.315 44.8898 145.321 44.8898C147.073 44.8898 148.796 44.9 150.508 44.9203C152.22 44.9406 153.639 44.9913 154.763 45.0724V43.8351C153.426 43.754 152.433 43.5816 151.764 43.3382C151.096 43.0948 150.65 42.5877 150.417 41.8372C150.184 41.0867 150.072 39.9609 150.072 38.46V24.0587H153.385H154.135C155.432 24.0587 156.435 24.444 157.165 25.2148C157.894 25.9856 158.461 27.0302 158.887 28.3486C159.312 29.667 159.657 31.1985 159.951 32.953L161.075 39.2105C161.531 41.7966 162.494 43.5714 163.953 44.5247C165.412 45.4781 167.266 45.9649 169.525 45.9649C171.359 45.9649 172.919 45.6809 174.216 45.1231C175.513 44.5653 176.719 43.6323 177.843 42.3341L176.962 41.5836Z" fill="white"/>
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
</mask>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<g mask="url(#mask1_654_5095)">
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
<path d="M-5.03308 -5.03807H222.329V51.0381H-5.03308V-5.03807Z" fill="white"/>
|
||||||
<defs
|
</g>
|
||||||
id="defs892">
|
</g>
|
||||||
<clipPath
|
<mask id="mask2_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="180" y="0" width="38" height="46">
|
||||||
id="clip-69">
|
<path d="M180.314 0.657146H217.296V45.1238H180.314V0.657146Z" fill="white"/>
|
||||||
<path
|
</mask>
|
||||||
clip-rule="nonzero"
|
<g mask="url(#mask2_654_5095)">
|
||||||
d="M 0 0.320312 L 53.457031 0.320312 L 53.457031 57 L 0 57 Z M 0 0.320312 "
|
<mask id="mask3_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="180" y="0" width="38" height="46">
|
||||||
id="path456" />
|
<path d="M206.05 19.7991C205.574 21.6348 204.692 23.1155 203.426 24.2412C202.159 25.367 200.285 25.9349 197.823 25.9349H195.138V7.40588C195.138 5.23554 195.381 3.79541 195.857 3.08548C196.334 2.37556 197.387 2.0206 199.018 2.0206C201.855 2.0206 203.851 3.00435 205.016 4.96171C206.181 6.91907 206.769 9.89061 206.769 13.8459C206.769 15.9757 206.526 17.9533 206.05 19.789M212.838 4.26193C209.961 1.96989 205.624 0.813727 199.829 0.813727C199.039 0.813727 197.701 0.83401 195.827 0.874577C193.953 0.915144 192.017 0.935428 190.011 0.935428C188.259 0.935428 186.496 0.915144 184.723 0.874577C182.95 0.83401 181.541 0.793443 180.498 0.752876V2.0206C181.795 2.06116 182.777 2.22343 183.466 2.48712C184.155 2.7508 184.611 3.25789 184.844 3.9881C185.077 4.71831 185.189 5.85419 185.189 7.39574V38.4498C185.189 39.9508 185.077 41.0766 184.844 41.827C184.611 42.5775 184.165 43.0745 183.497 43.328C182.828 43.5816 181.825 43.7438 180.498 43.8351V45.0826C181.876 45.042 183.416 45.0014 185.128 44.9609C186.84 44.9203 188.593 44.9 190.376 44.9C192.625 44.9 194.773 44.9203 196.82 44.9609C198.866 45.0014 200.528 45.042 201.825 45.0826V43.7134C199.951 43.6729 198.532 43.5309 197.57 43.2773C196.607 43.0238 195.959 42.5065 195.624 41.7155C195.29 40.9244 195.128 39.7074 195.128 38.0847V27.1823H198.441C202.109 27.1823 205.158 26.7462 207.57 25.874C209.981 25.0019 211.896 23.8558 213.294 22.4664C214.692 21.077 215.685 19.5456 216.262 17.8925C216.84 16.2394 217.134 14.6674 217.134 13.1664C217.134 9.53565 215.695 6.57425 212.818 4.28221" fill="white"/>
|
||||||
</clipPath>
|
</mask>
|
||||||
<clipPath
|
<g mask="url(#mask3_654_5095)">
|
||||||
id="clip-70">
|
<mask id="mask4_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="180" y="0" width="38" height="46">
|
||||||
<path
|
<path d="M180.362 0.70108H217.287V45.1324H180.362V0.70108Z" fill="white"/>
|
||||||
clip-rule="nonzero"
|
</mask>
|
||||||
d="M 19.445312 3.234375 C 20.039062 2.347656 21.363281 1.902344 23.410156 1.902344 C 26.96875 1.902344 29.472656 3.046875 30.933594 5.320312 C 32.394531 7.59375 33.132812 10.796875 33.132812 14.929688 C 33.132812 17.332031 32.828125 19.515625 32.230469 21.472656 C 31.632812 23.429688 30.527344 25.007812 28.9375 26.175781 C 27.347656 27.34375 24.996094 27.941406 21.910156 27.941406 L 18.542969 27.941406 L 18.542969 8.648438 C 18.542969 5.929688 18.847656 4.125 19.445312 3.234375 M 52.273438 51.480469 C 51.75 52.050781 51.269531 52.484375 50.824219 52.777344 C 50.378906 53.070312 49.820312 53.234375 49.132812 53.285156 C 48.140625 53.285156 47.378906 52.867188 46.859375 52.027344 C 46.335938 51.1875 45.839844 49.726562 45.371094 47.628906 L 43.566406 39.546875 C 42.878906 36.304688 41.40625 33.878906 39.128906 32.25 C 36.855469 30.625 33.375 29.621094 28.65625 29.199219 C 30.703125 28.972656 32.546875 28.617188 34.148438 28.132812 C 37.1875 27.21875 39.574219 25.984375 41.328125 24.449219 C 43.082031 22.910156 44.316406 21.230469 45.054688 19.425781 C 45.789062 17.621094 46.160156 15.832031 46.160156 14.050781 C 46.160156 9.703125 44.355469 6.351562 40.746094 3.972656 C 37.136719 1.597656 31.695312 0.402344 24.425781 0.402344 C 23.433594 0.402344 21.757812 0.425781 19.40625 0.476562 C 17.054688 0.527344 14.613281 0.554688 12.109375 0.554688 C 9.910156 0.554688 7.699219 0.527344 5.476562 0.476562 C 3.25 0.425781 1.484375 0.375 0.175781 0.324219 L 0.175781 1.902344 C 1.804688 1.953125 3.035156 2.15625 3.898438 2.484375 C 4.765625 2.816406 5.335938 3.453125 5.628906 4.367188 C 5.921875 5.28125 6.058594 6.707031 6.058594 8.636719 L 6.058594 47.554688 C 6.058594 49.433594 5.921875 50.84375 5.628906 51.785156 C 5.335938 52.726562 4.777344 53.363281 3.949219 53.667969 C 3.125 53.972656 1.855469 54.1875 0.175781 54.289062 L 0.175781 55.851562 C 1.484375 55.75 3.25 55.6875 5.476562 55.660156 C 7.699219 55.636719 10.0625 55.625 12.582031 55.625 C 14.777344 55.625 16.941406 55.636719 19.085938 55.660156 C 21.234375 55.6875 23.015625 55.75 24.425781 55.851562 L 24.425781 54.300781 C 22.746094 54.199219 21.503906 53.984375 20.664062 53.679688 C 19.824219 53.375 19.265625 52.738281 18.972656 51.796875 C 18.679688 50.859375 18.542969 49.445312 18.542969 47.566406 L 18.542969 29.519531 L 23.636719 29.519531 C 25.265625 29.519531 26.523438 30 27.4375 30.96875 C 28.351562 31.933594 29.066406 33.242188 29.597656 34.894531 C 30.132812 36.546875 30.5625 38.464844 30.933594 40.664062 L 32.34375 48.507812 C 32.914062 51.746094 34.121094 53.972656 35.953125 55.167969 C 37.785156 56.359375 40.109375 56.972656 42.941406 56.972656 C 45.242188 56.972656 47.199219 56.617188 48.828125 55.917969 C 50.453125 55.21875 51.96875 54.046875 53.378906 52.421875 Z M 52.273438 51.480469 "
|
<g mask="url(#mask4_654_5095)">
|
||||||
id="path459" />
|
<path d="M-5.03302 -5.03807H222.329V51.0381H-5.03302V-5.03807Z" fill="white"/>
|
||||||
</clipPath>
|
</g>
|
||||||
<clipPath
|
</g>
|
||||||
id="clip-71">
|
</g>
|
||||||
<rect
|
<mask id="mask5_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="103" y="0" width="30" height="46">
|
||||||
x="0"
|
<path d="M103.068 0.438099H132.61V45.1238H103.068V0.438099Z" fill="white"/>
|
||||||
y="0"
|
</mask>
|
||||||
width="54"
|
<g mask="url(#mask5_654_5095)">
|
||||||
height="57"
|
<mask id="mask6_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="103" y="0" width="30" height="46">
|
||||||
id="rect462" />
|
<path d="M103.214 0.641306L117.074 38.5817C117.904 40.7114 118.046 42.1009 117.479 42.7499C116.911 43.399 115.574 43.7641 113.446 43.8452V45.0927C114.449 45.0116 115.807 44.9609 117.509 44.9406C119.211 44.9203 121.035 44.9101 122.95 44.9101C124.662 44.9101 126.364 44.9203 128.046 44.9406C129.728 44.9609 131.207 45.0116 132.453 45.0927V43.8351C131.866 43.7945 131.207 43.4903 130.488 42.9223C129.769 42.3544 129.1 41.3301 128.512 39.819L113.315 0.631165L103.214 0.641306Z" fill="white"/>
|
||||||
</clipPath>
|
</mask>
|
||||||
<g
|
<g mask="url(#mask6_654_5095)">
|
||||||
id="source-89"
|
<mask id="mask7_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="103" y="0" width="30" height="46">
|
||||||
clip-path="url(#clip-71)">
|
<path d="M103.068 0.509548H132.531V45.1323H103.068V0.509548Z" fill="white"/>
|
||||||
<g
|
</mask>
|
||||||
clip-path="url(#clip-69)"
|
<g mask="url(#mask7_654_5095)">
|
||||||
id="g469">
|
<path d="M-5.03308 -5.0381H222.329V51.0381H-5.03308V-5.0381Z" fill="white"/>
|
||||||
<g
|
</g>
|
||||||
clip-path="url(#clip-70)"
|
</g>
|
||||||
id="g467">
|
</g>
|
||||||
<rect
|
<mask id="mask8_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="35" height="46">
|
||||||
x="-695.8"
|
<path d="M0 0H34.3559V46H0V0Z" fill="white"/>
|
||||||
y="-332.199998"
|
</mask>
|
||||||
width="2073.6"
|
<g mask="url(#mask8_654_5095)">
|
||||||
height="1166.39999"
|
<mask id="mask9_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="35" height="46">
|
||||||
fill="rgb(100%, 100%, 100%)"
|
<path d="M12.4846 8.79529C13.7308 7.84197 15.4836 7.3653 17.7328 7.3653C20.0631 7.3653 21.8766 7.93324 23.1634 9.06912C24.4501 10.205 25.0985 11.7973 25.0985 13.8459H34.298C34.298 11.1685 33.6091 8.76487 32.2312 6.66552C30.8533 4.56617 28.908 2.92321 26.4156 1.76705C23.9232 0.610884 21.0762 0.0328026 17.8848 0.0328026C14.7136 0.0328026 11.8564 0.560174 9.3134 1.62506C6.77035 2.68995 4.81495 4.17065 3.4573 6.06716C2.09966 7.96367 1.42084 10.1137 1.42084 12.5275C1.42084 17.1724 3.95376 20.8741 9.02971 23.6124C10.8939 24.6165 13.4167 25.6306 16.6082 26.6651C19.7997 27.6996 22.0084 28.6833 23.2343 29.6163C24.4602 30.5494 25.0782 31.8881 25.0782 33.6223C25.0782 35.2146 24.4602 36.462 23.2343 37.3545C22.0084 38.247 20.2961 38.6932 18.1077 38.6932C12.2212 38.6932 9.27287 36.2288 9.27287 31.2897H0.0328064C0.0328064 34.1801 0.772417 36.7156 2.25164 38.9062C3.73086 41.0968 5.8889 42.8209 8.7055 44.0785C11.5221 45.3361 14.6528 45.9649 18.0874 45.9649C23.0317 45.9649 26.9729 44.8594 29.8908 42.6587C32.8087 40.4579 34.2778 37.4255 34.2778 33.5514C34.2778 30.0727 33.0822 27.162 30.6912 24.8294C28.3001 22.4968 24.4805 20.5395 19.2525 18.9675C16.4056 18.1054 14.2576 17.1825 12.7987 16.1988C11.3397 15.215 10.6001 13.998 10.6001 12.5477C10.6001 10.9961 11.2283 9.73848 12.4745 8.78515" fill="white"/>
|
||||||
fill-opacity="1"
|
</mask>
|
||||||
id="rect465" />
|
<g mask="url(#mask9_654_5095)">
|
||||||
</g>
|
<mask id="mask10_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="-1" width="36" height="48">
|
||||||
</g>
|
<path d="M-0.0545349 -0.0649948H34.3834V46.0899H-0.0545349V-0.0649948Z" fill="white"/>
|
||||||
</g>
|
</mask>
|
||||||
<clipPath
|
<g mask="url(#mask10_654_5095)">
|
||||||
id="clip-74">
|
<path d="M-5.03302 -5.0381H222.329V51.0381H-5.03302V-5.0381Z" fill="white"/>
|
||||||
<path
|
</g>
|
||||||
clip-rule="nonzero"
|
</g>
|
||||||
d="M 0.601562 0.191406 L 46.679688 0.191406 L 46.679688 55.917969 L 0.601562 55.917969 Z M 0.601562 0.191406 "
|
</g>
|
||||||
id="path478" />
|
<mask id="mask11_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="40" y="0" width="38" height="46">
|
||||||
</clipPath>
|
<path d="M40.7019 0.438099H77.2461V45.3429H40.7019V0.438099Z" fill="white"/>
|
||||||
<clipPath
|
</mask>
|
||||||
id="clip-75">
|
<g mask="url(#mask11_654_5095)">
|
||||||
<path
|
<mask id="mask12_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="40" y="0" width="38" height="46">
|
||||||
clip-rule="nonzero"
|
<path d="M67.8946 18.7647H49.9514V0.651451H40.7519V45.1637H49.9514V26.1986H67.8946V45.1637H77.0941V0.651451H67.8946V18.7647Z" fill="white"/>
|
||||||
d="M 32.761719 24.179688 C 32.164062 26.480469 31.058594 28.335938 29.46875 29.746094 C 27.878906 31.160156 25.527344 31.871094 22.441406 31.871094 L 19.074219 31.871094 L 19.074219 8.648438 C 19.074219 5.929688 19.378906 4.125 19.976562 3.234375 C 20.574219 2.347656 21.894531 1.902344 23.941406 1.902344 C 27.5 1.902344 30.003906 3.132812 31.464844 5.585938 C 32.925781 8.039062 33.664062 11.765625 33.664062 16.71875 C 33.664062 19.390625 33.359375 21.867188 32.761719 24.167969 M 41.277344 4.710938 C 37.667969 1.835938 32.226562 0.390625 24.957031 0.390625 C 23.964844 0.390625 22.289062 0.414062 19.9375 0.464844 C 17.585938 0.515625 15.160156 0.542969 12.640625 0.542969 C 10.441406 0.542969 8.230469 0.515625 6.007812 0.464844 C 3.785156 0.414062 2.015625 0.363281 0.707031 0.3125 L 0.707031 1.902344 C 2.335938 1.953125 3.566406 2.15625 4.429688 2.484375 C 5.296875 2.816406 5.867188 3.453125 6.160156 4.367188 C 6.453125 5.28125 6.59375 6.707031 6.59375 8.636719 L 6.59375 47.554688 C 6.59375 49.433594 6.453125 50.84375 6.160156 51.785156 C 5.867188 52.726562 5.308594 53.347656 4.46875 53.667969 C 3.632812 53.984375 2.371094 54.1875 0.707031 54.300781 L 0.707031 55.867188 C 2.4375 55.816406 4.367188 55.765625 6.515625 55.714844 C 8.664062 55.660156 10.863281 55.636719 13.097656 55.636719 C 15.921875 55.636719 18.617188 55.660156 21.183594 55.714844 C 23.75 55.765625 25.835938 55.816406 27.460938 55.867188 L 27.460938 54.148438 C 25.109375 54.097656 23.332031 53.921875 22.125 53.601562 C 20.914062 53.285156 20.101562 52.636719 19.683594 51.644531 C 19.261719 50.65625 19.058594 49.128906 19.058594 47.097656 L 19.058594 33.433594 L 23.214844 33.433594 C 27.816406 33.433594 31.640625 32.886719 34.667969 31.792969 C 37.691406 30.699219 40.09375 29.265625 41.847656 27.523438 C 43.601562 25.78125 44.847656 23.863281 45.570312 21.792969 C 46.296875 19.71875 46.664062 17.75 46.664062 15.867188 C 46.664062 11.320312 44.859375 7.609375 41.25 4.734375 "
|
</mask>
|
||||||
id="path481" />
|
<g mask="url(#mask12_654_5095)">
|
||||||
</clipPath>
|
<mask id="mask13_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="40" y="0" width="38" height="46">
|
||||||
<clipPath
|
<path d="M40.697 0.509552H77.2395V45.3239H40.697V0.509552Z" fill="white"/>
|
||||||
id="clip-76">
|
</mask>
|
||||||
<rect
|
<g mask="url(#mask13_654_5095)">
|
||||||
x="0"
|
<path d="M-5.03302 -5.03809H222.329V51.0381H-5.03302V-5.03809Z" fill="white"/>
|
||||||
y="0"
|
</g>
|
||||||
width="47"
|
</g>
|
||||||
height="56"
|
</g>
|
||||||
id="rect484" />
|
<mask id="mask14_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="81" y="0" width="30" height="46">
|
||||||
</clipPath>
|
<path d="M81.4039 0.438099H110.289V45.3429H81.4039V0.438099Z" fill="white"/>
|
||||||
<g
|
</mask>
|
||||||
id="source-92"
|
<g mask="url(#mask14_654_5095)">
|
||||||
clip-path="url(#clip-76)">
|
<mask id="mask15_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="81" y="0" width="30" height="46">
|
||||||
<g
|
<path d="M95.3311 33.6223H110.255L107.53 26.1986H97.8742L102.555 12.5883L98.1274 0.651451L81.5825 45.1637H91.3595L95.3311 33.6223Z" fill="white"/>
|
||||||
clip-path="url(#clip-74)"
|
</mask>
|
||||||
id="g491">
|
<g mask="url(#mask15_654_5095)">
|
||||||
<g
|
<mask id="mask16_654_5095" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="81" y="0" width="30" height="46">
|
||||||
clip-path="url(#clip-75)"
|
<path d="M81.4485 0.509552H110.338V45.3239H81.4485V0.509552Z" fill="white"/>
|
||||||
id="g489">
|
</mask>
|
||||||
<rect
|
<g mask="url(#mask16_654_5095)">
|
||||||
x="-751.8"
|
<path d="M-5.03305 -5.03809H222.329V51.0381H-5.03305V-5.03809Z" fill="white"/>
|
||||||
y="-332.199998"
|
</g>
|
||||||
width="2073.6"
|
</g>
|
||||||
height="1166.39999"
|
</g>
|
||||||
fill="rgb(100%, 100%, 100%)"
|
|
||||||
fill-opacity="1"
|
|
||||||
id="rect487" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<clipPath
|
|
||||||
id="clip-79">
|
|
||||||
<path
|
|
||||||
clip-rule="nonzero"
|
|
||||||
d="M 0.640625 0.078125 L 37.601562 0.078125 L 37.601562 55.917969 L 0.640625 55.917969 Z M 0.640625 0.078125 "
|
|
||||||
id="path500" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
id="clip-80">
|
|
||||||
<path
|
|
||||||
clip-rule="nonzero"
|
|
||||||
d="M 0.761719 0.171875 L 18.148438 47.71875 C 19.1875 50.386719 19.367188 52.128906 18.65625 52.941406 C 17.941406 53.753906 16.265625 54.214844 13.597656 54.316406 L 13.597656 55.878906 C 14.855469 55.777344 16.558594 55.714844 18.691406 55.6875 C 20.828125 55.660156 23.117188 55.648438 25.519531 55.648438 C 27.664062 55.648438 29.800781 55.660156 31.910156 55.6875 C 34.019531 55.714844 35.875 55.777344 37.4375 55.878906 L 37.4375 54.300781 C 36.703125 54.25 35.875 53.871094 34.972656 53.160156 C 34.070312 52.445312 33.234375 51.164062 32.496094 49.269531 L 13.429688 0.160156 Z M 0.761719 0.171875 "
|
|
||||||
id="path503" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
id="clip-81">
|
|
||||||
<rect
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="38"
|
|
||||||
height="56"
|
|
||||||
id="rect506" />
|
|
||||||
</clipPath>
|
|
||||||
<g
|
|
||||||
id="source-95"
|
|
||||||
clip-path="url(#clip-81)">
|
|
||||||
<g
|
|
||||||
clip-path="url(#clip-79)"
|
|
||||||
id="g513">
|
|
||||||
<g
|
|
||||||
clip-path="url(#clip-80)"
|
|
||||||
id="g511">
|
|
||||||
<rect
|
|
||||||
x="-654.8"
|
|
||||||
y="-332.199998"
|
|
||||||
width="2073.6"
|
|
||||||
height="1166.39999"
|
|
||||||
fill="rgb(100%, 100%, 100%)"
|
|
||||||
fill-opacity="1"
|
|
||||||
id="rect509" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<clipPath
|
|
||||||
id="clip-84">
|
|
||||||
<path
|
|
||||||
clip-rule="nonzero"
|
|
||||||
d="M 0.285156 0.371094 L 43.382812 0.371094 L 43.382812 58 L 0.285156 58 Z M 0.285156 0.371094 "
|
|
||||||
id="path522" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
id="clip-85">
|
|
||||||
<path
|
|
||||||
clip-rule="nonzero"
|
|
||||||
d="M 15.945312 11.390625 C 17.511719 10.195312 19.707031 9.597656 22.53125 9.597656 C 25.453125 9.597656 27.726562 10.3125 29.34375 11.734375 C 30.957031 13.15625 31.769531 15.152344 31.769531 17.71875 L 43.308594 17.71875 C 43.308594 14.367188 42.445312 11.351562 40.71875 8.722656 C 38.988281 6.089844 36.550781 4.03125 33.421875 2.582031 C 30.296875 1.136719 26.722656 0.410156 22.722656 0.410156 C 18.742188 0.410156 15.160156 1.070312 11.96875 2.40625 C 8.777344 3.742188 6.324219 5.597656 4.621094 7.972656 C 2.917969 10.347656 2.066406 13.042969 2.066406 16.066406 C 2.066406 21.890625 5.246094 26.527344 11.613281 29.960938 C 13.953125 31.21875 17.117188 32.488281 21.121094 33.785156 C 25.121094 35.082031 27.894531 36.3125 29.429688 37.484375 C 30.96875 38.652344 31.746094 40.332031 31.746094 42.503906 C 31.746094 44.5 30.96875 46.0625 29.429688 47.179688 C 27.894531 48.300781 25.746094 48.859375 23 48.859375 C 15.617188 48.859375 11.917969 45.769531 11.917969 39.582031 L 0.328125 39.582031 C 0.328125 43.203125 1.253906 46.378906 3.109375 49.125 C 4.964844 51.871094 7.671875 54.03125 11.207031 55.605469 C 14.738281 57.183594 18.667969 57.972656 22.976562 57.972656 C 29.175781 57.972656 34.121094 56.585938 37.78125 53.828125 C 41.441406 51.070312 43.285156 47.269531 43.285156 42.414062 C 43.285156 38.054688 41.785156 34.40625 38.785156 31.484375 C 35.785156 28.5625 30.996094 26.109375 24.4375 24.140625 C 20.867188 23.058594 18.171875 21.902344 16.339844 20.667969 C 14.511719 19.4375 13.582031 17.910156 13.582031 16.09375 C 13.582031 14.148438 14.371094 12.574219 15.933594 11.378906 "
|
|
||||||
id="path525" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
id="clip-86">
|
|
||||||
<rect
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="44"
|
|
||||||
height="58"
|
|
||||||
id="rect528" />
|
|
||||||
</clipPath>
|
|
||||||
<g
|
|
||||||
id="source-98"
|
|
||||||
clip-path="url(#clip-86)">
|
|
||||||
<g
|
|
||||||
clip-path="url(#clip-84)"
|
|
||||||
id="g535">
|
|
||||||
<g
|
|
||||||
clip-path="url(#clip-85)"
|
|
||||||
id="g533">
|
|
||||||
<rect
|
|
||||||
x="-525.8"
|
|
||||||
y="-331.199998"
|
|
||||||
width="2073.6"
|
|
||||||
height="1166.39999"
|
|
||||||
fill="rgb(100%, 100%, 100%)"
|
|
||||||
fill-opacity="1"
|
|
||||||
id="rect531" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<clipPath
|
|
||||||
id="clip-88">
|
|
||||||
<path
|
|
||||||
clip-rule="nonzero"
|
|
||||||
d="M 34.453125 0.1875 L 45.996094 0.1875 L 45.996094 22.882812 L 34.453125 22.882812 Z M 0.40625 0.1875 L 11.945312 0.1875 L 11.945312 22.882812 L 0.40625 22.882812 Z M 0.40625 22.882812 L 45.996094 22.882812 L 45.996094 32.199219 L 0.40625 32.199219 Z M 0.40625 32.199219 L 11.945312 32.199219 L 11.945312 55.96875 L 0.40625 55.96875 Z M 34.453125 32.199219 L 45.996094 32.199219 L 45.996094 55.96875 L 34.453125 55.96875 Z M 34.453125 32.199219 "
|
|
||||||
id="path541" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
id="clip-89">
|
|
||||||
<rect
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="46"
|
|
||||||
height="56"
|
|
||||||
id="rect544" />
|
|
||||||
</clipPath>
|
|
||||||
<g
|
|
||||||
id="source-101"
|
|
||||||
clip-path="url(#clip-89)">
|
|
||||||
<g
|
|
||||||
clip-path="url(#clip-88)"
|
|
||||||
id="g549">
|
|
||||||
<rect
|
|
||||||
x="-576.8"
|
|
||||||
y="-332.199998"
|
|
||||||
width="2073.6"
|
|
||||||
height="1166.39999"
|
|
||||||
fill="rgb(100%, 100%, 100%)"
|
|
||||||
fill-opacity="1"
|
|
||||||
id="rect547" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<clipPath
|
|
||||||
id="clip-92">
|
|
||||||
<path
|
|
||||||
clip-rule="nonzero"
|
|
||||||
d="M 0.519531 0.078125 L 36.636719 0.078125 L 36.636719 56 L 0.519531 56 Z M 0.519531 0.078125 "
|
|
||||||
id="path558" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
id="clip-93">
|
|
||||||
<path
|
|
||||||
clip-rule="nonzero"
|
|
||||||
d="M 17.871094 41.503906 L 36.59375 41.503906 L 33.175781 32.199219 L 21.0625 32.199219 L 26.933594 15.144531 L 21.378906 0.1875 L 0.625 55.96875 L 12.890625 55.96875 Z M 17.871094 41.503906 "
|
|
||||||
id="path561" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath
|
|
||||||
id="clip-94">
|
|
||||||
<rect
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="37"
|
|
||||||
height="56"
|
|
||||||
id="rect564" />
|
|
||||||
</clipPath>
|
|
||||||
<g
|
|
||||||
id="source-104"
|
|
||||||
clip-path="url(#clip-94)">
|
|
||||||
<g
|
|
||||||
clip-path="url(#clip-92)"
|
|
||||||
id="g571">
|
|
||||||
<g
|
|
||||||
clip-path="url(#clip-93)"
|
|
||||||
id="g569">
|
|
||||||
<rect
|
|
||||||
x="-627.8"
|
|
||||||
y="-332.199998"
|
|
||||||
width="2073.6"
|
|
||||||
height="1166.39999"
|
|
||||||
fill="rgb(100%, 100%, 100%)"
|
|
||||||
fill-opacity="1"
|
|
||||||
id="rect567" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</defs>
|
|
||||||
<use
|
|
||||||
xlink:href="#source-98"
|
|
||||||
transform="matrix(1, 0, 0, 1, 209, 153)"
|
|
||||||
id="use894" />
|
|
||||||
<use
|
|
||||||
xlink:href="#source-101"
|
|
||||||
transform="matrix(1, 0, 0, 1, 260, 154)"
|
|
||||||
id="use896" />
|
|
||||||
<use
|
|
||||||
xlink:href="#source-104"
|
|
||||||
transform="matrix(1, 0, 0, 1, 311, 154)"
|
|
||||||
id="use898" />
|
|
||||||
<use
|
|
||||||
xlink:href="#source-95"
|
|
||||||
transform="matrix(1, 0, 0, 1, 338, 154)"
|
|
||||||
id="use900" />
|
|
||||||
<use
|
|
||||||
xlink:href="#source-89"
|
|
||||||
transform="matrix(1, 0, 0, 1, 379, 154)"
|
|
||||||
id="use902" />
|
|
||||||
<use
|
|
||||||
xlink:href="#source-92"
|
|
||||||
transform="matrix(1, 0, 0, 1, 435, 154)"
|
|
||||||
id="use904" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
<svg width="218" height="46" viewBox="0 0 218 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<mask id="mask0_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="135" y="0" width="43" height="46">
|
||||||
|
<path d="M135.235 0.657146H177.907V46H135.235V0.657146Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_654_6424)">
|
||||||
|
<mask id="mask1_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="135" y="0" width="43" height="46">
|
||||||
|
<path d="M150.792 3.08547C151.268 2.37555 152.322 2.02058 153.953 2.02058C156.79 2.02058 158.786 2.93334 159.951 4.74872C161.116 6.5641 161.704 9.11982 161.704 12.4159C161.704 14.3327 161.46 16.0771 160.984 17.6389C160.508 19.2007 159.627 20.4583 158.36 21.3914C157.094 22.3244 155.219 22.8011 152.757 22.8011H150.072V7.40586C150.072 5.23552 150.316 3.79539 150.792 3.08547ZM176.962 41.5836C176.546 42.04 176.161 42.3848 175.807 42.6181C175.452 42.8513 175.006 42.9832 174.459 43.0238C173.669 43.0238 173.061 42.6891 172.646 42.0197C172.23 41.3504 171.835 40.1841 171.46 38.5107L170.022 32.0605C169.474 29.4744 168.299 27.5373 166.486 26.2391C164.672 24.941 161.896 24.1398 158.137 23.8051C159.768 23.6226 161.237 23.3386 162.514 22.9532C164.936 22.223 166.84 21.2392 168.238 20.0121C169.637 18.7849 170.619 17.4462 171.207 16.0061C171.795 14.566 172.088 13.136 172.088 11.7161C172.088 8.24763 170.65 5.5702 167.772 3.67369C164.895 1.77718 160.559 0.823855 154.763 0.823855C153.973 0.823855 152.636 0.844138 150.761 0.884705C148.887 0.925272 146.942 0.945556 144.946 0.945556C143.193 0.945556 141.43 0.925272 139.657 0.884705C137.884 0.844138 136.476 0.803571 135.432 0.763004V2.02058C136.729 2.06115 137.712 2.22342 138.401 2.48711C139.09 2.75079 139.546 3.25788 139.779 3.98809C140.012 4.71829 140.123 5.85417 140.123 7.39572V38.4498C140.123 39.9508 140.012 41.0765 139.779 41.827C139.546 42.5775 139.1 43.0846 138.441 43.328C137.783 43.5714 136.77 43.7438 135.432 43.825V45.0724C136.476 44.9913 137.884 44.9406 139.657 44.9203C141.43 44.9 143.315 44.8898 145.321 44.8898C147.073 44.8898 148.796 44.9 150.508 44.9203C152.22 44.9406 153.639 44.9913 154.763 45.0724V43.8351C153.426 43.754 152.433 43.5816 151.764 43.3382C151.096 43.0948 150.65 42.5877 150.417 41.8372C150.184 41.0867 150.072 39.9609 150.072 38.46V24.0587H153.385H154.135C155.432 24.0587 156.435 24.444 157.165 25.2148C157.894 25.9856 158.461 27.0302 158.887 28.3486C159.312 29.667 159.657 31.1985 159.951 32.953L161.075 39.2105C161.531 41.7966 162.494 43.5714 163.953 44.5247C165.412 45.4781 167.266 45.9649 169.525 45.9649C171.359 45.9649 172.919 45.6809 174.216 45.1231C175.513 44.5653 176.719 43.6323 177.843 42.3341L176.962 41.5836Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask1_654_6424)">
|
||||||
|
<path d="M-5.03304 -5.03807H222.329V51.0381H-5.03304V-5.03807Z" fill="#1D1D1B"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<mask id="mask2_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="180" y="0" width="38" height="46">
|
||||||
|
<path d="M180.314 0.657146H217.296V45.1238H180.314V0.657146Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask2_654_6424)">
|
||||||
|
<mask id="mask3_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="180" y="0" width="38" height="46">
|
||||||
|
<path d="M206.05 19.7991C205.574 21.6348 204.692 23.1155 203.426 24.2412C202.159 25.367 200.285 25.9349 197.823 25.9349H195.138V7.40588C195.138 5.23554 195.381 3.79541 195.857 3.08548C196.334 2.37556 197.387 2.0206 199.018 2.0206C201.855 2.0206 203.851 3.00435 205.016 4.96171C206.182 6.91907 206.769 9.89061 206.769 13.8459C206.769 15.9757 206.526 17.9533 206.05 19.789M212.838 4.26193C209.961 1.96989 205.624 0.813727 199.829 0.813727C199.039 0.813727 197.701 0.83401 195.827 0.874577C193.953 0.915144 192.017 0.935428 190.011 0.935428C188.259 0.935428 186.496 0.915144 184.723 0.874577C182.95 0.83401 181.541 0.793443 180.498 0.752876V2.0206C181.795 2.06116 182.777 2.22343 183.466 2.48712C184.155 2.7508 184.611 3.25789 184.844 3.9881C185.077 4.71831 185.189 5.85419 185.189 7.39574V38.4498C185.189 39.9508 185.077 41.0766 184.844 41.827C184.611 42.5775 184.165 43.0745 183.497 43.328C182.828 43.5816 181.825 43.7438 180.498 43.8351V45.0826C181.876 45.042 183.416 45.0014 185.128 44.9609C186.84 44.9203 188.593 44.9 190.376 44.9C192.625 44.9 194.773 44.9203 196.82 44.9609C198.866 45.0014 200.528 45.042 201.825 45.0826V43.7134C199.951 43.6729 198.532 43.5309 197.57 43.2773C196.607 43.0238 195.959 42.5065 195.624 41.7155C195.29 40.9244 195.128 39.7074 195.128 38.0847V27.1823H198.441C202.109 27.1823 205.158 26.7462 207.57 25.874C209.981 25.0019 211.896 23.8558 213.294 22.4664C214.692 21.077 215.685 19.5456 216.262 17.8925C216.84 16.2394 217.134 14.6674 217.134 13.1664C217.134 9.53565 215.695 6.57425 212.818 4.28221" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask3_654_6424)">
|
||||||
|
<mask id="mask4_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="180" y="0" width="38" height="46">
|
||||||
|
<path d="M180.362 0.70108H217.287V45.1324H180.362V0.70108Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask4_654_6424)">
|
||||||
|
<path d="M-5.03302 -5.03807H222.329V51.0381H-5.03302V-5.03807Z" fill="#1D1D1B"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<mask id="mask5_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="103" y="0" width="30" height="46">
|
||||||
|
<path d="M103.068 0.438099H132.61V45.1238H103.068V0.438099Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask5_654_6424)">
|
||||||
|
<mask id="mask6_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="103" y="0" width="30" height="46">
|
||||||
|
<path d="M103.214 0.641306L117.074 38.5817C117.904 40.7114 118.046 42.1009 117.479 42.7499C116.912 43.399 115.574 43.7641 113.446 43.8452V45.0927C114.45 45.0116 115.807 44.9609 117.509 44.9406C119.211 44.9203 121.035 44.9101 122.95 44.9101C124.662 44.9101 126.364 44.9203 128.046 44.9406C129.728 44.9609 131.207 45.0116 132.453 45.0927V43.8351C131.866 43.7945 131.207 43.4903 130.488 42.9223C129.769 42.3544 129.1 41.3301 128.512 39.819L113.315 0.631165L103.214 0.641306Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask6_654_6424)">
|
||||||
|
<mask id="mask7_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="103" y="0" width="30" height="46">
|
||||||
|
<path d="M103.068 0.509548H132.531V45.1323H103.068V0.509548Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask7_654_6424)">
|
||||||
|
<path d="M-5.03304 -5.0381H222.329V51.0381H-5.03304V-5.0381Z" fill="#1D1D1B"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<mask id="mask8_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="35" height="46">
|
||||||
|
<path d="M0 0H34.3559V46H0V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask8_654_6424)">
|
||||||
|
<mask id="mask9_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="35" height="46">
|
||||||
|
<path d="M12.4846 8.79529C13.7308 7.84197 15.4836 7.3653 17.7328 7.3653C20.0631 7.3653 21.8766 7.93324 23.1634 9.06912C24.4501 10.205 25.0985 11.7973 25.0985 13.8459H34.298C34.298 11.1685 33.6091 8.76487 32.2312 6.66552C30.8533 4.56617 28.908 2.92321 26.4156 1.76705C23.9232 0.610884 21.0762 0.0328026 17.8848 0.0328026C14.7136 0.0328026 11.8564 0.560174 9.31339 1.62506C6.77034 2.68995 4.81493 4.17065 3.45729 6.06716C2.09965 7.96367 1.42083 10.1137 1.42083 12.5275C1.42083 17.1724 3.95374 20.8741 9.0297 23.6124C10.8939 24.6165 13.4167 25.6306 16.6082 26.6651C19.7996 27.6996 22.0083 28.6833 23.2343 29.6163C24.4602 30.5494 25.0782 31.8881 25.0782 33.6223C25.0782 35.2146 24.4602 36.462 23.2343 37.3545C22.0083 38.247 20.2961 38.6932 18.1077 38.6932C12.2212 38.6932 9.27286 36.2288 9.27286 31.2897H0.032794C0.032794 34.1801 0.772405 36.7156 2.25163 38.9062C3.73085 41.0968 5.88889 42.8209 8.70549 44.0785C11.5221 45.3361 14.6528 45.9649 18.0874 45.9649C23.0316 45.9649 26.9729 44.8594 29.8908 42.6587C32.8087 40.4579 34.2778 37.4255 34.2778 33.5514C34.2778 30.0727 33.0822 27.162 30.6912 24.8294C28.3001 22.4968 24.4805 20.5395 19.2525 18.9675C16.4055 18.1054 14.2576 17.1825 12.7987 16.1988C11.3397 15.215 10.6001 13.998 10.6001 12.5477C10.6001 10.9961 11.2283 9.73848 12.4745 8.78515" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask9_654_6424)">
|
||||||
|
<mask id="mask10_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="-1" width="36" height="48">
|
||||||
|
<path d="M-0.054543 -0.0649948H34.3834V46.0899H-0.054543V-0.0649948Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask10_654_6424)">
|
||||||
|
<path d="M-5.03301 -5.0381H222.329V51.0381H-5.03301V-5.0381Z" fill="#008764"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<mask id="mask11_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="40" y="0" width="38" height="46">
|
||||||
|
<path d="M40.7019 0.438099H77.2462V45.3429H40.7019V0.438099Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask11_654_6424)">
|
||||||
|
<mask id="mask12_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="40" y="0" width="38" height="46">
|
||||||
|
<path d="M67.8946 18.7647H49.9515V0.651451H40.7519V45.1637H49.9515V26.1986H67.8946V45.1637H77.0941V0.651451H67.8946V18.7647Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask12_654_6424)">
|
||||||
|
<mask id="mask13_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="40" y="0" width="38" height="46">
|
||||||
|
<path d="M40.697 0.509552H77.2395V45.3239H40.697V0.509552Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask13_654_6424)">
|
||||||
|
<path d="M-5.03302 -5.03809H222.329V51.0381H-5.03302V-5.03809Z" fill="#008764"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<mask id="mask14_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="81" y="0" width="30" height="46">
|
||||||
|
<path d="M81.4039 0.438099H110.289V45.3429H81.4039V0.438099Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask14_654_6424)">
|
||||||
|
<mask id="mask15_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="81" y="0" width="30" height="46">
|
||||||
|
<path d="M95.3311 33.6223H110.255L107.53 26.1986H97.8742L102.555 12.5883L98.1274 0.651451L81.5825 45.1637H91.3595L95.3311 33.6223Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask15_654_6424)">
|
||||||
|
<mask id="mask16_654_6424" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="81" y="0" width="30" height="46">
|
||||||
|
<path d="M81.4486 0.509552H110.338V45.3239H81.4486V0.509552Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask16_654_6424)">
|
||||||
|
<path d="M-5.03304 -5.03809H222.329V51.0381H-5.03304V-5.03809Z" fill="#008764"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.8 KiB |
@@ -4,4 +4,5 @@ export default {
|
|||||||
// Config options...
|
// Config options...
|
||||||
// Server-side render by default, to enable SPA mode set this to `false`
|
// Server-side render by default, to enable SPA mode set this to `false`
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
allowedActionOrigins: ["sharper.ly", "*.sharper.ly"]
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -21,4 +21,7 @@ export default defineConfig({
|
|||||||
resolver, so the server build ends up with exactly one React. */
|
resolver, so the server build ends up with exactly one React. */
|
||||||
noExternal: ["react-router"],
|
noExternal: ["react-router"],
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
allowedHosts: ["dev.sharper.ly", "sharper.ly"]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dev": "pnpm -r --parallel --stream dev",
|
"dev": "pnpm -r --parallel --stream dev",
|
||||||
"dev:web": "pnpm --filter web dev",
|
"dev:web": "pnpm --filter web dev",
|
||||||
"dev:cms": "pnpm --filter cms develop",
|
"dev:cms": "pnpm --filter cms develop",
|
||||||
|
"seed": "pnpm --filter cms seed",
|
||||||
"build": "pnpm --filter cms build && pnpm --filter web build",
|
"build": "pnpm --filter cms build && pnpm --filter web build",
|
||||||
"start": "pnpm -r --parallel --stream start",
|
"start": "pnpm -r --parallel --stream start",
|
||||||
"typecheck": "pnpm -r --stream typecheck"
|
"typecheck": "pnpm -r --stream typecheck"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ importers:
|
|||||||
'@strapi/plugin-users-permissions':
|
'@strapi/plugin-users-permissions':
|
||||||
specifier: 5.53.0
|
specifier: 5.53.0
|
||||||
version: 5.53.0(62i3dwrrxte2my3iox3m7s6qt4)
|
version: 5.53.0(62i3dwrrxte2my3iox3m7s6qt4)
|
||||||
|
'@strapi/provider-email-nodemailer':
|
||||||
|
specifier: ^5.54.0
|
||||||
|
version: 5.54.0
|
||||||
'@strapi/strapi':
|
'@strapi/strapi':
|
||||||
specifier: 5.53.0
|
specifier: 5.53.0
|
||||||
version: 5.53.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.2)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(esbuild@0.28.2)(hono@4.13.7)(koa@2.16.4)(lightningcss@1.33.0)(pg@8.23.0)(postcss@8.5.28)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.51.2)(ts-toolbelt@9.6.0)(type-fest@0.21.3)
|
version: 5.53.0(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.2)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(@swc/helpers@0.5.23)(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(codemirror@5.65.21)(esbuild@0.28.2)(hono@4.13.7)(koa@2.16.4)(lightningcss@1.33.0)(pg@8.23.0)(postcss@8.5.28)(react-dom@18.3.1(react@18.3.1))(react-router-dom@6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(redux@4.2.1)(styled-components@6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.51.2)(ts-toolbelt@9.6.0)(type-fest@0.21.3)
|
||||||
@@ -2194,6 +2197,10 @@ packages:
|
|||||||
react-router-dom: ^6.30.3
|
react-router-dom: ^6.30.3
|
||||||
styled-components: ^6.0.0
|
styled-components: ^6.0.0
|
||||||
|
|
||||||
|
'@strapi/provider-email-nodemailer@5.54.0':
|
||||||
|
resolution: {integrity: sha512-G1zxBU2f8lGbz/A+lTO2HNZtr5gB2yTle3MV35DuvJdjTvaX0gvef+VcqbfuXrC7e7bqWcPzwkoc2GHpVgt/vw==}
|
||||||
|
engines: {node: '>=20.0.0 <=26.x.x', npm: '>=6.0.0'}
|
||||||
|
|
||||||
'@strapi/provider-email-sendmail@5.53.0':
|
'@strapi/provider-email-sendmail@5.53.0':
|
||||||
resolution: {integrity: sha512-3G1Q/rm7+4uR6/kSOUWfiKdEENiGqYrEdHtDp6KtHMQmdzl730FqWAnhzk8sPF5mhPT7OHBciJtXZNZE4b/iDw==}
|
resolution: {integrity: sha512-3G1Q/rm7+4uR6/kSOUWfiKdEENiGqYrEdHtDp6KtHMQmdzl730FqWAnhzk8sPF5mhPT7OHBciJtXZNZE4b/iDw==}
|
||||||
engines: {node: '>=20.0.0 <=26.x.x', npm: '>=6.0.0'}
|
engines: {node: '>=20.0.0 <=26.x.x', npm: '>=6.0.0'}
|
||||||
@@ -9330,6 +9337,10 @@ snapshots:
|
|||||||
- ts-toolbelt
|
- ts-toolbelt
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
|
'@strapi/provider-email-nodemailer@5.54.0':
|
||||||
|
dependencies:
|
||||||
|
nodemailer: 9.0.1
|
||||||
|
|
||||||
'@strapi/provider-email-sendmail@5.53.0':
|
'@strapi/provider-email-sendmail@5.53.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
nodemailer: 9.0.1
|
nodemailer: 9.0.1
|
||||||
|
|||||||