Files
2026-09-23 21:57:24 +02:00

323 lines
16 KiB
Markdown

# 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.
```tsx
// 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` plus the dark-ground steps `--shadow-dark-lg|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:
```css
--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-*`
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
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
<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.
**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 (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
**Ghost is a text link, not a button.** The published Figma Ghost cell fills on
hover (`background/accent` behind a `base/turquoise-dark` label - 1.40:1, a
WCAG AA failure). The Claude Design drafts never fill a ghost control: every
"All updates ->" / "What we do ->" is plain text in `--text-primary` that turns
accent green under the pointer while the label-to-arrow gap opens from 8px to
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
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 `#141413` | sunken `#0b0b0a` | muted (white 8%) | accent-subtle (green 24%) | danger-subtle (red 24%) |
| --- | --- | --- | --- | --- | --- | --- |
| `--text-primary` neutral-50 | 16.02 | 17.49 | 18.68 | 12.71 | 12.40 | 12.86 |
| `--text-secondary` neutral-300 | 9.46 | 10.33 | 11.04 | 7.51 | 7.32 | 7.60 |
| `--text-tertiary` neutral-400 | 5.56 | 6.07 | 6.48 | 4.41 | 4.30 | 4.46 |
| `--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 | 9.35 | 9.99 | 6.80 | 6.63 | 6.87 |
| `--text-danger` red-400 | 5.39 | 5.89 | 6.29 | 4.28 | 4.17 | 4.33 |
| `--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.
Everything below them is only cleared against `--surface-page` and
`--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
between 4.17 and 4.78 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 an opaque surface, or raise it 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` neutral-50 / dark page (no fill in any state) | 16.02 |
| ghost `fg-hover` green-400 / dark page | 6.02 |
| ghost `fg` neutral-800 / light page | 16.88 |
| 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-800 / light page | 16.88 |
| **secondary `fg-disabled` neutral-500 / dark page** | **2.36** |
| **secondary `fg-disabled` neutral-400 / light page** | **3.04** |
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.
### 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.