feat: control room hero, SEO/AEO optimization and design system fixes
Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
This commit is contained in:
1 parent
0920370f30
commit
b791b7de9e
94 files changed
+2621
-2889
No files matched your search
@@ -1,407 +1,132 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Experience from "./Experience";
|
||||
import fc from "fast-check";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
// Mock the portfolio data hook
|
||||
jest.mock("@/lib/usePortfolioData");
|
||||
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
||||
typeof usePortfolioData
|
||||
>;
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
type SectionMockProps = {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
disableChildrenAnimation?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => false,
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
article: ({ children, ...props }: any) => (
|
||||
<article {...props}>{children}</article>
|
||||
),
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Section component
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, ...props }: any) => (
|
||||
<section {...props}>{children}</section>
|
||||
Section: ({
|
||||
children,
|
||||
title,
|
||||
eyebrow: _eyebrow,
|
||||
description: _description,
|
||||
disableChildrenAnimation: _disableChildrenAnimation,
|
||||
...props
|
||||
}: SectionMockProps) => (
|
||||
<section {...props}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock animation components
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <div data-testid="draw-line" />,
|
||||
DrawLine: () => <span data-testid="draw-line" />,
|
||||
}));
|
||||
|
||||
jest.mock("./animations/CounterValue", () => ({
|
||||
CounterValue: ({ value, suffix }: any) => (
|
||||
<span>
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
function makeExperience(index: number): PortfolioExperience {
|
||||
return {
|
||||
id: `experience-${index}`,
|
||||
title: `Experience ${index}`,
|
||||
company: "Company",
|
||||
period: `${2020 + index} - Present`,
|
||||
location: "Remote",
|
||||
type: index % 2 === 0 ? "Software" : "Hardware",
|
||||
description: "Description",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock SectionLoading
|
||||
jest.mock("./SectionLoading", () => ({
|
||||
SectionLoading: ({ message }: any) => <div>{message}</div>,
|
||||
}));
|
||||
|
||||
describe("Property 11: Semantic Heading Hierarchy", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should maintain proper heading hierarchy (h2 for section, h3 for cards)", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any sequence of heading elements in the Experience section,
|
||||
* the heading levels should follow proper semantic hierarchy (h2 for section
|
||||
* title, h3 for experience card titles, no skipped levels). This ensures
|
||||
* screen readers can properly navigate the document structure.
|
||||
*/
|
||||
describe("Experience heading hierarchy", () => {
|
||||
it("renders one h2 section heading and h3 headings for featured cards", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.string(),
|
||||
title: fc.string({ minLength: 1 }),
|
||||
company: fc.string({ minLength: 1 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.string({ minLength: 1 }),
|
||||
achievements: fc.array(fc.string({ minLength: 1 })),
|
||||
technologies: fc.array(fc.string({ minLength: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 10 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
fc.property(fc.integer({ min: 1, max: 8 }), (count) => {
|
||||
const experiences = Array.from({ length: count }, (_, index) =>
|
||||
makeExperience(index),
|
||||
);
|
||||
const { container, unmount } = render(
|
||||
<Experience experiences={experiences} />,
|
||||
);
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
|
||||
// Get all heading elements in order
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
expect(headings[0]?.tagName).toBe("H2");
|
||||
expect(container.querySelector("section > h2")).toHaveTextContent(
|
||||
"Experiência em engenharia de software e sistemas críticos",
|
||||
);
|
||||
expect(container.querySelectorAll("section > h2")).toHaveLength(1);
|
||||
expect(container.querySelectorAll("article h3")).toHaveLength(count);
|
||||
expect(
|
||||
new Set(
|
||||
Array.from(container.querySelectorAll("article h3")).map(
|
||||
(heading) => heading.textContent,
|
||||
),
|
||||
),
|
||||
).toEqual(new Set(experiences.map((experience) => experience.title)));
|
||||
expect(headings.some((heading) => heading.tagName === "H4")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
if (headings.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract heading levels
|
||||
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
|
||||
|
||||
// Check for proper hierarchy:
|
||||
// 1. Should start with h2 (section title)
|
||||
// 2. Experience card titles should be h3
|
||||
// 3. No skipped levels (difference should not be > 1)
|
||||
|
||||
// First heading should be h2 (section title)
|
||||
if (levels[0] !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for skipped levels
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
const diff = levels[i] - levels[i - 1];
|
||||
// Difference should be -1, 0, or 1 (no skipping levels)
|
||||
if (Math.abs(diff) > 1 && diff > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All experience card titles should be h3
|
||||
const cardTitles = container.querySelectorAll("article h3");
|
||||
if (cardTitles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
unmount();
|
||||
}),
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not skip heading levels in the hierarchy", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any generated set of experiences, the heading hierarchy
|
||||
* should never skip levels (e.g., h2 directly to h4). This is critical
|
||||
* for accessibility as screen reader users rely on heading levels to
|
||||
* understand document structure.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.uuid(),
|
||||
title: fc.lorem({ maxCount: 5 }),
|
||||
company: fc.lorem({ maxCount: 2 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.lorem({ maxCount: 10 }),
|
||||
achievements: fc.array(fc.lorem({ maxCount: 5 }), { maxLength: 3 }),
|
||||
technologies: fc.array(fc.lorem({ maxCount: 1 }), { maxLength: 5 }),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 2, maxLength: 8 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
it("renders no card headings when no experiences are supplied", () => {
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Get all headings
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
|
||||
|
||||
// Check that we never skip levels when going deeper
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
const currentLevel = levels[i];
|
||||
const previousLevel = levels[i - 1];
|
||||
|
||||
// When going deeper (increasing level number), should only increase by 1
|
||||
if (
|
||||
currentLevel > previousLevel &&
|
||||
currentLevel - previousLevel > 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use h2 for section title consistently", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any Experience section render, the main section title
|
||||
* should always be an h2 element. This maintains consistency with the
|
||||
* overall page structure where main sections use h2.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.string(),
|
||||
title: fc.string({ minLength: 1 }),
|
||||
company: fc.string({ minLength: 1 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.string({ minLength: 1 }),
|
||||
achievements: fc.array(fc.string({ minLength: 1 })),
|
||||
technologies: fc.array(fc.string({ minLength: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 5 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the section element
|
||||
const section = container.querySelector("section");
|
||||
if (!section) return false;
|
||||
|
||||
// The first heading in the section should be h2
|
||||
const firstHeading = section.querySelector("h1, h2, h3, h4, h5, h6");
|
||||
if (!firstHeading) return false;
|
||||
|
||||
return firstHeading.tagName === "H2";
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use h3 for all experience card titles", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any set of experience cards rendered, each card's title
|
||||
* should be an h3 element. This maintains proper hierarchy under the
|
||||
* section h2 and ensures consistent navigation for assistive technology.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.uuid(),
|
||||
title: fc.lorem({ maxCount: 5 }),
|
||||
company: fc.lorem({ maxCount: 2 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.lorem({ maxCount: 10 }),
|
||||
achievements: fc.array(fc.lorem({ maxCount: 5 })),
|
||||
technologies: fc.array(fc.lorem({ maxCount: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 10 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find all article elements (experience cards)
|
||||
const articles = container.querySelectorAll("article");
|
||||
|
||||
if (articles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each article should have at least one h3 for the title
|
||||
let allArticlesHaveH3 = true;
|
||||
|
||||
articles.forEach((article) => {
|
||||
const h3Elements = article.querySelectorAll("h3");
|
||||
// Should have at least one h3 (the title)
|
||||
if (h3Elements.length === 0) {
|
||||
allArticlesHaveH3 = false;
|
||||
}
|
||||
});
|
||||
|
||||
return allArticlesHaveH3;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain hierarchy across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any number of component re-renders with the same data,
|
||||
* the heading hierarchy should remain consistent. This ensures the
|
||||
* semantic structure is stable and reliable for assistive technology.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.string(),
|
||||
title: fc.string({ minLength: 1 }),
|
||||
company: fc.string({ minLength: 1 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.string({ minLength: 1 }),
|
||||
achievements: fc.array(fc.string({ minLength: 1 })),
|
||||
technologies: fc.array(fc.string({ minLength: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 5 },
|
||||
),
|
||||
fc.integer({ min: 2, max: 5 }),
|
||||
(experiences, numRenders) => {
|
||||
let previousHierarchy: number[] | null = null;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container, unmount } = render(<Experience />);
|
||||
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
const currentHierarchy = headings.map((h) =>
|
||||
parseInt(h.tagName.substring(1)),
|
||||
);
|
||||
|
||||
if (previousHierarchy !== null) {
|
||||
// Hierarchy should be identical across renders
|
||||
if (
|
||||
JSON.stringify(currentHierarchy) !==
|
||||
JSON.stringify(previousHierarchy)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
previousHierarchy = currentHierarchy;
|
||||
unmount();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
expect(container.querySelector("section > h2")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("article h3")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user