Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .claude/hooks/format-lint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,16 @@ lint() {
}

case "${file##*.}" in
ts | tsx | js | jsx | mjs | cjs | json | jsonc | json5 | css)
ts | tsx | js | jsx | mjs | cjs | json | jsonc | json5)
# Without --ignore-path, oxfmt also reads .prettierignore, which excludes
# every extension oxfmt owns.
run oxfmt --ignore-path .gitignore "$file" >/dev/null
lint oxlint --type-aware "$file"
;;
# oxfmt's directory scan formats these too, so `pnpm check` fails on an unformatted one —
# but oxlint has no rules for them, so they are formatted and not linted.
yaml | yml | toml)
# but oxlint has no rules for them, so they are formatted and not linted. Passing one to
# oxlint is not a no-op: it reports "No files found to lint" and exits non-zero.
css | yaml | yml | toml)
run oxfmt --ignore-path .gitignore "$file" >/dev/null
;;
astro)
Expand Down
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ Pages. **Zero client-side framework runtime** — `.astro` components and plain

## Toolchain ownership

| Extensions | Linter | Formatter |
| ------------------------------------- | ---------------------------------------------- | --------- |
| `.ts .js .mjs .cjs .json .jsonc .css` | oxlint (type-aware, vendored nkzw + anti-slop) | oxfmt |
| `.astro` | ESLint (typed, jsx-a11y-strict) | Prettier |
| `.md` | — | Prettier |
| Extensions | Linter | Formatter |
| -------------------------------- | ---------------------------------------------- | --------- |
| `.ts .js .mjs .cjs .json .jsonc` | oxlint (type-aware, vendored nkzw + anti-slop) | oxfmt |
| `.css` | — (oxlint has no CSS rules) | oxfmt |
| `.astro` | ESLint (typed, jsx-a11y-strict) | Prettier |
| `.md` | — | Prettier |

The Claude Code hook in `.claude/hooks/format-lint.sh` runs the right pair on every file you
edit, and feeds lint failures back to you. Do not reach for the other toolchain by hand.
Expand Down
76 changes: 70 additions & 6 deletions plan/06-homepage.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,74 @@ Built from primitives; these are the reusable section vocabulary for all later p

## Acceptance criteria

- [ ] Homepage copy parity with legacy (diff the visible text; intentional omissions listed in PR).
- [ ] All sections built from `ui/` + `ui/primitives/` — zero one-off styling that bypasses tokens.
- [ ] Sponsors render from the collection; deactivating a sponsor file removes it without code changes.
- [ ] Zero client JS except the video poster/in-view script and nav toggle; reduced-motion shows poster.
- [ ] Lighthouse A11y = 100, SEO = 100 (mobile) on the preview.
- [x] Homepage copy parity with legacy (diff the visible text; intentional omissions listed in PR).
Every legacy string is present. Changes, all listed under "Notes and deviations": the hero
heading became the page's `h1`; two section headings were added where legacy had none
("Our programs", "Our sponsors"); "highschool" → "high school"; "Contact us!" moved from the
closing heading into its paragraph so the tagline could take that heading.
- [x] All sections built from `ui/` + `ui/primitives/` — zero one-off styling that bypasses tokens.
- [x] Sponsors render from the collection; deactivating a sponsor file removes it without code
changes — the three `active: false` records (Fives, VFW, Wellspan) are already absent.
- [x] Zero client JS except the nav toggle. The homepage hero is a photograph, not a video (see
below), so it ships no script at all; the whole page's only `<script>` is the shell's sheet
toggle.
- [x] Lighthouse A11y = 100, SEO = 100 (mobile) on the production preview — with Best Practices
100 as well. Trace on Slow 4G + 4× CPU: **LCP 1369 ms, CLS 0.00**.
- [ ] ⛔ **Owner conformance sign-off recorded; any DESIGN.md amendments merged via §11 process.**
- [ ] `pnpm check && pnpm build` green.
- [x] `pnpm check && pnpm build` green.

## Notes and deviations

