SHARP-ER design tokens
Two tiers, so changing the brand is a one-line edit in one file.
app/app.css entry - imports tailwindcss then the three files below
app/styles/primitives.css TIER 1 raw values, no meaning (@theme)
app/styles/semantic.css TIER 2 roles that alias tier 1 (per surface)
app/styles/typography.css TIER 2 type roles, @font-face, EN/AR font switch
The one rule
Components may reference only tier-2 tokens. Never a raw hex, never a tier-1 primitive.
This binds app/app.css too. Its @layer base block styles the document, so
the body type is set from --type-body-base-size / --type-body-base-leading
(the semantic alias of the Body/Medium role, declared in semantic.css) rather
than from --text-md / --leading-6 directly. Only primitives.css,
semantic.css and typography.css may name a tier-1 token.
// yes
<button className="bg-[var(--interactive-primary-bg)] text-[var(--interactive-primary-fg)]" />
<button className="bg-interactive-primary text-text-on-accent rounded-control" />
<h2 className="heading-h2 text-[var(--text-primary)]" />
// no
<button className="bg-[#008764]" /> // raw hex
<button className="bg-[var(--color-green-600)]" /> // tier-1 primitive
<h2 className="text-[28px] leading-[36px]" /> // raw type
Grep for violations: grep -rn "#[0-9a-fA-F]\{6\}\|--color-green\|--color-neutral\|--color-red" app/components app/routes.
Two ways to spend a tier-2 token
bg-[var(--surface-page)]- works for every token, always correct.- A Tailwind utility from the bridge at the bottom of
semantic.css. The name is always<tailwind-prefix>-<token-name>:--surface-page->bg-surface-page,--text-primary->text-text-primary,--border-subtle->border-border-subtle,--radius-card->rounded-card. The bridge uses@theme inline, so these still re-resolve inside a surface.
Type roles are utilities: display-sm, heading-h1, heading-h2, heading-h3,
body-lg, body-lg-bold, body-md, body-md-bold, body-sm, body-sm-bold,
label-md. They set family, size, line-height, tracking and weight together, and
they compose with variants (md:heading-h1).
Tier 1 - primitives (primitives.css)
A Tailwind 4 @theme static block. Raw ramps only, named by value:
--color-green-600, never --color-accent.
- colour -
white,black,transparent,neutral-50..900,green-50..900,red-50..900, plus alpha steps (white-a12,black-a56,green-a24, ...) - type -
--font-en/--font-ar/--font-sans/--font-mono,--text-xs..5xl,--leading-4..12,--tracking-tighter..wider,--font-weight-regular..black - space
--spacing-2xs..5xl- radius--radius-none..full- border width--border-width-sm..xl- opacity--opacity-0..100 - shadow
--shadow-xs..xl- duration--duration-instant..slower- easing--ease-standard | decelerate | accelerate | emphasized - z ladder
--z-0..70- containers--container-prose | content | wide
The hexes taken verbatim from the published Figma variables are marked [figma];
the rest of each ramp is derived from them (tints mix with white, shades multiply
toward black).
Changing a brand colour
Edit the ramp in primitives.css and nothing else. To move the brand green:
--color-green-600: #008764; /* brand -> every accent, primary button, link */
--color-green-700: #006b4f; /* hover */
--color-green-800: #00563f; /* active */
--color-green-400: #4daa93; /* disabled fill, accent text on dark */
Same for --color-red-* (danger) and --color-neutral-800/900 (the dark
surface). No component file changes.
Tier 2 - semantics (semantic.css)
Plain CSS custom properties whose value is always var(--<primitive>). Families:
--surface-* --text-* --border-* --interactive-<variant>-* --field-*
--focus-ring-* --overlay-* --radius-* --control-* --layout-*
--elevation-* --motion-* --layer-* --type-body-base-*
Interactive tokens are fully regular - for each of the five Figma button
variants (primary, secondary, dark, danger, ghost) there is
--interactive-<variant>-bg -bg-hover -bg-active -bg-disabled
--interactive-<variant>-fg -fg-hover -fg-disabled
--interactive-<variant>-border -border-hover -border-disabled
Figma renders the disabled state as the default fill at 40% opacity; that
opacity is available as --control-opacity-disabled, and the -disabled
tokens above give the equivalent opaque colour for cases where a translucent
button would show the page through it.
Surfaces, not a global theme
The home page is dark by default (#1d1d1b), while the nav and footer also have
light variants, so the whole tier-2 palette is redefined per surface rather than
per theme. :root is the dark surface; any element can flip its own subtree:
<html lang="en" dir="ltr"> <!-- dark, the default -->
<header data-surface="light"> <!-- light nav on a dark page -->
<main>...</main>
<footer data-surface="light"> <!-- light footer -->
</html>
data-surface also sets color-scheme and paints background-color /
color on itself, so a flipped section is complete without extra classes.
Because both surface blocks define the same token names, a component written
against --text-primary is automatically correct on either one - which is why
components must never reach past a role to a primitive.
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
CSS logical properties (ps-, pe-, ms-, me-, start-, end-) so RTL
mirrors without either.
Fonts
Family stacks live in primitives.css; @font-face and the language switch live
in typography.css. <html lang="ar"> (or any :lang(ar) subtree) rebinds
--font-heading / --font-body / --font-ui to the Arabic stack and resets all
letter-spacing to normal, because Arabic is a joined script. Nothing else changes.
Files in apps/web/public/fonts/:
| file | family | weight | status |
|---|---|---|---|
Panton-Regular.woff2 |
Panton | 400 | present |
Panton-Bold.woff2 |
Panton | 700 | present |
Panton-SemiBold.woff2 |
Panton | 500 | not supplied |
Montserrat-Arabic-Regular.woff2 |
Montserrat Arabic | 400 | present |
Montserrat-Arabic-Medium.woff2 |
Montserrat Arabic | 500-600 | present |
Montserrat-Arabic-Bold.woff2 |
Montserrat Arabic | 700 | present |
The communication guideline PDF names the Arabic face "Montaser Arabic". That is a typo in the guideline - the correct face is Montserrat Arabic, confirmed with the client on 2026-09-11. Do not add a "Montaser Arabic" family back to
--font-ar.
One gap, handled without any code change when the file arrives:
- Panton SemiBold.
label-mdasks for weight 500 and, with no SemiBold face declared, CSS font matching resolves it to Panton Regular. When the cut is supplied, add one@font-facefor"Panton"atfont-weight: 500 600intypography.css. Do not declare that face before the file exists - a face that fails to load drops weight-500 text out of the family altogether. Filenames are case-sensitive in production - match the table exactly.
Known deviation from Figma
The published Ghost button hover paints background/accent (#008764) and keeps
base/turquoise-dark (#006b4f) as the label - 1.40:1, which fails WCAG AA.
--interactive-ghost-fg-hover is therefore --color-white on both surfaces
(4.52:1, passes AA for body text). The two lines are marked (a11y) in
semantic.css; revert them to var(--color-green-700) for pixel-exact parity
if the designer prefers the failure.
Contrast audit
WCAG 2.1 relative luminance, computed from the sRGB hexes in primitives.css.
Translucent surfaces are composited over their own --surface-page first.
AA needs 4.5:1 for body text, 3:1 for large text (>=24px, or >=18.66px
bold) and for non-text UI boundaries (1.4.11).
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-primary neutral-50 |
16.02 | 12.91 | 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-tertiary neutral-400 |
5.56 | 4.48 | 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-link-hover green-300 |
8.56 | 6.90 | 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-disabled neutral-500 |
2.36 | 1.91 | 2.76 | 1.88 | 1.83 | 1.90 |
--text-primary, --text-secondary and --text-link-hover pass AA everywhere.
Everything below them is only cleared against --surface-page and
--surface-sunken, which is where the design actually uses them:
--text-tertiary,--text-accent,--text-linkand--text-dangerland between 4.17 and 4.85 on--surface-raisedand on the three translucent fills - AA for large text, not for body text. Card bodies use--text-secondary; keep tertiary/accent/danger body copy on the page or sunken surface, or raise the text a step.--text-disabledis 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 the only token allowed below 3:1.
Light surface ([data-surface="light"])
| text token | page/raised #ffffff |
sunken #f8f9fa |
muted #e8e8e8 |
accent-subtle #dbeee9 |
danger-subtle #fae2e2 |
|---|---|---|---|---|---|
--text-primary neutral-800 |
16.88 | 16.02 | 13.78 | 14.00 | 13.70 |
--text-secondary neutral-500 |
7.14 | 6.77 | 5.83 | 5.92 | 5.80 |
--text-tertiary neutral-500 |
7.14 | 6.77 | 5.83 | 5.92 | 5.80 |
--text-link-hover green-800 |
8.74 | 8.29 | 7.13 | 7.25 | 7.09 |
--text-link green-700 |
6.53 | 6.19 | 5.33 | 5.42 | 5.30 |
--text-danger red-600 |
5.78 | 5.48 | 4.72 | 4.79 | 4.69 |
--text-accent green-600 |
4.52 | 4.28 | 3.69 | 3.75 | 3.67 |
--text-disabled neutral-400 |
3.04 | 2.88 | 2.48 | 2.52 | 2.47 |
--text-tertiaryused to point at neutral-400 (#949492), 3.04:1 on white - a body-text failure. It now points at neutral-500, the same primitive as--text-secondary; the two are kept as separate roles so the ramp can be re-split when a compliant mid-grey is added.#949492survives on--text-disabledonly, where 1.4.3 exempts it.--text-accent(the brand green) is 4.52:1 on white and passes, but only just- it drops to 3.67-3.75 on the muted and subtle fills, which is large-text
only. Use
--text-link(green-700, 5.30+ everywhere) for accent body copy on a tinted light panel.
- it drops to 3.67-3.75 on the muted and subtle fills, which is large-text
only. Use
Foreground on a filled background
| pair | ratio |
|---|---|
--text-on-accent white on --surface-accent green-600 |
4.52 |
--text-on-accent white on --surface-danger red-500 |
4.59 |
dark --text-on-inverse neutral-800 on --surface-inverse white |
16.88 |
light --text-on-inverse neutral-50 on --surface-inverse neutral-900 |
18.68 |
Interactive variants (identical on both surfaces unless noted)
| pair | ratio |
|---|---|
primary fg white / bg green-600 |
4.52 |
primary fg-hover white / bg-hover green-700 |
6.53 |
primary white / bg-active green-800 |
8.74 |
primary fg-disabled white / bg-disabled green-400 |
2.80 |
danger fg white / bg red-500 |
4.59 |
danger white / bg-hover red-600 |
5.78 |
danger white / bg-active red-700 |
7.32 |
danger fg-disabled white / bg-disabled red-300 |
2.11 |
dark fg white / bg neutral-900 |
19.69 |
dark white / bg-hover black |
21.00 |
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 |
ghost fg-hover white / bg-hover green-600 (was green-900, 2.62) |
4.52 |
ghost white / bg-active green-700 |
6.53 |
ghost fg green-400 / dark page |
6.02 |
ghost fg green-600 / light page |
4.52 |
secondary fg neutral-50 / dark page |
16.02 |
secondary fg neutral-800 / light page |
16.88 |
secondary fg-disabled neutral-500 / dark page |
2.36 |
secondary fg-disabled neutral-400 / light page |
3.04 |
The four 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.
Fields and boundaries
| pair | dark | light |
|---|---|---|
--field-fg on --field-bg |
12.71 | 16.88 |
--field-placeholder on --field-bg |
4.41 | 3.04 |
--field-border-focus / --focus-ring-color against the page |
6.02 | 4.52 |
--field-border against the page |
2.20 | 1.46 |
--border-subtle against the page |
1.44 | 1.23 |
Two things to know here:
--field-placeholderis 3.04:1 on the light surface. A placeholder is not a label and never carries information in this codebase (Inputalways renders a real<label>), so it is treated like disabled text. Do not promote it to hint text.--field-borderand--border-subtleare below the 3:1 that 1.4.11 asks of a control boundary. They are decorative hairlines: the field's state is carried by--field-border-focus(6.02 / 4.52), by the focus ring, and by the--text-dangererror message, none of which rely on the resting border. A field whose only affordance is the resting border would fail.