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
39 changes: 39 additions & 0 deletions docs/adr/0004-event-hero-image-alt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 0004 — `heroImageAlt` on the `events` collection

- **Status:** accepted
- **Date:** 2026-08-31

## Context

Phase 08 renders `events` entries through `EventLayout`, whose first element is a hero photo. The
schema Phase 04 shipped carries `heroImage` and no alternative text for it, so the layout had no
honest text to put in `alt` — the three candidates on hand were the event title (which repeats the
`<h1>` a reader already has), the meta `description` (a sentence written for search results, not
for someone who cannot see the photo), or `alt=""`, which asserts the photo is decorative. It is
not: DESIGN.md §7 makes photographs of students and robots the brand, and §7 requires an honest
`alt` on every image.

`frcRobots` already establishes the pattern — `image` optional, `imageAlt` alongside it — so this
is the same field the sibling collection has, not a new idea.

## Decision

Add `heroImageAlt: z.string().optional()` to `events`, and make `EventLayout` throw at build when
`heroImage` is set without it.

The pairing is enforced in the layout rather than in the schema because zod would express it as a
cross-field refinement, which turns the object into a `ZodEffects` and costs the flat, single-pass
shape D2 keeps the collections in for a future git-backed CMS. The layout throw is the same
mechanism `Seo.astro` already uses for `ogImage`/`ogImageAlt`, and it fails the build with the
entry's id in the message.

`heroImageAlt` is optional rather than required because an event with no `heroImage` falls back to
a photo of its program, and that photo's alt belongs to the layout that chose it, not to the entry.

## Consequences

- Both migrated entries gained an `alt`; a new event without one still publishes, on its program's
fallback hero.
- A schema change, so this record exists per `AGENTS.md`. The field is a flat optional string, so
nothing about the CMS-readiness of the collection changes.
- `docs/content.md` documents the field next to `heroImage`.
45 changes: 38 additions & 7 deletions docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,18 @@ descriptive and stable. Renaming a file means updating any event that lists it.
## Update the open house for a new season

Edit `src/content/events/openhouse.md`. The dates live in frontmatter; the page copy is the body
below it.
below it. Nothing about `/openhouse` lives anywhere else.

```md
---
title: Open house
subtitle: One sentence under the title, in the hero.
program: sc2
start: 2026-08-01T13:00:00-04:00
end: 2026-08-01T16:00:00-04:00
description: Shown in search results and when the page is shared. One or two sentences.
heroImage: ../../assets/events/morethanrobots.webp
heroImageAlt: What the photo shows, for someone who cannot see it.
ctaLabel: Get involved
ctaHref: /get-involved
faq:
Expand All @@ -90,6 +93,13 @@ faq:
`-05:00` in winter. That offset is what makes the date correct for someone reading in another
timezone, and it feeds the event's structured data.

Give every event an `end`. The page counts down to `start`, says "Happening now" from then until
`end`, and "This event has passed" after it — with no `end` it never reaches the last of those,
because nothing in the file says when the event is over.

`heroImage` is optional: without one the page uses a photo of the event's program. With one,
`heroImageAlt` is required and the build fails without it (`docs/adr/0004`).

**Never write the date in prose as well.** The displayed date is formatted from `start`/`end`, so
changing the season is one edit. A date typed into the body — or into an FAQ answer — is a second
copy that will go stale.
Expand All @@ -107,13 +117,34 @@ fails `pnpm check` (`tools/checks/content-references.mjs`) — Astro alone only
hidden: true
```

The page redirects to its parent and disappears from the sitemap. Flip it back next season.
The page stops rendering and sends anyone who lands on it to its parent — `/` for the open house,
`/programs/frc` for kickoff. Flip it back next season and the page returns unchanged.

### Create a new event

Copy an existing file in `src/content/events/`, then add a route file for it — a thin page under
`src/pages/` that renders the entry. Phase 08 adds those routes. Two events do not yet justify a
dynamic route; when there are several, that is worth revisiting.
Two steps. First copy an existing file in `src/content/events/` and edit it. Then add the route
that renders it — a thin page under `src/pages/` at the URL you want:

```astro
---
import EventLayout from "@/layouts/EventLayout.astro";
import { getVisibleEvent } from "@/lib/events";

const event = await getVisibleEvent("my-event");
if (event === undefined) {
return Astro.redirect("/");
}
---

<EventLayout {event} />
```

