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 ( +
+ {emblemAlt} + + {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, )} >
- {/* ---- brand + sitemap (figma 140:7684 / 140:8686) ---------------- */} -
- {/* --- brand column (figma 140:7685 / 140:8687) ----------------- */} -
mirrors the row. - "lg:w-[345px] lg:shrink-0 lg:border-e lg:border-[var(--border-subtle)] lg:pe-2xl", - )} - > + {/* ---- brand column + sitemap --------------------------------- */} +
+
-

{copy.tagline}

- -

- {copy.description} + {/* One line: the full name and the dates the project runs. */} +

+ {copy.brandLine}

- {/* --- EU visibility (figma 140:7688 / 238:2325) -------------- */} -
- {copy.euEmblemAlt} -

- {copy.euStatement} -

-
+ {/* The draft draws each channel as a bordered button with its + glyph and a written label, not as a bare icon. */} + {social.length > 0 && ( + <> +

{copy.socialHeading}

+
    + {social.map((link) => ( +
  • + +
  • + ))} +
+ + )} - {socialLinks.length > 0 && } + {/* EU visibility, through the shared lockup the hero uses. */} +
- {/* --- sitemap columns (figma 380:12402) ------------------------ */} -