- **The hero has no video, because the legacy homepage hero has none.** `legacy/src/app/page.tsx`
renders `HeroHeader img="/image/legos.webp"`; the 22 MB `home-video.mp4` belongs to
`/programs/frc`. So `Hero.astro` takes its media through a **slot** — the brief's "media slot
(video or image)" — and D20's poster/in-view/reduced-motion handling lands with the page that
actually has a video (Phase 07), rather than shipping unused here. Only the slotted element
changes between the two.
- **Section carries the atmosphere device, and it is single-valued.** DESIGN.md §2 allows exactly
one device per section boundary and says "never stacked", so `atmosphere` is one prop rather
than four booleans — two devices on one section is unrepresentable. The page cycles them: ghost
numerals 01–03 on the three narrative sections, a tint band on the FIRST interlude, an ambient
pool on the video, a ruler divider before the sponsors, and registration marks (§2.8, itself a
listed device) on the closing pocket.
- **The hero emphasizes one phrase, not two.** Legacy highlighted "Robots" *and* underlined "So
are we."; §2.1 permits exactly one. The swipe stays on "Robots", and the hand-markup budget goes
to the sanctioned tagline in the CTA band — "Real ⬭Skills⬭. Real ⬭Robots⬭. Real ⬭Fun⬭.", three
chalk ovals, no two sharing a variant.
- **Hero height is content-driven** (`min-h-[30rem]` from `md`), not legacy's
`calc(101vh - 4rem)`. The mobile layout follows §7: photo on top fading into the ground, copy
below on solid ground — so legacy's "Find out more" scroll button has nothing left to solve and
is gone with it.
- **The video is a link-out, not an embed.** Legacy mounted a react-player YouTube embed. A framed
poster linking to the video keeps the page at zero third-party JS and zero third-party cookies,
which is part of how Best Practices stays at 100. The URL is `site.urls.teamVideo`.
- **Five join cards are laid out 2 + 3** via `@utility feature-grid-2-3` in `global.css` — DESIGN.md
§5's own worked example. It lives in the stylesheet, not in `FeatureGrid.astro`, for the two
reasons standing rule 2 names: the spans land on slotted children, and the boundaries read the
breakpoint tokens. Legacy reused `IconBooks` on two of the five; the scholarships card now takes
`school`.
- **The FIRST identity is art-directed with `<picture>`.** The spelled-out acronym is illegible
below `md`, so the two renditions are a `<source>` swap rather than a `hidden`/`md:hidden` pair
(standing rule 5). `<Image>` cannot render into a `<source>`, so `getImage()` supplies the
right-sized WebP the raw 2560px PNG would otherwise skip.
- **`nav.donate` and `nav.sponsors` are named entries** in `src/data/site.ts`, spread into
`nav.primary` the way `calendar` already was. The CTA band and the footer both link to them, and
standing rule 1 forbids finding a list entry by index or re-typing its href.
- **`SPONSOR_LEVELS` is now exported from `src/content.config.ts`.** Declaration order is display
order, so `SponsorStrip` sorts by index into the schema's own vocabulary instead of restating the
ranking.
- **Images moved out of `public/`** into `src/assets/{fll,frc,sc2,first}/` so astro:assets
processes them, and `tools/assets/optimize-sources.mjs --write` re-encoded the seven that
qualified (1.33 MB saved). Phase 09 still owns the rest of `public/image` and the per-call-site
`widths`/`quality` tuning.
- **`.claude/hooks/format-lint.sh` blocked every CSS edit.** It routed `.css` to
`oxlint --type-aware`, which has no CSS rules and exits 1 with "No files found to lint". CSS now
sits in the format-only branch beside YAML and TOML, and `AGENTS.md`'s toolchain table says so.

### Open for the review gate

- **Two sponsor logos ship white backgrounds baked into the PNG** (Manitowoc, Orrstown), so they
read as white plates on the dark ground, and `yb-dark.png` is a dark badge that nearly
disappears. Both fixes are outside this phase's authority: either transparent-background assets
from the sponsors, or a documented plate treatment added to DESIGN.md §8 first (§11). Flagging
rather than inventing one, since the sponsors page in Phase 07 needs the same answer.
Binary file removed public/image/first/first-horizontal-acro-dark.png
Binary file not shown.
Binary file removed public/image/frc-driveteam.webp
Binary file not shown.
Binary file removed public/image/frc-driveteam2.webp
Binary file not shown.
Binary file removed public/image/hands-on-2.webp
Binary file not shown.
Binary file removed public/image/leadership-1.webp
Binary file not shown.
Binary file removed public/image/lego-robots.webp
Binary file not shown.
Binary file removed public/image/legos.webp
Binary file not shown.
Binary file added src/assets/first/first-horizontal-acro-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/fll/lego-robots.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/fll/legos.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/frc/frc-driveteam.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/frc/frc-driveteam2.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
File renamed without changes
File renamed without changes
Binary file added src/assets/sc2/hands-on-2.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sc2/leadership-1.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions src/components/ui/CtaBanner.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
import Card from "@/components/ui/primitives/Card.astro";
import CardContent from "@/components/ui/primitives/CardContent.astro";
import RegistrationMarks from "@/components/ui/primitives/RegistrationMarks.astro";
import { cn } from "@/lib/cn";

