diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a289cb..a2a09a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,10 +9,43 @@ This repository builds the **Hack Michigan (HackMI)** marketing site. It started
## [Unreleased]
+## [1.1.4] - 2026-05-19
+
+### Added
+
+- Typed Sanity fetch layer (`src/lib/sanity/fetchers.ts`): `safeFetch` wrappers, optional strict mode via `PUBLIC_SANITY_STRICT_MODE`, and `getChallengeWinners()` for sponsor challenge winners.
+- **`awardingSponsor`** on hackathon projects in Sanity (radio list, one winner per sponsor) with Studio validation preventing duplicate winners (`studio-hack-michigan-/schemaTypes/hackathonProject.ts`).
+- Shared sponsor-challenge metadata (`src/lib/sanity/challengeSponsors.ts`, `studio-hack-michigan-/schemaTypes/challengeSponsors.ts`) kept in sync between Studio and the site.
+- **`SponsorWinnersSection`** on `/projects/` — highlights DTE, StartMidwest, AI Collective Detroit, and Compass Detroit challenge winners with logos and links to project detail pages (`src/components/SponsorWinnersSection.astro`).
+- Static winner fallbacks (`src/data/challengeWinnerFallbacks.ts`) until every winning project is tagged in Sanity.
+- Team **`teamDescription`** portable text (up to 1,000 plain-text characters) and wide-logo guidance on team documents; rendered on `/projects/team/[slug]/` (`studio-hack-michigan-/schemaTypes/team.ts`).
+- **`hasSanityImage`** image guard and `teamLogo` URL helper in the Astro Sanity client layer.
+- **`portableTextPlainLength`** utility in Studio for consistent portable-text length validation (`studio-hack-michigan-/schemaTypes/portableTextUtils.ts`).
+- **Media** and **Fuel** sponsor tiers; logos for Why Not Collab (media) and Luminesso Coffee (fuel).
+- AI Collective Detroit as a **community** sponsor.
+- Navigation **scroll-spy** (IntersectionObserver) so active nav links track the section in view (`src/components/Navigation.astro`, `src/data/nav.ts`).
+- `rel="sponsored"` on external sponsor links via `MaybeLink` (`src/components/primitives/MaybeLink.astro`).
+- Shared deploy **rate-limit** module (`src/lib/deploy/rate-limit.js`).
+- Vitest coverage for challenge sponsors, fetchers, team logos, `MaybeLink`, and sponsor logo data.
+- Playwright visual-regression baselines for the projects grid and related UI updates.
+
### Changed
-- Playwright E2E specs updated to match the new `/projects/` route for the “Projects” navigation link (instead of asserting `/hackathon/`).
-- Canonicalized the legacy `/hackathon/` listing URL by redirecting it to `/projects/` (detail routes under `/hackathon/*` remain unchanged).
+- Hackathon browsing moved under **`/projects/`**: listing, project detail, team detail, and variants routes now live in `src/pages/projects/` (removed `src/pages/hackathon/index.astro`).
+- **Vercel redirects** send `/hackathon/` and legacy `/hackathon/project/*` and `/hackathon/team/*` URLs to their `/projects/` equivalents (`vercel.json`).
+- Playwright E2E specs updated for the `/projects/` “Projects” nav target (replacing `/hackathon/`).
+- Refactored **project cards** and project/team detail layouts (`src/components/cms/ProjectCard.astro`, `src/pages/projects/project/[slug].astro`, `src/pages/projects/team/[slug].astro`).
+- Event **schedule/agenda** aligned with the finalized three-day agenda (`src/data/eventAgenda.ts`).
+- Landing-page **section order** matches navigation; dim secondary text uses a shared `--font-size-body` fluid scale (`src/styles/global.css`).
+- Sponsor logo data expanded (additional tiers/logos, `rel=sponsored` behavior, tests in `src/data/sponsorLogos.test.ts`).
+- Sanity Studio dependency refresh (`studio-hack-michigan-/package.json`).
+
+### Fixed
+
+- Team description validation counts **plain-text characters**, not Portable Text blocks.
+- Duplicate `.team-description` wrapper and unsupported `max-width: stretch` on team pages.
+- Luminesso logo **light surface** treatment and fuel-tier grid layout.
+- Sponsor link UX, register URLs, mobile breakpoints, and Playwright CI routing.
## [1.1.3] - 2026-05-01
@@ -134,6 +167,7 @@ _Initial [astro-darkness](https://github.com/kpab/astro-darkness) template relea
- @astrojs/rss for feed generation
- @astrojs/sitemap for SEO optimization
+[1.1.4]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.3...v1.1.4
[1.1.3]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.2...v1.1.3
[1.1.2]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.0...v1.1.1
diff --git a/README.md b/README.md
index 55ee1d2..63e8776 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Marketing site and hackathon showcase for **Hack Michigan** — [hackmichigan.co

-[](https://github.com/Compass-Detroit/hackmi26/releases)
+[](https://github.com/Compass-Detroit/hackmi26/releases)
[](https://astro.build/)
[](https://threejs.org/)
[](LICENSE)
diff --git a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png
index c6b4ca7..68134c7 100644
Binary files a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png and b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png differ
diff --git a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png
index 390396b..6d597f1 100644
Binary files a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png and b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png differ
diff --git a/package-lock.json b/package-lock.json
index 24908ce..8b95894 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "hackmichigan",
- "version": "1.1.1",
+ "version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hackmichigan",
- "version": "1.1.1",
+ "version": "1.1.4",
"license": "MIT",
"dependencies": {
"@astrojs/mdx": "^4.0.0",
diff --git a/package.json b/package.json
index 7806176..0a943b0 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "hackmichigan",
"type": "module",
- "version": "1.1.1",
+ "version": "1.1.4",
"description": "Hack Michigan (HackMI) — marketing site and hackathon showcase for the Detroit tech corridor, built with Astro + Sanity.",
"keywords": [
"astro",
diff --git a/src/components/SponsorChallengeSection.astro b/src/components/SponsorChallengeSection.astro
new file mode 100644
index 0000000..a935f35
--- /dev/null
+++ b/src/components/SponsorChallengeSection.astro
@@ -0,0 +1,130 @@
+---
+import Button from "./primitives/Button.astro";
+import { EVENT_URLS } from "../data/event";
+---
+
+
+
+
diff --git a/src/components/SponsorWinnersSection.astro b/src/components/SponsorWinnersSection.astro
new file mode 100644
index 0000000..817cb41
--- /dev/null
+++ b/src/components/SponsorWinnersSection.astro
@@ -0,0 +1,212 @@
+---
+/**
+ * SponsorWinnersSection — showcase grid of challenge winners.
+ *
+ * This is a thin orchestrator:
+ * 1. Merges Sanity winner data with static fallbacks.
+ * 2. Renders a header, a `` grid, and a footnote.
+ *
+ * Individual card rendering is delegated to `WinnerCard.astro` (SRP).
+ */
+import {
+ buildWinnerBySponsorMap,
+ CHALLENGE_SPONSOR_OPTIONS,
+} from "../lib/sanity/challengeSponsors";
+import type { HackathonProjectPreview } from "../lib/sanity/schemas";
+import { CHALLENGE_WINNER_FALLBACKS } from "../data/challengeWinnerFallbacks";
+import { withBase } from "../data/nav";
+import { EVENT } from "../data/event";
+import WinnerCard from "./cms/WinnerCard.astro";
+import type { WinnerLogoStyle } from "./cms/WinnerCard.astro";
+
+interface Props {
+ winners?: HackathonProjectPreview[];
+ logoStyle?: WinnerLogoStyle;
+}
+
+const { winners = [], logoStyle = "project-hero" } = Astro.props;
+const base = import.meta.env.BASE_URL;
+
+const winnerBySponsor = buildWinnerBySponsorMap(winners);
+
+const winnerRows = CHALLENGE_SPONSOR_OPTIONS.map((sponsor) => {
+ const project = winnerBySponsor.get(sponsor.value);
+ const fallback = CHALLENGE_WINNER_FALLBACKS[sponsor.value];
+ const projectSlug = project?.slug.current ?? fallback.projectSlug;
+ const projectTitle = project?.title ?? fallback.projectTitle;
+ const teamName = project?.team?.name ?? fallback.teamName;
+ const projectHref = withBase(base, `/projects/project/${projectSlug}/`);
+
+ return { sponsor, project, projectHref, projectTitle, teamName };
+});
+---
+
+
+
+
+
+ {
+ winnerRows.map(
+ ({ sponsor, project, projectHref, projectTitle, teamName }, index) => (
+ -
+
+
+ ),
+ )
+ }
+
+
+
+
+
+
diff --git a/src/components/cms/ProjectCard.astro b/src/components/cms/ProjectCard.astro
index edc9cd0..80cb44d 100644
--- a/src/components/cms/ProjectCard.astro
+++ b/src/components/cms/ProjectCard.astro
@@ -16,6 +16,7 @@
*/
import {
urlFor,
+ challengeSponsorByKey,
HACKATHON_CARD_DESCRIPTION_CHARS,
portableTextToPlainText,
truncatePlainText,
@@ -23,6 +24,7 @@ import {
} from "../../lib/sanity";
import { withBase } from "../../data/nav";
import TeamBadge from "./TeamBadge.astro";
+import WinnerRibbon from "./WinnerRibbon.astro";
interface Props {
project: HackathonProjectPreview;
@@ -43,19 +45,31 @@ const descriptionPreview =
HACKATHON_CARD_DESCRIPTION_CHARS,
)
: "";
+const challengeWinner = project.awardingSponsor
+ ? challengeSponsorByKey(project.awardingSponsor)
+ : undefined;
---
{
variant === "compact" ? (
+ {challengeWinner && (
+
+ )}
{project.mainImage && (
)}
@@ -63,7 +77,17 @@ const descriptionPreview =
) : (
-
+
+ {challengeWinner && (
+
+ )}
{project.mainImage && (
@@ -71,6 +95,7 @@ const descriptionPreview =
src={urlFor(project.mainImage).width(600).url()}
alt={project.title}
loading="lazy"
+ decoding="async"
/>
)}
@@ -117,6 +142,13 @@ const descriptionPreview =
overflow: hidden;
}
+ .project-card--winner {
+ border-color: var(--color-winner-glow);
+ box-shadow:
+ var(--shadow-glass),
+ 0 0 0 1px rgb(245 158 11 / 12%);
+ }
+
.image-wrapper img {
width: 100%;
aspect-ratio: 16/9;
@@ -124,7 +156,7 @@ const descriptionPreview =
}
.content {
- padding: 1.5rem;
+ padding: var(--space-md);
}
.title-link {
@@ -136,9 +168,9 @@ const descriptionPreview =
color: var(--color-primary);
}
- h2 {
+ .project-card h2 {
font-size: 1.5rem;
- margin-bottom: 0.75rem;
+ margin-bottom: var(--space-xs);
transition: color var(--duration-fast) ease;
}
@@ -146,7 +178,7 @@ const descriptionPreview =
color: var(--color-text-dim);
font-size: 0.95rem;
line-height: 1.6;
- margin-bottom: 1.5rem;
+ margin-bottom: var(--space-md);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
@@ -154,12 +186,12 @@ const descriptionPreview =
}
.tag-list {
- margin-bottom: 1.5rem;
+ margin-bottom: var(--space-md);
}
.links {
display: flex;
- gap: 1rem;
+ gap: var(--space-sm);
}
.links a {
@@ -190,7 +222,7 @@ const descriptionPreview =
}
.compact-info {
- padding: 1rem;
+ padding: var(--space-sm);
}
.compact-info h3 {
diff --git a/src/components/cms/ProjectCard.test.ts b/src/components/cms/ProjectCard.test.ts
index f1f79a1..b0b1fb4 100644
--- a/src/components/cms/ProjectCard.test.ts
+++ b/src/components/cms/ProjectCard.test.ts
@@ -82,6 +82,21 @@ describe("ProjectCard", () => {
});
});
+ it("renders challenge winner ribbon at top of full card", async () => {
+ const container = await AstroContainer.create();
+ const html = await container.renderToString(ProjectCard, {
+ props: {
+ project: { ...baseProject, awardingSponsor: "dte" as const },
+ },
+ });
+ const $ = cheerio.load(html);
+
+ expect($("article.project-card--winner").length).toBe(1);
+ expect($(".winner-ribbon").first().text()).toContain("DTE AI Challenge");
+ expect($("article .winner-ribbon").length).toBe(1);
+ expect($(".card-link").prev(".winner-ribbon").length).toBe(1);
+ });
+
it("renders compact variant as a single clickable card", async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(ProjectCard, {
@@ -93,4 +108,37 @@ describe("ProjectCard", () => {
expect($("article.project-card").length).toBe(0);
expect($(".compact-info h3").text()).toBe("MittenHarvest AI");
});
+
+ it("renders winner ribbon in compact variant when awardingSponsor is set", async () => {
+ const container = await AstroContainer.create();
+ const html = await container.renderToString(ProjectCard, {
+ props: {
+ project: { ...baseProject, awardingSponsor: "dte" as const },
+ variant: "compact",
+ },
+ });
+ const $ = cheerio.load(html);
+
+ expect($(".winner-ribbon").length).toBe(1);
+ expect($(".winner-ribbon").text()).toContain("DTE AI Challenge");
+ });
+
+ it("renders main image with lazy loading in full card", async () => {
+ const container = await AstroContainer.create();
+ const html = await container.renderToString(ProjectCard, {
+ props: {
+ project: {
+ ...baseProject,
+ mainImage: {
+ _type: "image",
+ asset: { _ref: "image-abc-200x200-png" },
+ },
+ },
+ },
+ });
+ const $ = cheerio.load(html);
+
+ expect($("img").length).toBeGreaterThanOrEqual(1);
+ expect($("img").attr("loading")).toBe("lazy");
+ });
});
diff --git a/src/components/cms/WinnerCard.astro b/src/components/cms/WinnerCard.astro
new file mode 100644
index 0000000..acfa369
--- /dev/null
+++ b/src/components/cms/WinnerCard.astro
@@ -0,0 +1,362 @@
+---
+/**
+ * WinnerCard — individual challenge winner card for the winners grid.
+ *
+ * Extracted from `SponsorWinnersSection` (SRP) so the section component
+ * is a thin orchestrator and each card is independently testable.
+ *
+ * Supports three `logoStyle` modes:
+ * - `"project-hero"` (default) — project screenshot + small sponsor chip
+ * - `"banner"` — large centered sponsor logo block
+ * - `"inline"` — small sponsor mark inline with challenge label
+ */
+import { urlFor } from "../../lib/sanity";
+import {
+ isSponsorLogoLight,
+ type ChallengeSponsor,
+} from "../../lib/sanity/challengeSponsors";
+import type { HackathonProjectPreview } from "../../lib/sanity/schemas";
+import { publicAssetUrl } from "../../data/siteLogos";
+import MaybeLink from "../primitives/MaybeLink.astro";
+
+export type WinnerLogoStyle = "banner" | "inline" | "project-hero";
+
+interface Props {
+ sponsor: ChallengeSponsor;
+ project?: HackathonProjectPreview;
+ projectHref: string;
+ projectTitle: string;
+ teamName?: string;
+ logoStyle?: WinnerLogoStyle;
+ index: number;
+}
+
+const {
+ sponsor,
+ project,
+ projectHref,
+ projectTitle,
+ teamName,
+ logoStyle = "project-hero",
+ index,
+} = Astro.props;
+
+const base = import.meta.env.BASE_URL;
+const logoSrc = publicAssetUrl(base, sponsor.logoSrc);
+const isLight = isSponsorLogoLight(sponsor);
+const hasMedia = logoStyle === "project-hero" && !!project?.mainImage;
+---
+
+
+
+
+ {String(index + 1).padStart(2, "0")}
+
+ Winner
+
+
+ {
+ hasMedia && project?.mainImage && (
+
+
+
+ )
+ }
+
+ {
+ logoStyle === "banner" ? (
+
+
+
+ ) : (
+
+ )
+ }
+
+ {
+ logoStyle === "banner" && (
+ {sponsor.challengeLabel}
+ )
+ }
+
+
+
+ {teamName && {teamName}
}
+
+
+ View project
+ →
+
+
+
+
diff --git a/src/components/cms/WinnerRibbon.astro b/src/components/cms/WinnerRibbon.astro
new file mode 100644
index 0000000..fd92893
--- /dev/null
+++ b/src/components/cms/WinnerRibbon.astro
@@ -0,0 +1,37 @@
+---
+/**
+ * WinnerRibbon — shared "challenge winner" ribbon strip.
+ *
+ * Used at the top of both `ProjectCard` and `WinnerCard` to display the
+ * sponsor challenge label. Extracted to a single component so the markup,
+ * gradient, and a11y attributes stay consistent (DRY).
+ */
+interface Props {
+ label: string;
+}
+
+const { label } = Astro.props;
+---
+
+
+ {label} winner
+
+
+
diff --git a/src/data/challengeWinnerFallbacks.ts b/src/data/challengeWinnerFallbacks.ts
new file mode 100644
index 0000000..97acf28
--- /dev/null
+++ b/src/data/challengeWinnerFallbacks.ts
@@ -0,0 +1,28 @@
+import type { ChallengeSponsorKey } from "../lib/sanity/challengeSponsors";
+
+/**
+ * Published winner slugs/titles until every project is tagged in Sanity.
+ * Remove entries as `awardingSponsor` is set on live documents.
+ */
+export const CHALLENGE_WINNER_FALLBACKS: Record<
+ ChallengeSponsorKey,
+ { projectSlug: string; projectTitle: string; teamName?: string }
+> = {
+ dte: {
+ projectSlug: "poleproof",
+ projectTitle: "PoleProof: Predicting Wind Turbine Pole Integrity",
+ },
+ startMidwest: {
+ projectSlug: "trestle",
+ projectTitle: "Trestle — Midwest Founder Resource Discover Engine",
+ },
+ aiCollectiveDetroit: {
+ projectSlug: "verify-ai-agents",
+ projectTitle: "Verify AI Agents",
+ },
+ compassDetroit: {
+ projectSlug: "shorewatch",
+ projectTitle: "ShoreWatch: A Detroit Waterway Monitoring System",
+ teamName: "Team Dunamis",
+ },
+};
diff --git a/src/lib/sanity/challengeSponsorSync.test.ts b/src/lib/sanity/challengeSponsorSync.test.ts
new file mode 100644
index 0000000..396768f
--- /dev/null
+++ b/src/lib/sanity/challengeSponsorSync.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { CHALLENGE_SPONSOR_OPTIONS } from "./challengeSponsors";
+
+describe("challenge sponsor sync: studio ↔ frontend", () => {
+ const studioSrc = readFileSync(
+ resolve("studio-hack-michigan-/schemaTypes/challengeSponsors.ts"),
+ "utf8",
+ );
+
+ it("every frontend sponsor value exists in the studio schema", () => {
+ for (const { value } of CHALLENGE_SPONSOR_OPTIONS) {
+ expect(studioSrc).toContain(`value: '${value}'`);
+ }
+ });
+
+ it("studio and frontend have the same number of sponsor entries", () => {
+ const studioValues = [...studioSrc.matchAll(/value:\s*'(\w+)'/g)].map(
+ (m) => m[1],
+ );
+ expect(studioValues.length).toBe(CHALLENGE_SPONSOR_OPTIONS.length);
+ });
+});
diff --git a/src/lib/sanity/challengeSponsors.test.ts b/src/lib/sanity/challengeSponsors.test.ts
new file mode 100644
index 0000000..b191642
--- /dev/null
+++ b/src/lib/sanity/challengeSponsors.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildWinnerBySponsorMap,
+ CHALLENGE_SPONSOR_OPTIONS,
+ challengeSponsorByKey,
+ isSponsorLogoLight,
+ normalizeChallengeSponsorKey,
+} from "./challengeSponsors";
+import type { HackathonProjectPreview } from "./schemas";
+
+describe("challengeSponsors", () => {
+ it("maps legacy hackMichigan to Compass Detroit", () => {
+ expect(normalizeChallengeSponsorKey("hackMichigan")).toBe("compassDetroit");
+ expect(challengeSponsorByKey("hackMichigan").title).toBe("Compass Detroit");
+ expect(challengeSponsorByKey("hackMichigan").challengeLabel).toBe(
+ "Hack Michigan AI Challenge",
+ );
+ });
+
+ it("buildWinnerBySponsorMap indexes projects by normalized sponsor key", () => {
+ const shorewatch = {
+ _id: "p1",
+ title: "ShoreWatch",
+ slug: { current: "shorewatch" },
+ awardingSponsor: "hackMichigan",
+ } as HackathonProjectPreview;
+
+ const map = buildWinnerBySponsorMap([shorewatch]);
+ expect(map.get("compassDetroit")).toEqual(shorewatch);
+ });
+
+ it("resolves compassDetroit as the Hack Michigan AI Challenge sponsor", () => {
+ expect(challengeSponsorByKey("compassDetroit").challengeLabel).toBe(
+ "Hack Michigan AI Challenge",
+ );
+ });
+
+ it("normalizeChallengeSponsorKey returns undefined for undefined input", () => {
+ expect(normalizeChallengeSponsorKey(undefined)).toBeUndefined();
+ });
+
+ it("normalizeChallengeSponsorKey returns undefined for unknown key", () => {
+ expect(normalizeChallengeSponsorKey("nonExistentSponsor")).toBeUndefined();
+ });
+
+ it("normalizeChallengeSponsorKey returns valid key unchanged", () => {
+ expect(normalizeChallengeSponsorKey("dte")).toBe("dte");
+ });
+
+ it("challengeSponsorByKey throws for unknown key", () => {
+ expect(() => challengeSponsorByKey("bogus" as never)).toThrow(
+ "Unknown challenge sponsor: bogus",
+ );
+ });
+
+ it("buildWinnerBySponsorMap returns empty map for empty array", () => {
+ const map = buildWinnerBySponsorMap([]);
+ expect(map.size).toBe(0);
+ });
+
+ it("buildWinnerBySponsorMap with duplicate sponsor keys keeps last entry", () => {
+ const first = {
+ _id: "p1",
+ title: "First",
+ slug: { current: "first" },
+ awardingSponsor: "dte",
+ } as HackathonProjectPreview;
+
+ const second = {
+ _id: "p2",
+ title: "Second",
+ slug: { current: "second" },
+ awardingSponsor: "dte",
+ } as HackathonProjectPreview;
+
+ const map = buildWinnerBySponsorMap([first, second]);
+ expect(map.get("dte")).toEqual(second);
+ });
+
+ it("isSponsorLogoLight returns true for light surface, false for others", () => {
+ const light = CHALLENGE_SPONSOR_OPTIONS.find((s) => isSponsorLogoLight(s))!;
+ expect(isSponsorLogoLight(light)).toBe(true);
+
+ const dark = CHALLENGE_SPONSOR_OPTIONS.find((s) => !isSponsorLogoLight(s))!;
+ expect(isSponsorLogoLight(dark)).toBe(false);
+ });
+
+ it.each(CHALLENGE_SPONSOR_OPTIONS)(
+ "challengeSponsorByKey resolves $value",
+ (option) => {
+ const result = challengeSponsorByKey(option.value);
+ expect(result).toBe(option);
+ },
+ );
+});
diff --git a/src/lib/sanity/challengeSponsors.ts b/src/lib/sanity/challengeSponsors.ts
new file mode 100644
index 0000000..15918a3
--- /dev/null
+++ b/src/lib/sanity/challengeSponsors.ts
@@ -0,0 +1,108 @@
+/**
+ * Sponsor AI challenge tracks — one winning project per sponsor.
+ * Keep in sync with `studio-hack-michigan-/schemaTypes/challengeSponsors.ts`.
+ */
+
+/** Shape of a single challenge sponsor entry. */
+export interface ChallengeSponsor {
+ readonly value: string;
+ readonly title: string;
+ readonly challengeLabel: string;
+ readonly logoSrc: string;
+ readonly logoHref: string;
+ /** Surface the logo sits on — affects chip/wrap background. Defaults to `"dark"`. */
+ readonly logoSurface?: "light" | "dark";
+}
+
+export const CHALLENGE_SPONSOR_OPTIONS = [
+ {
+ value: "dte",
+ title: "DTE Energy",
+ challengeLabel: "DTE AI Challenge",
+ logoSrc: "/images/sponsors/gold-dte-300x.webp",
+ logoHref: "https://www.dteenergy.com",
+ },
+ {
+ value: "startMidwest",
+ title: "StartMidwest",
+ challengeLabel: "StartMidwest AI Challenge",
+ logoSrc: "/images/sponsors/media-startMidwest.webp",
+ logoHref: "https://www.start-midwest.com/",
+ },
+ {
+ value: "aiCollectiveDetroit",
+ title: "The AI Collective Detroit",
+ challengeLabel: "The AI Collective Detroit Challenge",
+ logoSrc: "/images/sponsors/com_AI_Collective_Detroit.png",
+ logoHref: "https://www.aicollective.com/chapters/detroit",
+ logoSurface: "light",
+ },
+ {
+ value: "compassDetroit",
+ title: "Compass Detroit",
+ challengeLabel: "Hack Michigan AI Challenge",
+ logoSrc: "/images/site/compass-logo.svg",
+ logoHref: "https://compassdetroit.com",
+ },
+] as const satisfies readonly ChallengeSponsor[];
+
+export type ChallengeSponsorKey =
+ (typeof CHALLENGE_SPONSOR_OPTIONS)[number]["value"];
+
+/** Pre-built index for O(1) sponsor lookup by key. */
+const SPONSOR_BY_KEY = new Map<
+ string,
+ (typeof CHALLENGE_SPONSOR_OPTIONS)[number]
+>(CHALLENGE_SPONSOR_OPTIONS.map((s) => [s.value, s]));
+
+/** @deprecated Studio value before Compass was named explicitly — maps to `compassDetroit`. */
+export const CHALLENGE_SPONSOR_LEGACY_KEYS = {
+ hackMichigan: "compassDetroit",
+} as const satisfies Record;
+
+export type ChallengeSponsorStoredKey =
+ | ChallengeSponsorKey
+ | keyof typeof CHALLENGE_SPONSOR_LEGACY_KEYS;
+
+export function normalizeChallengeSponsorKey(
+ key: string | undefined,
+): ChallengeSponsorKey | undefined {
+ if (!key) return undefined;
+ if (key in CHALLENGE_SPONSOR_LEGACY_KEYS) {
+ return CHALLENGE_SPONSOR_LEGACY_KEYS[
+ key as keyof typeof CHALLENGE_SPONSOR_LEGACY_KEYS
+ ];
+ }
+ return SPONSOR_BY_KEY.has(key) ? (key as ChallengeSponsorKey) : undefined;
+}
+
+export function challengeSponsorByKey(
+ key: ChallengeSponsorStoredKey,
+): (typeof CHALLENGE_SPONSOR_OPTIONS)[number] {
+ const normalized = normalizeChallengeSponsorKey(key);
+ const match = normalized ? SPONSOR_BY_KEY.get(normalized) : undefined;
+ if (!match) throw new Error(`Unknown challenge sponsor: ${key}`);
+ return match;
+}
+
+/** Whether a sponsor entry has a light logo surface. */
+export function isSponsorLogoLight(sponsor: ChallengeSponsor): boolean {
+ return sponsor.logoSurface === "light";
+}
+
+/** Any object that carries an optional `awardingSponsor` key. */
+interface HasAwardingSponsor {
+ awardingSponsor?: string;
+}
+
+/** Index winner objects by normalized `awardingSponsor` (last write wins). */
+export function buildWinnerBySponsorMap(
+ winners: readonly T[],
+): Map {
+ const map = new Map();
+ for (const project of winners) {
+ const key = normalizeChallengeSponsorKey(project.awardingSponsor);
+ if (key) map.set(key, project);
+ }
+ return map;
+}
diff --git a/src/lib/sanity/fetchers.test.ts b/src/lib/sanity/fetchers.test.ts
index f3f1835..899c5b3 100644
--- a/src/lib/sanity/fetchers.test.ts
+++ b/src/lib/sanity/fetchers.test.ts
@@ -13,6 +13,7 @@ vi.mock("./client", () => ({
import {
getProject,
getProjectSlugs,
+ getChallengeWinners,
getProjects,
getTeam,
getTeamSlugs,
@@ -32,6 +33,13 @@ describe("sanity fetchers", () => {
consoleErrorSpy.mockRestore();
});
+ it("getChallengeWinners returns empty array when query returns null", async () => {
+ fetchMock.mockResolvedValueOnce(null);
+
+ await expect(getChallengeWinners()).resolves.toEqual([]);
+ expect(fetchMock).toHaveBeenCalledWith(QUERIES.challengeWinners);
+ });
+
it("getProjects returns empty array when query returns null", async () => {
fetchMock.mockResolvedValueOnce(null);
diff --git a/src/lib/sanity/fetchers.ts b/src/lib/sanity/fetchers.ts
index af8ef14..769a088 100644
--- a/src/lib/sanity/fetchers.ts
+++ b/src/lib/sanity/fetchers.ts
@@ -41,26 +41,23 @@ async function safeFetch(
}
}
-// ---- Projects --------------------------------------------------------------
-
-export function getProjectSlugs(): Promise {
+/** Shorthand for list-fetchers that normalise Sanity's nullable arrays. */
+function fetchList(label: string, query: string): Promise {
return safeFetch(
- "getProjectSlugs",
- sanityClient
- .fetch(QUERIES.projectSlugs)
- .then((s) => s ?? []),
+ label,
+ sanityClient.fetch(query).then((r) => r ?? []),
[],
);
}
+// ---- Projects --------------------------------------------------------------
+
+export function getProjectSlugs(): Promise {
+ return fetchList("getProjectSlugs", QUERIES.projectSlugs);
+}
+
export function getProjects(): Promise {
- return safeFetch(
- "getProjects",
- sanityClient
- .fetch(QUERIES.projectList)
- .then((p) => p ?? []),
- [],
- );
+ return fetchList("getProjects", QUERIES.projectList);
}
export function getProject(slug: string): Promise {
@@ -73,24 +70,22 @@ export function getProject(slug: string): Promise {
);
}
+/** Sponsor challenge winners (at most one project per `awardingSponsor`). */
+export function getChallengeWinners(): Promise {
+ return fetchList(
+ "getChallengeWinners",
+ QUERIES.challengeWinners,
+ );
+}
+
// ---- Teams -----------------------------------------------------------------
export function getTeamSlugs(): Promise {
- return safeFetch(
- "getTeamSlugs",
- sanityClient.fetch(QUERIES.teamSlugs).then((s) => s ?? []),
- [],
- );
+ return fetchList("getTeamSlugs", QUERIES.teamSlugs);
}
export function getTeams(): Promise {
- return safeFetch(
- "getTeams",
- sanityClient
- .fetch(QUERIES.teamList)
- .then((t) => t ?? []),
- [],
- );
+ return fetchList("getTeams", QUERIES.teamList);
}
export function getTeam(slug: string): Promise {
diff --git a/src/lib/sanity/index.ts b/src/lib/sanity/index.ts
index 07300a5..3aa0d2a 100644
--- a/src/lib/sanity/index.ts
+++ b/src/lib/sanity/index.ts
@@ -5,6 +5,7 @@
* import { getProjects, urlFor, type HackathonProjectPreview } from "~/lib/sanity";
*/
+export * from "./challengeSponsors";
export * from "./client";
export * from "./fetchers";
export * from "./memberSocials";
diff --git a/src/lib/sanity/queries.ts b/src/lib/sanity/queries.ts
index b56dc85..2544f94 100644
--- a/src/lib/sanity/queries.ts
+++ b/src/lib/sanity/queries.ts
@@ -22,6 +22,7 @@ export const projectPreviewProjection = `{
technologies,
githubUrl,
demoUrl,
+ awardingSponsor,
"team": team->{ _id, name, slug }
}`;
@@ -34,6 +35,7 @@ export const projectDetailProjection = `{
mainImage,
technologies,
track,
+ awardingSponsor,
githubUrl,
demoUrl,
videoUrl,
@@ -48,15 +50,18 @@ export const teamDetailProjection = `{
logo,
teamDescription,
members,
- "projects": *[_type == "hackathonProject" && references(^._id) && defined(slug.current)] ${projectPreviewProjection}
+ "projects": *[_type == "hackathonProject" && references(^._id) && defined(slug.current)] ${projectPreviewProjection} | order(defined(awardingSponsor) desc, awardingSponsor asc, title asc)
}`;
export const QUERIES = {
projectSlugs: `*[_type == "hackathonProject" && defined(slug.current)] | order(slug.current asc).slug.current`,
- projectList: `*[_type == "hackathonProject" && defined(slug.current)] ${projectPreviewProjection} | order(title asc)`,
+ projectList: `*[_type == "hackathonProject" && defined(slug.current)] ${projectPreviewProjection} | order(defined(awardingSponsor) desc, awardingSponsor asc, title asc)`,
projectBySlug: `*[_type == "hackathonProject" && slug.current == $slug][0] ${projectDetailProjection}`,
teamSlugs: `*[_type == "team" && defined(slug.current)] | order(slug.current asc).slug.current`,
- teamList: `*[_type == "team"] ${teamDetailProjection} | order(name asc)`,
+ teamList: `*[_type == "team" && defined(slug.current)] ${teamDetailProjection} | order(name asc)`,
teamBySlug: `*[_type == "team" && slug.current == $slug][0] ${teamDetailProjection}`,
+
+ /** One entry per sponsor that has a winner assigned in Studio. */
+ challengeWinners: `*[_type == "hackathonProject" && defined(awardingSponsor)] ${projectPreviewProjection} | order(awardingSponsor asc)`,
} as const;
diff --git a/src/lib/sanity/schemas.ts b/src/lib/sanity/schemas.ts
index 1aa91fc..928e4a7 100644
--- a/src/lib/sanity/schemas.ts
+++ b/src/lib/sanity/schemas.ts
@@ -1,3 +1,5 @@
+import type { ChallengeSponsorStoredKey } from "./challengeSponsors";
+
/**
* Typed shapes mirroring the Sanity studio schemas at
* `studio-hack-michigan-/schemaTypes/`.
@@ -70,23 +72,30 @@ export interface HackathonProject {
videoUrl?: string;
technologies?: string[];
track?: string;
+ /** Set on the single winning project per sponsor AI challenge. */
+ awardingSponsor?: ChallengeSponsorStoredKey;
}
/**
* A reduced project shape for listing pages — only the fields the card
- * renderer needs. Keep in sync with `projectPreviewProjection` in queries.ts.
+ * renderer needs. Derived from `HackathonProject` via `Pick` so the two
+ * types can never drift independently.
+ *
+ * Keep in sync with `projectPreviewProjection` in queries.ts.
*/
-export interface HackathonProjectPreview {
- _id: string;
- title: string;
- slug: Slug;
- team?: TeamRef;
- description?: PortableTextBlock[];
- mainImage?: SanityImage;
- technologies?: string[];
- githubUrl?: string;
- demoUrl?: string;
-}
+export type HackathonProjectPreview = Pick<
+ HackathonProject,
+ | "_id"
+ | "title"
+ | "slug"
+ | "team"
+ | "description"
+ | "mainImage"
+ | "technologies"
+ | "githubUrl"
+ | "demoUrl"
+ | "awardingSponsor"
+>;
/** Minimal team info embedded on a project (post-dereference). */
export interface TeamRef {
diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro
index 7d2c2d1..efe40e9 100644
--- a/src/pages/projects/index.astro
+++ b/src/pages/projects/index.astro
@@ -1,17 +1,24 @@
---
-import { getProjects, type HackathonProjectPreview } from "../../lib/sanity";
+import {
+ getChallengeWinners,
+ getProjects,
+ type HackathonProjectPreview,
+} from "../../lib/sanity";
import PageLayout from "../../layouts/PageLayout.astro";
import ProjectCard from "../../components/cms/ProjectCard.astro";
-import Button from "../../components/primitives/Button.astro";
-import { EVENT_URLS } from "../../data/event";
+import SponsorWinnersSection from "../../components/SponsorWinnersSection.astro";
// Don't break the build if Sanity is unreachable; fall through to the
// empty-state below. Strict-mode CI flips this via `PUBLIC_SANITY_STRICT_MODE`.
let projects: HackathonProjectPreview[] = [];
+let challengeWinners: HackathonProjectPreview[] = [];
try {
- projects = await getProjects();
+ [projects, challengeWinners] = await Promise.all([
+ getProjects(),
+ getChallengeWinners(),
+ ]);
} catch (err) {
- console.error("[projects] getProjects() failed, rendering empty state:", err);
+ console.error("[projects] Sanity fetch failed, rendering empty state:", err);
}
---
@@ -28,37 +35,7 @@ try {
-
+
{
@@ -81,117 +58,34 @@ try {
.page-header {
text-align: center;
- margin-bottom: 4rem;
+ margin-bottom: var(--space-2xl);
}
.page-title {
font-family: var(--font-heading);
font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 700;
- margin-bottom: 0.75rem;
+ margin-bottom: var(--space-xs);
color: var(--color-text);
}
.page-subtitle {
- font-size: 1.1rem;
+ font-size: var(--font-size-body);
color: var(--color-text-dim);
max-width: 600px;
margin: 0 auto;
}
- .sponsor-challenge {
- display: grid;
- grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr);
- gap: var(--space-lg);
- align-items: stretch;
- margin-bottom: var(--space-2xl);
- padding: clamp(1.5rem, 4vw, 2.5rem);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-xl);
- background:
- radial-gradient(
- circle at 15% 20%,
- rgb(139 92 246 / 24%),
- transparent 35%
- ),
- var(--surface-glass-raised-bg);
- box-shadow: var(--shadow-glass);
- }
-
- .sponsor-copy {
- align-self: center;
- }
-
- .section-eyebrow {
- margin: 0 0 var(--space-xs);
- color: var(--color-accent-orange);
- font-size: 0.82rem;
- font-weight: 800;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- }
-
- .sponsor-copy h2,
- .sponsor-panel h3 {
- color: var(--color-text);
- line-height: 1.1;
- }
-
- .sponsor-copy h2 {
- max-width: 12ch;
- margin-bottom: var(--space-sm);
- font-size: clamp(2rem, 6vw, 3.5rem);
- }
-
- .sponsor-copy p {
- max-width: 690px;
- color: var(--color-text-dim);
- font-size: 1.08rem;
- line-height: 1.7;
- }
-
- .sponsor-panel {
- display: flex;
- flex-direction: column;
- gap: var(--space-sm);
- padding: var(--space-md);
- border: 1px solid rgb(130 177 254 / 30%);
- border-radius: var(--radius-lg);
- background: rgb(2 6 23 / 48%);
- }
-
- .sponsor-panel h3 {
- font-size: 1.35rem;
- }
-
- .sponsor-panel ul {
- display: grid;
- gap: var(--space-xs);
- margin: 0;
- padding-left: 1.1rem;
- color: var(--color-text-dim);
- line-height: 1.55;
- }
-
- .sponsor-panel li::marker {
- color: var(--color-accent-orange);
- }
-
- .sponsor-panel :global(.btn) {
- align-self: flex-start;
- margin-top: auto;
- }
-
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
- gap: 2rem;
+ gap: var(--space-lg);
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
- padding: 3rem 1rem;
+ padding: var(--space-xl) var(--space-sm);
color: var(--color-text-dim);
font-size: 1.05rem;
border: 1px dashed var(--color-border);
@@ -200,19 +94,6 @@ try {
}
@media (max-width: 48rem) {
- .sponsor-challenge {
- grid-template-columns: 1fr;
- margin-bottom: var(--space-xl);
- }
-
- .sponsor-copy h2 {
- max-width: 100%;
- }
-
- .sponsor-panel :global(.btn) {
- align-self: stretch;
- }
-
.project-grid {
grid-template-columns: 1fr;
}
diff --git a/src/pages/projects/project/[slug].astro b/src/pages/projects/project/[slug].astro
index bde43e1..c81fdf6 100644
--- a/src/pages/projects/project/[slug].astro
+++ b/src/pages/projects/project/[slug].astro
@@ -1,5 +1,6 @@
---
import {
+ challengeSponsorByKey,
getProject,
getProjectSlugs,
urlFor,
@@ -16,18 +17,21 @@ export async function getStaticPaths() {
return slugs.map((slug) => ({ params: { slug } }));
}
+const base = import.meta.env.BASE_URL;
const slug = Astro.params.slug;
-if (!slug) return Astro.redirect("/404");
+if (!slug) return Astro.redirect(withBase(base, "/404"));
const project: HackathonProject | null = await getProject(slug);
-if (!project) return Astro.redirect("/404");
-const base = import.meta.env.BASE_URL;
+if (!project) return Astro.redirect(withBase(base, "/404"));
const descriptionBlocks = project.description ?? [];
const hasDescription = descriptionBlocks.length > 0;
const metaDescription =
truncatePlainText(portableTextToPlainText(descriptionBlocks), 160) ||
project.title;
+const challengeLabel = project.awardingSponsor
+ ? challengeSponsorByKey(project.awardingSponsor).challengeLabel
+ : undefined;
---
)
}
+ {
+ challengeLabel && (
+ • {challengeLabel} winner
+ )
+ }
{project.track && • {project.track}}
{project.title}
@@ -64,6 +73,8 @@ const metaDescription =
alt={project.title}
loading="eager"
decoding="async"
+ width={1200}
+ height={675}
/>
)
@@ -146,12 +157,12 @@ const metaDescription =