Files
sharper.ly/apps/web/app/styles/README.md
2026-09-11 02:46:56 +02:00

14 KiB

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

  1. bg-[var(--surface-page)] - works for every token, always correct.
  2. 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-md asks 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-face for "Panton" at font-weight: 500 600 in typography.css. Do not declare that face before the file exists - a face that fails to load drops weight-500 text out of the family altogether. 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-link and --text-danger land between 4.17 and 4.85 on --surface-raised and 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-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 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-tertiary used 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. #949492 survives on --text-disabled only, 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.

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-placeholder is 3.04:1 on the light surface. A placeholder is not a label and never carries information in this codebase (Input always renders a real <label>), so it is treated like disabled text. Do not promote it to hint text.
  • --field-border and --border-subtle are 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-danger error message, none of which rely on the resting border. A field whose only affordance is the resting border would fail.