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

THE THIRD DESIGN DROP

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

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

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

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

SEEDING THE CMS

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

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

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

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

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

THE FIGURES BAND, AND THE BAND THAT REPLACED IT

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

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

VERIFIED

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

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

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

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

8.1 KiB
Raw Blame History

CLAUDE.md — working brief for agent sessions

Read this before touching anything. Human setup instructions live in README.md; this file is the short version plus the rules that are easy to break.

What the project is

SHARP-ER — Shared Action on Rule of Law Progress – Exit Route. EU-funded, nationwide Libya, December 2025 – December 2027. Implemented by Acted (lead), United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE). Supports Libyan civil society, local institutions and communities on justice, accountability and governance. Successor to SHARP I and II (2019–2024).

Tone in all copy: professional, neutral, factual, inclusive, non-confrontational. Not political, not advocacy, not campaign-style.

Architecture in one screen

apps/web   React Router 8 (framework mode, ssr: true) · React 19 · Vite 8 · Tailwind 4
           SSR only reads the CMS from loaders. No client-side fetch on first paint.
           /            302 (Vary: Accept-Language) -> /en or /ar
           /:lang       locale layout, validates params.lang, provides { locale, dir }
           /:lang/slug  ten top-level sections + detail routes
           Route ids: root · locale · home · about · whatWeDo · activities · events ·
                      applications · news · contact · resources · knowledgeHub (+ *Detail)

apps/cms   Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (en default, ar) · draft & publish
           src/api/**        content types (schema.json is the contract, commit it)
           src/bootstrap/**  runs on every boot: creates locales, grants Public permissions
                             (find/findOne on content; create-only on submission types)
           config/           server · admin · database · middlewares (CORS) · plugins

docs/      Source material: ToR, approved EN/AR draft copy, brand guideline. READ ONLY.

Data flow: browser → React Router SSR loader → Strapi REST (~/lib/strapi, server only) → PostgreSQL. STRAPI_TOKEN never reaches the browser (~/lib/env.server.ts is .server, and the bundler enforces it). Every page falls back to bundled draft copy when the CMS is unreachable — the site must never 500 because Strapi is down.

Hard rules

  1. Design tokens are two-tier. Components use tier 2 only. Tier 1 (app/styles/primitives.css) holds raw values; tier 2 (semantic.css, typography.css) holds meanings. Never a raw hex, never a tier-1 primitive, never a raw font-size / line-height — use the type-role utilities (display-sm, heading-h2, body-md, label-md, …). Check: grep -rn "#[0-9a-fA-F]\{6\}\|--color-green\|--color-neutral\|--color-red" apps/web/app/components apps/web/app/routes → must return nothing.
  2. Logical properties only. Never left / right. ps- pe- ms- me- start- end-, margin-inline-start, text-align: start. 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.
  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.
  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, create_new_file, upload_assets, shaders, plugins, …). Before the first get_design_context, read skill://figma/figma-design-to-code/SKILL.md and pass 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 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.
  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 an English string is a bug — that is why Card.Content's actionLabel is required.
  8. EU visibility is contractual. "Funded by the European Union" badge top-right, project logo top-left, the three partner logos always shown together, no standalone partner branding. Do not restyle or omit them.
  9. No long-running servers or installs without being asked. Do not run pnpm install, pnpm dev, strapi develop or docker compose up speculatively. If you need a new dependency, report it instead of installing it.
  10. No ?lang= parameter, ever. The locale is a URL prefix on both languages.

Where things live

You need It is in
Tokens, surfaces, type roles apps/web/app/styles/ (+ its own README.md)
UI components apps/web/app/components/ui/{Button,Input,Card,Navigation,Footer,LanguageSwitcher}
Page modules apps/web/app/routes/*.tsx; shared kits in routes/_page-kit/, routes/_shared/, routes/_events-opportunities/
Route config, slugs apps/web/app/routes.ts, apps/web/app/lib/route-slugs.ts
i18n apps/web/app/i18n/ (config.ts, paths.ts, t.ts, format.ts, messages/*.json)
Strapi client, SEO, env apps/web/app/lib/{strapi.ts,seo.ts,env.server.ts,route-handle.ts,site-navigation.ts}
HTML shell, canonical, hreflang apps/web/app/root.tsx
Content types apps/cms/src/api/<name>/content-types/<name>/schema.json
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}

Conventions that bite

  • meta reads loaderData, not data. In React Router 8, matches[0].data is undefined; use readRootSeo(matches) from ~/routes/_shared/listing.
  • Do not pass alternates: true to buildMeta. root.tsx emits the canonical and the hreflang alternates for every route; a leaf that repeats them duplicates the tags. Use handle.canonicalPath to override the canonical.
  • app/routes.ts existence-checks section modules and Vite does not re-evaluate it when a file appears — restart the dev server after adding a route module.
  • Strapi 5 addresses entries by documentId, not by a numeric id, and localisation is a locale query parameter on the same entry.
  • Submission content types are write-only for the public (create, never find). They hold personal data. The field is language, not locale — locale is reserved by the i18n plugin.
  • .server.ts files never reach the browser. Anything touching STRAPI_TOKEN, the database or process.env belongs in one.
  • Licensed fonts are not in the repository (.gitignore excludes public/fonts/*.woff2). Expect fallback typefaces locally; never commit a font file.

Verifying a change

pnpm typecheck                     # react-router typegen + tsc (web; the CMS is checked by its build)
pnpm --filter web build            # the SSR build must succeed
bash -n scripts/<name>.sh          # any shell script you touched
docker compose config              # any docker-compose.yml change (needs the docker CLI)

Then, in a browser or with curl, on both locales:

curl -sI http://localhost:5173/            # 302 + Vary: Accept-Language
curl -s  http://localhost:5173/en | head -c 200
curl -s  http://localhost:5173/ar | head -c 200   # lang="ar" dir="rtl" in the first bytes

Checklist before calling a UI change done: renders in both locales; Arabic mirrors correctly; no raw hex or tier-1 token; no physical-direction utility; keyboard focus is visible; text comes from the message catalogue or the CMS, not from a literal in the component.