Files
sharper.ly/apps/web/app/components/ui/Button/Button.css
IllusionandClaude Opus 5 aedef6daeb Rebuild the site to the Claude Design drafts
Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the
project's own design system: tier-2 tokens, the published type roles, logical
properties throughout, and both locales.

Surfaces
- The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts
  do; the body previously painted the band colour, so every band was a step
  too light.
- A card is drawn one step off whatever it stands on (#1D1D1B on the ground,
  #141413 inside a band). Components paint --surface-card and a band re-points
  it for its subtree, so a card can never come out the same colour as its band.

Shell
- Nav: translucent ground with a blur, chevrons on group triggers, an accent
  rule under the current section, the drafts' compact dropdown card, and an
  EN | AR toggle in place of the globe menu.
- Footer: the drafts' four-column grid, one-line brand text, Facebook as a
  bordered button, the EU lockup, the implemented-by row and the funding
  disclaimer.

Pages
- Home: hero with counting figures, launch band, three areas of change,
  newsroom, FAQ accordion and the consortium band.
- The nine interior pages rebuilt and then audited section by section against
  their drafts.
- Applications: the three-step application form now posts real submissions to
  the Strapi `application` type, validated server-side and working without
  JavaScript.

Motion
- Every animation in the drafts, as classes in _page-kit/motion.css: staggered
  entrances, scroll-driven reveals, the drifting washes (each page carries its
  own geometry), the sheen, the counting figures and the accordion. The
  drafts' coloured hover glow is deliberately not shipped.

CMS
- New `faq` collection type behind the home page's accordion.
- `role` added to the consortium member component so an editor can supply the
  chip and the deliverable separately.

Content
- No invented figure, date, name or commitment ships: the five-working-day
  reply, the placeholder email and Facebook handle, the sample news, events,
  publications and FAQs, the invented application rules and the TBD counters
  are all withheld pending real content. Figures that survived (3 consortium
  partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec
  2027) are verified against docs/ and the bundled draft copy.

Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20
page/locale URLs render 200 through the built SSR handler, including with the
CMS unreachable; no raw hex or physical-direction utility in components or
routes.

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

267 lines
11 KiB
CSS

/* ============================================================================
BUTTON - figma frame 14:487 (Variant x Size x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens are referenced here. The `--sh-btn-*` custom
properties are component-local plumbing: each variant block binds them to a
tier-2 token once, and the state rules below read them, so the whole 5x3x3
matrix is expressed as five small blocks instead of forty-five selectors.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`w-full`, `mt-lg`, `text-text-secondary`, ...) still wins.
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.
HEIGHTS. Figma draws the stroke *inside* the frame, so a Secondary button is
the same height as a Primary one. Borders are therefore subtracted from the
padding, which lands every size on its designed box:
sm 6.5*2 + 20 + 1.5*2 = 36 md 10.5*2 + 24 + 1.5*2 = 48
lg 10.5*2 + 28 + 1.5*2 = 52
The 20/24/28 line-heights come from the type role the component puts on the
element (body-sm-bold / body-md-bold / body-lg-bold).
DEVIATIONS FROM FIGMA, deliberate:
* Focus-visible is not drawn in Figma at all. WCAG 2.4.7 requires it, so the
semantic focus ring is applied here; filled variants switch to the inverse
ring colour because a green ring on a green fill is invisible.
* "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
would make the button jump on hover. Padding is kept on all ghost states.
* Disabled uses the opaque `-bg-disabled` / `-fg-disabled` tokens rather than
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.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Base - shared by every variant and size.
A one-cell grid stacks the label and the loading spinner without either
transforms or absolute offsets, so it stays correct under dir="rtl".
------------------------------------------------------------------ */
.sh-btn {
display: inline-grid;
grid-template-areas: "sh-btn-stack";
place-items: center;
border-style: solid;
border-width: var(--control-border-width);
border-radius: var(--radius-control);
background-color: var(--sh-btn-bg);
color: var(--sh-btn-fg);
border-color: var(--sh-btn-border);
text-align: center;
text-decoration: none;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-entrance);
}
/* The Claude Design drafts lift a button 3px under the pointer and set it
back down on press. Pointer devices only - a touch "hover" would leave the
control raised after the tap - and never while disabled or busy, where the
control must not look live. app.css collapses the transition under
prefers-reduced-motion, so the lift becomes an instant, harmless offset.
The drafts also put a coloured glow behind the lifted button; that is
deliberately not shipped (client decision). */
@media (hover: hover) {
.sh-btn:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: translateY(-3px);
}
}
.sh-btn:active:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: none;
}
.sh-btn--block {
display: grid;
inline-size: 100%;
}
.sh-btn__content {
grid-area: sh-btn-stack;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--control-gap);
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
/* The 24px box the icon is drawn inside in Figma. Kept on the slot rather
than on the icon so a consumer's own glyph inherits the same geometry. */
.sh-btn__icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
}
/* ---------------------------------------------------------------------
Sizes - Figma: sm 16/8 (space.md/space.xs), md + lg 24/12 (space.lg/sm)
------------------------------------------------------------------ */
.sh-btn[data-size="sm"] {
padding-inline: calc(var(--control-padding-inline-sm) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-sm) - var(--control-border-width));
}
.sh-btn[data-size="md"] {
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
}
.sh-btn[data-size="lg"] {
padding-inline: calc(var(--control-padding-inline-lg) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-lg) - var(--control-border-width));
}
/* ---------------------------------------------------------------------
Variants - the only place a token name appears twice.
------------------------------------------------------------------ */
.sh-btn[data-variant="primary"] {
--sh-btn-bg: var(--interactive-primary-bg);
--sh-btn-bg-hover: var(--interactive-primary-bg-hover);
--sh-btn-bg-active: var(--interactive-primary-bg-active);
--sh-btn-bg-disabled: var(--interactive-primary-bg-disabled);
--sh-btn-fg: var(--interactive-primary-fg);
--sh-btn-fg-hover: var(--interactive-primary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-primary-fg-disabled);
--sh-btn-border: var(--interactive-primary-border);
--sh-btn-border-hover: var(--interactive-primary-border-hover);
--sh-btn-border-disabled: var(--interactive-primary-border-disabled);
}
.sh-btn[data-variant="secondary"] {
--sh-btn-bg: var(--interactive-secondary-bg);
--sh-btn-bg-hover: var(--interactive-secondary-bg-hover);
--sh-btn-bg-active: var(--interactive-secondary-bg-active);
--sh-btn-bg-disabled: var(--interactive-secondary-bg-disabled);
--sh-btn-fg: var(--interactive-secondary-fg);
--sh-btn-fg-hover: var(--interactive-secondary-fg-hover);
--sh-btn-fg-disabled: var(--interactive-secondary-fg-disabled);
--sh-btn-border: var(--interactive-secondary-border);
--sh-btn-border-hover: var(--interactive-secondary-border-hover);
--sh-btn-border-disabled: var(--interactive-secondary-border-disabled);
}
.sh-btn[data-variant="dark"] {
--sh-btn-bg: var(--interactive-dark-bg);
--sh-btn-bg-hover: var(--interactive-dark-bg-hover);
--sh-btn-bg-active: var(--interactive-dark-bg-active);
--sh-btn-bg-disabled: var(--interactive-dark-bg-disabled);
--sh-btn-fg: var(--interactive-dark-fg);
--sh-btn-fg-hover: var(--interactive-dark-fg-hover);
--sh-btn-fg-disabled: var(--interactive-dark-fg-disabled);
--sh-btn-border: var(--interactive-dark-border);
--sh-btn-border-hover: var(--interactive-dark-border-hover);
--sh-btn-border-disabled: var(--interactive-dark-border-disabled);
}
.sh-btn[data-variant="danger"] {
--sh-btn-bg: var(--interactive-danger-bg);
--sh-btn-bg-hover: var(--interactive-danger-bg-hover);
--sh-btn-bg-active: var(--interactive-danger-bg-active);
--sh-btn-bg-disabled: var(--interactive-danger-bg-disabled);
--sh-btn-fg: var(--interactive-danger-fg);
--sh-btn-fg-hover: var(--interactive-danger-fg-hover);
--sh-btn-fg-disabled: var(--interactive-danger-fg-disabled);
--sh-btn-border: var(--interactive-danger-border);
--sh-btn-border-hover: var(--interactive-danger-border-hover);
--sh-btn-border-disabled: var(--interactive-danger-border-disabled);
}
.sh-btn[data-variant="ghost"] {
--sh-btn-bg: var(--interactive-ghost-bg);
--sh-btn-bg-hover: var(--interactive-ghost-bg-hover);
--sh-btn-bg-active: var(--interactive-ghost-bg-active);
--sh-btn-bg-disabled: var(--interactive-ghost-bg-disabled);
--sh-btn-fg: var(--interactive-ghost-fg);
--sh-btn-fg-hover: var(--interactive-ghost-fg-hover);
--sh-btn-fg-disabled: var(--interactive-ghost-fg-disabled);
--sh-btn-border: var(--interactive-ghost-border);
--sh-btn-border-hover: var(--interactive-ghost-border-hover);
--sh-btn-border-disabled: var(--interactive-ghost-border-disabled);
}
/* Figma gives the ghost md/lg cells radius.md while every other cell uses
radius.sm - reproduced rather than normalised. */
.sh-btn[data-variant="ghost"][data-size="md"],
.sh-btn[data-variant="ghost"][data-size="lg"] {
border-radius: var(--radius-control-lg);
}
/* ---------------------------------------------------------------------
States
------------------------------------------------------------------ */
@media (hover: hover) {
.sh-btn:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-hover);
color: var(--sh-btn-fg-hover);
border-color: var(--sh-btn-border-hover);
}
}
/* No `-fg-active` / `-border-active` exists in the token contract, so the
pressed state deepens the fill only. */
.sh-btn:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--sh-btn-bg-active);
}
.sh-btn:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
/* A green ring on a green (or red, or near-black) fill reads as nothing. */
.sh-btn[data-variant="primary"]:focus-visible,
.sh-btn[data-variant="danger"]:focus-visible,
.sh-btn[data-variant="dark"]:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
.sh-btn:disabled,
.sh-btn[aria-disabled="true"] {
background-color: var(--sh-btn-bg-disabled);
color: var(--sh-btn-fg-disabled);
border-color: var(--sh-btn-border-disabled);
cursor: not-allowed;
}
/* ---------------------------------------------------------------------
Loading - the label keeps its box (no reflow) and stays in the
accessibility tree, which `visibility: hidden` would not allow.
------------------------------------------------------------------ */
.sh-btn[data-loading="true"] {
cursor: progress;
}
.sh-btn[data-loading="true"] .sh-btn__content {
opacity: 0;
}
.sh-btn__spinner {
grid-area: sh-btn-stack;
inline-size: 1em;
block-size: 1em;
border: var(--control-border-width) solid currentColor;
border-block-start-color: transparent;
border-radius: var(--radius-pill);
/* The contract publishes fast/base/slow only; two slows is the ~640ms a
spinner wants without inventing a duration token. */
animation: sh-btn-spin calc(var(--motion-duration-slow) * 2) linear infinite;
}
}
@keyframes sh-btn-spin {
to {
rotate: 1turn;
}
}