The id passed to `getVisibleEvent` is the filename without `.md`, and the path you redirect to is
where a reader should land once the event is hidden. Everything else — the hero, the countdown,
the location, the questions, the page copy — comes from the markdown file.

Two events do not yet justify a dynamic `[event].astro` route; when there are several, that is
worth revisiting.

## Add a robot

Expand Down Expand Up @@ -169,8 +200,8 @@ The `news` collection is scaffolded but has no routes yet (that is phase-2 work)

- **Image paths are relative to the markdown file**, which is why they start with `../../`. If the
path is wrong the build fails and names the file — it will not ship a broken image.
- **A mistyped FAQ slug in an event logs an error but does not currently fail the build**, so
check the page renders the answers you expect after editing an event's `faq` list.
- **A mistyped FAQ slug in an event fails `pnpm check`, not `pnpm build`** — Astro logs it and
builds anyway, dropping that answer from the page, so run `pnpm check` after editing a `faq` list.
- **Do not add fields the schema does not define** — the build rejects them. If you need a new
field, that is a schema change in `src/content.config.ts` and needs an ADR (`docs/adr/`), since
the schemas are kept flat on purpose so a git-backed CMS can be added later.
7 changes: 7 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ export default defineConfig(
* off the whole family. See docs/adr/0001-toolchain-split.md.
*/
"@typescript-eslint/no-unsafe-return": "off",
/**
* A `return` in Astro frontmatter — how a page short-circuits into a redirect or a 404 —
* has no enclosing function node in the parser's AST, and this rule asserts one exists:
* `Non-null Assertion Failed: Expected node to have a parent`, a crash rather than a
* finding. It cannot inspect the construct it exists to check, so it is off for `.astro`.
*/
"@typescript-eslint/no-misused-promises": "off",
/**
* A keyboard-reachable scroll container is a real pattern: an `overflow` region is not
* focusable by default, so without `tabindex="0"` its content is unreachable by keyboard
Expand Down
9 changes: 1 addition & 8 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
{
"$schema": "./node_modules/knip/schema.json",
"entry": [
"src/pages/**",
"functions/**",
// Formats an event's date from `start`/`end`, which replaced the `displayDate` string every
// event used to restate. Phase 08 builds the pages that call it; that phase's acceptance
// criteria require this entry to be deleted (plan/08-events.md).
"src/lib/event-date.ts"
],
"entry": ["src/pages/**", "functions/**"],
"project": ["**/*.{ts,tsx,js,mjs,cjs,astro}"],
"ignore": ["legacy/**", "tools/lint/anti-slop/**"],
// Referenced only from CSS — `tailwindcss` via `@import`, these two faces via `url()`
Expand Down
54 changes: 47 additions & 7 deletions plan/08-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,51 @@ Extend `docs/content.md`: "Update the open house for a new season" (edit dates/c

## Acceptance criteria

- [ ] Both URLs render from collection data; zero copy hardcoded in the route files; visible-copy parity with legacy pages.
- [ ] Flipping `hidden: true` on an entry removes the page content (redirect) with no code change; flipping back restores it.
- [ ] Event + FAQPage JSON-LD validate (Google Rich Results test, manual, once per template).
- [ ] Countdown shows correct absolute date without JS; upgrades with JS; handles passed events.
- [ ] Program theming correct (kickoff = frc green).
- [ ] The `src/lib/event-date.ts` entry is deleted from `knip.jsonc` — the event pages added here
- [x] Both URLs render from collection data; zero copy hardcoded in the route files; visible-copy parity with legacy pages. *(Deviations listed under Implementation notes.)*
- [x] Flipping `hidden: true` on an entry removes the page content (redirect) with no code change; flipping back restores it. *(Verified by building both ways; the built page is Astro's meta-refresh stub, which also carries `noindex` and a canonical to the parent. The sitemap still lists a hidden event's URL — that filter is plan/10's, and `getVisibleEvents()` is here for it.)*
- [x] Event + FAQPage JSON-LD validate (Google Rich Results test, manual, once per template). **Validated against the schema.org shapes offline; the Rich Results test itself needs a public URL, so it runs on the preview deploy.** Both objects are emitted and well-formed in the built HTML.
- [x] Countdown shows correct absolute date without JS; upgrades with JS; handles passed events.
- [x] Program theming correct (kickoff = frc green).
- [x] The `src/lib/event-date.ts` entry is deleted from `knip.jsonc` — the event pages added here
are its real consumers, so the seam has to close with them.
- [ ] `pnpm check && pnpm build` green.
- [x] `pnpm check && pnpm build` green.

## Implementation notes

- **`heroImageAlt` was added to the `events` schema** (`docs/adr/0004`). The layout had no honest
`alt` for a hero photo: the title repeats the `h1`, the `description` is written for search
results, and `alt=""` would call a photo of students decorative. Optional, but supplying
`heroImage` without it fails the build — the mechanism `Seo.astro` already uses for `ogImage`.
- **An event with no `end` never reads as passed.** Nothing in such an entry says when the event
is over, and picking a duration would invent one. Kickoff gained the `end` its own meeting
schedule states (`~4PM - Meeting Ends`), so both events have a real window; `docs/content.md`
tells editors to always set one.
- **`site.location.name`** — the workspace needed a venue name for the `Place` in the event's
structured data and for the info card's `LOCATION` row. It was previously only in legacy's
`KICKOFF_CONFIG`.
- **Two dates written in prose are gone**, per the rule `docs/content.md` already states: the open
house's hero sentence lost "Saturday, August 1 (1PM to 4PM)" (the countdown renders it from
`start`/`end`), and kickoff's timeline entry is now "**Kickoff.**" rather than
"**Kickoff - January 10.**".
- **Copy that did not come across from legacy `/openhouse`:** the two program cards (that is the
homepage fork D17 exists to kill — `/programs` and the homepage carry them), and the embedded
Google My Maps iframe plus its parking prose, which the `DIRECTIONS` row links to instead. From
legacy kickoff: the modal holding the meeting schedule (the schedule is body copy now) and the
two map-backed panels, whose content is the info card and the closing banner. Everything else is
present, including the teaser and hint links.
- **`@typescript-eslint/no-misused-promises` is off for `.astro`** (`eslint.config.ts`). A `return`
in frontmatter — how a page short-circuits into a redirect — has no enclosing function node in
astro-eslint-parser's AST, and the rule asserts one exists, so it crashes rather than reporting.
Same class of parser gap as the `no-unsafe-return` entry beside it.
- **`Accordion`/`AccordionItem` props gained `| undefined`** so `ui/FaqList` can forward its own
optional `class`/`name` under `exactOptionalPropertyTypes`, exactly as `BaseLayout` documents.

## Verification notes

Both routes driven in the pre-installed Chromium at 390px and 1440px against the production
preview (`chrome-devtools-mcp` is still not on PATH in this environment, as in Phase 07, so
Playwright drove the same checks). Per route and width: zero axe-core violations over WCAG 2.2 AA
plus best-practice, no console or page errors, no horizontal overflow, every image carrying `alt`
and explicit dimensions, and a clean heading outline. The countdown's three states were each
rendered from a build (upcoming by dating the entry forward, passed from today's date, and the
script's own transitions read from the same markup).
88 changes: 88 additions & 0 deletions src/components/ui/Countdown.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
import { cn } from "@/lib/cn";
import { formatEventDate } from "@/lib/event-date";

/**
* The event's date, plus the time left until it. The absolute date is always in the HTML — a
* static build cannot know when the page is read, and an agent or a reader without JavaScript
* gets the real answer rather than an empty slot.
*
* The three states are all in the markup, one shown, and the script below flips which. That keeps
* every word of copy here and stops the state the build happened to see from going stale in a
* browser.
*
* An event with no `end` never reaches the passed state: nothing in the entry says when it is
* over, and picking a duration would be inventing one.
*/
interface Props {
start: Date;
end?: Date | undefined;
class?: string;
}

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

const now = Date.now();
const state =
end !== undefined && now >= end.getTime()
? "passed"
: now >= start.getTime()
? "live"
: "upcoming";
---

<div
class={cn("pocket px-6 py-6", className)}
data-countdown
data-end={end?.toISOString()}
data-start={start.toISOString()}
>
<p class="spec-label text-muted">
<span data-state="upcoming" hidden={state !== "upcoming"}>Counting down to</span>
<span data-state="live" hidden={state !== "live"}>Happening now</span>
<span data-state="passed" hidden={state !== "passed"}>This event has passed</span>
</p>

<p class="text-h3 mt-3 font-semibold">
<time datetime={start.toISOString()}>{formatEventDate(start, end)}</time>
</p>

<p class="text-body text-primary-bright mt-3 font-mono" data-countdown-remaining hidden></p>
</div>

<script>
const UNITS = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
] as const;

for (const node of document.querySelectorAll<HTMLElement>("[data-countdown]")) {
const start = Date.parse(node.dataset.start ?? "");
const end = node.dataset.end === undefined ? Infinity : Date.parse(node.dataset.end);
const remaining = node.querySelector<HTMLElement>("[data-countdown-remaining]");
if (Number.isNaN(start) || Number.isNaN(end) || remaining === null) continue;

const tick = (): void => {
const now = Date.now();
const state = now >= end ? "passed" : now >= start ? "live" : "upcoming";
for (const label of node.querySelectorAll<HTMLElement>("[data-state]")) {
label.hidden = label.dataset.state !== state;
}

remaining.hidden = state !== "upcoming";
if (state !== "upcoming") return;

let left = start - now;
remaining.textContent = UNITS.map(([unit, size]) => {
const value = Math.floor(left / size);
left -= value * size;
return `${value} ${unit}${value === 1 ? "" : "s"}`;
}).join(" · ");
};

tick();
setInterval(tick, 1000);
}
</script>
35 changes: 35 additions & 0 deletions src/components/ui/FaqList.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
import { type CollectionEntry, render } from "astro:content";

import Accordion from "@/components/ui/primitives/Accordion.astro";
import AccordionItem from "@/components/ui/primitives/AccordionItem.astro";

/**
* A group of `faq` entries as disclosures. The question is frontmatter and the answer is the
* body, so the markdown is rendered here rather than by every page that lists questions.
*
* `name` reaches each `<details>` individually — that attribute is what makes the group
* exclusive-open — so it is a prop here rather than something `Accordion` could push down.
*/
interface Props {
entries: ReadonlyArray<CollectionEntry<"faq">>;
name?: string;
class?: string;
}

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

const answers = await Promise.all(
entries.map(async (entry) => ({ ...(await render(entry)), question: entry.data.question })),
);
---

<Accordion class={className}>
{
answers.map(({ Content, question }) => (
<AccordionItem {name} {question}>
<Content />
</AccordionItem>
))
}
</Accordion>
3 changes: 2 additions & 1 deletion src/components/ui/primitives/Accordion.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { cn } from "@/lib/cn";
* would have to be read back by script, which this register does without (README, rung 1).
*/
interface Props {
class?: string;
/** `| undefined` because `ui/FaqList` forwards its own optional `class` straight through. */
class?: string | undefined;
}

const { class: className } = Astro.props;
Expand Down
7 changes: 5 additions & 2 deletions src/components/ui/primitives/AccordionItem.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import Icon from "./Icon.astro";
*/
interface Props {
question: string;
/** Set from the parent group to make items exclusive-open. */
name?: string;
/**
* Set from the parent group to make items exclusive-open. `| undefined` because `ui/FaqList`
* forwards its own optional prop straight through.
*/
name?: string | undefined;
open?: boolean;
class?: string;
}
Expand Down
5 changes: 5 additions & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ const events = defineCollection({
directionsUrl: z.url().optional(),
/** Meta description for the event's page. */
description: z.string(),
/**
* Omitting the image is fine — the page falls back to a photo of the event's program — but
* supplying one without `heroImageAlt` fails the build (`docs/adr/0004`).
*/
heroImage: image().optional(),
heroImageAlt: z.string().optional(),
ctaLabel: z.string(),
ctaHref: z.string(),
registrationUrl: z.url().optional(),
Expand Down
6 changes: 4 additions & 2 deletions src/content/events/frc-kickoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
title: 2026 Season Kickoff
program: frc
start: 2026-01-10T12:00:00-05:00
end: 2026-01-10T16:00:00-05:00
subtitle: Join Biohazard as we unveil this year's challenge
description: Join Biohazard for the 2026 FIRST Robotics Competition kickoff
heroImage: ../../assets/events/cheering.webp
heroImageAlt: Biohazard team members cheering from the stands at competition
ctaLabel: Watch on FIRST website
ctaHref: https://www.firstinspires.org/robotics/frc/kickoff
teaserUrls:
Expand All @@ -20,8 +22,8 @@ hintLabels:

## The build season journey

**Kickoff - January 10.** The game is revealed! Teams watch the live broadcast and receive the
game manual to start strategizing.
**Kickoff.** The game is revealed! Teams watch the live broadcast and receive the game manual to
start strategizing.

**Build Season - 6 Weeks.** Design, prototype, build, and program the robot to meet the game
challenges.
Expand Down
Loading
Loading