diff --git a/apps/cms/src/api/faq/content-types/faq/schema.json b/apps/cms/src/api/faq/content-types/faq/schema.json
new file mode 100644
index 0000000..bf5c5e5
--- /dev/null
+++ b/apps/cms/src/api/faq/content-types/faq/schema.json
@@ -0,0 +1,48 @@
+{
+ "kind": "collectionType",
+ "collectionName": "faqs",
+ "info": {
+ "singularName": "faq",
+ "pluralName": "faqs",
+ "displayName": "FAQ",
+ "description": "A question and answer shown in the FAQ accordion."
+ },
+ "options": {
+ "draftAndPublish": true
+ },
+ "pluginOptions": {
+ "i18n": {
+ "localized": true
+ }
+ },
+ "attributes": {
+ "question": {
+ "type": "string",
+ "required": true,
+ "maxLength": 250,
+ "pluginOptions": {
+ "i18n": {
+ "localized": true
+ }
+ }
+ },
+ "answer": {
+ "type": "richtext",
+ "required": true,
+ "pluginOptions": {
+ "i18n": {
+ "localized": true
+ }
+ }
+ },
+ "order": {
+ "type": "integer",
+ "default": 0,
+ "pluginOptions": {
+ "i18n": {
+ "localized": false
+ }
+ }
+ }
+ }
+}
diff --git a/apps/cms/src/api/faq/controllers/faq.ts b/apps/cms/src/api/faq/controllers/faq.ts
new file mode 100644
index 0000000..7e2caa9
--- /dev/null
+++ b/apps/cms/src/api/faq/controllers/faq.ts
@@ -0,0 +1,7 @@
+/**
+ * faq controller
+ */
+
+import { factories } from '@strapi/strapi';
+
+export default factories.createCoreController('api::faq.faq');
diff --git a/apps/cms/src/api/faq/routes/faq.ts b/apps/cms/src/api/faq/routes/faq.ts
new file mode 100644
index 0000000..e9f783c
--- /dev/null
+++ b/apps/cms/src/api/faq/routes/faq.ts
@@ -0,0 +1,7 @@
+/**
+ * faq router
+ */
+
+import { factories } from '@strapi/strapi';
+
+export default factories.createCoreRouter('api::faq.faq');
diff --git a/apps/cms/src/api/faq/services/faq.ts b/apps/cms/src/api/faq/services/faq.ts
new file mode 100644
index 0000000..5578d01
--- /dev/null
+++ b/apps/cms/src/api/faq/services/faq.ts
@@ -0,0 +1,7 @@
+/**
+ * faq service
+ */
+
+import { factories } from '@strapi/strapi';
+
+export default factories.createCoreService('api::faq.faq');
diff --git a/apps/cms/src/components/page/consortium-member.json b/apps/cms/src/components/page/consortium-member.json
index c9955ff..6d9b436 100644
--- a/apps/cms/src/components/page/consortium-member.json
+++ b/apps/cms/src/components/page/consortium-member.json
@@ -12,6 +12,10 @@
"relation": "oneToOne",
"target": "api::partner.partner"
},
+ "role": {
+ "type": "string",
+ "maxLength": 120
+ },
"roleDescription": {
"type": "text"
}
diff --git a/apps/cms/types/generated/components.d.ts b/apps/cms/types/generated/components.d.ts
index 3c6f26f..98bfc57 100644
--- a/apps/cms/types/generated/components.d.ts
+++ b/apps/cms/types/generated/components.d.ts
@@ -159,6 +159,10 @@ export interface PageConsortiumMember extends Struct.ComponentSchema {
};
attributes: {
partner: Schema.Attribute.Relation<'oneToOne', 'api::partner.partner'>;
+ role: Schema.Attribute.String &
+ Schema.Attribute.SetMinMaxLength<{
+ maxLength: 120;
+ }>;
roleDescription: Schema.Attribute.Text;
};
}
diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts
index 028c5cd..d07a703 100644
--- a/apps/cms/types/generated/contentTypes.d.ts
+++ b/apps/cms/types/generated/contentTypes.d.ts
@@ -1095,6 +1095,59 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema {
};
}
+export interface ApiFaqFaq extends Struct.CollectionTypeSchema {
+ collectionName: 'faqs';
+ info: {
+ description: 'A question and answer shown in the FAQ accordion.';
+ displayName: 'FAQ';
+ pluralName: 'faqs';
+ singularName: 'faq';
+ };
+ options: {
+ draftAndPublish: true;
+ };
+ pluginOptions: {
+ i18n: {
+ localized: true;
+ };
+ };
+ attributes: {
+ answer: Schema.Attribute.RichText &
+ Schema.Attribute.Required &
+ Schema.Attribute.SetPluginOptions<{
+ i18n: {
+ localized: true;
+ };
+ }>;
+ createdAt: Schema.Attribute.DateTime;
+ createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
+ Schema.Attribute.Private;
+ locale: Schema.Attribute.String;
+ localizations: Schema.Attribute.Relation<'oneToMany', 'api::faq.faq'>;
+ order: Schema.Attribute.Integer &
+ Schema.Attribute.SetPluginOptions<{
+ i18n: {
+ localized: false;
+ };
+ }> &
+ Schema.Attribute.DefaultTo<0>;
+ publishedAt: Schema.Attribute.DateTime;
+ question: Schema.Attribute.String &
+ Schema.Attribute.Required &
+ Schema.Attribute.SetPluginOptions<{
+ i18n: {
+ localized: true;
+ };
+ }> &
+ Schema.Attribute.SetMinMaxLength<{
+ maxLength: 250;
+ }>;
+ updatedAt: Schema.Attribute.DateTime;
+ updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
+ Schema.Attribute.Private;
+ };
+}
+
export interface ApiHomePageHomePage extends Struct.SingleTypeSchema {
collectionName: 'home_pages';
info: {
@@ -2664,6 +2717,7 @@ declare module '@strapi/strapi' {
'api::contact-submission.contact-submission': ApiContactSubmissionContactSubmission;
'api::event-registration.event-registration': ApiEventRegistrationEventRegistration;
'api::event.event': ApiEventEvent;
+ 'api::faq.faq': ApiFaqFaq;
'api::home-page.home-page': ApiHomePageHomePage;
'api::knowledge-item.knowledge-item': ApiKnowledgeItemKnowledgeItem;
'api::news-article.news-article': ApiNewsArticleNewsArticle;
diff --git a/apps/web/app/app.css b/apps/web/app/app.css
index 0e96817..e3500bf 100644
--- a/apps/web/app/app.css
+++ b/apps/web/app/app.css
@@ -30,7 +30,13 @@
html,
body,
[data-surface] {
- background-color: var(--surface-page);
+ /* The design drafts stand the whole site on #0B0B0A and lift their
+ bands off it with #1D1D1B, so the document's ground is the SUNKEN
+ surface and `--surface-page` is the colour of a band. A section that
+ wants the lighter band paints `bg-[var(--surface-page)]`; one that
+ wants the ground paints nothing, or `bg-[var(--surface-sunken)]` when
+ it needs to say so explicitly. */
+ background-color: var(--surface-sunken);
color: var(--text-primary);
}
@@ -86,3 +92,18 @@
}
}
}
+
+/* ---------------------------------------------------------------------------
+ BANDS
+ The design drafts alternate the #0B0B0A ground with lighter #1D1D1B bands,
+ and a card inside a band is drawn DARKER than the band (#141413) while a
+ card on the ground is drawn LIGHTER (#1D1D1B). A section paints itself with
+ `.sh-band` and every Card, InfoPanel and tile inside it follows, instead of
+ each route guessing its own fill.
+ ------------------------------------------------------------------------ */
+@layer components {
+ .sh-band {
+ background-color: var(--surface-page);
+ --surface-card: var(--surface-raised);
+ }
+}
diff --git a/apps/web/app/components/ui/Button/Button.css b/apps/web/app/components/ui/Button/Button.css
index 5f16a75..5caa2ad 100644
--- a/apps/web/app/components/ui/Button/Button.css
+++ b/apps/web/app/components/ui/Button/Button.css
@@ -57,7 +57,26 @@
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);
+ 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 {
diff --git a/apps/web/app/components/ui/Card/CardShell.tsx b/apps/web/app/components/ui/Card/CardShell.tsx
index aefa266..8b27dd9 100644
--- a/apps/web/app/components/ui/Card/CardShell.tsx
+++ b/apps/web/app/components/ui/Card/CardShell.tsx
@@ -159,7 +159,7 @@ export function CardShell({
className={cx(
"sh-card",
linked && "sh-card--linked",
- "relative flex bg-surface-raised text-text-primary",
+ "relative flex bg-[var(--surface-card)] text-text-primary",
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
className,
)}
diff --git a/apps/web/app/components/ui/EuBadge/EuBadge.tsx b/apps/web/app/components/ui/EuBadge/EuBadge.tsx
new file mode 100644
index 0000000..a2f6155
--- /dev/null
+++ b/apps/web/app/components/ui/EuBadge/EuBadge.tsx
@@ -0,0 +1,112 @@
+import { getDirection, type Locale } from "../LanguageSwitcher";
+
+/* Exported from Figma assets/eu-en (224:11275) and assets/eu-en - darkBg
+ (238:2325). The source files are downscaled copies of the Figma exports. */
+const EMBLEM_SRC = {
+ light: "/assets/eu-flag.png",
+ dark: "/assets/eu-flag-dark.png",
+} as const;
+
+const EMBLEM_INTRINSIC_WIDTH = 192;
+const EMBLEM_INTRINSIC_HEIGHT = 128;
+
+/**
+ * Drawn emblem box per size. "md" is the 48x32 the navigation and the drawer
+ * use; "sm" is the 36x24 the home hero draws above the wordmark (478:15951).
+ * Only the emblem changes - see the note on the statement below.
+ */
+const EMBLEM_BOX = {
+ md: "h-[32px] w-[48px]",
+ sm: "h-[24px] w-[36px]",
+} as const;
+
+export type EuBadgeSize = keyof typeof EMBLEM_BOX;
+
+/** Which tier-2 surface the badge is sitting on. `:root` is the dark one. */
+export type EuBadgeSurface = keyof typeof EMBLEM_SRC;
+
+export interface EuBadgeProps {
+ /** "Funded by the European Union", already in the reader's language. */
+ statement: string;
+ /** Alt text for the emblem. Empty when the statement is rendered beside it. */
+ emblemAlt?: string;
+ /** Language of `statement` - drives the font switch and the text direction. */
+ locale: Locale;
+ surface: EuBadgeSurface;
+ /** "md" (48x32) by default; "sm" (36x24) for the hero lockup. */
+ size?: EuBadgeSize;
+ className?: string;
+}
+
+/**
+ * The mandatory EU visibility lockup: emblem + funding statement.
+ *
+ * The lockup itself is pinned to `dir="ltr"` so the emblem always leads, which
+ * is how the Arabic navigation frame (334:526) draws it; only the statement
+ * carries the reader's own direction. A mirroring ancestor still moves the
+ * whole badge from the inline start to the inline end in Arabic.
+ *
+ * DEVIATION FROM FIGMA (colour): the design sets the statement in EU reflex
+ * blue (hex 0f2a8e). A raw hex is not permitted in a component here, and there
+ * is no semantic token for a third-party brand colour, so the statement uses
+ * --text-primary. It stays legible on both surfaces and the emblem still
+ * carries the EU colours. Add a tier-2 token if the blue has to come back.
+ *
+ * DEVIATION FROM FIGMA (type): the hero badge sets the statement at 10px. The
+ * type scale has no 10px role and a raw font-size is not permitted, so both
+ * sizes render the statement at `label-md` (12px). The lockup is then a little
+ * taller than the 24px emblem at "sm"; it is also more legible, which EU
+ * visibility guidance asks for. Add a tier-2 role if 10px is required.
+ */
+export function EuBadge({
+ statement,
+ emblemAlt = "",
+ locale,
+ surface,
+ size = "md",
+ className,
+}: EuBadgeProps) {
+ return (
+
+
+
+ {statement}
+
+
+ );
+}
+
+export default EuBadge;
+
+/* ---------------------------------------------------------------------------
+ USAGE
+ ---------------------------------------------------------------------------
+ // In the mobile drawer, on the dark surface:
+
+
+ // Above the home hero wordmark, at the smaller emblem size:
+
+ ------------------------------------------------------------------------ */
diff --git a/apps/web/app/components/ui/EuBadge/index.ts b/apps/web/app/components/ui/EuBadge/index.ts
new file mode 100644
index 0000000..e3817ba
--- /dev/null
+++ b/apps/web/app/components/ui/EuBadge/index.ts
@@ -0,0 +1,2 @@
+export { EuBadge, default } from "./EuBadge";
+export type { EuBadgeProps, EuBadgeSize, EuBadgeSurface } from "./EuBadge";
diff --git a/apps/web/app/components/ui/Footer/Footer.css b/apps/web/app/components/ui/Footer/Footer.css
new file mode 100644
index 0000000..1eb967a
--- /dev/null
+++ b/apps/web/app/components/ui/Footer/Footer.css
@@ -0,0 +1,40 @@
+/* ============================================================================
+ FOOTER - the sitemap link's hover
+ ----------------------------------------------------------------------------
+ The design draft moves a hovered footer link along ONE axis and eases it:
+
+ transition: color .2s ease, transform .25s ease;
+ :hover { color: #4DAA93; transform: translateX(4px); }
+
+ Two reasons this is CSS rather than Tailwind utilities:
+
+ 1. `translate-x-*` compiles to the two-value form
+ `translate: var(--tw-translate-x) var(--tw-translate-y)`, so whatever Y
+ translate happens to be in scope rides along and the link drifts
+ diagonally. `translate: 4px 0` pins the second axis at zero.
+ 2. The movement has to be in the transition list. A `transition-[color]`
+ utility animates the colour and snaps the offset.
+
+ The direction is physical because `translate` has no logical form, so the
+ RTL rule mirrors it - the link always moves toward the reading edge.
+ ========================================================================= */
+
+@layer theme, base, components, utilities;
+
+@layer components {
+ .sh-footer-link {
+ transition:
+ color var(--motion-duration-fast) var(--motion-ease-standard),
+ translate var(--motion-duration-base) var(--motion-ease-standard);
+ }
+
+ @media (hover: hover) {
+ .sh-footer-link:hover {
+ translate: 4px 0;
+ }
+
+ [dir="rtl"] .sh-footer-link:hover {
+ translate: -4px 0;
+ }
+ }
+}
diff --git a/apps/web/app/components/ui/Footer/Footer.tsx b/apps/web/app/components/ui/Footer/Footer.tsx
index cd72e6f..797ed27 100644
--- a/apps/web/app/components/ui/Footer/Footer.tsx
+++ b/apps/web/app/components/ui/Footer/Footer.tsx
@@ -36,12 +36,14 @@ import type {
import {
DEFAULT_FOOTER_COPY,
DEFAULT_FOOTER_PARTNERS,
- DEFAULT_FOOTER_SOCIAL_LINKS,
+ defaultFooterSocialLinks,
FOOTER_ASSETS,
FOOTER_EU_EMBLEM_SIZE,
FOOTER_WORDMARK_SIZE,
defaultFooterLinkGroups,
} from "./content";
+import "./Footer.css";
+import { EuBadge } from "../EuBadge";
import { FooterSocialIcon } from "./FooterSocialIcon";
const cx = (...parts: readonly (string | false | undefined)[]): string =>
@@ -69,18 +71,20 @@ const TRANSITION =
/** Body/Large Bold links - 18/28, as the footer frame sets them. */
const LINK_CLASS = cx(
FOCUS_RING,
- TRANSITION,
- "body-lg-bold inline-block text-[var(--text-primary)]",
- "hover:text-[var(--text-link)] hover:underline underline-offset-4",
+ /* Colour AND the 4px nudge, both eased, both in ./Footer.css - see the note
+ there on why this is not a `translate-x` utility. */
+ "sh-footer-link",
+ "body-md-bold inline-block no-underline text-[var(--text-primary)]",
+ "hover:text-[var(--text-accent)]",
);
/** Label/Medium - the "implemented by" label (figma 228:3035). */
const LABEL_CLASS = "label-md uppercase";
-/** Body/Small Bold - the sitemap column headings (figma 140:7823 / 140:7834).
+/** The drafts set the column headings as a small uppercase label.
`--text-tertiary` is already remapped to a readable step on the light
surface, so the same token is correct on both. */
-const GROUP_HEADING_CLASS = "body-sm-bold uppercase text-[var(--text-tertiary)]";
+const GROUP_HEADING_CLASS = "label-md uppercase text-[var(--text-tertiary)]";
/**
* Internal links go through the router so the footer navigates client-side;
@@ -106,7 +110,7 @@ export function Footer({
locale = "en",
dir,
linkGroups,
- socialLinks = DEFAULT_FOOTER_SOCIAL_LINKS,
+ socialLinks,
partners = DEFAULT_FOOTER_PARTNERS,
copy: copyOverrides,
homeHref,
@@ -118,6 +122,9 @@ export function Footer({
// would blank the default, so only defined overrides are applied.
const copy: FooterCopy = { ...DEFAULT_FOOTER_COPY[locale], ...definedOnly(copyOverrides) };
const groups = linkGroups ?? defaultFooterLinkGroups(locale);
+ /* `[]` from a caller hides the row deliberately; `undefined` means "nobody
+ said", so the project's own confirmed channels stand in. */
+ const social = socialLinks ?? defaultFooterSocialLinks(locale);
const home = homeHref ?? `/${locale}`;
return (
@@ -126,26 +133,17 @@ export function Footer({
dir={dir}
data-surface={surface}
className={cx(
- // data-surface sets the palette, but the frame puts the footer on its
- // own ground (base/black on dark) with a rule separating it from the
- // page, so those two are stated here rather than inherited.
- "w-full border-t border-[var(--border-default)] bg-[var(--surface-footer)]",
+ /* The drafts stand the footer on its own ground (#141413) under a
+ hairline, rather than letting it inherit the page surface. */
+ "w-full border-t border-[var(--border-subtle)] bg-[var(--surface-footer)]",
"px-lg pb-2xl pt-[var(--layout-section-gap)] md:px-xl",
className,
)}
>