From f7f877e0d66daf83f1c03f480e13cd6c06052f88 Mon Sep 17 00:00:00 2001 From: GMNAPI Date: Thu, 12 Mar 2026 11:06:56 +0100 Subject: [PATCH 1/9] feat(skills): redesign section with compact cards and detail modal Apply the same two-level pattern as Projects: compact SkillCard (icon + summary + first 3 skill pills + hover overlay with usage bars) opens a SkillModal with full skill metadata (description, years, projects, keywords). Adds `leadership` id to SkillCategory type and fixes the missing icon. 263 tests pass. --- apps/portfolio/messages/en.json | 4 + apps/portfolio/messages/es.json | 4 + apps/portfolio/src/features/skills/Skills.tsx | 105 ++---------- .../features/skills/components/SkillCard.tsx | 109 ++++++++++++ .../features/skills/components/SkillModal.tsx | 119 +++++++++++++ apps/portfolio/src/shared/constants/skills.ts | 2 +- .../tests/features/skills/Skills.test.tsx | 160 ++++-------------- .../skills/components/SkillCard.test.tsx | 122 +++++++++++++ .../skills/components/SkillModal.test.tsx | 129 ++++++++++++++ 9 files changed, 535 insertions(+), 219 deletions(-) create mode 100644 apps/portfolio/src/features/skills/components/SkillCard.tsx create mode 100644 apps/portfolio/src/features/skills/components/SkillModal.tsx create mode 100644 apps/portfolio/tests/features/skills/components/SkillCard.test.tsx create mode 100644 apps/portfolio/tests/features/skills/components/SkillModal.test.tsx diff --git a/apps/portfolio/messages/en.json b/apps/portfolio/messages/en.json index e7f7328..256c9f8 100644 --- a/apps/portfolio/messages/en.json +++ b/apps/portfolio/messages/en.json @@ -182,6 +182,10 @@ "experienceLabel": "{years}+ years", "usageLabel": "{percentage}% repo usage", "projectsLabel": "{count} projects", + "aria": { + "openCategory": "Open details for {name}", + "closeModal": "Close modal" + }, "categories": [ { "id": "backend", diff --git a/apps/portfolio/messages/es.json b/apps/portfolio/messages/es.json index cfa0777..73d20dd 100644 --- a/apps/portfolio/messages/es.json +++ b/apps/portfolio/messages/es.json @@ -182,6 +182,10 @@ "experienceLabel": "{years}+ años", "usageLabel": "{percentage}% uso repos", "projectsLabel": "{count} proyectos", + "aria": { + "openCategory": "Abrir detalles de {name}", + "closeModal": "Cerrar modal" + }, "categories": [ { "id": "backend", diff --git a/apps/portfolio/src/features/skills/Skills.tsx b/apps/portfolio/src/features/skills/Skills.tsx index 34047a1..3839a8c 100644 --- a/apps/portfolio/src/features/skills/Skills.tsx +++ b/apps/portfolio/src/features/skills/Skills.tsx @@ -1,20 +1,13 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { m } from 'framer-motion'; import { useLocale, useTranslations } from 'next-intl'; import type { SkillCategory, SkillSummary } from '@/shared/constants/skills'; import { fadeInUp, staggerContainer } from '@/shared/utils/motion'; - -const CATEGORY_ICONS: Record = { - backend: '🛠️', - frontend: '🎨', - databases: '🗄️', - devops: '⚙️', - 'ai-automation': '🤖', - testing: '✅', -}; +import { SkillCard } from './components/SkillCard'; +import { SkillModal } from './components/SkillModal'; const SEGMENT_COLORS = ['#38bdf8', '#34d399', '#fbbf24', '#f97316', '#a855f7', '#ef4444']; @@ -23,6 +16,8 @@ export function Skills() { const tSkills = useTranslations('skills'); const numberLocale = locale === 'es' ? 'es-ES' : 'en-US'; + const [selectedCategory, setSelectedCategory] = useState(null); + const categories = tSkills.raw('categories') as SkillCategory[]; const summary = tSkills.raw('summary') as SkillSummary; const languages = summary.mainLanguagesShare; @@ -192,96 +187,16 @@ export function Skills() { - {/* Skill categories */} + {/* Skill category cards */} {categories.map((category) => ( - -
-
-

- {CATEGORY_ICONS[category.id]} {category.name} -

-

{category.summary}

-
- - {tSkills('experienceLabel', { years: category.experienceYears })} - -
- -
    - {category.skills.map((skill) => { - const usageLabel = - skill.usagePercentage !== undefined - ? tSkills('usageLabel', { - percentage: percentFormatter.format(skill.usagePercentage), - }) - : null; - - return ( -
  • -
    -

    {skill.name}

    - {usageLabel && ( - - {usageLabel} - - )} -
    -

    {skill.description}

    - {(skill.usagePercentage !== undefined || - skill.experienceYears !== undefined || - skill.experienceProjects !== undefined) && ( -
    - {skill.usagePercentage !== undefined && ( -
    -
    -
    -
    - )} - {skill.experienceYears !== undefined && ( - - {tSkills('experienceLabel', { years: skill.experienceYears })} - - )} - {skill.experienceProjects !== undefined && ( - - {tSkills('projectsLabel', { count: skill.experienceProjects })} - - )} -
    - )} - {skill.keywords && ( -
    - {skill.keywords.map((keyword) => ( - - {keyword} - - ))} -
    - )} -
  • - ); - })} -
-
+ ))}
+ + {/* Detail modal */} + setSelectedCategory(null)} /> ); } diff --git a/apps/portfolio/src/features/skills/components/SkillCard.tsx b/apps/portfolio/src/features/skills/components/SkillCard.tsx new file mode 100644 index 0000000..bbeed0e --- /dev/null +++ b/apps/portfolio/src/features/skills/components/SkillCard.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { m } from 'framer-motion'; +import { useTranslations } from 'next-intl'; + +import { fadeInUp } from '@/shared/utils/motion'; +import type { SkillCategory } from '@/shared/constants/skills'; + +export const CATEGORY_ICONS: Record = { + backend: '🛠️', + frontend: '🎨', + databases: '🗄️', + devops: '⚙️', + 'ai-automation': '🤖', + testing: '✅', + leadership: '🤝', +}; + +const MAX_VISIBLE_SKILLS = 3; + +interface SkillCardProps { + category: SkillCategory; + onOpen: (category: SkillCategory) => void; +} + +export function SkillCard({ category, onOpen }: SkillCardProps) { + const t = useTranslations('skills'); + + const visibleSkills = category.skills.slice(0, MAX_VISIBLE_SKILLS); + const overflowCount = category.skills.length - MAX_VISIBLE_SKILLS; + + return ( + onOpen(category)} + role="article" + aria-label={t('aria.openCategory', { name: category.name })} + > + {/* Default card content */} +
+ {/* Header: icon + name + experience badge */} +
+

+ {CATEGORY_ICONS[category.id]} {category.name} +

+ + {t('experienceLabel', { years: category.experienceYears })} + +
+ + {/* Summary truncated to 2 lines */} +

+ {category.summary} +

+ + {/* Skill name pills: first 3 + overflow */} +
+ {visibleSkills.map((skill) => ( + + {skill.name} + + ))} + {overflowCount > 0 && ( + + +{overflowCount} + + )} +
+
+ + {/* Hover overlay */} +
+

+ {CATEGORY_ICONS[category.id]} {category.name} +

+
    + {category.skills.map((skill) => ( +
  • +
    + {skill.name} + {skill.usagePercentage !== undefined && ( + + {skill.usagePercentage}% + + )} +
    + {skill.usagePercentage !== undefined && ( +
    +
    + )} +
  • + ))} +
+

Ver detalles →

+
+
+ ); +} diff --git a/apps/portfolio/src/features/skills/components/SkillModal.tsx b/apps/portfolio/src/features/skills/components/SkillModal.tsx new file mode 100644 index 0000000..23c9ed7 --- /dev/null +++ b/apps/portfolio/src/features/skills/components/SkillModal.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useMemo } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; + +import { Modal } from '@/shared/components/ui/Modal'; +import type { SkillCategory } from '@/shared/constants/skills'; +import { CATEGORY_ICONS } from './SkillCard'; + +interface SkillModalProps { + category: SkillCategory | null; + onClose: () => void; +} + +export function SkillModal({ category, onClose }: SkillModalProps) { + const t = useTranslations('skills'); + const locale = useLocale(); + const numberLocale = locale === 'es' ? 'es-ES' : 'en-US'; + + const percentFormatter = useMemo( + () => + new Intl.NumberFormat(numberLocale, { + maximumFractionDigits: 1, + minimumFractionDigits: 0, + }), + [numberLocale] + ); + + if (!category) return null; + + return ( + + {/* Header */} +
+
+

+ {CATEGORY_ICONS[category.id]} {category.name} +

+ + {t('experienceLabel', { years: category.experienceYears })} + +
+ +
+ + {/* Summary */} +
+

{category.summary}

+
+ + {/* Skill list */} +
    + {category.skills.map((skill) => { + const usageLabel = + skill.usagePercentage !== undefined + ? t('usageLabel', { + percentage: percentFormatter.format(skill.usagePercentage), + }) + : null; + + return ( +
  • +
    +

    {skill.name}

    + {usageLabel && ( + {usageLabel} + )} +
    + + {skill.usagePercentage !== undefined && ( +
    +
    +
    +
    + )} + +

    {skill.description}

    + + {(skill.experienceYears !== undefined || skill.experienceProjects !== undefined) && ( +
    + {skill.experienceYears !== undefined && ( + {t('experienceLabel', { years: skill.experienceYears })} + )} + {skill.experienceProjects !== undefined && ( + {t('projectsLabel', { count: skill.experienceProjects })} + )} +
    + )} + + {skill.keywords && skill.keywords.length > 0 && ( +
    + {skill.keywords.map((keyword) => ( + + {keyword} + + ))} +
    + )} +
  • + ); + })} +
+
+ ); +} diff --git a/apps/portfolio/src/shared/constants/skills.ts b/apps/portfolio/src/shared/constants/skills.ts index f7729eb..e9cf832 100644 --- a/apps/portfolio/src/shared/constants/skills.ts +++ b/apps/portfolio/src/shared/constants/skills.ts @@ -8,7 +8,7 @@ export interface SkillItem { } export interface SkillCategory { - id: 'backend' | 'frontend' | 'databases' | 'devops' | 'ai-automation' | 'testing'; + id: 'backend' | 'frontend' | 'databases' | 'devops' | 'ai-automation' | 'testing' | 'leadership'; name: string; summary: string; experienceYears: number; diff --git a/apps/portfolio/tests/features/skills/Skills.test.tsx b/apps/portfolio/tests/features/skills/Skills.test.tsx index fec0ec6..d687821 100644 --- a/apps/portfolio/tests/features/skills/Skills.test.tsx +++ b/apps/portfolio/tests/features/skills/Skills.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; @@ -6,7 +6,6 @@ import { NextIntlClientProvider } from 'next-intl'; import esMessages from '../../../messages/es.json'; import enMessages from '../../../messages/en.json'; import { Skills } from '@/features/skills/Skills'; -import type { SkillItem } from '@/shared/constants/skills'; function renderSkills(locale: 'es' | 'en' = 'es') { const messages = locale === 'es' ? esMessages : enMessages; @@ -36,150 +35,68 @@ describe('Skills Section', () => { expect(articles.length).toBe(esMessages.skills.categories.length); }); - it('should display skills from constants', () => { + it('should display category names on cards', () => { renderSkills(); esMessages.skills.categories.forEach((category) => { - category.skills.slice(0, 2).forEach((skill) => { - expect( - screen.getAllByText(new RegExp(`^${skill.name}$`, 'i'), { exact: false }).length - ).toBeGreaterThan(0); - }); + expect(screen.getAllByText(new RegExp(category.name, 'i')).length).toBeGreaterThan(0); }); }); - it('should show usage information when available', () => { + it('should display experience badges on cards', () => { renderSkills(); - const usage = screen.getAllByText(/% uso repos/i); - expect(usage.length).toBeGreaterThan(0); + const experienceBadges = screen.getAllByText(/\d+\+ años/i); + expect(experienceBadges.length).toBeGreaterThan(0); }); - describe('Skill Metadata Conditionals', () => { - it('should render usage percentage label when skill has usagePercentage', () => { - renderSkills(); - // Find skills with usagePercentage defined - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithUsage = skillsData.find((skill) => skill.usagePercentage !== undefined); - - if (skillWithUsage) { - // Should display the percentage somewhere - const usageTexts = screen.queryAllByText(/% uso repos/i); - expect(usageTexts.length).toBeGreaterThan(0); - } - }); + it('should NOT display skill descriptions in cards (they are in the modal)', () => { + renderSkills(); + // Skill descriptions should not be visible until a card is clicked + const firstCategory = esMessages.skills.categories[0]; + const firstSkillDescription = firstCategory.skills[0].description; + expect(screen.queryByText(firstSkillDescription)).not.toBeInTheDocument(); + }); - it('should not render usage label when skill has no usagePercentage', () => { - renderSkills(); - // All skills in the test data should have usagePercentage - // This tests the conditional logic by verifying proper rendering - const { container } = renderSkills(); - expect(container).toBeTruthy(); - }); + it('should open modal with skill details when a card is clicked', () => { + renderSkills(); + const firstCategory = esMessages.skills.categories[0]; + const articles = screen.getAllByRole('article'); + fireEvent.click(articles[0]); - it('should render experience years when skill has experienceYears', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithYears = skillsData.find((skill) => skill.experienceYears !== undefined); - - if (skillWithYears) { - // Should find year labels - const yearLabels = screen.queryAllByText(/años/i); - expect(yearLabels.length).toBeGreaterThan(0); - } - }); + // Modal should now show the first skill's description + const firstSkillDescription = firstCategory.skills[0].description; + expect(screen.getByText(firstSkillDescription)).toBeInTheDocument(); + }); - it('should render experience projects when skill has experienceProjects', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithProjects = skillsData.find((skill) => skill.experienceProjects !== undefined); - - if (skillWithProjects) { - // Should find project count labels - const projectLabels = screen.queryAllByText(/proyectos/i); - expect(projectLabels.length).toBeGreaterThan(0); - } - }); + it('should close modal when Escape is pressed', () => { + renderSkills(); + const articles = screen.getAllByRole('article'); + fireEvent.click(articles[0]); - it('should render all metadata when skill has all fields', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const fullSkill = skillsData.find( - (skill) => - skill.usagePercentage !== undefined && - skill.experienceYears !== undefined && - skill.experienceProjects !== undefined - ); - - if (fullSkill) { - // Should display usage, years, and projects - expect(screen.queryAllByText(/% uso repos/i).length).toBeGreaterThan(0); - expect(screen.queryAllByText(/años/i).length).toBeGreaterThan(0); - expect(screen.queryAllByText(/proyectos/i).length).toBeGreaterThan(0); - } - }); + // Modal is open — now close with Escape + fireEvent.keyDown(document, { key: 'Escape' }); - it('should not render metadata section when skill has no metadata fields', () => { - // This test verifies the compound conditional (lines 236-238) - renderSkills(); - const { container } = renderSkills(); - - // Count how many skills have metadata sections - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillsWithMetadata = skillsData.filter( - (skill) => - skill.usagePercentage !== undefined || - skill.experienceYears !== undefined || - skill.experienceProjects !== undefined - ); - - // All skills in the data should have at least some metadata - expect(skillsWithMetadata.length).toBeGreaterThan(0); - expect(container).toBeTruthy(); - }); + const firstCategory = esMessages.skills.categories[0]; + const firstSkillDescription = firstCategory.skills[0].description; + expect(screen.queryByText(firstSkillDescription)).not.toBeInTheDocument(); }); - describe('Keywords', () => { - it('should render keywords when skill has keywords array', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithKeywords = skillsData.find( - (skill) => skill.keywords && skill.keywords.length > 0 - ); - - if (skillWithKeywords) { - // Should render the keyword badges - skillWithKeywords.keywords?.forEach((keyword: string) => { - const keywordElements = screen.queryAllByText(keyword); - expect(keywordElements.length).toBeGreaterThan(0); - }); - } - }); - - it('should not render keywords section when skill has no keywords', () => { - renderSkills(); - // This tests the conditional on line 265 - const { container } = renderSkills(); - expect(container).toBeTruthy(); - }); + it('should show usage information in modal when available', () => { + renderSkills(); + const articles = screen.getAllByRole('article'); + fireEvent.click(articles[0]); + const usage = screen.getAllByText(/% uso repos/i); + expect(usage.length).toBeGreaterThan(0); }); describe('Donut Chart', () => { it('should render donut chart with language distribution', () => { renderSkills(); - // Chart should show percentages const percentages = screen.getAllByText(/% del stack/i); expect(percentages.length).toBeGreaterThan(0); }); - it('should handle empty languages array gracefully', () => { - // This tests the edge case when languages array is empty - renderSkills(); - const { container } = renderSkills(); - expect(container).toBeTruthy(); - }); - it('should calculate total usage correctly', () => { renderSkills(); - // Should display the total repositories count const reposText = screen.getByText(/repos analizados/i); expect(reposText).toBeInTheDocument(); }); @@ -187,15 +104,12 @@ describe('Skills Section', () => { describe('Localization', () => { it('should format percentages according to locale', () => { - // Spanish locale const { container: esContainer } = renderSkills('es'); const esContent = esContainer.textContent || ''; - // English locale const { container: enContainer } = renderSkills('en'); const enContent = enContainer.textContent || ''; - // Both should have percentage content expect(esContent).toMatch(/%/); expect(enContent).toMatch(/%/); }); diff --git a/apps/portfolio/tests/features/skills/components/SkillCard.test.tsx b/apps/portfolio/tests/features/skills/components/SkillCard.test.tsx new file mode 100644 index 0000000..42c9e6c --- /dev/null +++ b/apps/portfolio/tests/features/skills/components/SkillCard.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; + +import esMessages from '../../../../messages/es.json'; +import { SkillCard } from '@/features/skills/components/SkillCard'; +import type { SkillCategory } from '@/shared/constants/skills'; + +const testCategory: SkillCategory = { + id: 'backend', + name: 'Backend', + summary: 'Migraciones ERP a SaaS y APIs REST de alto rendimiento', + experienceYears: 6, + skills: [ + { + name: 'PHP', + description: 'Lenguaje principal en múltiples repositorios', + usagePercentage: 31, + experienceYears: 6, + experienceProjects: 18, + keywords: ['Symfony', 'Laravel', 'OOP'], + }, + { + name: 'Symfony 7', + description: 'Framework para APIs REST', + usagePercentage: 25, + keywords: ['DDD', 'CQRS'], + }, + { + name: 'Node.js', + description: 'Servicios backend asíncronos', + usagePercentage: 15, + }, + { + name: 'API Platform', + description: 'Framework para APIs RESTful y GraphQL', + usagePercentage: 10, + }, + ], +}; + +function renderCard(category = testCategory, onOpen = vi.fn()) { + return render( + + + + ); +} + +describe('SkillCard', () => { + describe('Rendering', () => { + it('renders the category name', () => { + renderCard(); + expect(screen.getAllByText(/Backend/i).length).toBeGreaterThan(0); + }); + + it('renders the experience badge', () => { + renderCard(); + expect(screen.getAllByText(/6\+ años/i).length).toBeGreaterThan(0); + }); + + it('renders the summary truncated', () => { + renderCard(); + expect(screen.getByText(testCategory.summary)).toBeInTheDocument(); + }); + + it('renders max 3 skill name pills', () => { + renderCard(); + // First 3 skill names visible as pills + expect(screen.getAllByText('PHP').length).toBeGreaterThan(0); + expect(screen.getAllByText('Symfony 7').length).toBeGreaterThan(0); + expect(screen.getAllByText('Node.js').length).toBeGreaterThan(0); + }); + + it('shows overflow badge when there are more than 3 skills', () => { + renderCard(); + // 4 skills - 3 visible = 1 more + expect(screen.getByText('+1')).toBeInTheDocument(); + }); + + it('does not show overflow badge when 3 or fewer skills', () => { + const fewSkills: SkillCategory = { + ...testCategory, + skills: testCategory.skills.slice(0, 3), + }; + renderCard(fewSkills); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it('renders all skills in the hover overlay', () => { + renderCard(); + // All 4 skills should appear (some in default view, all in overlay) + expect(screen.getAllByText('API Platform').length).toBeGreaterThan(0); + }); + }); + + describe('Interaction', () => { + it('calls onOpen with the category when clicked', () => { + const onOpen = vi.fn(); + renderCard(testCategory, onOpen); + const article = screen.getByRole('article'); + fireEvent.click(article); + expect(onOpen).toHaveBeenCalledOnce(); + expect(onOpen).toHaveBeenCalledWith(testCategory); + }); + + it('is a clickable article element', () => { + renderCard(); + const article = screen.getByRole('article'); + expect(article).toHaveClass('cursor-pointer'); + }); + }); + + describe('Accessibility', () => { + it('has an aria-label describing the category', () => { + renderCard(); + const article = screen.getByRole('article'); + expect(article).toHaveAttribute('aria-label'); + expect(article.getAttribute('aria-label')).toContain('Backend'); + }); + }); +}); diff --git a/apps/portfolio/tests/features/skills/components/SkillModal.test.tsx b/apps/portfolio/tests/features/skills/components/SkillModal.test.tsx new file mode 100644 index 0000000..dbccb46 --- /dev/null +++ b/apps/portfolio/tests/features/skills/components/SkillModal.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; + +import esMessages from '../../../../messages/es.json'; +import { SkillModal } from '@/features/skills/components/SkillModal'; +import type { SkillCategory } from '@/shared/constants/skills'; + +const testCategory: SkillCategory = { + id: 'backend', + name: 'Backend', + summary: 'Migraciones ERP a SaaS y APIs REST de alto rendimiento', + experienceYears: 6, + skills: [ + { + name: 'PHP', + description: 'Lenguaje principal en múltiples repositorios', + usagePercentage: 31, + experienceYears: 6, + experienceProjects: 18, + keywords: ['Symfony', 'Laravel', 'OOP'], + }, + { + name: 'Symfony 7', + description: 'Framework para APIs REST', + usagePercentage: 25, + keywords: ['DDD', 'CQRS'], + }, + ], +}; + +function renderModal(category: SkillCategory | null = testCategory, onClose = vi.fn()) { + return render( + + + + ); +} + +describe('SkillModal', () => { + describe('Rendering', () => { + it('renders nothing when category is null', () => { + const { container } = renderModal(null); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders category name when open', () => { + renderModal(); + expect(screen.getAllByText(/Backend/i).length).toBeGreaterThan(0); + }); + + it('renders category summary', () => { + renderModal(); + expect(screen.getByText(testCategory.summary)).toBeInTheDocument(); + }); + + it('renders experience badge', () => { + renderModal(); + expect(screen.getAllByText(/6\+ años/i).length).toBeGreaterThan(0); + }); + + it('renders all skill names', () => { + renderModal(); + expect(screen.getByText('PHP')).toBeInTheDocument(); + expect(screen.getByText('Symfony 7')).toBeInTheDocument(); + }); + + it('renders skill descriptions', () => { + renderModal(); + expect(screen.getByText('Lenguaje principal en múltiples repositorios')).toBeInTheDocument(); + }); + + it('renders skill keywords', () => { + renderModal(); + expect(screen.getByText('Symfony')).toBeInTheDocument(); + expect(screen.getByText('Laravel')).toBeInTheDocument(); + expect(screen.getByText('OOP')).toBeInTheDocument(); + }); + + it('renders usage percentage labels', () => { + renderModal(); + const usageLabels = screen.getAllByText(/% uso repos/i); + expect(usageLabels.length).toBeGreaterThan(0); + }); + + it('renders experience years for skills that have them', () => { + renderModal(); + const yearLabels = screen.queryAllByText(/años/i); + expect(yearLabels.length).toBeGreaterThan(0); + }); + + it('renders project count for skills that have them', () => { + renderModal(); + expect(screen.getByText(/18 proyectos/i)).toBeInTheDocument(); + }); + + it('has role="dialog" and aria-modal', () => { + renderModal(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); + }); + + describe('Close interactions', () => { + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn(); + renderModal(testCategory, onClose); + const closeBtn = screen.getByRole('button', { name: /cerrar modal/i }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when Escape key is pressed', () => { + const onClose = vi.fn(); + renderModal(testCategory, onClose); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when backdrop is clicked', () => { + const onClose = vi.fn(); + renderModal(testCategory, onClose); + const backdrop = document.querySelector('.fixed.inset-0'); + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop!); + expect(onClose).toHaveBeenCalledOnce(); + }); + }); +}); From bce73b3ec93857a9eee11a6222400ae612468451 Mon Sep 17 00:00:00 2001 From: GMNAPI Date: Thu, 12 Mar 2026 11:22:07 +0100 Subject: [PATCH 2/9] fix(skills): improve hover overlay progress bar contrast --- apps/portfolio/src/features/skills/components/SkillCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/portfolio/src/features/skills/components/SkillCard.tsx b/apps/portfolio/src/features/skills/components/SkillCard.tsx index bbeed0e..2194668 100644 --- a/apps/portfolio/src/features/skills/components/SkillCard.tsx +++ b/apps/portfolio/src/features/skills/components/SkillCard.tsx @@ -91,9 +91,9 @@ export function SkillCard({ category, onOpen }: SkillCardProps) { )} {skill.usagePercentage !== undefined && ( -
+
From f77117a877a9f395c9802adc318ef8fdea4031b1 Mon Sep 17 00:00:00 2001 From: GMNAPI Date: Thu, 12 Mar 2026 15:49:11 +0100 Subject: [PATCH 8/9] style(skills): fix Prettier formatting in SkillModal --- apps/portfolio/src/features/skills/components/SkillModal.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/portfolio/src/features/skills/components/SkillModal.tsx b/apps/portfolio/src/features/skills/components/SkillModal.tsx index 23c9ed7..a0c9139 100644 --- a/apps/portfolio/src/features/skills/components/SkillModal.tsx +++ b/apps/portfolio/src/features/skills/components/SkillModal.tsx @@ -65,7 +65,10 @@ export function SkillModal({ category, onClose }: SkillModalProps) { : null; return ( -
  • +
  • {skill.name}

    {usageLabel && ( From df2cea8d4d28b0eba17904b754557cb33d4e01a3 Mon Sep 17 00:00:00 2001 From: GMNAPI Date: Thu, 12 Mar 2026 16:09:56 +0100 Subject: [PATCH 9/9] ci: remove uploadArtifacts from Lighthouse action to fix artifact API 400 error --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf54900..3b3e20a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,6 @@ jobs: with: urls: | http://localhost:3000 - uploadArtifacts: true temporaryPublicStorage: true - name: Stop production server