/**
* The closing "get involved" band — the one feature pocket a view is allowed to crop-mark
* (DESIGN.md §2.8).
*
* Slots: `title` (wrapped in the heading), the default body copy, and `actions`.
*/
interface Props {
headingLevel?: "h2" | "h3";
class?: string;
}

const { headingLevel: Heading = "h2", class: className } = Astro.props;
---

<Card class={cn("relative", className)} feature>
<RegistrationMarks />
<CardContent class="px-6 pt-12 pb-12 text-center md:px-12">
<Heading>
<slot name="title" />
</Heading>
<div class="measure text-body-lg mx-auto mt-5">
<slot />
</div>
{
Astro.slots.has("actions") && (
<div class="mt-8 flex flex-wrap justify-center gap-3">
<slot name="actions" />
</div>
)
}
</CardContent>
</Card>
109 changes: 109 additions & 0 deletions src/components/ui/FeatureCard.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
import type { ImageMetadata } from "astro";

import { Image } from "astro:assets";

import Badge from "@/components/ui/primitives/Badge.astro";
import Card from "@/components/ui/primitives/Card.astro";
import CardContent from "@/components/ui/primitives/CardContent.astro";
import CardFooter from "@/components/ui/primitives/CardFooter.astro";
import CardHeader from "@/components/ui/primitives/CardHeader.astro";
import CardTitle from "@/components/ui/primitives/CardTitle.astro";
import Icon from "@/components/ui/primitives/Icon.astro";
import { cn } from "@/lib/cn";

/**
* The card the section grids are built from (DESIGN.md §8): an accent-tinted icon tile, a title
* over the machined 32px rule, body copy, and an optional footer link whose hit area covers the
* whole pocket. Replaces `legacy/src/components/cards/FeatureCard.tsx`.
*
* The photo is inset rather than bled to the pocket's top edge: a full-width image would cover
* the inset shadow that makes the card read as machined *into* the page (§2).
*
* Slots: `title` (so a title carrying markup still works) and the default body.
*/
interface Props {
/** Tabler icon name for the tile. */
icon: string;
image?: ImageMetadata;
/** Required alongside `image`. */
imageAlt?: string;
/** A spec chip in the header row — an age range, a season, a tier. */
badge?: string;
/** Turns the whole pocket into the link's hit area. */
href?: string;
/** The footer link's text, and therefore its accessible name — make it specific. */
cta?: string;
/** Renders the grid inside the pocket floor. Feature moments only; dense grids stay plain. */
feature?: boolean;
class?: string;
}

const {
icon,
image,
imageAlt,
badge,
href,
cta = "Learn more",
feature = false,
class: className,
} = Astro.props;

if (image !== undefined && imageAlt === undefined) {
throw new Error("FeatureCard: an `image` needs an `imageAlt`.");
}
---

<Card
as="article"
class={cn("relative flex flex-col", className)}
feature={feature}
interactive={href !== undefined}
>
{
image !== undefined && (
<div class="p-4 pb-0">
<Image
alt={imageAlt ?? ""}
class="aspect-16/10 w-full rounded-md object-cover"
loading="lazy"
sizes="(min-width: 64rem) 30rem, (min-width: 48rem) 45vw, 92vw"
src={image}
widths={[400, 640, 900]}
/>
</div>
)
}

<CardHeader>
<div class="flex items-start justify-between gap-4">
<span
class="bg-primary/12 text-primary-bright grid size-12 shrink-0 place-items-center rounded-sm"
>
<Icon name={icon} size={24} />
</span>
{badge !== undefined && <Badge>{badge}</Badge>}
</div>
<CardTitle rule>
<slot name="title" />
</CardTitle>
</CardHeader>

