(
{cardLabel}
{cardDescription}
-
-
+
+ {showCTA && ctaText && href && (
+ // Use the same component as HeroSection to perfectly match styling
+
+ {ctaText}
+
+ )}
+
)
@@ -210,7 +235,7 @@ type CardHeadingProps = BaseProps & {
React.ComponentPropsWithoutRef<'a'>
const CardHeading = forwardRef(
- ({children, as = 'h3', className, href, onMouseEnter, onMouseLeave, onBlur, onFocus, ...rest}, ref) => {
+ ({children, as = 'h3', className, href, target, rel, onMouseEnter, onMouseLeave, onBlur, onFocus, ...rest}, ref) => {
return (
(
onMouseLeave={onMouseLeave}
onBlur={onBlur}
onFocus={onFocus}
+ target={target}
+ rel={rel}
>
{children}
@@ -263,4 +290,12 @@ export const Card = Object.assign(CardRoot, {
Icon: CardIcon,
Heading: CardHeading,
Description: CardDescription,
-})
\ No newline at end of file
+})
+
+// Convenience components: Event cards keep the CTA, Copy cards hide it.
+export const CardEvent = (props: CardProps) =>
+export const CardCopy = (props: CardProps) =>
+
+// Also keep runtime aliases on the Card object for backwards compatibility
+;(Card as any).Event = CardEvent
+;(Card as any).Copy = CardCopy
\ No newline at end of file
diff --git a/src/components/subcomponents/Constants.tsx b/src/components/subcomponents/Constants.tsx
index 74f238d..fdeafd7 100644
--- a/src/components/subcomponents/Constants.tsx
+++ b/src/components/subcomponents/Constants.tsx
@@ -20,4 +20,4 @@ export const TriColorGradients = ['green-blue-purple'] as const
// TODO: consider generating the scale from style dictionary and serve from the brand-primitives package
export const BaseSizeScale = [4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 64, 80, 96, 112, 128] as const
-type BaseSizeScale = (typeof BaseSizeScale)[number]
\ No newline at end of file
+export type BaseSize = (typeof BaseSizeScale)[number]
\ No newline at end of file
diff --git a/src/components/subcomponents/EventCard.tsx b/src/components/subcomponents/EventCard.tsx
new file mode 100644
index 0000000..1c1b2a7
--- /dev/null
+++ b/src/components/subcomponents/EventCard.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import { Card } from './Card'
+import type { EventData } from '../../utils/eventFilters'
+import { EVENT_CONFIG } from '../../utils/events.constants'
+import cardStyles from '../css/Card.module.css'
+import { formatDateEs } from '../../utils/date'
+
+export type EventCardProps = {
+ event: EventData
+ /**
+ * When true, links open in a new tab (adds target="_blank" and rel="noopener noreferrer").
+ * Defaults to true to match Timeline behavior.
+ */
+ openInNewTab?: boolean
+}
+
+/**
+ * Presentation component for a single event card,
+ * using the shared Card and the same CTA style as HeroSection.
+ */
+export const EventCard: React.FC = ({ event, openInNewTab = true }) => {
+ const target = openInNewTab ? '_blank' : undefined
+ const rel = openInNewTab ? 'noopener noreferrer' : undefined
+ return (
+
+
+ {event.event_name}
+ {formatDateEs(event.event_date)}
+
+ )
+}
+
+export default EventCard
diff --git a/src/components/subcomponents/ListMessage.tsx b/src/components/subcomponents/ListMessage.tsx
new file mode 100644
index 0000000..1c19ff0
--- /dev/null
+++ b/src/components/subcomponents/ListMessage.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import { Timeline, Stack } from '@primer/react-brand'
+
+export type ListMessageProps = {
+ message: string
+ fullWidth?: boolean
+}
+
+/**
+ * Generic message renderer for list-like sections.
+ * Internally uses the Timeline subcomponent for consistent styling.
+ */
+const ListMessage: React.FC = ({ message, fullWidth = true }) => {
+ return (
+
+
+ {message}
+
+
+ )
+}
+
+export default ListMessage
diff --git a/src/components/subcomponents/MinimalFooter.tsx b/src/components/subcomponents/MinimalFooter.tsx
index fe0bbde..9800f62 100644
--- a/src/components/subcomponents/MinimalFooter.tsx
+++ b/src/components/subcomponents/MinimalFooter.tsx
@@ -81,7 +81,7 @@ const socialLinkData = {
} as const
type SocialLinkName = keyof typeof socialLinkData
-type SocialLink = (typeof socialLinkData)[SocialLinkName]
+// type SocialLinkInfo = (typeof socialLinkData)[SocialLinkName] (not used)
const socialLinkNames = Object.keys(socialLinkData) as SocialLinkName[]
@@ -112,13 +112,8 @@ function Root({
// find Footer.Footnotes children
const footerFootnoteChild = () => {
const footnotes = React.Children.toArray(children).find(child => {
- if (!React.isValidElement(child)) {
- return false
- }
-
- if (child.type && child.type === Footnotes) {
- return true
- }
+ if (!React.isValidElement(child)) return false
+ return child.type === Footnotes
})
return footnotes
}
@@ -128,14 +123,7 @@ function Root({
* If more than 5 links are required, we should encourage usage of Footer instead.
*/
const LinkChildren = React.Children.toArray(children)
- .filter(child => {
- // if not valid element
- if (React.isValidElement(child)) {
- if (child.type === Link) {
- return child
- }
- }
- })
+ .filter(child => React.isValidElement(child) && child.type === Link)
.slice(0, 5)
const currentYear = new Date().getFullYear()
diff --git a/src/components/subcomponents/PastEventsItem.tsx b/src/components/subcomponents/PastEventsItem.tsx
new file mode 100644
index 0000000..ed0fef7
--- /dev/null
+++ b/src/components/subcomponents/PastEventsItem.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import { Timeline, Link, Animate } from '@primer/react-brand'
+import type { EventData } from '../../utils/eventFilters'
+import { formatDateEs } from '../../utils/date'
+
+export type PastEventsItemProps = {
+ event: EventData
+ /** Open link in a new tab. Defaults true to match public site behavior. */
+ openInNewTab?: boolean
+}
+
+/**
+ * Small timeline item for past events to unify date + link rendering.
+ */
+const PastEventsItem: React.FC = ({ event, openInNewTab = true }) => {
+ const target = openInNewTab ? '_blank' : undefined
+ const rel = openInNewTab ? 'noopener noreferrer' : undefined
+
+ return (
+
+ {formatDateEs(event.event_date)}
+
+
+ {event.event_name}
+
+
+
+ )
+}
+
+export default PastEventsItem
diff --git a/src/hooks/useEvents.ts b/src/hooks/useEvents.ts
new file mode 100644
index 0000000..fc889a7
--- /dev/null
+++ b/src/hooks/useEvents.ts
@@ -0,0 +1,37 @@
+import { useEffect, useState } from 'react';
+import type { EventData } from '../utils/eventFilters';
+
+/**
+ * Fetch events from public/data/issues.json with loading & error state.
+ * Uses PUBLIC_URL so it works in dev and on GitHub Pages.
+ */
+export function useEvents() {
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = async () => {
+ try {
+ const jsonUrl = `${process.env.PUBLIC_URL}/data/issues.json`;
+ const res = await fetch(jsonUrl);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: EventData[] = await res.json();
+ if (!cancelled) setEvents(Array.isArray(data) ? data : []);
+ } catch (e) {
+ if (!cancelled) setError(e instanceof Error ? e.message : 'Unknown error');
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+ load();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return { events, loading, error };
+}
+
+export default useEvents;
diff --git a/src/index.css b/src/index.css
index 30f1230..2dcab2b 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,16 +1,30 @@
+/* Design System Imports */
+@import './styles/design-tokens.css';
+@import './styles/typography.css';
+@import './styles/buttons.css';
+@import './styles/cards.css';
+@import './styles/animations.css';
+@import './styles/responsive.css';
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
body {
margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
+ font-family: var(--font-family-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- background-color: var(--brand-color-canvas-default);
- color: var(--brand-color-text-default);
- transition: background-color 0.2s ease, color 0.2s ease;
+ background-color: var(--ds-bg-default);
+ color: var(--ds-text-default);
+ transition: background-color var(--duration-normal) ease,
+ color var(--duration-normal) ease;
}
code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
+ font-family: var(--font-family-mono);
}
diff --git a/src/setupTests.ts b/src/setupTests.ts
index 8f2609b..94fac71 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -3,3 +3,18 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+
+// Polyfill matchMedia for @primer/react-brand ThemeProvider and components
+if (!window.matchMedia) {
+ // @ts-ignore
+ window.matchMedia = (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {}, // deprecated
+ removeListener: () => {}, // deprecated
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ });
+}
diff --git a/src/styles/animations.css b/src/styles/animations.css
new file mode 100644
index 0000000..7684c60
--- /dev/null
+++ b/src/styles/animations.css
@@ -0,0 +1,233 @@
+/* Animations & Transitions */
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideInUp {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideInDown {
+ from {
+ transform: translateY(-20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideInLeft {
+ from {
+ transform: translateX(-20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideInRight {
+ from {
+ transform: translateX(20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes scaleIn {
+ from {
+ transform: scale(0.95);
+ opacity: 0;
+ }
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.7;
+ }
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -1000px 0;
+ }
+ 100% {
+ background-position: 1000px 0;
+ }
+}
+
+@keyframes gradient-shift {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+/* Animation Utility Classes */
+
+.animate-fade-in {
+ animation: fadeIn var(--duration-normal) var(--ease-out) forwards;
+}
+
+.animate-slide-in-up {
+ animation: slideInUp var(--duration-normal) var(--ease-out) forwards;
+}
+
+.animate-slide-in-down {
+ animation: slideInDown var(--duration-normal) var(--ease-out) forwards;
+}
+
+.animate-slide-in-left {
+ animation: slideInLeft var(--duration-slow) var(--ease-out) forwards;
+}
+
+.animate-slide-in-right {
+ animation: slideInRight var(--duration-slow) var(--ease-out) forwards;
+}
+
+.animate-scale-in {
+ animation: scaleIn var(--duration-normal) var(--ease-out) forwards;
+}
+
+.animate-pulse {
+ animation: pulse var(--duration-slower) infinite;
+}
+
+/* Stagger Animation Helper */
+
+.animate-stagger > * {
+ animation: slideInUp var(--duration-slow) var(--ease-out) backwards;
+}
+
+.animate-stagger > *:nth-child(1) {
+ animation-delay: 0ms;
+}
+
+.animate-stagger > *:nth-child(2) {
+ animation-delay: 100ms;
+}
+
+.animate-stagger > *:nth-child(3) {
+ animation-delay: 200ms;
+}
+
+.animate-stagger > *:nth-child(4) {
+ animation-delay: 300ms;
+}
+
+.animate-stagger > *:nth-child(5) {
+ animation-delay: 400ms;
+}
+
+.animate-stagger > *:nth-child(n + 6) {
+ animation-delay: 500ms;
+}
+
+/* Loading Skeleton */
+
+.skeleton {
+ background: linear-gradient(
+ 90deg,
+ var(--ds-bg-muted) 0%,
+ var(--ds-bg-hover) 50%,
+ var(--ds-bg-muted) 100%
+ );
+ background-size: 1000px 100%;
+ animation: shimmer 2s infinite;
+ border-radius: var(--radius-md);
+}
+
+/* Smooth Transitions */
+
+.transition-smooth {
+ transition: var(--transition-normal);
+}
+
+.transition-fast {
+ transition: var(--transition-fast);
+}
+
+.transition-slow {
+ transition: var(--transition-slow);
+}
+
+/* Hover Effects */
+
+.hover-lift {
+ transition: var(--transition-fast);
+}
+
+.hover-lift:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.hover-glow {
+ transition: var(--transition-fast);
+}
+
+[data-color-mode='dark'] .hover-glow:hover {
+ box-shadow: 0 0 20px rgba(88, 166, 255, 0.3);
+}
+
+[data-color-mode='light'] .hover-glow:hover {
+ box-shadow: 0 0 20px rgba(9, 105, 218, 0.2);
+}
+
+.hover-opacity {
+ transition: var(--transition-fast);
+}
+
+.hover-opacity:hover {
+ opacity: 0.8;
+}
+
+/* Focus Visible */
+
+.focus-ring:focus-visible {
+ outline: 2px solid var(--ds-accent-primary);
+ outline-offset: 2px;
+ border-radius: var(--radius-md);
+}
+
+/* Disable Animations for Reduced Motion */
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/src/styles/buttons.css b/src/styles/buttons.css
new file mode 100644
index 0000000..31f7994
--- /dev/null
+++ b/src/styles/buttons.css
@@ -0,0 +1,158 @@
+/* Button Styles */
+
+.btn,
+button {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-body);
+ font-weight: var(--font-weight-semibold);
+ padding: var(--space-md) var(--space-lg);
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: var(--transition-fast);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ outline: none;
+ text-decoration: none;
+ user-select: none;
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Primary Button */
+.btn-primary,
+button.primary {
+ background-color: var(--ds-accent-primary);
+ color: white;
+ box-shadow: var(--shadow-xs);
+}
+
+.btn-primary:hover:not(:disabled),
+button.primary:hover:not(:disabled) {
+ background-color: color-mix(in srgb, var(--ds-accent-primary) 90%, black);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
+}
+
+.btn-primary:focus,
+button.primary:focus {
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.2);
+}
+
+.btn-primary:active:not(:disabled),
+button.primary:active:not(:disabled) {
+ transform: translateY(0);
+ box-shadow: var(--shadow-xs);
+}
+
+/* Secondary Button */
+.btn-secondary,
+button.secondary {
+ background-color: var(--ds-bg-subtle);
+ color: var(--ds-accent-primary);
+ border: 1px solid var(--ds-border-default);
+}
+
+.btn-secondary:hover:not(:disabled),
+button.secondary:hover:not(:disabled) {
+ background-color: var(--ds-bg-muted);
+ border-color: var(--ds-accent-primary);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
+}
+
+.btn-secondary:focus,
+button.secondary:focus {
+ box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
+ border-color: var(--ds-accent-primary);
+}
+
+/* Tertiary Button (Minimal) */
+.btn-tertiary,
+button.tertiary {
+ background-color: transparent;
+ color: var(--ds-accent-primary);
+ border: none;
+}
+
+.btn-tertiary:hover:not(:disabled),
+button.tertiary:hover:not(:disabled) {
+ background-color: var(--ds-bg-hover);
+ box-shadow: none;
+}
+
+.btn-tertiary:focus,
+button.tertiary:focus {
+ background-color: var(--ds-bg-subtle);
+ outline: 2px solid var(--ds-accent-primary);
+ outline-offset: 2px;
+}
+
+/* Small Button */
+.btn-sm {
+ padding: var(--space-sm) var(--space-md);
+ font-size: var(--font-size-sm);
+}
+
+/* Large Button */
+.btn-lg {
+ padding: var(--space-lg) var(--space-2xl);
+ font-size: var(--font-size-body-lg);
+}
+
+/* Full Width */
+.btn-block {
+ width: 100%;
+}
+
+/* Icon Button */
+.btn-icon {
+ padding: var(--space-md);
+ border-radius: var(--radius-md);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+}
+
+/* Danger Button */
+.btn-danger {
+ background-color: var(--ds-accent-red);
+ color: white;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background-color: color-mix(in srgb, var(--ds-accent-red) 90%, black);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
+}
+
+/* Success Button */
+.btn-success {
+ background-color: var(--ds-accent-green);
+ color: white;
+}
+
+.btn-success:hover:not(:disabled) {
+ background-color: color-mix(in srgb, var(--ds-accent-green) 90%, black);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
+}
+
+/* Link Button */
+.btn-link {
+ background-color: transparent;
+ color: var(--ds-accent-primary);
+ padding: 0;
+ text-decoration: underline;
+}
+
+.btn-link:hover:not(:disabled) {
+ opacity: 0.8;
+}
diff --git a/src/styles/cards.css b/src/styles/cards.css
new file mode 100644
index 0000000..71cfe30
--- /dev/null
+++ b/src/styles/cards.css
@@ -0,0 +1,159 @@
+/* Card Styles */
+
+.card {
+ background-color: var(--ds-bg-subtle);
+ border: 1px solid var(--ds-border-default);
+ border-radius: var(--radius-lg);
+ padding: var(--space-lg);
+ transition: var(--transition-normal);
+ box-shadow: var(--shadow-xs);
+}
+
+.card:hover {
+ border-color: var(--ds-accent-primary);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-2px);
+}
+
+.card:focus {
+ outline: 2px solid var(--ds-accent-primary);
+ outline-offset: 2px;
+}
+
+/* Card Variants */
+
+.card-flat {
+ border: 1px solid var(--ds-border-default);
+ background-color: var(--ds-bg-subtle);
+ box-shadow: none;
+}
+
+.card-elevated {
+ border: none;
+ box-shadow: var(--shadow-md);
+}
+
+.card-elevated:hover {
+ box-shadow: var(--shadow-lg);
+}
+
+/* Card Sections */
+
+.card-header {
+ padding-bottom: var(--space-md);
+ border-bottom: 1px solid var(--ds-border-default);
+ margin-bottom: var(--space-md);
+}
+
+.card-header h3 {
+ margin: 0;
+ font-size: var(--font-size-h4);
+ color: var(--ds-text-default);
+}
+
+.card-body {
+ padding: var(--space-lg) 0;
+}
+
+.card-body p {
+ margin-bottom: var(--space-md);
+ color: var(--ds-text-muted);
+}
+
+.card-body p:last-child {
+ margin-bottom: 0;
+}
+
+.card-footer {
+ padding-top: var(--space-md);
+ border-top: 1px solid var(--ds-border-default);
+ margin-top: var(--space-md);
+ display: flex;
+ gap: var(--space-md);
+ align-items: center;
+ justify-content: flex-end;
+}
+
+/* Card with Image */
+
+.card-image {
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-lg);
+}
+
+.card-image img {
+ width: 100%;
+ height: auto;
+ display: block;
+ transition: var(--transition-normal);
+}
+
+.card:hover .card-image img {
+ transform: scale(1.05);
+}
+
+/* Card Grid */
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: var(--space-lg);
+ margin: var(--space-2xl) 0;
+}
+
+@media (max-width: 768px) {
+ .card-grid {
+ grid-template-columns: 1fr;
+ gap: var(--space-md);
+ }
+}
+
+/* Card Badge */
+
+.card-badge {
+ display: inline-block;
+ padding: var(--space-xs) var(--space-md);
+ background-color: var(--ds-accent-primary);
+ color: white;
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ margin-bottom: var(--space-md);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.card-badge.secondary {
+ background-color: var(--ds-bg-muted);
+ color: var(--ds-accent-primary);
+ border: 1px solid var(--ds-border-default);
+}
+
+.card-badge.success {
+ background-color: var(--ds-accent-green);
+}
+
+.card-badge.warning {
+ background-color: var(--ds-accent-yellow);
+ color: black;
+}
+
+.card-badge.danger {
+ background-color: var(--ds-accent-red);
+}
+
+/* Card Compact */
+
+.card-compact {
+ padding: var(--space-md);
+ border-radius: var(--radius-md);
+}
+
+/* Card Large */
+
+.card-lg {
+ padding: var(--space-2xl);
+ border-radius: var(--radius-xl);
+}
diff --git a/src/styles/design-tokens.css b/src/styles/design-tokens.css
new file mode 100644
index 0000000..6653986
--- /dev/null
+++ b/src/styles/design-tokens.css
@@ -0,0 +1,125 @@
+/* Design Tokens - GitHub Community Spain
+ * Inspired by GitHub Copilot CLI design aesthetic
+ */
+
+:root,
+[data-color-mode='auto'],
+[data-color-mode='light'] {
+ /* Light Mode Colors */
+ --ds-bg-default: #ffffff;
+ --ds-bg-subtle: #f6f8fa;
+ --ds-bg-muted: #eaeef2;
+ --ds-bg-hover: #f3f5f7;
+ --ds-bg-active: #e8ebf0;
+
+ --ds-text-default: #24292f;
+ --ds-text-muted: #656d76;
+ --ds-text-subtle: #8c959f;
+ --ds-text-disabled: #d0d7de;
+
+ /* Accent Colors - Light */
+ --ds-accent-primary: #0969da;
+ --ds-accent-purple: #8957e5;
+ --ds-accent-green: #1a7f37;
+ --ds-accent-red: #d1242f;
+ --ds-accent-yellow: #9e6a03;
+
+ /* Borders */
+ --ds-border-default: #d0d7de;
+ --ds-border-muted: #e5e7eb;
+ --ds-border-subtle: #f0f0f0;
+}
+
+[data-color-mode='dark'] {
+ /* Dark Mode Colors */
+ --ds-bg-default: #0d1117;
+ --ds-bg-subtle: #161b22;
+ --ds-bg-muted: #21262d;
+ --ds-bg-hover: #30363d;
+ --ds-bg-active: #3d444d;
+
+ --ds-text-default: #e6edf3;
+ --ds-text-muted: #8b949e;
+ --ds-text-subtle: #6e7681;
+ --ds-text-disabled: #444c56;
+
+ /* Accent Colors - Dark */
+ --ds-accent-primary: #58a6ff;
+ --ds-accent-purple: #d2a8ff;
+ --ds-accent-green: #3fb950;
+ --ds-accent-red: #f85149;
+ --ds-accent-yellow: #d29922;
+
+ /* Borders */
+ --ds-border-default: #30363d;
+ --ds-border-muted: #21262d;
+ --ds-border-subtle: #1c2128;
+}
+
+/* Typography */
+--font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
+ 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
+ 'Helvetica Neue', sans-serif;
+--font-family-mono: 'Cascadia Code', 'Courier New', monospace;
+
+--font-size-h1: 2.5rem;
+--font-size-h2: 2rem;
+--font-size-h3: 1.5rem;
+--font-size-h4: 1.25rem;
+--font-size-body-lg: 1.125rem;
+--font-size-body: 1rem;
+--font-size-sm: 0.875rem;
+--font-size-xs: 0.75rem;
+
+--font-weight-regular: 400;
+--font-weight-semibold: 600;
+--font-weight-bold: 700;
+
+--line-height-condensed: 1.2;
+--line-height-default: 1.5;
+--line-height-relaxed: 1.6;
+--line-height-loose: 1.8;
+
+/* Spacing System (8px base) */
+--space-xs: 0.25rem;
+--space-sm: 0.5rem;
+--space-md: 1rem;
+--space-lg: 1.5rem;
+--space-xl: 2rem;
+--space-2xl: 2.5rem;
+--space-3xl: 3rem;
+--space-4xl: 4rem;
+
+/* Border Radius */
+--radius-sm: 0.25rem;
+--radius-md: 0.5rem;
+--radius-lg: 0.75rem;
+--radius-xl: 1rem;
+--radius-full: 9999px;
+
+/* Shadows */
+--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.06);
+--shadow-sm: 0 3px 6px rgba(0, 0, 0, 0.12);
+--shadow-md: 0 6px 12px rgba(0, 0, 0, 0.16);
+--shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.20);
+--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.24);
+
+/* Animations */
+--ease-in: cubic-bezier(0.4, 0, 1, 1);
+--ease-out: cubic-bezier(0, 0, 0.2, 1);
+--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+
+--duration-fast: 100ms;
+--duration-normal: 200ms;
+--duration-slow: 300ms;
+--duration-slower: 500ms;
+
+/* Transitions */
+--transition-fast: all var(--duration-fast) var(--ease-out);
+--transition-normal: all var(--duration-normal) var(--ease-in-out);
+--transition-slow: all var(--duration-slow) var(--ease-in-out);
+
+/* Layout */
+--max-width-container: 1280px;
+--max-width-lg: 1024px;
+--max-width-md: 768px;
diff --git a/src/styles/responsive.css b/src/styles/responsive.css
new file mode 100644
index 0000000..41c6b2b
--- /dev/null
+++ b/src/styles/responsive.css
@@ -0,0 +1,371 @@
+/* Responsive & Layout Styles */
+
+/* Container */
+
+.container {
+ width: 100%;
+ max-width: var(--max-width-container);
+ margin: 0 auto;
+ padding: 0 var(--space-xl);
+}
+
+.container-lg {
+ max-width: var(--max-width-lg);
+}
+
+.container-md {
+ max-width: var(--max-width-md);
+}
+
+@media (max-width: 768px) {
+ .container {
+ padding: 0 var(--space-lg);
+ }
+}
+
+@media (max-width: 480px) {
+ .container {
+ padding: 0 var(--space-md);
+ }
+}
+
+/* Section Spacing */
+
+section {
+ padding: var(--space-4xl) 0;
+}
+
+section.section-tight {
+ padding: var(--space-2xl) 0;
+}
+
+section.section-loose {
+ padding: var(--space-4xl) 0;
+}
+
+@media (max-width: 768px) {
+ section {
+ padding: var(--space-2xl) 0;
+ }
+
+ section.section-loose {
+ padding: var(--space-3xl) 0;
+ }
+}
+
+@media (max-width: 480px) {
+ section {
+ padding: var(--space-xl) 0;
+ }
+}
+
+/* Grid System */
+
+.grid {
+ display: grid;
+ gap: var(--space-lg);
+}
+
+.grid-cols-2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.grid-cols-3 {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.grid-cols-4 {
+ grid-template-columns: repeat(4, 1fr);
+}
+
+@media (max-width: 1024px) {
+ .grid-cols-4 {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .grid-cols-2,
+ .grid-cols-3,
+ .grid-cols-4 {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Flexbox Utilities */
+
+.flex {
+ display: flex;
+}
+
+.flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.flex-between {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.flex-column {
+ flex-direction: column;
+}
+
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.gap-sm {
+ gap: var(--space-sm);
+}
+
+.gap-md {
+ gap: var(--space-md);
+}
+
+.gap-lg {
+ gap: var(--space-lg);
+}
+
+.gap-xl {
+ gap: var(--space-xl);
+}
+
+/* Alignment */
+
+.text-center {
+ text-align: center;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-right {
+ text-align: right;
+}
+
+/* Margin Utilities */
+
+.m-0 {
+ margin: 0;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.mt-xs {
+ margin-top: var(--space-xs);
+}
+
+.mt-sm {
+ margin-top: var(--space-sm);
+}
+
+.mt-md {
+ margin-top: var(--space-md);
+}
+
+.mt-lg {
+ margin-top: var(--space-lg);
+}
+
+.mt-xl {
+ margin-top: var(--space-xl);
+}
+
+.mt-2xl {
+ margin-top: var(--space-2xl);
+}
+
+.mb-xs {
+ margin-bottom: var(--space-xs);
+}
+
+.mb-sm {
+ margin-bottom: var(--space-sm);
+}
+
+.mb-md {
+ margin-bottom: var(--space-md);
+}
+
+.mb-lg {
+ margin-bottom: var(--space-lg);
+}
+
+.mb-xl {
+ margin-bottom: var(--space-xl);
+}
+
+.mb-2xl {
+ margin-bottom: var(--space-2xl);
+}
+
+/* Padding Utilities */
+
+.p-sm {
+ padding: var(--space-sm);
+}
+
+.p-md {
+ padding: var(--space-md);
+}
+
+.p-lg {
+ padding: var(--space-lg);
+}
+
+.p-xl {
+ padding: var(--space-xl);
+}
+
+.px-md {
+ padding: 0 var(--space-md);
+}
+
+.px-lg {
+ padding: 0 var(--space-lg);
+}
+
+.py-md {
+ padding: var(--space-md) 0;
+}
+
+.py-lg {
+ padding: var(--space-lg) 0;
+}
+
+/* Display Utilities */
+
+.hidden {
+ display: none;
+}
+
+.visible {
+ display: block;
+}
+
+.inline-block {
+ display: inline-block;
+}
+
+.inline {
+ display: inline;
+}
+
+/* Visibility on Different Screens */
+
+@media (max-width: 768px) {
+ .hide-mobile {
+ display: none !important;
+ }
+
+ .show-mobile {
+ display: block !important;
+ }
+}
+
+@media (min-width: 769px) {
+ .hide-desktop {
+ display: none !important;
+ }
+
+ .show-desktop {
+ display: block !important;
+ }
+}
+
+/* Overflow Handling */
+
+.overflow-hidden {
+ overflow: hidden;
+}
+
+.overflow-auto {
+ overflow: auto;
+}
+
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Aspect Ratio */
+
+.aspect-square {
+ aspect-ratio: 1 / 1;
+}
+
+.aspect-video {
+ aspect-ratio: 16 / 9;
+}
+
+.aspect-thumbnail {
+ aspect-ratio: 4 / 3;
+}
+
+/* Width & Height */
+
+.w-full {
+ width: 100%;
+}
+
+.h-full {
+ height: 100%;
+}
+
+.min-h-screen {
+ min-height: 100vh;
+}
+
+.min-h-full {
+ min-height: 100%;
+}
+
+/* Z-Index Scale */
+
+.z-0 {
+ z-index: 0;
+}
+
+.z-10 {
+ z-index: 10;
+}
+
+.z-20 {
+ z-index: 20;
+}
+
+.z-50 {
+ z-index: 50;
+}
+
+.z-100 {
+ z-index: 100;
+}
+
+/* Position */
+
+.relative {
+ position: relative;
+}
+
+.absolute {
+ position: absolute;
+}
+
+.fixed {
+ position: fixed;
+}
+
+.sticky {
+ position: sticky;
+ top: 0;
+}
diff --git a/src/styles/typography.css b/src/styles/typography.css
new file mode 100644
index 0000000..2301ef5
--- /dev/null
+++ b/src/styles/typography.css
@@ -0,0 +1,145 @@
+/* Typography Styles */
+
+h1 {
+ font-size: var(--font-size-h1);
+ font-weight: var(--font-weight-bold);
+ line-height: var(--line-height-condensed);
+ color: var(--ds-text-default);
+ margin: var(--space-2xl) 0 var(--space-lg);
+ font-family: var(--font-family-sans);
+}
+
+h2 {
+ font-size: var(--font-size-h2);
+ font-weight: var(--font-weight-bold);
+ line-height: var(--line-height-condensed);
+ color: var(--ds-text-default);
+ margin: var(--space-2xl) 0 var(--space-md);
+ font-family: var(--font-family-sans);
+}
+
+h3 {
+ font-size: var(--font-size-h3);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--line-height-condensed);
+ color: var(--ds-text-default);
+ margin: var(--space-lg) 0 var(--space-md);
+ font-family: var(--font-family-sans);
+}
+
+h4 {
+ font-size: var(--font-size-h4);
+ font-weight: var(--font-weight-semibold);
+ line-height: var(--line-height-default);
+ color: var(--ds-text-default);
+ margin: var(--space-lg) 0 var(--space-sm);
+ font-family: var(--font-family-sans);
+}
+
+p {
+ font-size: var(--font-size-body);
+ line-height: var(--line-height-relaxed);
+ color: var(--ds-text-muted);
+ margin: 0 0 var(--space-md);
+ font-family: var(--font-family-sans);
+}
+
+p.large {
+ font-size: var(--font-size-body-lg);
+ color: var(--ds-text-default);
+}
+
+p.small {
+ font-size: var(--font-size-sm);
+ color: var(--ds-text-subtle);
+}
+
+/* Links */
+a {
+ color: var(--ds-accent-primary);
+ text-decoration: none;
+ font-weight: var(--font-weight-semibold);
+ transition: var(--transition-fast);
+}
+
+a:hover {
+ text-decoration: underline;
+ opacity: 0.8;
+}
+
+a:focus {
+ outline: 2px solid var(--ds-accent-primary);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
+/* Code */
+code {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ background-color: var(--ds-bg-muted);
+ color: var(--ds-accent-primary);
+ padding: var(--space-xs) var(--space-sm);
+ border-radius: var(--radius-sm);
+}
+
+pre {
+ background-color: var(--ds-bg-muted);
+ border: 1px solid var(--ds-border-default);
+ border-radius: var(--radius-lg);
+ padding: var(--space-lg);
+ overflow-x: auto;
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-sm);
+ line-height: var(--line-height-default);
+}
+
+pre code {
+ background-color: transparent;
+ color: var(--ds-text-default);
+ padding: 0;
+}
+
+/* Lists */
+ul,
+ol {
+ font-size: var(--font-size-body);
+ line-height: var(--line-height-relaxed);
+ color: var(--ds-text-muted);
+ margin: var(--space-md) 0;
+ padding-left: var(--space-2xl);
+}
+
+li {
+ margin-bottom: var(--space-sm);
+}
+
+/* Blockquote */
+blockquote {
+ border-left: 4px solid var(--ds-accent-primary);
+ padding: var(--space-md) var(--space-lg);
+ margin: var(--space-lg) 0;
+ background-color: var(--ds-bg-subtle);
+ border-radius: var(--radius-md);
+ font-style: italic;
+ color: var(--ds-text-muted);
+}
+
+/* Strong & Emphasis */
+strong,
+b {
+ font-weight: var(--font-weight-bold);
+ color: var(--ds-text-default);
+}
+
+em,
+i {
+ font-style: italic;
+}
+
+/* Horizontal Rule */
+hr {
+ border: none;
+ border-top: 1px solid var(--ds-border-default);
+ margin: var(--space-3xl) 0;
+}
diff --git a/src/utils/__tests__/date.test.ts b/src/utils/__tests__/date.test.ts
new file mode 100644
index 0000000..edeba08
--- /dev/null
+++ b/src/utils/__tests__/date.test.ts
@@ -0,0 +1,15 @@
+import { formatDateEs } from '../date'
+
+describe('formatDateEs', () => {
+ it('formats a valid date as dd/mm/yyyy in es-ES', () => {
+ const formatted = formatDateEs('2025-10-05')
+ // Spanish locale uses dd/mm/yyyy
+ expect(formatted).toBe('05/10/2025')
+ })
+
+ it('returns the original string for invalid dates', () => {
+ const input = 'not-a-date'
+ const formatted = formatDateEs(input)
+ expect(formatted).toBe(input)
+ })
+})
diff --git a/src/utils/__tests__/eventFilters.test.ts b/src/utils/__tests__/eventFilters.test.ts
new file mode 100644
index 0000000..614f9b9
--- /dev/null
+++ b/src/utils/__tests__/eventFilters.test.ts
@@ -0,0 +1,32 @@
+import { filterAndSortEvents, type EventData } from '../eventFilters'
+
+describe('filterAndSortEvents', () => {
+ const fixedNow = new Date('2025-10-28T12:00:00Z')
+
+ beforeAll(() => {
+ jest.useFakeTimers()
+ // Use system time to make new Date() deterministic
+ // Note: Date parsing without time is local; using UTC time avoids DST issues here
+ jest.setSystemTime(fixedNow)
+ })
+
+ afterAll(() => {
+ jest.useRealTimers()
+ })
+
+ it('splits into upcoming and past and sorts correctly', () => {
+ const events: EventData[] = [
+ { event_id: '1', event_name: 'Past Early', event_link: '#1', event_date: '2025-10-01' },
+ { event_id: '2', event_name: 'Today Midnight', event_link: '#2', event_date: '2025-10-28' },
+ { event_id: '3', event_name: 'Tomorrow', event_link: '#3', event_date: '2025-10-29' },
+ ]
+
+ const { upcomingEvents, pastEvents } = filterAndSortEvents(events)
+
+ // upcoming: strictly greater than now -> only 2025-10-29
+ expect(upcomingEvents.map(e => e.event_id)).toEqual(['3'])
+
+ // past: <= now, sorted descending -> today, then early month
+ expect(pastEvents.map(e => e.event_id)).toEqual(['2', '1'])
+ })
+})
diff --git a/src/utils/date.ts b/src/utils/date.ts
new file mode 100644
index 0000000..7742b56
--- /dev/null
+++ b/src/utils/date.ts
@@ -0,0 +1,16 @@
+// Shared date formatting helpers for the site
+// Keep minimal and reusable to avoid duplicating logic across sections
+
+export function formatDateEs(dateString: string): string {
+ try {
+ const date = new Date(dateString);
+ if (isNaN(date.getTime())) return dateString;
+ return date.toLocaleDateString('es-ES', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ });
+ } catch {
+ return dateString;
+ }
+}
diff --git a/src/utils/eventFilters.ts b/src/utils/eventFilters.ts
new file mode 100644
index 0000000..d93ed8e
--- /dev/null
+++ b/src/utils/eventFilters.ts
@@ -0,0 +1,66 @@
+import { EVENT_CONFIG } from './events.constants';
+
+/**
+ * Interface for event data structure
+ */
+export interface EventData {
+ event_id: string;
+ event_name: string;
+ event_link: string;
+ event_date: string;
+ event_image?: string;
+}
+
+/**
+ * Interface for grouped events
+ */
+export interface EventsGrouped {
+ upcomingEvents: EventData[];
+ pastEvents: EventData[];
+}
+
+/**
+ * Filters and sorts events into two groups: upcoming and past
+ * @param events - Array of events to filter
+ * @returns Object with upcoming and past events, each sorted appropriately
+ */
+export const filterAndSortEvents = (events: EventData[]): EventsGrouped => {
+ const now = new Date();
+
+ const upcomingEvents = events
+ .filter(event => new Date(event.event_date) > now)
+ .sort((a, b) =>
+ new Date(a.event_date).getTime() - new Date(b.event_date).getTime()
+ );
+
+ const pastEvents = events
+ .filter(event => new Date(event.event_date) <= now)
+ .sort((a, b) =>
+ new Date(b.event_date).getTime() - new Date(a.event_date).getTime()
+ );
+
+ return { upcomingEvents, pastEvents };
+};
+
+/**
+ * Calculates responsive card width based on viewport and card width
+ * @param cardWidth - Natural width of card in px (null = not measured)
+ * @param viewportWidth - Viewport width
+ * @param breakpoint - Breakpoint for responsive switching (defaults to RESPONSIVE_BREAKPOINT)
+ * @returns CSS value string (px or %)
+ */
+export const calculateResponsiveWidth = (
+ cardWidth: number | null,
+ viewportWidth: number,
+ breakpoint: number = EVENT_CONFIG.RESPONSIVE_BREAKPOINT
+): string => {
+ if (!cardWidth) return '100%';
+
+ // Use percentage on small screens
+ if (viewportWidth < breakpoint) {
+ return '90%';
+ }
+
+ // Use measured px on large screens
+ return `${cardWidth}px`;
+};
diff --git a/src/utils/events.constants.ts b/src/utils/events.constants.ts
new file mode 100644
index 0000000..8cecbff
--- /dev/null
+++ b/src/utils/events.constants.ts
@@ -0,0 +1,30 @@
+/**
+ * Centralized constants for the events section
+ * Eliminates magic numbers scattered throughout the codebase
+ */
+
+export const EVENT_CONFIG = {
+ // Default logo when event has no image
+ DEFAULT_LOGO: `${process.env.PUBLIC_URL}/images/logos/svg/Meetup.svg`,
+
+ // Card width constraints
+ MIN_CARD_WIDTH: 320, // Minimum width in px
+ MAX_CARD_WIDTH: 900, // Maximum width in px
+
+ // Breakpoint for responsive design
+ RESPONSIVE_BREAKPOINT: 640, // In px, below uses %
+
+ // Image dimensions within card
+ CARD_IMAGE_HEIGHT: 260, // Image height in px
+ CARD_MIN_HEIGHT: 320, // Minimum card height
+
+ // Text labels
+ CTA_TEXT: 'Ver evento',
+ UPCOMING_TITLE: 'Próximos eventos',
+ PAST_TITLE: 'Eventos pasados',
+
+ // Animation timing
+ ANIMATION_DURATION: 220, // ms
+} as const;
+
+export type EventConfig = typeof EVENT_CONFIG;