diff --git a/@l10n/es-ES/translations.yaml b/@l10n/es-ES/translations.yaml index 211dbe89b0..f0b49ea228 100644 --- a/@l10n/es-ES/translations.yaml +++ b/@l10n/es-ES/translations.yaml @@ -2,6 +2,7 @@ navbar.about: Acerca de navbar.docs: Docs navbar.resources: Recursos navbar.community: Comunidad +navbar.showcase: Escaparate footer.about: Acerca de footer.docs: Docs footer.resources: Recursos diff --git a/@l10n/ja/translations.yaml b/@l10n/ja/translations.yaml index bf239e6bbd..c8ed224af2 100644 --- a/@l10n/ja/translations.yaml +++ b/@l10n/ja/translations.yaml @@ -16,6 +16,7 @@ navbar.about: 概要 navbar.docs: ドキュメント navbar.resources: リソース navbar.community: コミュニティ +navbar.showcase: ショーケース footer.about: 概要 footer.docs: ドキュメント footer.resources: リソース diff --git a/@theme/components/Navbar/constants/navigation.ts b/@theme/components/Navbar/constants/navigation.ts index d17ea89c10..87a1e5ced6 100644 --- a/@theme/components/Navbar/constants/navigation.ts +++ b/@theme/components/Navbar/constants/navigation.ts @@ -14,6 +14,7 @@ export const navItems: NavItem[] = [ { label: "Use Cases", labelTranslationKey: "navbar.usecases", href: "/use-cases", hasSubmenu: true }, { label: "Community", labelTranslationKey: "navbar.community", href: "/community", hasSubmenu: true }, { label: "Network", labelTranslationKey: "navbar.network", href: "/resources", hasSubmenu: true }, + { label: "Showcase (Temporary)", labelTranslationKey: "navbar.showcase", href: "/showcase", hasSubmenu: false }, ]; // Develop submenu data structure diff --git a/postcss.config.cjs b/postcss.config.cjs index b255d27355..dca0557dff 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -19,13 +19,14 @@ module.exports = { ...(isProduction || process.env.PURGECSS === 'true' ? [ purgecss({ - // Scan all content files for class names + // Scan all content files for class names (TSX/TS/HTML = usage; SCSS = BEM modifier definitions) content: [ './**/*.tsx', './**/*.ts', './**/*.md', './**/*.yaml', './**/*.html', + './**/*.scss', './static/js/**/*.js', './static/vendor/**/*.js', // Ignore node_modules except for specific libraries that inject classes @@ -42,7 +43,11 @@ module.exports = { const m = match.match(/["']([^"']*)["']/); return m ? m[1].split(/\s+/) : []; }); - return [...broadMatches, ...classes]; + // Extract BEM modifier classes from SCSS (e.g. .bds-callout-media-banner--green) + // These are dynamically applied in TSX via template literals so PurgeCSS won't find them as literals + const scssBemMatches = content.match(/\.(bds-[a-z0-9-]+--[a-z0-9-]+)/g) || []; + const bemModifiers = [...new Set(scssBemMatches.map(m => m.replace(/^\./, '')))]; + return [...new Set([...broadMatches, ...classes, ...bemModifiers])]; }, // Safelist - classes that should never be removed @@ -68,6 +73,9 @@ module.exports = { /^col-/, // Column classes /^bds-grid__col/, // PageGrid column classes (dynamic span values) /^bds-grid__offset/, // PageGrid offset classes + // BDS BEM modifier classes - applied dynamically via template literals (e.g. bds-callout-media-banner--green) + // Required: PurgeCSS cannot find these in TSX/SCSS due to interpolation + /^bds-[a-z0-9-]+--/, /^g-/, // Gap utilities /^p-/, // Padding utilities /^m-/, // Margin utilities diff --git a/shared/components/CardStat/CardStat.scss b/shared/components/CardStat/CardStat.scss index 35e07da9bd..bcb4f2ff05 100644 --- a/shared/components/CardStat/CardStat.scss +++ b/shared/components/CardStat/CardStat.scss @@ -44,7 +44,8 @@ $bds-card-stat-transition-timing: $bds-transition-timing; .bds-card-stat { // Layout - display: flex; + // !important needed to override PageGridCol's default display (e.g. grid/flex from parent) + display: flex !important; flex-direction: column; width: 100%; min-height: 200px; diff --git a/shared/components/CardStat/CardStat.tsx b/shared/components/CardStat/CardStat.tsx index 2fac23b049..6b7602b513 100644 --- a/shared/components/CardStat/CardStat.tsx +++ b/shared/components/CardStat/CardStat.tsx @@ -87,43 +87,41 @@ export const CardStat: React.FC = ({ const isNumericSuperscript = superscript && /^[0-9]+$/.test(superscript); return ( - -
- {/* Text section */} -
-
- {statistic}{superscript && {superscript}}
-
{label}
-
- - {/* Buttons section */} - {hasButtons && ( -
- {primaryButton && ( - - )} - {secondaryButton && ( - - )} -
- )} + + {/* Text section */} +
+
+ {statistic}{superscript && {superscript}}
+
{label}
+ + {/* Buttons section */} + {hasButtons && ( +
+ {primaryButton && ( + + )} + {secondaryButton && ( + + )} +
+ )}
); }; diff --git a/shared/components/CardTextIcon/README.md b/shared/components/CardTextIcon/README.md index be433d75b3..29bd83ea06 100644 --- a/shared/components/CardTextIcon/README.md +++ b/shared/components/CardTextIcon/README.md @@ -92,9 +92,9 @@ CardTextIconCard displays an icon at the top, followed by a heading and descript | Breakpoint | Icon Size | Padding | Gap | |------------|-----------|---------|-----| -| Base (< 576px) | 56px | 16px | 16px | -| MD (576px - 991px) | 60px | 20px | 20px | -| LG (≥ 992px) | 64px | 24px | 24px | +| Base (< 576px) | 32px | 16px | 16px | +| MD (576px - 991px) | 36px | 20px | 20px | +| LG (≥ 992px) | 40px | 32px | 24px | ## Files diff --git a/shared/components/Video/Video.scss b/shared/components/Video/Video.scss new file mode 100644 index 0000000000..f3b66aa9af --- /dev/null +++ b/shared/components/Video/Video.scss @@ -0,0 +1,188 @@ +// BDS Video Component Styles +// Brand Design System - Flexible video component supporting native video, +// YouTube/Vimeo/Wistia embeds, and cover image + modal playback. +// +// .bds-video - Container +// .bds-video__element - Native video element +// .bds-video__iframe - Embed iframe +// .bds-video__cover-button - Clickable cover (opens modal) +// .bds-video__cover-image - Cover image +// .bds-video__play-icon - Play overlay icon +// .bds-video-modal - Full-screen modal overlay +// .bds-video-modal__backdrop - Backdrop (click to close) +// .bds-video-modal__content - Modal content wrapper +// .bds-video-modal__close - Close button +// .bds-video-modal__video - Video/iframe container in modal + +.bds-video { + width: 100%; + height: auto; + overflow: hidden; + position: relative; + + &--aspect-16-9 { + aspect-ratio: 16 / 9; + } + + &--aspect-4-3 { + aspect-ratio: 4 / 3; + } + + &--aspect-1-1 { + aspect-ratio: 1 / 1; + } + + &__inner { + width: 100%; + height: 100%; + position: relative; + } + + &__element { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; + } + + &__iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + + &__cover-button { + position: relative; + display: block; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + border: none; + background: none; + cursor: pointer; + overflow: hidden; + } + + &__cover-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; + transition: transform 0.3s ease; + } + + &__cover-button:hover &__cover-image { + transform: scale(1.02); + } + + &__play-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + + svg { + width: 64px; + height: 64px; + transition: transform 0.2s ease; + } + } + + &__cover-button:hover &__play-icon svg { + transform: scale(1.1); + } +} + +// ============================================================================= +// Modal +// ============================================================================= + +.bds-video-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.bds-video-modal__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + cursor: pointer; +} + +.bds-video-modal__content { + position: relative; + top: 0; + left: 0; + width: 100%; + max-width: 90vw; + max-height: 90vh; + aspect-ratio: 16 / 9; + z-index: 2; +} + +.bds-video-modal__close { + position: absolute; + top: -2.5rem; + right: 0; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + background: transparent; + color: white; + font-size: 2rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + } + + span { + line-height: 1; + } +} + +.bds-video-modal__video { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + border-radius: 4px; + + .bds-video__element, + .bds-video__iframe, + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } +} diff --git a/shared/components/Video/Video.tsx b/shared/components/Video/Video.tsx new file mode 100644 index 0000000000..da8def3bbd --- /dev/null +++ b/shared/components/Video/Video.tsx @@ -0,0 +1,278 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import clsx from 'clsx'; +import type { DesignConstrainedVideoProps } from 'shared/utils/types'; + +/** Native HTML video source */ +export type VideoSourceNative = { + type: 'native'; + props: DesignConstrainedVideoProps; +}; + +/** + * Embed source - raw HTML iframe code from YouTube/Vimeo/Wistia + * + * ⚠️ **SECURITY WARNING**: embedCode uses regex-based parsing which can be bypassed + * with malformed HTML (e.g., newlines in attributes, missing quotes, unusual spacing). + * **Always prefer `embedUrl` over `embedCode` when possible.** + * + * Only use embedCode when: + * - The HTML comes from a trusted source you control + * - You cannot extract the URL beforehand + * + * The component validates against TRUSTED_EMBED_ORIGINS, but regex parsing is not + * foolproof against all HTML variations. + */ +export type VideoSourceEmbedCode = { + type: 'embed'; + embedCode: string; + embedUrl?: never; +}; + +/** + * Embed source - direct embed URL (safer, preferred) + * + * ✅ **RECOMMENDED**: This is the preferred and safer method for embedding videos. + * Directly validates the URL against TRUSTED_EMBED_ORIGINS without HTML parsing. + */ +export type VideoSourceEmbedUrl = { + type: 'embed'; + embedUrl: string; + embedCode?: never; +}; + +export type VideoSource = + | VideoSourceNative + | VideoSourceEmbedCode + | VideoSourceEmbedUrl; + +export interface VideoProps { + /** + * Video source: native HTML video or embed (YouTube/Vimeo/Wistia) + * + * **For embeds, prefer `embedUrl` over `embedCode`:** + * - ✅ `embedUrl`: Direct URL validation (safer, recommended) + * - ⚠️ `embedCode`: Regex-based HTML parsing (can be bypassed, use with caution) + */ + source: VideoSource; + /** Optional cover image - when provided, video shows in modal on click */ + coverImage?: { + src: string; + alt: string; + }; + /** Aspect ratio for container (default 16/9) */ + aspectRatio?: '16/9' | '4/3' | '1/1'; + /** Additional className for container */ + className?: string; +} + +/** Trusted embed origins for embedCode sanitization */ +const TRUSTED_EMBED_ORIGINS = [ + 'youtube.com', + 'www.youtube.com', + 'youtube-nocookie.com', + 'www.youtube-nocookie.com', + 'vimeo.com', + 'player.vimeo.com', + 'fast.wistia.net', + 'fast.wistia.com', +]; + +function isTrustedEmbedUrl(url: string): boolean { + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + return TRUSTED_EMBED_ORIGINS.some( + (origin) => host === origin || host.endsWith('.' + origin) + ); + } catch { + return false; + } +} + +/** + * Extract iframe src from embed code if from trusted origin + * + * ⚠️ **SECURITY WARNING**: This regex-based parser is NOT foolproof and can be bypassed + * with malformed HTML. Known bypass vectors include: + * - Newlines or unusual whitespace in attributes + * - Missing or unusual quote characters + * - HTML comments or CDATA sections + * - Encoded characters or HTML entities + * + * This function should only be used as a fallback when embedUrl is not available. + * Always prefer using embedUrl directly when possible. + * + * @param embedCode - Raw HTML iframe embed code (from trusted sources only) + * @returns Extracted URL if valid and from trusted origin, null otherwise + */ +function extractEmbedUrlFromCode(embedCode: string): string | null { + const iframeMatch = embedCode.match(/]+src=["']([^"']+)["']/i); + if (!iframeMatch) return null; + const url = iframeMatch[1]; + return isTrustedEmbedUrl(url) ? url : null; +} + +export const Video = React.forwardRef( + (props, ref) => { + const { + source, + coverImage, + aspectRatio = '16/9', + className, + } = props; + + const [isModalOpen, setIsModalOpen] = React.useState(false); + const closeButtonRef = useRef(null); + const previousActiveElementRef = useRef(null); + + const closeModal = useCallback(() => { + setIsModalOpen(false); + }, []); + + useEffect(() => { + if (!isModalOpen) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeModal(); + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isModalOpen, closeModal]); + + // Focus trap: capture previous focus, focus close button, restore on close + useEffect(() => { + if (isModalOpen) { + previousActiveElementRef.current = document.activeElement as HTMLElement | null; + closeButtonRef.current?.focus(); + } else { + previousActiveElementRef.current?.focus(); + } + }, [isModalOpen]); + + + + const renderVideoContent = () => { + if (source.type === 'native') { + return ( +