diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd85dd5..e1cfde1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,6 @@ jobs: - name: Build run: pnpm build - # The app shell links to routes Phase 07 has not built yet, so `.lycheeignore` excludes - # exactly those. This fails if one of them has since been built, so the exclusions cannot - # outlive their purpose and start hiding a real 404. - - name: Check for stale link exclusions - run: node tools/checks/stale-link-ignores.mjs - # --root-dir is required for the root-relative hrefs Astro emits (/_astro/...); # without it lychee cannot resolve them in local files and errors on every page. - name: Link check diff --git a/.lycheeignore b/.lycheeignore deleted file mode 100644 index 6674d25..0000000 --- a/.lycheeignore +++ /dev/null @@ -1,29 +0,0 @@ -# Routes the app shell links to that do not exist yet. -# -# The header, footer, and 404 page are built in Phase 05 but link to the full site's routes, -# which Phase 07 builds. Until then the offline link check would fail on every page carrying the -# chrome — so these ten routes are excluded, and nothing else is. -# -# **Delete each entry as its page lands.** `tools/checks/stale-link-ignores.mjs` runs in CI and -# fails if an excluded route exists in `dist`, so an entry cannot quietly outlive its purpose and -# start hiding a real 404. Phase 07 is done when this file is gone. -# -# Anchored on purpose: /dist/programs$ must not also swallow /dist/programs/frc. - -# Phase 07 group A -/dist/about$ -/dist/sponsors$ - -# Phase 07 group B -/dist/contact$ -/dist/donate$ -/dist/get-involved$ - -# Phase 07 group C -/dist/programs$ -/dist/programs/fll$ -/dist/programs/frc$ -/dist/programs/frc/robots$ - -# Phase 07 group D -/dist/calendar/sc2$ diff --git a/DESIGN.md b/DESIGN.md index f429513..94f6a15 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -192,6 +192,7 @@ CSS-only (D3, D20). Motion confirms — it never decorates. - **Stat band**: SCP 600 numeral in `primary` + Inter caption in `body`, on a pocket (feature moments get the grid floor). - **Forms**: visible `Label` above every field; `card` bg inputs, 1px `border`, focus = `ring` 2px; errors in the destructive text token with icon + `aria-describedby`. - **Icons**: Tabler, outline, 2px stroke, 20/24/32. Always with text or `aria-label`. No emoji as UI. +- **Long-form prose** (pages carrying an argument rather than a grid — about, news, an event body): one flowing column at prose measure. Lists take `primary` markers, `body` text, and one level of nesting at most. Blockquotes take a 2px `primary` rule on the leading edge and `body-lg` `foreground` text, with the attribution beneath in `muted` `small` — no quote glyphs, no italics; the rule is the signal. Paragraph rhythm 1rem, with 2rem above a heading that follows copy. ## 9. Accessibility diff --git a/astro.config.ts b/astro.config.ts index 38c8250..f075272 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -1,12 +1,32 @@ import sitemap from "@astrojs/sitemap"; import tailwindcss from "@tailwindcss/vite"; -import { defineConfig } from "astro/config"; +import { defineConfig, envField } from "astro/config"; // Loaded through jiti, and this module imports nothing from `astro:*`, so the config can read the // same origin everything else derives canonical and OG URLs from. import { site } from "./src/data/site"; export default defineConfig({ + /** + * Typed environment, so a page reads a variable rather than an untyped `import.meta.env` + * lookup and a missing one fails the build instead of rendering `undefined` into markup. + */ + env: { + schema: { + /** + * The Turnstile widget's public site key — it ships in the page HTML by design, and the + * secret half stays in the Pages Function's environment. The default is Cloudflare's + * documented always-passes test key, so a fresh clone and every preview deploy have a + * working form with no setup; production sets the real key in the Pages dashboard + * (docs/tooling.md). + */ + PUBLIC_TURNSTILE_SITE_KEY: envField.string({ + access: "public", + context: "client", + default: "1x00000000000000000000AA", + }), + }, + }, // /styleguide is a noindex review artifact; a URL that is both in the sitemap and // noindex is a "Submitted URL marked 'noindex'" error in Search Console. integrations: [sitemap({ filter: (page) => !page.includes("/styleguide") })], diff --git a/docs/tooling.md b/docs/tooling.md index 219520c..9d93c23 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -178,13 +178,21 @@ vendored rule directory are skipped. ## Environment variables -| Variable | Where | Purpose | -| --------------------------- | --------------------- | ----------------------------------------------- | -| `PUBLIC_TURNSTILE_SITE_KEY` | build (public) | Turnstile widget on the contact form (Phase 07) | -| `TS_SECRET_KEY` | Pages Function secret | Turnstile server-side verification | -| `SLACK_FORM_POST_GENERIC` | Pages Function secret | Slack webhook for contact submissions | - -The dev/test Turnstile key that always passes is in `legacy/.env.development`. +| Variable | Where | Purpose | +| --------------------------- | --------------------- | ------------------------------------- | +| `PUBLIC_TURNSTILE_SITE_KEY` | build (public) | Turnstile widget on the contact form | +| `TS_SECRET_KEY` | Pages Function secret | Turnstile server-side verification | +| `SLACK_FORM_POST_GENERIC` | Pages Function secret | Slack webhook for contact submissions | + +`PUBLIC_TURNSTILE_SITE_KEY` is declared in `astro.config.ts`'s `env.schema`, so pages import it +from `astro:env/client` rather than reaching into an untyped `import.meta.env`. It **defaults to +Cloudflare's documented always-passes test key** (`1x00000000000000000000AA`), which is why a +fresh clone and every preview deploy have a working form with no setup — and why the production +value has to be set deliberately, in the Cloudflare Pages dashboard, alongside the `TS_SECRET_KEY` +its server half checks against. A site key is public by design: it ships in the page's HTML. + +The secret half has a matching always-passes test value baked into `functions/api/form/submit.ts` +for the same reason, so the whole round trip works locally without credentials. ## CI diff --git a/functions/api/calendar/[name].ts b/functions/api/calendar/[name].ts new file mode 100644 index 0000000..5d36b3b --- /dev/null +++ b/functions/api/calendar/[name].ts @@ -0,0 +1,84 @@ +import { upcomingEvents } from "@/ics"; +import type { CalendarEvent } from "@/types"; + +/** + * A branded agenda needs the calendar's events as data, not as a Google iframe (D19) — so this + * fetches the public ICS feed server-side and hands the page JSON. + * + * Doing it here rather than in the browser is what makes the feature possible at all: the feed + * sends no CORS headers, so a page cannot read it directly. + */ + +/** + * Mirrors `site.calendars` in `src/data/site.ts` — Pages Functions are bundled separately and + * cannot import from `src/`, so the two ids are duplicated verbatim rather than transformed, + * which keeps them diffable against their source. These are the base64 form Google's embed URL + * uses; the ICS endpoint wants the address inside. + */ +const CALENDARS = { + frc: "Y19hYjljNWJlYTEwODgyYzAxYTAxOGNiZDUxYWIyMzcwYmY4NDk5NDZiZTRlMjUzNTAwZmZmMWQxMGZkY2M4NjFhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20", + sc2: "Y19wcDlkOXRrbGRrbThmdXZtcjMyZTBwZTgxc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t", +}; + +type CalendarName = keyof typeof CALENDARS; + +const isCalendarName = (value: string | undefined): value is CalendarName => + value !== undefined && Object.hasOwn(CALENDARS, value); + +/** How far ahead the agenda looks. */ +const WINDOW_DAYS = 90; + +/** Fifteen minutes: a schedule change should surface the same day, not the same minute. */ +const MAX_AGE = 900; + +/** What `/api/calendar/` answers with, either way. */ +interface CalendarResponse { + events?: Array; + message?: string; +} + +const json = (body: CalendarResponse, status: number, cacheable: boolean): Response => + new Response(JSON.stringify(body), { + headers: { + "Cache-Control": cacheable ? `public, max-age=${String(MAX_AGE)}` : "no-store", + "Content-Type": "application/json", + }, + status, + }); + +export const onRequestGet: PagesFunction = async ({ + params, + request, + waitUntil, +}) => { + const name = Array.isArray(params.name) ? params.name[0] : params.name; + if (!isCalendarName(name)) { + return json({ message: "Unknown calendar" }, 404, false); + } + + // Cloudflare's edge cache, keyed on the request, so one fetch of the feed serves every visitor + // for the freshness window instead of each browser holding its own copy. + const cache = caches.default; + const cached = await cache.match(request); + if (cached !== undefined) { + return cached; + } + + const address = atob(CALENDARS[name]); + const feed = `https://calendar.google.com/calendar/ical/${encodeURIComponent(address)}/public/basic.ics`; + + try { + const upstream = await fetch(feed, { cf: { cacheTtl: MAX_AGE } }); + if (!upstream.ok) { + return json({ message: `Calendar feed returned ${String(upstream.status)}` }, 502, false); + } + + const events = upcomingEvents(await upstream.text(), Date.now(), WINDOW_DAYS); + const response = json({ events }, 200, true); + waitUntil(cache.put(request, response.clone())); + return response; + } catch (error) { + console.error(error); + return json({ message: "Could not reach the calendar feed" }, 502, false); + } +}; diff --git a/functions/ics.ts b/functions/ics.ts new file mode 100644 index 0000000..83df11f --- /dev/null +++ b/functions/ics.ts @@ -0,0 +1,512 @@ +import type { CalendarEvent } from "@/types"; + +/** + * Just enough iCalendar to render an agenda from a public Google Calendar feed (D19). Not a + * general RFC 5545 implementation: it reads `VEVENT`s, resolves their times, expands the + * recurrence rules Google actually emits for a team calendar, and stops there. + * + * Recurrence is not optional here. A robotics team's calendar is mostly repeating build + * sessions, whose single `DTSTART` sits months in the past — a parser that ignored `RRULE` would + * render an empty agenda on a calendar that is anything but. + */ + +/** Anything past this is a malformed or hostile feed, not a schedule. */ +const MAX_OCCURRENCES = 5000; + +/** + * Content lines may be folded across several physical lines, continued by a leading space or + * tab (RFC 5545 §3.1). Unfolding has to happen before anything else is read. + */ +const unfold = (feed: string): Array => { + const lines: Array = []; + for (const raw of feed.replaceAll("\r\n", "\n").split("\n")) { + if ((raw.startsWith(" ") || raw.startsWith("\t")) && lines.length > 0) { + lines[lines.length - 1] += raw.slice(1); + } else { + lines.push(raw); + } + } + return lines; +}; + +interface Line { + name: string; + params: Record; + value: string; +} + +/** `DTSTART;TZID=America/New_York:20260107T183000` → name, params, value. */ +const parseLine = (line: string): Line | undefined => { + const colon = line.indexOf(":"); + if (colon === -1) { + return undefined; + } + + const [name, ...rest] = line.slice(0, colon).split(";"); + if (name === undefined) { + return undefined; + } + + const params: Record = {}; + for (const part of rest) { + const equals = part.indexOf("="); + if (equals !== -1) { + params[part.slice(0, equals).toUpperCase()] = part.slice(equals + 1).replaceAll('"', ""); + } + } + + return { name: name.toUpperCase(), params, value: line.slice(colon + 1) }; +}; + +/** TEXT values escape commas, semicolons, backslashes, and newlines (RFC 5545 §3.3.11). */ +const unescapeText = (value: string): string => + value + .replaceAll(String.raw`\n`, "\n") + .replaceAll(String.raw`\N`, "\n") + .replaceAll(String.raw`\,`, ",") + .replaceAll(String.raw`\;`, ";") + .replaceAll(String.raw`\\`, "\\"); + +/** + * A named zone's UTC offset in milliseconds at a given instant. Workers ship full ICU, so the + * zone database is the runtime's rather than a table this would have to keep current. + */ +const zoneOffset = (instant: number, timeZone: string): number => { + const parts = new Intl.DateTimeFormat("en-US", { + day: "2-digit", + hour: "2-digit", + hour12: false, + minute: "2-digit", + month: "2-digit", + second: "2-digit", + timeZone, + year: "numeric", + }).formatToParts(new Date(instant)); + + const field = (type: string): number => Number(parts.find((part) => part.type === type)?.value); + // `hour12: false` renders midnight as 24 in some ICU versions; both mean the same instant. + const hour = field("hour") % 24; + const wall = Date.UTC( + field("year"), + field("month") - 1, + field("day"), + hour, + field("minute"), + field("second"), + ); + return wall - instant; +}; + +/** + * A wall-clock time in a named zone, as an instant. Offsets are looked up from a first guess and + * then re-checked, because the guess can land on the far side of a DST transition from the + * answer — one correction is enough for every real transition. + */ +const fromZone = (wall: number, timeZone: string): number => { + const once = wall - zoneOffset(wall, timeZone); + return wall - zoneOffset(once, timeZone); +}; + +interface Moment { + /** A `VALUE=DATE` property: an all-day event, with no meaningful time of day. */ + allDay: boolean; + /** Epoch milliseconds. */ + at: number; + /** + * The wall-clock reading, encoded as if it were UTC, plus the zone it was read in. Recurrence + * has to step on the wall clock and convert afterwards: stepping in absolute time instead + * moves a 6:30pm meeting to 7:30pm the week the clocks change. + */ + wall: number; + zone: string | undefined; +} + +/** `20260107T183000Z`, `20260107T183000`, or `20260107`. */ +const parseMoment = (line: Line): Moment | undefined => { + const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(line.value); + if (match === null) { + return undefined; + } + + const [, year, month, day, hour, minute, second, utc] = match; + const wall = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour ?? "0"), + Number(minute ?? "0"), + Number(second ?? "0"), + ); + + const allDay = hour === undefined; + const zone = line.params["TZID"]; + if (allDay || utc === "Z" || zone === undefined) { + // A date, an explicit UTC stamp, and a floating time all read as written. + return { allDay, at: wall, wall, zone: undefined }; + } + + try { + return { allDay, at: fromZone(wall, zone), wall, zone }; + } catch { + // An unknown zone id would otherwise throw out of the whole feed for one bad event. + return { allDay, at: wall, wall, zone: undefined }; + } +}; + +/** Reads a wall clock back as an instant, in the zone the property named — or as written. */ +const resolver = + (zone: string | undefined) => + (wall: number): number => { + if (zone === undefined) { + return wall; + } + try { + return fromZone(wall, zone); + } catch { + return wall; + } + }; + +/** Weekday codes in `BYDAY`, indexed to match `Date#getUTCDay`. */ +const DAYS: ReadonlyArray = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + +/** The weekday a `BYDAY` code names, or -1 — no assertion, so an unknown code stays a miss. */ +const dayIndex = (code: string): number => DAYS.indexOf(code); + +interface Rule { + /** `MO`, `TU`, … for weekly; `2TU`, `-1FR`, … for monthly. */ + byDay: Array; + byMonthDay: Array; + count?: number | undefined; + freq: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + interval: number; + until?: number | undefined; +} + +const parseRule = (value: string): Rule | undefined => { + const parts = new Map( + value.split(";").map((part) => { + const equals = part.indexOf("="); + return [part.slice(0, equals).toUpperCase(), part.slice(equals + 1)] as const; + }), + ); + + const freq = parts.get("FREQ"); + if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") { + return undefined; + } + + const until = parts.get("UNTIL"); + const count = parts.get("COUNT"); + const byDay = parts.get("BYDAY"); + const byMonthDay = parts.get("BYMONTHDAY"); + + return { + byDay: byDay === undefined ? [] : byDay.split(","), + byMonthDay: + byMonthDay === undefined ? [] : byMonthDay.split(",").map(Number).filter(Number.isInteger), + count: count === undefined ? undefined : Number(count), + freq, + interval: Number(parts.get("INTERVAL") ?? "1") || 1, + until: + until === undefined + ? undefined + : parseMoment({ name: "UNTIL", params: {}, value: until })?.at, + }; +}; + +/** + * The wall clocks a rule produces from its start, bounded by the window and a hard cap. Dates + * step in wall time; `toInstant` is only consulted to test `UNTIL` and the window, which are + * absolute. + */ +const expand = ( + rule: Rule, + start: number, + windowEnd: number, + toInstant: (wall: number) => number, +): Array => { + const occurrences: Array = []; + const first = new Date(start); + const time = { + hour: first.getUTCHours(), + minute: first.getUTCMinutes(), + second: first.getUTCSeconds(), + }; + + const emit = (at: number): boolean => { + if (rule.until !== undefined && toInstant(at) > rule.until) { + return false; + } + occurrences.push(at); + return rule.count === undefined || occurrences.length < rule.count; + }; + + const dayCodes = new Set(rule.byDay.map((day) => day.slice(-2))); + + /** + * The rule's period, stepped `interval` at a time; each period then contributes either its own + * anchor date or the `BYDAY`/`BYMONTHDAY` dates within it. Bounded by the window rather than by + * the rule, so an unbounded weekly rule terminates. + */ + for (let period = 0; period < MAX_OCCURRENCES; period += 1) { + const cursor = new Date(start); + let dates: Array; + + if (rule.freq === "DAILY") { + cursor.setUTCDate(cursor.getUTCDate() + period * rule.interval); + dates = [cursor.getTime()]; + } else if (rule.freq === "WEEKLY") { + // Step to the week's Sunday, then take the requested weekdays inside it. + cursor.setUTCDate(cursor.getUTCDate() - cursor.getUTCDay() + period * 7 * rule.interval); + const week = dayCodes.size > 0 ? [...dayCodes] : [DAYS[first.getUTCDay()] ?? "SU"]; + dates = week.flatMap((code) => { + const index = dayIndex(code); + if (index === -1) { + return []; + } + const day = new Date(cursor); + day.setUTCDate(day.getUTCDate() + index); + return day.getTime() < start ? [] : [day.getTime()]; + }); + } else { + const months = rule.freq === "MONTHLY" ? rule.interval : rule.interval * 12; + cursor.setUTCDate(1); + cursor.setUTCMonth(cursor.getUTCMonth() + period * months); + dates = monthDates(cursor, rule, first); + } + + let exhausted = false; + for (const date of dates.toSorted((a, b) => a - b)) { + const at = withTime(date, time); + if (at < start) { + continue; + } + if (!emit(at)) { + exhausted = true; + break; + } + } + + const furthest = occurrences.at(-1); + if (exhausted || (furthest !== undefined && toInstant(furthest) > windowEnd)) { + break; + } + } + + return occurrences; +}; + +interface TimeOfDay { + hour: number; + minute: number; + second: number; +} + +const withTime = (date: number, time: TimeOfDay): number => { + const stamp = new Date(date); + stamp.setUTCHours(time.hour, time.minute, time.second, 0); + return stamp.getTime(); +}; + +/** The dates a monthly or yearly rule selects inside the month `cursor` starts. */ +const monthDates = (cursor: Date, rule: Rule, first: Date): Array => { + const year = cursor.getUTCFullYear(); + const month = cursor.getUTCMonth(); + const length = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + + if (rule.byMonthDay.length > 0) { + return rule.byMonthDay + .map((day) => (day < 0 ? length + day + 1 : day)) + .filter((day) => day >= 1 && day <= length) + .map((day) => Date.UTC(year, month, day)); + } + + if (rule.byDay.length > 0) { + return rule.byDay.flatMap((entry) => { + const code = entry.slice(-2); + const ordinal = Number(entry.slice(0, -2) || "0"); + const weekday = dayIndex(code); + if (weekday === -1) { + return []; + } + + const matches: Array = []; + for (let day = 1; day <= length; day += 1) { + const at = Date.UTC(year, month, day); + if (new Date(at).getUTCDay() === weekday) { + matches.push(at); + } + } + + if (ordinal === 0) { + return matches; + } + const picked = ordinal > 0 ? matches[ordinal - 1] : matches.at(ordinal); + return picked === undefined ? [] : [picked]; + }); + } + + // No BYxxx: the rule repeats the start date's day of the month, skipping months too short + // for it — a 31st never becomes the 1st of the next month. + const day = first.getUTCDate(); + return day > length ? [] : [Date.UTC(year, month, day)]; +}; + +interface RawEvent { + description: string; + end?: Moment | undefined; + excluded: Array; + location: string; + /** Set on an event that overrides one occurrence of its series. */ + recurrenceId?: number | undefined; + rule?: Rule | undefined; + start?: Moment | undefined; + status: string; + summary: string; + uid: string; +} + +const parseEvents = (feed: string): Array => { + const events: Array = []; + let current: RawEvent | undefined; + + for (const raw of unfold(feed)) { + if (raw === "BEGIN:VEVENT") { + current = { description: "", excluded: [], location: "", status: "", summary: "", uid: "" }; + continue; + } + if (raw === "END:VEVENT") { + if (current !== undefined) { + events.push(current); + } + current = undefined; + continue; + } + if (current === undefined) { + continue; + } + + const line = parseLine(raw); + if (line === undefined) { + continue; + } + + switch (line.name) { + case "UID": { + current.uid = line.value; + break; + } + case "SUMMARY": { + current.summary = unescapeText(line.value); + break; + } + case "DESCRIPTION": { + current.description = unescapeText(line.value); + break; + } + case "LOCATION": { + current.location = unescapeText(line.value); + break; + } + case "STATUS": { + current.status = line.value; + break; + } + case "DTSTART": { + current.start = parseMoment(line); + break; + } + case "DTEND": { + current.end = parseMoment(line); + break; + } + case "RRULE": { + current.rule = parseRule(line.value); + break; + } + case "RECURRENCE-ID": { + current.recurrenceId = parseMoment(line)?.at; + break; + } + case "EXDATE": { + for (const value of line.value.split(",")) { + const moment = parseMoment({ ...line, value }); + if (moment !== undefined) { + current.excluded.push(moment.at); + } + } + break; + } + default: { + break; + } + } + } + + return events; +}; + +/** + * Upcoming events from an iCalendar feed, earliest first. + * + * @param feed The raw `.ics` body. + * @param from Start of the window, epoch milliseconds. + * @param days How far ahead to look. + */ +export const upcomingEvents = (feed: string, from: number, days: number): Array => { + const until = from + days * 24 * 60 * 60 * 1000; + const parsed = parseEvents(feed).filter((event) => event.status !== "CANCELLED"); + + /** + * An event carrying `RECURRENCE-ID` replaces one occurrence of its series — a moved or edited + * instance. Keying overrides by uid and instant lets the series skip the occurrences that have + * one, so a rescheduled meeting appears once, at its new time. + */ + const overridden = new Set( + parsed.flatMap((event) => + event.recurrenceId === undefined ? [] : [`${event.uid}:${String(event.recurrenceId)}`], + ), + ); + + const results: Array = []; + + for (const event of parsed) { + const { start } = event; + if (start === undefined) { + continue; + } + + const toInstant = resolver(start.zone); + // Length measured on the wall clock, so an event spanning a clock change keeps its duration. + const length = event.end === undefined ? 0 : Math.max(0, event.end.wall - start.wall); + const walls = + event.rule === undefined ? [start.wall] : expand(event.rule, start.wall, until, toInstant); + + for (const wall of walls) { + const at = toInstant(wall); + const ends = toInstant(wall + length); + + // Compared as instants: an EXDATE or RECURRENCE-ID may be stamped in UTC even where the + // series it modifies is written in a named zone. + if (event.excluded.includes(at) || overridden.has(`${event.uid}:${String(at)}`)) { + continue; + } + // An event that started earlier today but has not finished is still upcoming. + if (ends < from || at > until) { + continue; + } + + results.push({ + allDay: start.allDay, + description: event.description, + end: new Date(ends).toISOString(), + location: event.location, + start: new Date(at).toISOString(), + title: event.summary, + }); + } + } + + return results.toSorted((a, b) => a.start.localeCompare(b.start)); +}; diff --git a/functions/types.ts b/functions/types.ts index 9ec3370..92a07d7 100644 --- a/functions/types.ts +++ b/functions/types.ts @@ -25,3 +25,16 @@ export interface TurnstileResponse { hostname: string; success: boolean; } + +/** + * One occurrence on a public calendar, as `/api/calendar/[name]` returns it. `start` and `end` + * are ISO 8601 instants; the page formats them in the visitor's own locale and zone. + */ +export interface CalendarEvent { + allDay: boolean; + description: string; + end: string; + location: string; + start: string; + title: string; +} diff --git a/knip.jsonc b/knip.jsonc index 47711c5..ff05508 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -3,9 +3,6 @@ "entry": [ "src/pages/**", "functions/**", - // Used by the program pages in Phase 07. It exists now because the theming contract - // belongs with the rest of the shell, not with the first page that happens to need it. - "src/layouts/ProgramLayout.astro", // 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). diff --git a/plan/07-pages.md b/plan/07-pages.md index 8a9f86a..01599ab 100644 --- a/plan/07-pages.md +++ b/plan/07-pages.md @@ -68,12 +68,24 @@ Shared rules for all groups: ## Acceptance criteria -- [ ] **`.lycheeignore` is deleted.** It exists only because the Phase 05 app shell links to the routes this phase builds; `tools/checks/stale-link-ignores.mjs` fails CI for any entry whose page has landed, so entries must be removed as each group ships. This phase is not done while the file exists. -- [ ] **`src/layouts/ProgramLayout.astro`'s `knip.jsonc` entry is deleted** — the program pages built in Group C are its real consumers, so the seam closes with them (same mechanism as Phase 08's `event-date` entry). -- [ ] All routes above build at their exact legacy URLs; visible-copy parity per page (intentional diffs listed in PRs). -- [ ] No page defines colors/spacing outside tokens; all imagery through astro:assets with dimensions. -- [ ] Contact form: successful submit verified against the real function on a preview deploy (test Turnstile key), error path exercised. -- [ ] Calendar pages show live events on preview; no-JS fallback link present in static HTML. -- [ ] Each page: unique title + description, breadcrumbs JSON-LD on nested routes, Lighthouse A11y = 100 spot-checked. -- [ ] `react-hook-form`, `valibot`, `@marsidev/react-turnstile`, `react-scroll-parallax`, `react-player` have no successors in the new tree (knip stays green). -- [ ] `pnpm check && pnpm build` green. +- [x] **`.lycheeignore` is deleted.** It exists only because the Phase 05 app shell links to the routes this phase builds; `tools/checks/stale-link-ignores.mjs` fails CI for any entry whose page has landed, so entries must be removed as each group ships. This phase is not done while the file exists. *(The guard script and its CI step went with it — a check that can now only ever no-op.)* +- [x] **`src/layouts/ProgramLayout.astro`'s `knip.jsonc` entry is deleted** — the program pages built in Group C are its real consumers, so the seam closes with them (same mechanism as Phase 08's `event-date` entry). +- [x] All routes above build at their exact legacy URLs; visible-copy parity per page (intentional diffs listed in PRs). +- [x] No page defines colors/spacing outside tokens; all imagery through astro:assets with dimensions. +- [ ] Contact form: successful submit verified against the real function on a preview deploy (test Turnstile key), error path exercised. **Error path exercised; the success path needs the preview deploy** — `astro preview` serves static output and does not run Pages Functions, and this development environment's egress proxy blocks `challenges.cloudflare.com`, so Turnstile cannot issue a token here. +- [ ] Calendar pages show live events on preview; no-JS fallback link present in static HTML. **Fallback link verified in the static HTML of both routes, and the agenda verified end to end — the real parser and the real page script — against a synthetic Google feed covering recurrence, exclusions, overrides, all-day and multi-day events, and a DST transition. Not yet run against the live feed:** the egress proxy blocks `calendar.google.com`, so this needs the preview deploy too. +- [x] Each page: unique title + description, breadcrumbs JSON-LD on nested routes, Lighthouse A11y = 100 spot-checked. *(axe-core over WCAG 2.2 AA + best-practice reports zero violations on every route at 390px and 1440px — a superset of Lighthouse's accessibility audit. Lighthouse itself arrives with the Phase 09 budgets.)* +- [x] `react-hook-form`, `valibot`, `@marsidev/react-turnstile`, `react-scroll-parallax`, `react-player` have no successors in the new tree (knip stays green). +- [x] `pnpm check && pnpm build` green. + +## Verification notes + +Driven against the production preview in the pre-installed Chromium at 390px and 1440px. The +`chrome-devtools` MCP the overview's standing rule 9 names could not start in this environment +(`chrome-devtools-mcp` is not on PATH and `mise` could not be installed), so Playwright drove the +same checks: per-route console and page errors, horizontal overflow, heading outline, `alt` +coverage, explicit image dimensions, and axe-core. + +Two things a preview deploy still has to confirm, both blocked by this environment rather than by +the code: a real Turnstile round trip on the contact form, and the calendar agenda against +Google's live feed. diff --git a/public/image/biohazard/2021-robot-pits.webp b/public/image/biohazard/2021-robot-pits.webp deleted file mode 100644 index a7903bc..0000000 Binary files a/public/image/biohazard/2021-robot-pits.webp and /dev/null differ diff --git a/public/image/biohazard/home-image.webp b/public/image/biohazard/home-image.webp deleted file mode 100644 index 3987b52..0000000 Binary files a/public/image/biohazard/home-image.webp and /dev/null differ diff --git a/public/image/drill-bits.webp b/public/image/drill-bits.webp deleted file mode 100644 index 3d4114e..0000000 Binary files a/public/image/drill-bits.webp and /dev/null differ diff --git a/public/image/hands-on-1.webp b/public/image/hands-on-1.webp deleted file mode 100644 index c0ec68a..0000000 Binary files a/public/image/hands-on-1.webp and /dev/null differ diff --git a/public/image/laptop.webp b/public/image/laptop.webp deleted file mode 100644 index ddac4e2..0000000 Binary files a/public/image/laptop.webp and /dev/null differ diff --git a/public/image/parts-notes.webp b/public/image/parts-notes.webp deleted file mode 100644 index d62fbb9..0000000 Binary files a/public/image/parts-notes.webp and /dev/null differ diff --git a/public/image/students.webp b/public/image/students.webp deleted file mode 100644 index 982ff8c..0000000 Binary files a/public/image/students.webp and /dev/null differ diff --git a/src/assets/fll/legos.webp b/src/assets/fll/legos.webp index 656e3c5..a1e98cb 100644 Binary files a/src/assets/fll/legos.webp and b/src/assets/fll/legos.webp differ diff --git a/public/image/biohazard/header-logo.svg b/src/assets/frc/biohazard-lockup.svg similarity index 100% rename from public/image/biohazard/header-logo.svg rename to src/assets/frc/biohazard-lockup.svg diff --git a/public/image/biohazard/logo.svg b/src/assets/frc/biohazard-logo.svg similarity index 100% rename from public/image/biohazard/logo.svg rename to src/assets/frc/biohazard-logo.svg diff --git a/src/assets/frc/frc-driveteam.webp b/src/assets/frc/frc-driveteam.webp index ec66a43..96a6437 100644 Binary files a/src/assets/frc/frc-driveteam.webp and b/src/assets/frc/frc-driveteam.webp differ diff --git a/src/assets/frc/home-image.webp b/src/assets/frc/home-image.webp new file mode 100644 index 0000000..225de15 Binary files /dev/null and b/src/assets/frc/home-image.webp differ diff --git a/src/assets/frc/robots/2021-robot-pits.webp b/src/assets/frc/robots/2021-robot-pits.webp new file mode 100644 index 0000000..90c790f Binary files /dev/null and b/src/assets/frc/robots/2021-robot-pits.webp differ diff --git a/src/assets/sc2/drill-bits.webp b/src/assets/sc2/drill-bits.webp new file mode 100644 index 0000000..58e57c4 Binary files /dev/null and b/src/assets/sc2/drill-bits.webp differ diff --git a/public/image/get-involved.webp b/src/assets/sc2/get-involved.webp similarity index 100% rename from public/image/get-involved.webp rename to src/assets/sc2/get-involved.webp diff --git a/src/assets/sc2/hands-on-1.webp b/src/assets/sc2/hands-on-1.webp new file mode 100644 index 0000000..ebe34e9 Binary files /dev/null and b/src/assets/sc2/hands-on-1.webp differ diff --git a/src/assets/sc2/laptop.webp b/src/assets/sc2/laptop.webp new file mode 100644 index 0000000..1ca9584 Binary files /dev/null and b/src/assets/sc2/laptop.webp differ diff --git a/src/assets/sc2/parts-notes.webp b/src/assets/sc2/parts-notes.webp new file mode 100644 index 0000000..1eab95f Binary files /dev/null and b/src/assets/sc2/parts-notes.webp differ diff --git a/src/assets/sc2/students.webp b/src/assets/sc2/students.webp new file mode 100644 index 0000000..b8432b2 Binary files /dev/null and b/src/assets/sc2/students.webp differ diff --git a/src/components/ui/ContactForm.astro b/src/components/ui/ContactForm.astro new file mode 100644 index 0000000..f694e3e --- /dev/null +++ b/src/components/ui/ContactForm.astro @@ -0,0 +1,292 @@ +--- +import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client"; + +import Button from "@/components/ui/primitives/Button.astro"; +import FieldError from "@/components/ui/primitives/FieldError.astro"; +import Icon from "@/components/ui/primitives/Icon.astro"; +import Input from "@/components/ui/primitives/Input.astro"; +import Label from "@/components/ui/primitives/Label.astro"; +import Textarea from "@/components/ui/primitives/Textarea.astro"; +import { site } from "@/data/site"; +import { cn } from "@/lib/cn"; + +/** + * The form behind `/api/form/submit`, rebuilt on the primitives and a single vanilla script — + * react-hook-form, valibot, and `@marsidev/react-turnstile` have no successors here (D3). + * + * Validation is the browser's own Constraint Validation API: the fields carry native attributes, + * and the script only borrows the messages so they can render in the system's own error style + * instead of a native bubble. Turnstile renders explicitly into its container once its API + * arrives, so the widget is never a React component's side effect. + */ +interface Props { + /** + * Which form this is, echoed into the Slack message the function posts. Legacy sent + * "Contact Us" from `/contact` and "Sponsor Question" from `/donate`; keep those strings so + * the two funnels stay distinguishable on the receiving end. + */ + name: string; + class?: string; +} + +const { name, class: className } = Astro.props; + +/** Ids must be unique per document, and `/donate` renders this form beneath other content. */ +const prefix = name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-"); + +const fields = [ + { autocomplete: "name", id: `${prefix}-name`, label: "Name", type: "text" }, + { autocomplete: "email", id: `${prefix}-email`, label: "Email", type: "email" }, +] as const; +--- + +
+
+ { + fields.map((field) => ( +
+ + + +
+ )) + } +
+ +
+ +