<CardContent class="flex-1">
<slot />
</CardContent>

{
href !== undefined && (
<CardFooter>
{/* The stretched pseudo-element makes the pocket clickable without nesting the card's
content inside the anchor, so the link keeps one short accessible name. */}
<a class="ui-link text-small font-medium after:absolute after:inset-0" href={href}>
{cta}
</a>
<Icon class="text-primary-bright" name="arrow-right" size={20} />
</CardFooter>
)
}
</Card>
25 changes: 25 additions & 0 deletions src/components/ui/FeatureGrid.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
import { cn } from "@/lib/cn";

/**
* The grid feature cards sit in. The column counts are a closed set because the layout math is a
* design decision rather than a call-site one (DESIGN.md §5: "No orphan rows: plan the math").
*/
interface Props {
/** `"2+3"` is the sanctioned five-card layout: two wide, then three. */
columns: 2 | 3 | "2+3";
class?: string;
}

const { columns, class: className } = Astro.props;

const layouts = {
2: "grid gap-6 md:grid-cols-2",
3: "grid gap-6 sm:grid-cols-2 lg:grid-cols-3",
"2+3": "feature-grid-2-3",
} as const;
---

<div class={cn(layouts[columns], className)}>
<slot />
</div>
6 changes: 1 addition & 5 deletions src/components/ui/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,7 @@ const year = new Date().getFullYear();
const sections = [
{
title: "Support us",
links: [
{ label: "Donate", href: "/donate" },
{ label: "Sponsors", href: "/sponsors" },
{ label: "Wishlist", href: site.urls.wishlist },
],
links: [nav.donate, nav.sponsors, { label: "Wishlist", href: site.urls.wishlist }],
},
{
title: "Programs",
Expand Down
75 changes: 75 additions & 0 deletions src/components/ui/Hero.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
import { cn } from "@/lib/cn";

/**
* The page hero (DESIGN.md §5, §7). The media arrives through a slot, so the same component
* carries a photograph here and a looping video on the pages that have one — only the slotted
* element changes.
*
* Two layouts, one image: below `md` the copy sits on solid ground *underneath* the photo, which
* fades into the page (§7 — "never gamble on scrims at small sizes"); from `md` up the photo
* fills the hero and the copy sits on a scrim built from `background`.
*
* Slots: `media`, `eyebrow`, `title` (wrapped in the `h1`), the default subhead, and `actions`.
*/
interface Props {
class?: string;
}

const { class: className } = Astro.props;
---

<section class={cn("relative", className)}>
<div class="hero-media relative aspect-3/2 md:absolute md:inset-0 md:aspect-auto">
<slot name="media" />
{
/* DESIGN.md §7: opaque at the text edge, ~20% at the far one. The desktop stops are placed
so the opaque band spans the copy column instead of ending halfway through it. */
}
<div
aria-hidden="true"
class="to-background md:from-background md:via-background/75 md:to-background/20 absolute inset-0 bg-linear-to-b from-transparent from-45% md:bg-linear-to-r md:from-35% md:via-65%"
>
</div>
</div>

<div class="container-page section-y relative md:flex md:min-h-[30rem] md:items-center">
<div class="max-w-2xl">
{
Astro.slots.has("eyebrow") && (
<p class="eyebrow text-primary">
<slot name="eyebrow" />
</p>
)
}
<h1 class="text-display mt-3">
<slot name="title" />
</h1>
<div class="measure text-body-lg text-foreground mt-5">
<slot />
</div>
{
Astro.slots.has("actions") && (
<div class="mt-8 flex flex-wrap gap-3">
<slot name="actions" />
</div>
)
}
</div>
</div>
</section>

<hr class="accent-rule" />

<style>
/**
* The slot takes an `<img>` or a `<video>`, and either has to fill the frame. `:global()`
* because the element is authored by the caller, so it carries no `data-astro-cid-*` of this
* component and a plain descendant selector would be rewritten to demand one.
*/
.hero-media :global(:is(img, video)) {
height: 100%;
width: 100%;
object-fit: cover;
}
</style>
Loading
Loading