diff --git a/src/Components/portfolio/PortfolioCard.tsx b/src/Components/portfolio/PortfolioCard.tsx index 888e86d..ea6b90e 100644 --- a/src/Components/portfolio/PortfolioCard.tsx +++ b/src/Components/portfolio/PortfolioCard.tsx @@ -12,6 +12,7 @@ type PortfolioCoverImageProps = { alt: string; className: string; cover: string; + objectPosition?: string; priority?: boolean; responsiveCover?: ResponsivePortfolioCover; sizes: string; @@ -27,6 +28,7 @@ export function PortfolioCoverImage({ alt, className, cover, + objectPosition, priority = false, responsiveCover, sizes, @@ -38,6 +40,9 @@ export function PortfolioCoverImage({ className={className} loading={priority ? "eager" : "lazy"} decoding="async" + {...(objectPosition === undefined + ? {} + : { style: { objectPosition } })} {...(priority ? { fetchpriority: "high" } : {})} {...(responsiveCover === undefined ? {} @@ -72,10 +77,13 @@ interface PortfolioCardProps { desc: string; details: ReactNode; expanded: boolean; + fullBleed?: boolean; headingLevel?: "h2" | "h3"; disclaimer?: string; + objectPosition?: string; priority?: boolean; responsiveCover?: ResponsivePortfolioCover; + sizes?: string; status?: string; tags: string; title: string; @@ -87,10 +95,13 @@ export default function PortfolioCard({ desc, details, expanded, + fullBleed = false, headingLevel = "h3", disclaimer, + objectPosition, priority = false, responsiveCover, + sizes = "(min-width: 1280px) 457px, (min-width: 768px) calc(41.67vw - 76px), calc(100vw - 88px)", status, tags, title, @@ -101,32 +112,39 @@ export default function PortfolioCard({ return ( -
-
-
+
+
+
-
+
{status && ( -

+

{status}

)} @@ -140,7 +158,7 @@ export default function PortfolioCard({

)}

• {tags}

-
+
{actions}
{details} diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1200.avif b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1200.avif new file mode 100644 index 0000000..cb83efc Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1200.avif differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1200.webp b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1200.webp new file mode 100644 index 0000000..2903825 Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1200.webp differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1600.avif b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1600.avif new file mode 100644 index 0000000..1150f58 Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1600.avif differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1600.webp b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1600.webp new file mode 100644 index 0000000..6bb74d6 Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-1600.webp differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-480.avif b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-480.avif new file mode 100644 index 0000000..c6f9791 Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-480.avif differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-480.webp b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-480.webp new file mode 100644 index 0000000..dd45e3f Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-480.webp differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-768.avif b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-768.avif new file mode 100644 index 0000000..51fa746 Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-768.avif differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover-768.webp b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-768.webp new file mode 100644 index 0000000..4fca83e Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover-768.webp differ diff --git a/src/assets/portfolio/lem-box/lem-box-portfolio-cover.jpg b/src/assets/portfolio/lem-box/lem-box-portfolio-cover.jpg new file mode 100644 index 0000000..eaa813e Binary files /dev/null and b/src/assets/portfolio/lem-box/lem-box-portfolio-cover.jpg differ diff --git a/src/data/portfolio/cases/lemBox.ts b/src/data/portfolio/cases/lemBox.ts index ea12884..5ae7d42 100644 --- a/src/data/portfolio/cases/lemBox.ts +++ b/src/data/portfolio/cases/lemBox.ts @@ -5,9 +5,19 @@ import lemBox768Avif from "../../../assets/lem-box/lem-box-768.avif"; import lemBox768Webp from "../../../assets/lem-box/lem-box-768.webp"; import lemBox1200Avif from "../../../assets/lem-box/lem-box-1200.avif"; import lemBox1200Webp from "../../../assets/lem-box/lem-box-1200.webp"; +import lemBoxPortfolioCover from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover.jpg"; +import lemBoxPortfolioCover480Avif from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-480.avif"; +import lemBoxPortfolioCover480Webp from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-480.webp"; +import lemBoxPortfolioCover768Avif from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-768.avif"; +import lemBoxPortfolioCover768Webp from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-768.webp"; +import lemBoxPortfolioCover1200Avif from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-1200.avif"; +import lemBoxPortfolioCover1200Webp from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-1200.webp"; +import lemBoxPortfolioCover1600Avif from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-1600.avif"; +import lemBoxPortfolioCover1600Webp from "../../../assets/portfolio/lem-box/lem-box-portfolio-cover-1600.webp"; import { definePortfolioCase, type Localized, + type PortfolioCardCover, type PortfolioAction, type ResponsivePortfolioCover, } from "../types"; @@ -29,6 +39,33 @@ const lemBoxResponsiveCover = { }, } as const satisfies ResponsivePortfolioCover; +const lemBoxPortfolioCardCover = { + cover: lemBoxPortfolioCover, + fullBleed: true, + objectPosition: "center 55%", + sizes: + "(min-width: 1280px) 513px, (min-width: 768px) calc(41.67vw - 21px), calc(100vw - 50px)", + responsiveCover: { + width: 1920, + height: 1440, + fit: "cover", + sources: { + avif: [ + { src: lemBoxPortfolioCover480Avif, width: 480 }, + { src: lemBoxPortfolioCover768Avif, width: 768 }, + { src: lemBoxPortfolioCover1200Avif, width: 1200 }, + { src: lemBoxPortfolioCover1600Avif, width: 1600 }, + ], + webp: [ + { src: lemBoxPortfolioCover480Webp, width: 480 }, + { src: lemBoxPortfolioCover768Webp, width: 768 }, + { src: lemBoxPortfolioCover1200Webp, width: 1200 }, + { src: lemBoxPortfolioCover1600Webp, width: 1600 }, + ], + }, + }, +} as const satisfies PortfolioCardCover; + const stack = [ "Next.js", "React", @@ -156,6 +193,7 @@ export const lemBoxCase = definePortfolioCase({ category: "systems", cover: "/img/lem-box-cover.png", responsiveCover: lemBoxResponsiveCover, + portfolioCardCover: lemBoxPortfolioCardCover, actions: lemBoxPublicLinks, content: { es: { diff --git a/src/data/portfolio/types.ts b/src/data/portfolio/types.ts index 68c8ed9..d02c156 100644 --- a/src/data/portfolio/types.ts +++ b/src/data/portfolio/types.ts @@ -119,6 +119,14 @@ export type ResponsivePortfolioCover = { }; }; +export type PortfolioCardCover = { + cover: string; + fullBleed: boolean; + objectPosition?: string; + responsiveCover: ResponsivePortfolioCover; + sizes: string; +}; + export function getPortfolioCoverFit( responsiveCover: ResponsivePortfolioCover | undefined, ): PortfolioCoverFit { @@ -143,6 +151,7 @@ type PortfolioCaseDefinitionBase< category: ProjectCategory; cover: string; responsiveCover?: ResponsivePortfolioCover; + portfolioCardCover?: PortfolioCardCover; actions: readonly PortfolioAction[]; content: Localized; home?: { diff --git a/src/pages/PortfolioPage.tsx b/src/pages/PortfolioPage.tsx index 85f1a39..9bf7667 100644 --- a/src/pages/PortfolioPage.tsx +++ b/src/pages/PortfolioPage.tsx @@ -91,6 +91,10 @@ export default function PortfolioPage() { const content = portfolioCase.content[language]; const detailsId = `portfolio-details-${portfolioCase.key}`; const caseStudy = portfolioCase.caseStudy; + const portfolioCardCover = portfolioCase.portfolioCardCover; + const cardCover = portfolioCardCover?.cover ?? portfolioCase.cover; + const cardResponsiveCover = + portfolioCardCover?.responsiveCover ?? portfolioCase.responsiveCover; const detailsExpanded = caseStudy === undefined && content.details !== undefined && @@ -171,7 +175,7 @@ export default function PortfolioPage() { ) } - cover={portfolioCase.cover} + cover={cardCover} desc={content.description} details={ detailsExpanded && content.details !== undefined ? ( @@ -186,13 +190,25 @@ export default function PortfolioPage() { ) : null } expanded={detailsExpanded} + {...(portfolioCardCover === undefined + ? {} + : { + fullBleed: portfolioCardCover.fullBleed, + sizes: portfolioCardCover.sizes, + ...(portfolioCardCover.objectPosition === undefined + ? {} + : { + objectPosition: + portfolioCardCover.objectPosition, + }), + })} headingLevel="h2" priority={portfolioIndex === 0} tags={content.tags.join(" · ")} title={content.title} - {...(portfolioCase.responsiveCover === undefined + {...(cardResponsiveCover === undefined ? {} - : { responsiveCover: portfolioCase.responsiveCover })} + : { responsiveCover: cardResponsiveCover })} {...(content.status === undefined ? {} : { status: content.status })} diff --git a/src/test/NoJsVisibility.test.tsx b/src/test/NoJsVisibility.test.tsx index 7c3f0d8..8a0ecae 100644 --- a/src/test/NoJsVisibility.test.tsx +++ b/src/test/NoJsVisibility.test.tsx @@ -84,7 +84,7 @@ const approvedFiles = { }, "src/Components/portfolio/PortfolioCard.tsx": { baselineHash: - "82db7710efc1da9799d2b53bc96eb15009f3f5da39aee86f84e3bec56983ecf8", + "c74bed74086dabd4988f143a677732349715168e106af4f6bcddc9d3bf615369", markers: 1, }, } as const; diff --git a/src/test/PortfolioSafety.test.ts b/src/test/PortfolioSafety.test.ts index fa3d2a1..806b910 100644 --- a/src/test/PortfolioSafety.test.ts +++ b/src/test/PortfolioSafety.test.ts @@ -126,6 +126,12 @@ const originalCoverContracts = [ hash: "e2e8d3adb8861a9f9895ea327ce0151abe5993dffc5eaecc879e61b83640ab82", }, ] as const; +const lemBoxPortfolioCoverContract = { + path: "src/assets/portfolio/lem-box/lem-box-portfolio-cover.jpg", + dimensions: { width: 1920, height: 1440 }, + bytes: 329_873, + hash: "414c465da87ec113cadda69b488d17905b12e4418c2583b3154f6dcfaf486b6d", +} as const; function getCase(key: (typeof expectedProjectKeys)[number]) { const portfolioCase = portfolioCases.find((item) => item.key === key); @@ -679,6 +685,61 @@ describe("portfolio architecture invariants", () => { }).toEqual({ width: 1200, height: 630 }); }); + it("isolates Rodrigo's exact LEM-BOX cover to the full-bleed Portfolio card", () => { + const coverPath = path.join(projectRoot, lemBoxPortfolioCoverContract.path); + const coverData = fs.readFileSync(coverPath); + const portfolioCardCover = lemBoxCase.portfolioCardCover; + if (portfolioCardCover === undefined) { + throw new Error("Missing Portfolio-only LEM-BOX cover"); + } + + expect(readJpegDimensions(coverData)).toEqual( + lemBoxPortfolioCoverContract.dimensions, + ); + expect(fs.statSync(coverPath).size).toBe( + lemBoxPortfolioCoverContract.bytes, + ); + expect( + crypto.createHash("sha256").update(coverData).digest("hex"), + ).toBe(lemBoxPortfolioCoverContract.hash); + expect(portfolioCardCover.cover).toContain( + "lem-box-portfolio-cover.jpg", + ); + expect(portfolioCardCover.fullBleed).toBe(true); + expect(portfolioCardCover.objectPosition).toBe("center 55%"); + expect(portfolioCardCover.sizes).toBe( + "(min-width: 1280px) 513px, (min-width: 768px) calc(41.67vw - 21px), calc(100vw - 50px)", + ); + expect(portfolioCardCover.responsiveCover.fit).toBe("cover"); + expect(getPortfolioCoverFit(portfolioCardCover.responsiveCover)).toBe( + "cover", + ); + expect({ + width: portfolioCardCover.responsiveCover.width, + height: portfolioCardCover.responsiveCover.height, + }).toEqual(lemBoxPortfolioCoverContract.dimensions); + + for (const format of ["avif", "webp"] as const) { + const candidates = portfolioCardCover.responsiveCover.sources[format]; + expect(candidates.map(({ width }) => width)).toEqual([ + 480, 768, 1200, 1600, + ]); + for (const candidate of candidates) { + const assetPath = path.join( + projectRoot, + `src/assets/portfolio/lem-box/lem-box-portfolio-cover-${candidate.width}.${format}`, + ); + expect(fs.existsSync(assetPath)).toBe(true); + expect(fs.statSync(assetPath).size).toBeGreaterThan(0); + } + } + + expect(lemBoxCase.cover).toBe("/img/lem-box-cover.png"); + expect(getPortfolioCoverFit(lemBoxCase.responsiveCover)).toBe("contain"); + expect(lemBoxCase.caseStudy.coverWidth).toBe(1200); + expect(lemBoxCase.caseStudy.coverHeight).toBe(630); + }); + it("publishes ZENTRA without private documents or restricted names", () => { const zentra = getCase("zentra"); const serialized = JSON.stringify(zentra); @@ -764,6 +825,133 @@ describe("portfolio architecture invariants", () => { ).not.toMatch(/[—–]/); }); + it("locks full Portfolio geometry without suppressing card content", () => { + const portfolioCardSource = fs.readFileSync( + path.join( + projectRoot, + "src/Components/portfolio/PortfolioCard.tsx", + ), + "utf8", + ); + + expect(portfolioCases).toHaveLength(8); + expect(portfolioCardSource).toContain( + 'expanded ? "" : "lg:h-[360px]"', + ); + expect(portfolioCardSource).toContain( + 'className="flex flex-col md:flex-row lg:h-full"', + ); + expect(portfolioCardSource).toContain( + "lg:h-full lg:aspect-auto", + ); + expect(portfolioCardSource).toContain( + 'className="p-6 md:w-7/12 lg:flex lg:h-full lg:flex-col"', + ); + expect(portfolioCardSource).toContain( + "lg:mt-auto lg:pt-4", + ); + expect(portfolioCardSource).toContain("inline-flex self-start"); + expect(portfolioCardSource).toContain("aspect-[16/9]"); + expect(portfolioCardSource).not.toContain("md:h-[280px]"); + expect(portfolioCardSource).not.toMatch( + /line-clamp|\btruncate\b|text-overflow|md:overflow-hidden/, + ); + }); + + it("preserves Portfolio actions, fits, Home, no-JS, Motion, and package boundaries", () => { + const portfolioCardSource = fs.readFileSync( + path.join( + projectRoot, + "src/Components/portfolio/PortfolioCard.tsx", + ), + "utf8", + ); + const portfolioPageSource = fs.readFileSync( + path.join(projectRoot, "src/pages/PortfolioPage.tsx"), + "utf8", + ); + const portfolioSectionSource = fs.readFileSync( + path.join(projectRoot, "src/Components/PortfolioSection.tsx"), + "utf8", + ); + const fileHashes = Object.fromEntries( + [ + "package.json", + "package-lock.json", + "src/pages/PortfolioPage.tsx", + "src/Components/PortfolioSection.tsx", + ].map((file) => [ + file, + crypto + .createHash("sha256") + .update(fs.readFileSync(path.join(projectRoot, file), "utf8")) + .digest("hex"), + ]), + ); + + expect( + Object.fromEntries( + portfolioCases.map((portfolioCase) => [ + portfolioCase.key, + portfolioCase.actions.map(({ href }) => href), + ]), + ), + ).toEqual({ + lem_box: [ + "https://lem-box.com", + "https://lem-box.com.uy", + "https://lem-box.com.ar", + ], + zentra: ["https://zentrascent.com"], + esteban: ["https://estebanfirpo.com"], + mutter: ["https://www.muttergames.com"], + magenta: ["https://magenta-paysandu-m5in.vercel.app"], + federico: ["https://www.federicoroma.com"], + boating: ["https://www.boatingadventuresmiami.com"], + campings_demo: [campingsRepository], + }); + expect( + Object.fromEntries( + portfolioCases.map((portfolioCase) => [ + portfolioCase.key, + getPortfolioCoverFit(portfolioCase.responsiveCover), + ]), + ), + ).toEqual({ + lem_box: "contain", + zentra: "cover", + esteban: "cover", + mutter: "cover", + magenta: "cover", + federico: "cover", + boating: "cover", + campings_demo: "cover", + }); + expect(portfolioCardSource).toContain("data-nojs-visible"); + expect(portfolioCardSource).toContain( + "initial={{ opacity: 0, y: 20 }}", + ); + expect(portfolioCardSource).toContain( + "whileInView={{ opacity: 1, y: 0 }}", + ); + expect(portfolioCardSource).toContain( + "transition={{ duration: 0.45 }}", + ); + expect(portfolioCardSource).toContain("viewport={{ once: true }}"); + expect(portfolioPageSource.match(/data-nojs-hide/g) ?? []).toHaveLength(2); + expect(portfolioSectionSource).not.toContain(" { const portfolioCardSource = fs.readFileSync( path.join( diff --git a/src/test/Routing.test.tsx b/src/test/Routing.test.tsx index b4f37f1..c56e220 100644 --- a/src/test/Routing.test.tsx +++ b/src/test/Routing.test.tsx @@ -267,6 +267,7 @@ describe("application routing", () => { const lemBoxHomeImage = lemBoxHomePicture?.querySelector("img"); expect(lemBoxHomePicture).not.toBeNull(); expect(lemBoxHomePicture?.querySelectorAll("img")).toHaveLength(1); + expect(lemBoxHomeImage).toHaveAttribute("src", "/img/lem-box-cover.png"); expect(lemBoxHomeImage).toHaveAttribute("loading", "lazy"); expect(lemBoxHomeImage).not.toHaveAttribute("fetchpriority"); expect(lemBoxHomeImage).toHaveClass("object-contain"); @@ -458,10 +459,37 @@ describe("application routing", () => { expect(portfolioImages).toHaveLength(8); expect(portfolioImages[0]).toHaveAttribute("loading", "eager"); expect(portfolioImages[0]).toHaveAttribute("fetchpriority", "high"); - expect(portfolioImages[0]).toHaveClass("object-contain"); + expect(portfolioImages[0]).toHaveAttribute("width", "1920"); + expect(portfolioImages[0]).toHaveAttribute("height", "1440"); + expect(portfolioImages[0]).toHaveAttribute( + "style", + "object-position: center 55%;", + ); + expect(portfolioImages[0]).toHaveClass("object-cover"); + expect(portfolioImages[0]).not.toHaveClass("object-contain", "rounded-xl"); + expect(portfolioImages[0]?.getAttribute("src")).toContain( + "lem-box-portfolio-cover.jpg", + ); expect( portfolioImages[0]?.closest("picture")?.querySelectorAll("img"), ).toHaveLength(1); + const lemBoxPortfolioSources = Array.from( + portfolioImages[0]?.closest("picture")?.querySelectorAll("source") ?? [], + ); + expect(lemBoxPortfolioSources.map((source) => source.type)).toEqual([ + "image/avif", + "image/webp", + ]); + for (const source of lemBoxPortfolioSources) { + expect(responsiveWidths(source)).toEqual([480, 768, 1200, 1600]); + expect(source).toHaveAttribute( + "sizes", + "(min-width: 1280px) 513px, (min-width: 768px) calc(41.67vw - 21px), calc(100vw - 50px)", + ); + } + const lemBoxMediaFrame = portfolioImages[0]?.closest("picture")?.parentElement; + expect(lemBoxMediaFrame).toHaveClass("aspect-[16/9]", "overflow-hidden"); + expect(lemBoxMediaFrame).not.toHaveClass("p-5", "md:p-7"); for (const image of portfolioImages.slice(1)) { expect(image).toHaveAttribute("loading", "lazy"); expect(image).not.toHaveAttribute("fetchpriority");