diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..2541db91 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,30 @@ +{ + "permissions": { + "allow": ["Bash(agent-browser:*)"] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "cd \"$CLAUDE_PROJECT_DIR\" && f=\"$(jq -r .tool_input.file_path)\" && case \"$f\" in \"$CLAUDE_PROJECT_DIR\"/*) hk fix -q -n --no-stage \"$f\" >&2 || exit 2 ;; esac", + "timeout": 60 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cd \"$CLAUDE_PROJECT_DIR\" && jq -e .stop_hook_active >/dev/null || hk fix --slow -q -n --no-stage >&2 || exit 2", + "timeout": 180 + } + ] + } + ] + } +} diff --git a/.claude/skills/agent-browser/SKILL.md b/.claude/skills/agent-browser/SKILL.md new file mode 100644 index 00000000..dc9bb54a --- /dev/null +++ b/.claude/skills/agent-browser/SKILL.md @@ -0,0 +1,52 @@ +--- +name: agent-browser +description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools. +allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +hidden: true +--- + +# agent-browser + +Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with accessibility-tree snapshots and compact `@eN` element refs. + +Install: `npm i -g agent-browser && agent-browser install` + +## Start here + +This file is a discovery stub, not the usage guide. Before running any `agent-browser` command, load the actual workflow content from the CLI: + +```bash +agent-browser skills get core # start here — workflows, common patterns, troubleshooting +agent-browser skills get core --full # include full command reference and templates +``` + +The CLI serves skill content that always matches the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `skills get core`. + +## Specialized skills + +Load a specialized skill when the task falls outside browser web pages: + +```bash +agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...) +agent-browser skills get slack # Slack workspace automation +agent-browser skills get dogfood # Exploratory testing / QA / bug hunts +agent-browser skills get derive-client # Record a HAR, derive a standalone API client for a site +agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs +agent-browser skills get protected-vercel-deployments # Access protected Vercel deployments +agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers +``` + +Run `agent-browser skills list` to see everything available on the installed version. + +## Why agent-browser + +- Fast native Rust CLI, not a Node.js wrapper +- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.) +- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency +- Accessibility-tree snapshots with element refs for reliable interaction +- Sessions, authentication vault, state persistence, video recording +- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers + +## Observability Dashboard + +The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. diff --git a/.claude/skills/shadcn-astro/SKILL.md b/.claude/skills/shadcn-astro/SKILL.md new file mode 100644 index 00000000..b0430168 --- /dev/null +++ b/.claude/skills/shadcn-astro/SKILL.md @@ -0,0 +1,114 @@ +--- +name: shadcn-astro +description: Port a shadcn/ui component to an Astro primitive in src/components/ui/primitives/. Use when a needed primitive does not exist yet, or when an existing one needs a variant or sub-part that shadcn already defines. +--- + +# Porting shadcn components to Astro primitives + +This site ships **zero client framework runtime**. shadcn is React + Radix, so a port is a +re-implementation, not a copy — but the _design API_ comes across almost verbatim, and should. + +## When to use this + +A component you need is missing from `src/components/ui/primitives/`. Check first: the primitive +may exist under shadcn's name already. + +Do **not** use this to add a variant to an existing primitive — edit its `*.variants.ts`. + +## Process + +### 1. Read the source + +Fetch the component from the shadcn registry: + +``` +https://ui.shadcn.com/r/styles/default/.json +``` + +Verify that URL still resolves before relying on it; the registry layout has changed before. If +it is unreachable, work from the documented anatomy on ui.shadcn.com instead — the goal is the +component's structure and variant vocabulary, not its exact source. + +Note three things: its **anatomy** (which sub-parts exist and how they nest), its **CVA +variants**, and which behaviors come from **Radix** rather than from CSS. + +### 2. Map the behavior down the interactivity ladder + +This is the whole job. For each Radix behavior, find the lowest-cost equivalent: + +| Radix provides | Astro equivalent | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Accordion (roving focus, exclusive open) | `
` + the native `name` attribute — see `AccordionItem.astro` | +| Dialog (focus trap, Esc, inert background, portal) | native `` + `showModal()` — see `Dialog.astro` | +| Tabs | `:target` or radio inputs, or a ~20-line script over `role="tablist"` | +| Popover / Dropdown | `:focus-within`, or the popover attribute — **must also work on tap and without JS**, so the trigger needs a real destination | +| Carousel (embla) | CSS scroll-snap track; buttons scroll by one slide — see `Carousel.astro` | +| Slot / `asChild` | an `as` prop, or infer from props (`Button` renders `` given `href`) | + +If a behavior cannot be reached without a framework, **drop it** and document the omission in +the component's `Props` JSDoc. Do not add a client framework to preserve a nicety. + +### 3. Write the component + +Follow `src/components/ui/primitives/README.md` — read it before writing. In short: + +- CVA recipe in `.variants.ts`, component in `.astro`. +- Variant classes copied nearly verbatim from shadcn, with **token names swapped for ours**. +- `class` prop merged through `cn()`, `...rest` spread on the root. +- Local `Props` interface extending `HTMLAttributes<"element">` with `class` omitted. + +### 4. Register it on /styleguide + +Every variant, size, and state, under the default theme and both program themes. This is not +optional — `/styleguide` is the review artifact. + +### 5. Verify + +```sh +pnpm check && pnpm build +``` + +Then keyboard-test it: tab order, Enter/Space/Esc as appropriate, visible focus ring, and the +component still usable with JavaScript disabled. + +## Rules + +- **No React, Preact, or Radix dependencies. Ever.** Not as a devDependency either. +- **Token names must already exist in `src/styles/global.css`.** If a shadcn class needs a token + we do not have, stop: propose the addition to `DESIGN.md` via its §11 process and get owner + review. Do not invent a hex value in a component — raw hex in components is banned (§2). +- **Radii come from the three-value scale** (`radius-sm`/`md`/`lg`). shadcn's `rounded-full` has + no equivalent here: pill UI is banned (§10). A circled word is a `ChalkOval`. +- **No drop shadows.** shadcn leans on `shadow-*`; this system has no elevation, only inset + pocket depth (§4). Delete those classes rather than translating them. +- **Minimum touch target 44px** for anything tappable (§9), which is why `Button`'s `md` is + `h-11` and not shadcn's `h-10`. + +## Worked example: Accordion + +shadcn's Accordion is Radix `Accordion.Root`/`Item`/`Trigger`/`Content` — a controlled component +with roving focus, `data-state` attributes, and a height animation, plus `type="single"` for +exclusive open. + +The Astro port is two files and no JavaScript: + +- `Accordion.astro` — a layout wrapper, nothing more. Astro cannot push a prop into slotted + children, so a wrapper `name` prop could only be read back by script; the caller passes the + same `name` to each item instead. +- `AccordionItem.astro` — `
` with `` as the trigger. + +What maps directly: + +- `type="single"` ⇒ the native `name` attribute. Browsers close sibling `
` sharing a + name, which is exactly exclusive-open. +- `data-state="open"` styling ⇒ the `open` attribute, targeted with Tailwind's `group-open:`. +- Trigger keyboard handling ⇒ the browser's, for free. + +What is dropped, and why it is fine: + +- **Roving arrow-key focus between items.** Native `` elements are plain tab stops. + Tab still reaches every item, so nothing is unreachable. +- **The height transition.** Animating `
` open height needs `content-visibility` + tricks or JS. Motion here would be decoration, not confirmation (§6), so it goes. + +Both omissions belong in the component's JSDoc, where the next reader will look. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..f76ad3d2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# For what Prettier does not format (toml, shell, gitignore-likes). +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.development b/.env.development deleted file mode 100644 index 6c116c60..00000000 --- a/.env.development +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_TS_SITE_KEY=1x00000000000000000000AA -# NEXT_PUBLIC_TS_SITE_KEY=2x00000000000000000000AB # Force-failure key for testing diff --git a/.env.production b/.env.production deleted file mode 100644 index 520fdce9..00000000 --- a/.env.production +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_IS_PROD=true \ No newline at end of file diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..b988d422 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,26 @@ +name: Setup +description: Install the mise-pinned toolchain and the pnpm dependencies, with both cached. +runs: + using: composite + steps: + # agent-browser in mise.toml is a local tool; CI installs only what the jobs run. + - uses: jdx/mise-action@v4 + with: + version: 2026.9.14 + install_args: node pnpm hk + cache: true + + # mise-action caches tool binaries, not pnpm's content-addressable store. + - name: Locate pnpm store + shell: bash + run: echo "PNPM_STORE=$(pnpm store path --silent)" >>"$GITHUB_ENV" + + - uses: actions/cache@v6 + with: + path: ${{ env.PNPM_STORE }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-store-${{ runner.os }}- + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile --prefer-offline diff --git a/.github/typescript-matchers.json b/.github/typescript-matchers.json new file mode 100644 index 00000000..b37fdb3b --- /dev/null +++ b/.github/typescript-matchers.json @@ -0,0 +1,32 @@ +{ + "problemMatcher": [ + { + "owner": "tsc", + "pattern": [ + { + "regexp": "^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + }, + { + "owner": "astro-check", + "pattern": [ + { + "regexp": "^(?:\u001b\\[[0-9;]*m)*([^\\s\u001b][^\u001b]*)(?:\u001b\\[[0-9;]*m)*:(?:\u001b\\[[0-9;]*m)*(\\d+)(?:\u001b\\[[0-9;]*m)*:(?:\u001b\\[[0-9;]*m)*(\\d+)(?:\u001b\\[[0-9;]*m)* - (?:\u001b\\[[0-9;]*m)*(error|warning)(?:\u001b\\[[0-9;]*m)* ts\\((\\d+)\\): (?:\u001b\\[[0-9;]*m)*(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a77329ef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main, staging] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + + # Every hk.pkl step over every file, each reporting its findings as inline annotations on + # the PR diff. + - name: Check + run: | + echo "::add-matcher::.github/typescript-matchers.json" + pnpm check + + build: + name: Build + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + env: + LHCI_VERSION: 0.15.1 + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + + - name: Build + id: build + run: pnpm build + + # Title and description uniqueness, and whether every og:image resolves, are only + # answerable across the whole built site. + - name: Page metadata + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: pnpm check:meta + + # --root-dir resolves the root-relative hrefs Astro emits. llms.txt is in scope because its + # links are hand-written paths that nothing else checks against the routes they name. + - name: Links + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --offline --include-fragments --no-progress + --root-dir ${{ github.workspace }}/dist + 'dist/**/*.html' 'dist/llms.txt' + fail: true + + # The build is deterministic, so a byte-identical dist/ measured with the same server, + # config, and lhci version has already passed; only the marker file is cached. + - name: Look up a previous Lighthouse pass + id: lighthouse-pass + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + uses: actions/cache/restore@v6 + with: + path: .lighthouseci/passed + key: lighthouse-${{ env.LHCI_VERSION }}-${{ hashFiles('dist/**', 'tools/ci/**') }} + lookup-only: true + + - name: Lighthouse CI + id: lighthouse + if: ${{ !cancelled() && steps.lighthouse-pass.outcome == 'success' && steps.lighthouse-pass.outputs.cache-hit != 'true' }} + run: | + pnpm dlx @lhci/cli@${{ env.LHCI_VERSION }} autorun --config=tools/ci/lighthouserc.json + touch .lighthouseci/passed + + # Into the log as well as the summary, so the waterfall behind a failed LCP assertion is + # readable from the API without downloading the report artifact. + - name: Summarize Lighthouse + if: ${{ !cancelled() && steps.lighthouse.outcome != 'skipped' }} + run: node tools/ci/lighthouse-summary.ts | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Note the skipped Lighthouse run + if: ${{ steps.lighthouse-pass.outputs.cache-hit == 'true' }} + run: echo "Lighthouse skipped, this exact build already passed." >>"$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v5 + if: ${{ !cancelled() && steps.lighthouse.outcome != 'skipped' }} + with: + name: lighthouse-reports + path: .lighthouseci/reports/ + + - uses: actions/cache/save@v6 + if: ${{ steps.lighthouse.outcome == 'success' }} + with: + path: .lighthouseci/passed + key: ${{ steps.lighthouse-pass.outputs.cache-primary-key }} diff --git a/.gitignore b/.gitignore index fd3dbb57..032d1a18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,10 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ +node_modules +.pnpm-store -# production -/build +# astro +.astro +dist # misc .DS_Store @@ -22,15 +12,14 @@ # debug npm-debug.log* -yarn-debug.log* -yarn-error.log* +pnpm-debug.log* # local env files .env*.local - -# vercel -.vercel +.dev.vars # typescript *.tsbuildinfo -next-env.d.ts + +# lighthouse ci +.lighthouseci/ diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2f4f0506..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v26.5.0 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..cf1becbd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist/ +.astro/ +.lighthouseci/ +pnpm-lock.yaml +.claude/settings.local.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..1bd475f5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"], + "tailwindStylesheet": "./src/styles/global.css", + "tailwindFunctions": ["cn", "cva"] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e11f647b..97c27d70 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,9 @@ { - "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"] + "recommendations": [ + "astro-build.astro-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "hverlin.mise-vscode" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index bae114eb..c81e489b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,25 +1,19 @@ { - "editor.defaultFormatter": "biomejs.biome", - "editor.tabSize": 2, "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit" + "source.fixAll.eslint": "explicit" }, - "js/ts.preferences.importModuleSpecifier": "non-relative", - "tailwindCSS.experimental.classRegex": [ - [ - "clsx\\(([^)]*)\\)", - "(?:'|\"|`)([^']*)(?:'|\"|`)", - "cn\\(([^)]*)\\)", - "classNames\\(([^)]+)\\)" - ] - ], - "js/ts.tsdk.path": "./node_modules/typescript/lib", - "workbench.editor.customLabels.enabled": true, - "workbench.editor.customLabels.patterns": { - "**/app/**/page.tsx": "${dirname} - Page", - "**/app/**/layout.tsx": "${dirname} - Layout", - "**/app/**/template.tsx": "${dirname} - Template" + "eslint.validate": ["javascript", "typescript", "astro"], + "files.associations": { + "*.css": "tailwindcss" + }, + "tailwindCSS.classFunctions": ["cn", "cva"], + "search.exclude": { + "dist/**": true, + "pnpm-lock.yaml": true }, - "css.lint.unknownAtRules": "ignore" + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.preferences.importModuleSpecifier": "non-relative", + "js/ts.experimental.useTsgo": false } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..44f24e01 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +## What this is + +The website of the South Central STEM Collective (SC2), a 501(c)(3) running FIRST robotics +and hands-on STEM programs in Franklin County, PA. Static Astro site, deployed by Cloudflare +Pages. **Zero client-side framework runtime** — `.astro` components and plain `} diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx deleted file mode 100644 index 640914ad..00000000 --- a/src/components/Calendar.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "cnfast"; - -interface Props { - calendars: Calendar[]; - bgColor?: CalColor; - noTitle?: boolean; - noNav?: boolean; - noDate?: boolean; - noPrint?: boolean; - noTabs?: boolean; - noCalList?: boolean; - noTimeZone?: boolean; - className?: string; -} - -interface Calendar { - src: string; - color?: CalColor; -} - -export enum CalColor { - Black = "%23333333", - NiceBrown = "%236B3304", - Pink = "%23B1365F", - Fuchsia = "%235C1158", - Red = "%23711616", - Crimson = "%23691426", - Orange = "%23BE6D00", - OrangeRed = "%23B1440E", - RedOrange = "%23853104", - BurntOrange = "%238C500B", - BrownOrange = "%23754916", - Gold = "%2388880E", - Goldenrod = "%23AB8B00", - DarkerGoldenrod = "%23856508", - LighterGreen = "%231B887A", - Green = "%2328754E", - ForestGreen = " %230D7813", - OliveGreen = "%23528800", - JungleGreen = "%23125A12", - AnotherOlive = "%232F6309", - AnotherGreen = "%232F6213", - SeaGreen = "%230F4B38", - GoldenOlive = "%235F6B02", - GreenGray = "%234A716C", - OliveGray = "%236E6E41", - DullNavy = "%2329527A", - StandardBlue = "%232952A3", - BlueGray = "%234E5D6C", - BlueSteel = "%235A6986", - DarkBlue = "%23060D5E", - SeaBlue = "%23113F47", - Violet = "%237A367A", - Purple = "%235229A3", - PurpleGray = "%23865A5A", - PurpleBrown = "%23705770", - DeepPurple = "%2323164E", - Magenta = "%235B123B", - YellowBrown = "%23875509", - Brown = "%238D6F47", - White = "%23ffffff", - Graphite = "%23616161", -} - -export function Calendar({ - calendars, - bgColor, - noTitle, - noNav, - noDate, - noPrint, - noTabs, - noCalList, - noTimeZone, - className, -}: Props): ReactNode { - let src = ""; - if (calendars) { - calendars.forEach((calendar) => { - let color = CalColor.Black; - if (calendar.color) color = calendar.color; - src += `&src=${calendar.src}&color=${color}`; - }); - } - - let bg = CalColor.Graphite; - if (bgColor) bg = bgColor; - - return ( - - ); -} diff --git a/src/components/Carousel.tsx b/src/components/Carousel.tsx deleted file mode 100644 index 426a051c..00000000 --- a/src/components/Carousel.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import AutoHeight from "embla-carousel-auto-height"; -import Autoplay from "embla-carousel-autoplay"; -import type useEmblaCarousel from "embla-carousel-react"; -import type { ReactNode } from "react"; - -import * as SUICarousel from "@/components/shadcn/ui/carousel"; - -type Props = { - slides: ReactNode[]; - noAuto?: boolean; - noLoop?: boolean; - delay?: number; - resize?: boolean; -}; - -export function Carousel({ - slides, - noLoop, - noAuto, - delay = 3000, - resize, -}: Props): ReactNode { - const plugins: Parameters[1] = []; - - if (!noAuto) { - plugins.push( - Autoplay({ delay, stopOnInteraction: false, stopOnMouseEnter: true }), - ); - } - - if (resize) { - plugins.push(AutoHeight()); - } - - return ( - - - {slides.map((slide, i) => ( - {slide} - ))} - - - ); -} diff --git a/src/components/Countdown.tsx b/src/components/Countdown.tsx deleted file mode 100644 index f3d28fc2..00000000 --- a/src/components/Countdown.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; - -import { cn } from "cnfast"; - -type TimeRemaining = { - days: number; - hours: number; - minutes: number; - seconds: number; - expired: boolean; -}; - -type CountdownLabels = { - days?: string; - hours?: string; - minutes?: string; - seconds?: string; -}; - -type CountdownProps = { - targetDate: Date; - labels?: CountdownLabels; - className?: string; - showSeconds?: boolean; - expiredContent?: ReactNode; -}; - -function calculateTimeRemaining(targetDate: Date): TimeRemaining { - const now = new Date(); - const diff = targetDate.getTime() - now.getTime(); - - if (diff <= 0) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; - } - - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - return { days, hours, minutes, seconds, expired: false }; -} - -export function Countdown({ - targetDate, - labels = { - days: "Days", - hours: "Hours", - minutes: "Minutes", - seconds: "Seconds", - }, - className, - showSeconds = true, - expiredContent, -}: CountdownProps): ReactNode { - const [timeRemaining, setTimeRemaining] = useState( - calculateTimeRemaining(targetDate), - ); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - const interval = setInterval(() => { - setTimeRemaining(calculateTimeRemaining(targetDate)); - }, 1000); - - return () => clearInterval(interval); - }, [targetDate]); - - if (timeRemaining.expired) { - if (expiredContent) { - return <>{expiredContent}; - } - return ( -
- The event has started! -
- ); - } - - const timeUnits = [ - { value: timeRemaining.days, label: labels.days }, - { value: timeRemaining.hours, label: labels.hours }, - { value: timeRemaining.minutes, label: labels.minutes }, - ]; - - if (showSeconds) { - timeUnits.push({ value: timeRemaining.seconds, label: labels.seconds }); - } - - return ( -
- Time until kickoff: - {timeUnits.map((unit, index) => ( -
-
- {mounted ? unit.value : "–"} -
-
- {unit.label} -
-
- ))} -
- ); -} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx deleted file mode 100644 index 0581121f..00000000 --- a/src/components/Footer.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Divider } from "@heroui/divider"; -import { Link } from "@heroui/link"; -import { - IconBrandFacebook, - IconBrandGithub, - IconBrandLinkedin, - IconMail, -} from "@tabler/icons-react"; -import type { ReactNode } from "react"; - -import { ActionIcon } from "@/components/ActionIcon"; -import { Carousel } from "@/components/Carousel"; -import { Image } from "@/components/Image"; -import { LogoFullColor } from "@/components/Logo"; -import type { Sponsor as SponsorSlide } from "@/data/sponsors"; -import { SponsorLevel, Sponsors } from "@/data/sponsors"; -import type { Icon } from "@/lib/utils"; -import { cn } from "cnfast"; - -type FooterSection = { - title: string; - links: FooterLink[]; -}; - -type FooterLink = { - title: string; - href: string; -}; - -type FooterButton = { - href: string; - icon: Icon; -}; - -const footerSections: FooterSection[] = [ - { - title: "Support Us", - links: [ - { title: "Donate", href: "/wiki/donations" }, - { title: "Sponsors", href: "/sponsors" }, - ], - }, - { - title: "Find us online", - links: [ - { title: "Facebook", href: "https://go.scstem.tech/facebook" }, - { title: "LinkedIn", href: "https://go.scstem.tech/linkedin" }, - { title: "GitHub", href: "https://go.scstem.tech/github" }, - ], - }, -]; - -const footerButtons: FooterButton[] = [ - { - href: "mailto:info@scstem.org", - icon: IconMail, - }, - { - href: "https://go.scstem.tech/facebook", - icon: IconBrandFacebook, - }, - { - href: "https://go.scstem.tech/linkedin", - icon: IconBrandLinkedin, - }, - { - href: "https://go.scstem.tech/github", - icon: IconBrandGithub, - }, -]; - -const footerSponsors = Sponsors.filter( - (sponsor) => - sponsor.level === SponsorLevel.Silver || - sponsor.level === SponsorLevel.Gold || - sponsor.level === SponsorLevel.Platinum, -); - -function Slide({ sponsor }: { sponsor: SponsorSlide }): ReactNode { - return ( - -
- {sponsor.logo ? ( - {`${sponsor.name} - ) : ( - - {sponsor.name} - - )} - - {sponsor.sub ? ( -
{sponsor.sub}
- ) : null} -
- - ); -} - -export function Footer({ className }: { className?: string }): ReactNode { - return ( -
-
- {footerSponsors.length > 0 ? ( -
-
- Special thanks to our sponsors for powering our mission. -
- -
- ( -
- -
- ))} - /> -
-
-
- {footerSponsors.map((sponsor) => ( -
- -
- ))} -
-
-
- ) : null} - - {/* Main Footer */} -
-
- -
- SC2 is 501(c)(3) non-profit focused on providing STEM - opportunities for students in and around Franklin County PA. -
-
- - {/* Links */} -
- {footerSections.map((section, i) => ( -
-
{section.title}
- - {section.links.map((link, i) => ( - - {link.title} - - ))} -
- ))} -
-
- - {/* Copyright */} -
-
- {`Copyright © ${new Date().getFullYear()} South Central STEM Collective.`} -
-
- {footerButtons.map((button, i) => ( - - ))} -
-
-
-
- ); -} diff --git a/src/components/Image.tsx b/src/components/Image.tsx deleted file mode 100644 index 0680965d..00000000 --- a/src/components/Image.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { StaticImport as ImageStaticImport } from "next/dist/shared/lib/get-img-props"; -import type { ImageProps } from "next/image"; -import NextImage from "next/image"; -import type { ReactNode } from "react"; - -import { cn } from "cnfast"; - -export type StaticImport = ImageStaticImport; - -// Helper component to wrap whatever image component we're using -export function Image({ className, ...props }: ImageProps): ReactNode { - // if (typeof props.src === "string" || props.src instanceof String) { - // return ; - // } - return ; -} diff --git a/src/components/JsonLd.astro b/src/components/JsonLd.astro new file mode 100644 index 00000000..8d49810e --- /dev/null +++ b/src/components/JsonLd.astro @@ -0,0 +1,15 @@ +--- +import type { JsonLdObject } from "@/lib/jsonld"; + +/** + * Serializes one structured-data object into the document. The data always comes from + * `src/lib/jsonld.ts`, i.e. from us, so `set:html` carries no untrusted input. + */ +interface Props { + data: JsonLdObject; +} + +const { data } = Astro.props; +--- + + diff --git a/src/components/LinkButton.tsx b/src/components/LinkButton.tsx deleted file mode 100644 index 62782783..00000000 --- a/src/components/LinkButton.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import { Button, type ButtonProps } from "@heroui/button"; -import Link from "next/link"; - -export function LinkButton(props: ButtonProps & { href: string }) { - return - * - * - * Cancel - * - * - * Confirm - * - * - * } - * > - *

Modal content goes here

- *
- * - * ); - * } - * ``` - */ -export function Modal({ - isOpen, - onOpenChange, - title, - children, - footer, - size = "md", - isDismissable = true, - hideCloseButton = false, - className, - scrollBehavior = "inside", -}: ModalProps) { - return ( - - - {(onClose) => ( - - {title && {title}} - {children} - {footer && {footer}} - - )} - - - ); -} - -export interface ModalCloseButtonProps extends ButtonProps { - /** - * Additional action to perform before closing - */ - onBeforeClose?: () => void; -} - -/** - * Button component that automatically closes the modal when clicked - * Can only be used inside Modal or ModalWithTrigger components - * - * @example - * ```tsx - * - * Got it - * - * ``` - */ -export function ModalCloseButton({ - onBeforeClose, - onPress, - ...props -}: ModalCloseButtonProps) { - const onClose = useModalClose(); - - const handlePress = (e: Parameters>[0]) => { - onBeforeClose?.(); - onPress?.(e); - onClose(); - }; - - return - - {children} - - - ); -} - -/** - * Re-export HeroUI's useDisclosure hook for convenience - */ -export { useDisclosure }; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx deleted file mode 100644 index af42bee4..00000000 --- a/src/components/Navbar.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client"; - -import type { ButtonProps } from "@heroui/react"; -import { - Accordion, - AccordionItem, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Link, - NavbarBrand, - NavbarContent, - NavbarItem, - NavbarMenu, - NavbarMenuItem, - NavbarMenuToggle, - Navbar as NUINavbar, -} from "@heroui/react"; -import { IconChevronDown, IconLego, IconRobot } from "@tabler/icons-react"; -import { usePathname } from "next/navigation"; -import type { ReactNode } from "react"; -import { useCallback, useState } from "react"; -import { LinkButton } from "@/components/LinkButton"; -import { cn } from "cnfast"; -import { LogoColor } from "./Logo"; - -type NavLink = { - href: string; - label: string; - element?: ReactNode; - icon?: ReactNode; - description?: string; - children?: NavLink[]; -}; - -const links: NavLink[] = [ - { - label: "About", - href: "/about", - }, - { - label: "Sponsors", - href: "/sponsors", - }, - { - label: "Programs", - href: "/programs", - children: [ - { - label: "FLL", - element: ( - <> - FIRST® Lego League - - ), - href: "/programs/fll", - icon: , - description: "A Hands-On Approach to STEM Learning", - }, - { - label: "FRC", - element: ( - <> - FIRST® Robotics Competition - - ), - href: "/programs/frc", - icon: , - description: - "Combining the excitement of sport with the rigors of science and technology", - }, - ], - }, - { - label: "Donate", - href: "/donate", - }, -]; - -function keyify(link: NavLink): string { - return link.label.toLowerCase().replace(/ /g, "_"); -} - -function GetInvolved({ - className, - href: _href, - as: _as, - ...props -}: ButtonProps): ReactNode { - return ( - - Get Involved - - ); -} - -export function Navbar(): ReactNode { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const pathname = usePathname(); - const isActive = useCallback( - (link: NavLink): boolean => pathname.startsWith(link.href), - [pathname], - ); - - const onMenuItemClick = () => { - setIsMenuOpen(false); - }; - - const navButtons = links.map((link) => ( - - {!link.children ? ( - - {link.element ?? link.label} - - ) : ( - - -
-
{link.element ? link.element : link.label}
- -
-
- - - {link.children.map((link) => ( - {link.description} - } - textValue={link.label} - href={link.href} - className={cn( - isActive(link) ? "border-x-2 border-primary" : null, - )} - > - {link.element ? link.element : link.label} - - ))} - -
- )} -
- )); - - const menuButtons = links.map((link) => ( - - {!link.children ? ( - - {link.element ?? link.label} - - ) : ( -
- - - {link.children.map((link) => ( - - {link.element ?? link.label} - - ))} - - -
- )} -
- )); - - return ( - - - -
- -
-
- South Central STEM Collective -
-
- - {navButtons} - - - - - - - - {menuButtons} - - - - -
- ); -} diff --git a/src/components/PatternBackground.tsx b/src/components/PatternBackground.tsx deleted file mode 100644 index c77ac3e6..00000000 --- a/src/components/PatternBackground.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "cnfast"; -import type { ColorScale } from "@/styles/theme"; -import { parseColor } from "@/styles/theme"; - -type PatternType = "circuit" | "topography" | "hexagons"; - -type Props = { - pattern?: PatternType; - classNames?: { - container?: string; - background?: string; - }; - color?: ColorScale; - children?: ReactNode; -}; - -export function PatternBackground({ - color, - pattern, - classNames, - children, -}: Props): ReactNode { - const parsedColor = parseColor(color); - - let url: string | undefined; - switch (pattern) { - case "circuit": - url = "/image/pattern/circuit.svg"; - break; - case "topography": - url = "/image/pattern/topography.svg"; - break; - case "hexagons": - url = "/image/pattern/hexagons.svg"; - break; - } - - return ( -
-
- {url ? ( -
- ) : null} -
{children}
-
-
- ); -} diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx deleted file mode 100644 index 8424a8a9..00000000 --- a/src/components/Providers.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { HeroUIProvider } from "@heroui/react"; -import { useRouter } from "next/navigation"; -import type { ReactNode } from "react"; -import { ParallaxProvider } from "react-scroll-parallax"; - -export function Providers({ - children, -}: { - children: React.ReactNode; -}): ReactNode { - const router = useRouter(); - return ( - - {children} - - ); -} diff --git a/src/components/Seo.astro b/src/components/Seo.astro new file mode 100644 index 00000000..be61fed3 --- /dev/null +++ b/src/components/Seo.astro @@ -0,0 +1,60 @@ +--- +import type { SeoProps } from "@/lib/seo"; + +import { programs, site } from "@/data/site"; +import { absoluteUrl } from "@/lib/links"; +import { color } from "@/lib/tokens"; + +/** + * Everything that goes in `` for discoverability. Rendered by BaseLayout, which passes + * these props straight through — so **`description` being required here is what stops a page + * from shipping without one.** Omitting it is a type error, not a review catch. + */ +type Props = SeoProps; + +const { title, description, og, noindex = false, theme } = Astro.props; + +/** The homepage is the site; anywhere else is a page *of* it. */ +const isHome = Astro.url.pathname === "/"; +const fullTitle = isHome ? site.name : site.titleTemplate.replace("%s", title); + +/** `trailingSlash: "always"` makes the pathname the trailing-slash form a page is served at. */ +const canonicalUrl = absoluteUrl(Astro.url.pathname); + +const card = + og === undefined + ? { ...site.ogImage, src: site.ogImage.path } + : { alt: og.alt, height: og.image.height, src: og.image.src, width: og.image.width }; +const imageUrl = absoluteUrl(card.src); + +/** Browser chrome follows the page's action accent, read from the same tokens the CSS ships. */ +const themeColor = color("primary", { + accent: theme === undefined ? "yellow" : programs[theme].accent, +}); +--- + +{fullTitle} + +{noindex ? ( + +) : ( + +)} + + + + +{!noindex && } + + + + + + + + + + + + + diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx deleted file mode 100644 index e3db0f29..00000000 --- a/src/components/VideoPlayer.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { IconPlayerPlay } from "@tabler/icons-react"; -import dynamic from "next/dynamic"; -import { type ReactElement, type ReactNode, useState } from "react"; - -import { cn } from "cnfast"; - -const ReactPlayer = dynamic(() => import("react-player"), { ssr: false }); - -type Props = { - url: string; - placeholder?: string | ReactElement; - classNames?: { - wrapper?: string; - player?: string; - icon?: string; - header?: string; - }; - header?: string | ReactElement; -}; - -export function VideoPlayer({ - url, - classNames, - placeholder, - header, -}: Props): ReactNode { - const [videoReady, setVideoReady] = useState(false); - return ( -
- {header ? ( -
- {header} -
- ) : null} - - - } - onReady={() => setVideoReady(true)} - /> -
- ); -} diff --git a/src/components/cards/FeatureCard.tsx b/src/components/cards/FeatureCard.tsx deleted file mode 100644 index 710473e5..00000000 --- a/src/components/cards/FeatureCard.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Card, CardBody, CardFooter, CardHeader } from "@heroui/card"; -import { Chip } from "@heroui/chip"; -import type { ReactNode } from "react"; -import type { StaticImport } from "@/components/Image"; -import { Image } from "@/components/Image"; -import { LinkButton } from "@/components/LinkButton"; -import type { Icon } from "@/lib/utils"; -import { cn } from "cnfast"; -import type { ColorScale } from "@/styles/theme"; -import { parseColor } from "@/styles/theme"; - -function CardImage({ - src, - alt, - className, -}: { - src: StaticImport; - alt: string; - className?: string; -}): ReactNode { - return ( - {alt} - ); -} - -export type FeatureCardProps = { - icon: Icon; - title: ReactNode; - body: ReactNode; - color?: ColorScale; - link?: string; - linkText?: string; - linkIcon?: Icon; - badge?: string; - img?: { - src: StaticImport; - alt: string; - placement?: "top" | "bottom"; - }; - classNames?: { - base?: string; - body?: string; - header?: string; - }; - footer?: ReactNode; -}; - -export function FeatureCard({ - title, - body, - color, - link, - linkText, - badge, - img, - classNames, - footer, - ...props -}: FeatureCardProps): ReactNode { - const parsedColor = parseColor(color); - return ( - - - -

{title}

-
- - - {img && (!img.placement || img.placement === "top") ? ( - - ) : null} -
-
- {badge ? ( - - {badge} - - ) : null} -
-
-
- {body} -
- - {img && img.placement === "bottom" ? ( - - ) : null} -
- - - {footer ? ( - {footer} - ) : link ? ( - - : null - } - > - {linkText ? linkText : "Learn More"} - - - ) : null} - - ); -} diff --git a/src/components/forms/ContactForm.tsx b/src/components/forms/ContactForm.tsx deleted file mode 100644 index d1ac5bfd..00000000 --- a/src/components/forms/ContactForm.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { Button } from "@heroui/react"; -import { valibotResolver } from "@hookform/resolvers/valibot"; -import { Turnstile } from "@marsidev/react-turnstile"; -import { IconCheck, IconSend } from "@tabler/icons-react"; -import { type ReactNode, useEffect, useState } from "react"; -import type { SubmitHandler } from "react-hook-form"; -import { useForm } from "react-hook-form"; -import type { InferInput } from "valibot"; -import { email, nonOptional, object, pipe, string } from "valibot"; -import { Input } from "@/components/forms/fields/Input"; -import { TextArea } from "@/components/forms/fields/TextArea"; -import type { APIResponse, GenericFormRequest } from "@/functions/types"; -import { cn } from "cnfast"; - -const contactSchema = object({ - name: nonOptional(string()), - email: pipe(string(), email("Please enter a valid email address")), - message: nonOptional(string()), - turnstileToken: nonOptional(string()), -}); -type ContactFormValues = InferInput; - -const contactDefaults: ContactFormValues = { - name: "", - email: "", - message: "", - turnstileToken: "", -}; - -const prod = process.env.NEXT_PUBLIC_IS_PROD === "true"; -const siteKey = process.env.NEXT_PUBLIC_TS_SITE_KEY; - -type Props = { - className?: string; - name: string; -}; - -export function ContactForm({ className, name }: Props): ReactNode { - const [submitting, setSubmitting] = useState(false); - const [formError, setFormError] = useState(""); - - const { - handleSubmit, - setValue, - setError, - clearErrors, - control, - reset, - formState: { - disabled, - isSubmitted, - isDirty, - isValid, - errors: { turnstileToken: turnstileTokenError }, - }, - } = useForm({ - resolver: valibotResolver(contactSchema), - defaultValues: contactDefaults, - mode: "onChange", - }); - - const submitDisabled = disabled || !isDirty || !isValid || submitting; - - useEffect(() => { - if (turnstileTokenError?.message) { - setFormError(turnstileTokenError.message); - } - }, [turnstileTokenError]); - - const onSubmit: SubmitHandler = (data) => { - if (submitDisabled) return; - setSubmitting(true); - - const submission: GenericFormRequest = { - form: name, - ...data, - }; - - fetch("/api/form/submit", { - method: "POST", - body: JSON.stringify(submission), - headers: { "Content-Type": "application/json" }, - }) - .then((res) => res.json()) - .then(async (res: APIResponse) => { - if (!res.success) { - setFormError(res.message ?? "Error submitting form"); - } - }) - .catch((err) => { - setFormError("Error submitting form"); - console.log(err); - }) - .finally(() => { - setSubmitting(false); - reset(contactDefaults); - }); - - console.log(submission); - }; - - return ( -
-
- - -
- diff --git a/src/components/ui/primitives/TitleBlock.astro b/src/components/ui/primitives/TitleBlock.astro new file mode 100644 index 00000000..691e6699 --- /dev/null +++ b/src/components/ui/primitives/TitleBlock.astro @@ -0,0 +1,47 @@ +--- +import { cn } from "@/lib/cn"; + +/** + * The engineering-drawing identity strip (DESIGN.md §2.9): bordered compartments, each an + * uppercase spec label over its value. Its home is the footer bottom — the drawing sheet's + * corner. Contact and event pages may use the stacked form as an info card. + */ +interface Field { + label: string; + value: string; + /** Codes, URLs, and numbers stay in the data voice; prose values set in Inter. */ + mono?: boolean; + href?: string; +} + +interface Props { + fields: readonly Field[]; + /** + * `strip` is the footer's full-width identity bar — horizontal from `md`, stacked below it. + * `stack` is the boxed column §2.9 sanctions for a contact or event page's info card, where + * the block sits in a narrow column that the horizontal form would crush. + */ + orientation?: "strip" | "stack"; + class?: string; +} + +const { fields, orientation = "strip", class: className } = Astro.props; +--- + +
+ {fields.map((field) => ( +
+
{field.label}
+
+ {field.href === undefined ? field.value : {field.value}} +
+
+ ))} +
diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 00000000..e8c24d04 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,152 @@ +import { defineCollection, reference, type SchemaContext } from "astro:content"; +import { glob } from "astro/loaders"; +import { z } from "astro/zod"; + +import { PROGRAM_KEYS } from "@/data/site"; + +/** + * Every maintainable fact about this site lives in `src/content/` as markdown with validated + * frontmatter. Editing content means editing one small file — see `docs/content.md`. + * + * Schemas stay deliberately **flat**: strings, enums, booleans, dates, numbers, and image + * paths. No nested objects beyond one level, no discriminated unions. That is what keeps a + * git-backed CMS (Keystatic and friends) a later addition rather than a restructuring, and it + * is why `location` below is two flat fields instead of an object. + */ + +/** + * Declaration order is display order, so anything listing sponsors sorts by index here rather + * than restating the ranking. + */ +export const SPONSOR_LEVELS = ["Platinum", "Gold", "Silver", "Bronze", "Friend"] as const; + +const sponsors = defineCollection({ + loader: glob({ base: "src/content/sponsors", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + name: z.string(), + level: z.enum(SPONSOR_LEVELS), + url: z.url().optional(), + logo: image().optional(), + /** + * `false` retires a sponsor without deleting the record — former sponsors stay in git as + * data rather than as commented-out code. + */ + active: z.boolean().default(true), + /** First year of support, as shown on the sponsors page. */ + since: z.number().int().optional(), + /** A qualifier shown under the name, e.g. a specific chapter or post. */ + sub: z.string().optional(), + /** Maintainer notes. Never rendered. */ + notes: z.string().optional(), + }), +}); + +const events = defineCollection({ + loader: glob({ base: "src/content/events", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + title: z.string(), + /** Hero subtitle, sitting directly under the title. Not a heading — it has no section. */ + subtitle: z.string().optional(), + program: z.enum(PROGRAM_KEYS), + start: z.date(), + end: z.date().optional(), + /** + * Location is optional and defaults to the workspace from `src/data/site.ts` — only an + * off-site event carries these, which also makes "off-site" a visible signal. + */ + locationName: z.string().optional(), + locationAddress: z.string().optional(), + 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(), + /** + * Season teaser videos, and this year's game hints, as flat parallel arrays (no + * nested objects). `hintLabels[i]` names `hintUrls[i]`; a length mismatch fails the build. + */ + teaserUrls: z.array(z.url()).optional(), + hintUrls: z.array(z.url()).optional(), + hintLabels: z.array(z.string()).optional(), + /** FAQ entries to render on the page, by their file id. */ + faq: z.array(reference("faq")).optional(), + /** + * `true` takes the page out of service: it redirects to its parent and drops out of the + * sitemap. This is how a past season is retired — one field, no code change. + */ + hidden: z.boolean().default(false), + }), +}); + +const faq = defineCollection({ + loader: glob({ base: "src/content/faq", pattern: "**/*.md" }), + schema: z.strictObject({ + question: z.string(), + program: z.enum(PROGRAM_KEYS).optional(), + tags: z.array(z.string()).optional(), + }), +}); + +const frcRobots = defineCollection({ + loader: glob({ base: "src/content/frc/robots", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + year: z.number().int(), + /** + * Overrides `year` in display copy where a robot spans two seasons ("2020-2021"). `year` + * stays the sort key. + */ + yearLabel: z.string().optional(), + name: z.string(), + /** The season's game, as FIRST named it. */ + game: z.string().optional(), + image: image().optional(), + imageAlt: z.string(), + /** Per-robot wordmark overlaid on the photo, where a season had one. */ + logo: image().optional(), + achievements: z.array(z.string()).optional(), + }), +}); + +/** FRC and FLL team photos are the same shape; the split is by directory. */ +const teamPhotoSchema = ({ image }: SchemaContext) => + z.strictObject({ + year: z.number().int(), + photo: image(), + alt: z.string(), + caption: z.string().optional(), + }); + +const frcTeamPhotos = defineCollection({ + loader: glob({ base: "src/content/frc/team-photos", pattern: "**/*.md" }), + schema: teamPhotoSchema, +}); + +const fllTeamPhotos = defineCollection({ + loader: glob({ base: "src/content/fll/team-photos", pattern: "**/*.md" }), + schema: teamPhotoSchema, +}); + +/** + * Collection *names* avoid slashes even though the content nests under `frc/` and `fll/` on + * disk: Astro writes each collection's editor JSON schema to + * `.astro/collections/.schema.json` without creating intermediate directories, so a name + * like `frc/robots` warns on every build and silently loses frontmatter autocomplete. + */ +export const collections = { + sponsors, + events, + faq, + frcRobots, + frcTeamPhotos, + fllTeamPhotos, +}; diff --git a/src/content/events/frc-kickoff.md b/src/content/events/frc-kickoff.md new file mode 100644 index 00000000..2a2448d1 --- /dev/null +++ b/src/content/events/frc-kickoff.md @@ -0,0 +1,53 @@ +--- +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: Watch the 2026 FIRST Robotics Competition game reveal with Biohazard on January 10 at our downtown Chambersburg workspace, and see how build season starts. +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: + - https://www.youtube.com/watch?v=2AEdAID2U2o + - https://www.youtube.com/watch?v=0JaguGctF1A +hintUrls: + - https://www.youtube.com/watch?v=0JaguGctF1A + - https://community.firstinspires.org/2025-game-hint-3-for-2026 +hintLabels: + - "Hint #1" + - "Hint #2" +--- + +## The build season journey + +**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. + +**Competition – March & April.** Compete in regional events, form alliances, and work toward +qualifying for the World Championship. + +**Ages 14–18.** High school students work alongside adult mentors from industry to tackle real +engineering challenges. + +## What is _FIRST®_ Robotics Competition? + +_FIRST®_ Robotics Competition is a varsity sport for the mind: high-school teams build robots and +compete against teams from around the world. [Meet Biohazard](/programs/frc/) to see what a season +on the team looks like. + +## Meeting schedule + +- 12 PM – Parents' meeting and safety meeting +- Lunch break and prepare for the game reveal +- Game reveal live stream begins +- ~4 PM – Meeting ends + +## Can't make it? + +Interested in joining Biohazard but can't attend kickoff? We'd still love to have you on the team! +Fill out the [get involved](/get-involved/) form and we'll be in touch. diff --git a/src/content/events/openhouse.md b/src/content/events/openhouse.md new file mode 100644 index 00000000..c4e60dda --- /dev/null +++ b/src/content/events/openhouse.md @@ -0,0 +1,24 @@ +--- +title: Open house +subtitle: See the robots, meet the teams, and find your place in downtown Chambersburg. +program: sc2 +start: 2026-08-01T13:00:00-04:00 +end: 2026-08-01T16:00:00-04:00 +description: Meet the students and mentors behind SC2's robotics and STEM programs at our open house in downtown Chambersburg. All ages welcome, no experience needed. +heroImage: ../../assets/events/morethanrobots.webp +heroImageAlt: Collage of photos capturing some of the many ways students can get involved including robot design, photography, video, and more. +ctaLabel: Get involved +ctaHref: /get-involved/ +faq: + - when-is-the-open-house + - what-age-to-join + - what-does-it-cost + - no-robotics-experience + - not-interested-in-robots + - cant-make-the-open-house +--- + +## Come see what we build + +Into LEGO®, code, video, business, carpentry, or leadership? There's a place for it here. Come see +the robots and talk with our students and mentors. diff --git a/src/content/faq/cant-make-the-open-house.md b/src/content/faq/cant-make-the-open-house.md new file mode 100644 index 00000000..27790372 --- /dev/null +++ b/src/content/faq/cant-make-the-open-house.md @@ -0,0 +1,8 @@ +--- +question: Can't make the open house? Is there another way to visit? +program: sc2 +tags: [openhouse, joining] +--- + +Yes! Fill out our [Get involved](/get-involved/) form and we'll reach out. There's a good chance +we have a meeting coming up you're welcome to visit! diff --git a/src/content/faq/no-robotics-experience.md b/src/content/faq/no-robotics-experience.md new file mode 100644 index 00000000..d825bbae --- /dev/null +++ b/src/content/faq/no-robotics-experience.md @@ -0,0 +1,6 @@ +--- +question: What if I don't know anything about robotics? +tags: [joining] +--- + +That's okay! Plenty of our students started with none, and we'll teach you what you need. diff --git a/src/content/faq/not-interested-in-robots.md b/src/content/faq/not-interested-in-robots.md new file mode 100644 index 00000000..f241236a --- /dev/null +++ b/src/content/faq/not-interested-in-robots.md @@ -0,0 +1,7 @@ +--- +question: What if I'm not interested in robots, engineering, or computers? +tags: [joining] +--- + +You should still check us out! Many of our students are into art, business, video production, +and more — there's a place on our teams for just about everyone. diff --git a/src/content/faq/what-age-to-join.md b/src/content/faq/what-age-to-join.md new file mode 100644 index 00000000..9dd4f2a7 --- /dev/null +++ b/src/content/faq/what-age-to-join.md @@ -0,0 +1,6 @@ +--- +question: What age do I have to be to join? +tags: [joining] +--- + +Ages 9–16 for FIRST LEGO League and 14–18 for Biohazard (FRC). Adults can join as mentors. diff --git a/src/content/faq/what-does-it-cost.md b/src/content/faq/what-does-it-cost.md new file mode 100644 index 00000000..31d8c87c --- /dev/null +++ b/src/content/faq/what-does-it-cost.md @@ -0,0 +1,6 @@ +--- +question: What does it cost to participate? +tags: [joining, cost] +--- + +Each team sets its own dues, which vary by age group; most are free or low-cost. diff --git a/src/content/faq/when-is-the-open-house.md b/src/content/faq/when-is-the-open-house.md new file mode 100644 index 00000000..30bb6fb9 --- /dev/null +++ b/src/content/faq/when-is-the-open-house.md @@ -0,0 +1,7 @@ +--- +question: When is the open house? +program: sc2 +tags: [openhouse] +--- + +See the date and time at the top of this page. diff --git a/src/content/fll/team-photos/2024.md b/src/content/fll/team-photos/2024.md new file mode 100644 index 00000000..2c9cf391 --- /dev/null +++ b/src/content/fll/team-photos/2024.md @@ -0,0 +1,6 @@ +--- +year: 2024 +photo: ../../../assets/team/fll/2024.webp +alt: The FIRST LEGO League teams in 2024 +caption: '"Team Jeremy" & "Toxic Musicians" - 2024' +--- diff --git a/src/content/frc/robots/2019-toxic-silver.md b/src/content/frc/robots/2019-toxic-silver.md new file mode 100644 index 00000000..a0327903 --- /dev/null +++ b/src/content/frc/robots/2019-toxic-silver.md @@ -0,0 +1,13 @@ +--- +year: 2019 +name: Toxic Silver +image: ../../../assets/frc/robots/2019-robot-field.webp +imageAlt: Toxic Silver on the competition field +achievements: + - Excellence in Engineering Award, Greater Pittsburgh Regional + - Rank 8, Battle O' Baltimore +--- + +With its shining aluminum lift and maneuverable mecanum drivetrain, Toxic Silver won us an +Excellence in Engineering Award at the Greater Pittsburgh Regional and Rank 8 at the Battle +O' Baltimore. diff --git a/src/content/frc/robots/2021-robo-fett.md b/src/content/frc/robots/2021-robo-fett.md new file mode 100644 index 00000000..7d02f027 --- /dev/null +++ b/src/content/frc/robots/2021-robo-fett.md @@ -0,0 +1,11 @@ +--- +year: 2021 +yearLabel: 2020-2021 +name: Robo Fett +image: ../../../assets/frc/robots/2021-robot-field.webp +imageAlt: Robo Fett on the competition field +--- + +Armed with a giant scissor-lift climber and an inline shooting mechanism, Robo Fett is one of two +Biohazard robots to use pneumatic wheels. Along one side, a large plexiglass panel protects the +electronics while proudly displaying the 2020 team logo. diff --git a/src/content/frc/robots/2022-omega.md b/src/content/frc/robots/2022-omega.md new file mode 100644 index 00000000..7b52c04b --- /dev/null +++ b/src/content/frc/robots/2022-omega.md @@ -0,0 +1,13 @@ +--- +year: 2022 +name: O'Mega +image: ../../../assets/frc/robots/2022-robot-field-3.webp +imageAlt: O'Mega on the competition field +achievements: + - Finalist + - Industrial Design Award +--- + +One of our heavier robots, O'Mega boasts a giant cannon, a sizeable intake, and two climbing arms. +An adjustable firing mechanism allowed it to shoot into both hoops in the 2022 game, and won us a +spot in the finals and an Industrial Design Award. diff --git a/src/content/frc/robots/2023-viper.md b/src/content/frc/robots/2023-viper.md new file mode 100644 index 00000000..c202014a --- /dev/null +++ b/src/content/frc/robots/2023-viper.md @@ -0,0 +1,14 @@ +--- +year: 2023 +name: Viper +image: ../../../assets/frc/robots/2023-robot-field.webp +imageAlt: Viper on the competition field +logo: ../../../assets/frc/robots/2023-viper-logo.webp +achievements: + - Industrial Design Award, Pittsburgh Regional +--- + +Competing in the 2023 Pittsburgh Regional Competition, Viper won us the Industrial Design Award, +and a few curious looks from fellow teams. Its main features are the three foot metal arm and 3d +printed pneumatic claw, used to atriculate inflatable cubes. Mecanum wheels allow it to fine-tune +its position, and dual cameras gave our drivers lots of visibilty. diff --git a/src/content/frc/robots/2024-troubleclef.md b/src/content/frc/robots/2024-troubleclef.md new file mode 100644 index 00000000..9973c55b --- /dev/null +++ b/src/content/frc/robots/2024-troubleclef.md @@ -0,0 +1,16 @@ +--- +year: 2024 +name: Troubleclef +image: ../../../assets/frc/robots/2024-robot-field.webp +imageAlt: Troubleclef on the competition field +logo: ../../../assets/frc/robots/2024-troubleclef-logo.webp +achievements: + - 15th place, Buckeye Valley Regional + - Imagery Award, 2023-2024 season +--- + +Amid more conventional and exposed designs, TroubleClef sticks out like a sore thumb with its +sleek gunmetal sides and concealed electronics. A 3d printed chain carries game pieces from the +ground up to the peak of the ramp, and a pair of motors shoot it forward. Coming back from a loss +in Pittsburgh, this machine got us 15th place at the Buckeye Valley Regional and helped us win our +Imagery Award in the 2023-2024 season. diff --git a/src/content/frc/robots/2025.md b/src/content/frc/robots/2025.md new file mode 100644 index 00000000..98f8ba9c --- /dev/null +++ b/src/content/frc/robots/2025.md @@ -0,0 +1,7 @@ +--- +year: 2025 +name: Coming soon +imageAlt: Photo not yet available +--- + +Coming soon... diff --git a/src/content/frc/team-photos/2013.md b/src/content/frc/team-photos/2013.md new file mode 100644 index 00000000..bea4d717 --- /dev/null +++ b/src/content/frc/team-photos/2013.md @@ -0,0 +1,6 @@ +--- +year: 2013 +photo: ../../../assets/team/frc/2013.webp +alt: The Biohazard team in 2013 +caption: '"Biohazard" - 2013' +--- diff --git a/src/content/frc/team-photos/2014.md b/src/content/frc/team-photos/2014.md new file mode 100644 index 00000000..c32724f8 --- /dev/null +++ b/src/content/frc/team-photos/2014.md @@ -0,0 +1,6 @@ +--- +year: 2014 +photo: ../../../assets/team/frc/2014.webp +alt: The Biohazard team in 2014 +caption: '"Biohazard" - 2014' +--- diff --git a/src/content/frc/team-photos/2015.md b/src/content/frc/team-photos/2015.md new file mode 100644 index 00000000..cd1c815b --- /dev/null +++ b/src/content/frc/team-photos/2015.md @@ -0,0 +1,6 @@ +--- +year: 2015 +photo: ../../../assets/team/frc/2015.webp +alt: The Biohazard team in 2015 +caption: '"Biohazard" - 2015' +--- diff --git a/src/content/frc/team-photos/2016.md b/src/content/frc/team-photos/2016.md new file mode 100644 index 00000000..8be3b024 --- /dev/null +++ b/src/content/frc/team-photos/2016.md @@ -0,0 +1,6 @@ +--- +year: 2016 +photo: ../../../assets/team/frc/2016.webp +alt: The Biohazard team in 2016 +caption: '"Biohazard" - 2016' +--- diff --git a/src/content/frc/team-photos/2017.md b/src/content/frc/team-photos/2017.md new file mode 100644 index 00000000..6d97e67f --- /dev/null +++ b/src/content/frc/team-photos/2017.md @@ -0,0 +1,6 @@ +--- +year: 2017 +photo: ../../../assets/team/frc/2017.webp +alt: The Biohazard team in 2017 +caption: '"Biohazard" - 2017' +--- diff --git a/src/content/frc/team-photos/2018.md b/src/content/frc/team-photos/2018.md new file mode 100644 index 00000000..f87049ad --- /dev/null +++ b/src/content/frc/team-photos/2018.md @@ -0,0 +1,6 @@ +--- +year: 2018 +photo: ../../../assets/team/frc/2018.webp +alt: The Biohazard team in 2018 +caption: '"Biohazard" - 2018' +--- diff --git a/src/content/frc/team-photos/2019.md b/src/content/frc/team-photos/2019.md new file mode 100644 index 00000000..7e2871f0 --- /dev/null +++ b/src/content/frc/team-photos/2019.md @@ -0,0 +1,6 @@ +--- +year: 2019 +photo: ../../../assets/team/frc/2019.webp +alt: The Biohazard team in 2019 +caption: '"Biohazard" - 2019' +--- diff --git a/src/content/frc/team-photos/2020.md b/src/content/frc/team-photos/2020.md new file mode 100644 index 00000000..53e02341 --- /dev/null +++ b/src/content/frc/team-photos/2020.md @@ -0,0 +1,6 @@ +--- +year: 2020 +photo: ../../../assets/team/frc/2020.webp +alt: The Biohazard team in 2020 +caption: '"Biohazard" - 2020' +--- diff --git a/src/content/frc/team-photos/2022.md b/src/content/frc/team-photos/2022.md new file mode 100644 index 00000000..10b2f9e2 --- /dev/null +++ b/src/content/frc/team-photos/2022.md @@ -0,0 +1,6 @@ +--- +year: 2022 +photo: ../../../assets/team/frc/2022.webp +alt: The Biohazard team in 2022 +caption: '"Biohazard" - 2022' +--- diff --git a/src/content/frc/team-photos/2023.md b/src/content/frc/team-photos/2023.md new file mode 100644 index 00000000..7aa4361b --- /dev/null +++ b/src/content/frc/team-photos/2023.md @@ -0,0 +1,6 @@ +--- +year: 2023 +photo: ../../../assets/team/frc/2023.webp +alt: The Biohazard team in 2023 +caption: '"Biohazard" - 2023' +--- diff --git a/src/content/frc/team-photos/2024.md b/src/content/frc/team-photos/2024.md new file mode 100644 index 00000000..4ec6107e --- /dev/null +++ b/src/content/frc/team-photos/2024.md @@ -0,0 +1,6 @@ +--- +year: 2024 +photo: ../../../assets/team/frc/2024.webp +alt: The Biohazard team in 2024 +caption: '"Biohazard" - 2024' +--- diff --git a/src/content/frc/team-photos/2025.md b/src/content/frc/team-photos/2025.md new file mode 100644 index 00000000..aef79330 --- /dev/null +++ b/src/content/frc/team-photos/2025.md @@ -0,0 +1,6 @@ +--- +year: 2025 +photo: ../../../assets/team/frc/2025.webp +alt: The Biohazard team in 2025 +caption: '"Biohazard" - 2025' +--- diff --git a/src/content/frc/team-photos/2026.md b/src/content/frc/team-photos/2026.md new file mode 100644 index 00000000..dcb7526b --- /dev/null +++ b/src/content/frc/team-photos/2026.md @@ -0,0 +1,6 @@ +--- +year: 2026 +photo: ../../../assets/team/frc/2026.webp +alt: The Biohazard team in 2026 +caption: '"Biohazard" - 2026' +--- diff --git a/src/content/sponsors/fives.md b/src/content/sponsors/fives.md new file mode 100644 index 00000000..c769a9b0 --- /dev/null +++ b/src/content/sponsors/fives.md @@ -0,0 +1,8 @@ +--- +name: Fives +level: Platinum +url: https://www.fivesgroup.com/ +logo: ../../assets/sponsors/fives.svg +active: false +since: 2022 +--- diff --git a/src/content/sponsors/jlg.md b/src/content/sponsors/jlg.md new file mode 100644 index 00000000..84cdb05d --- /dev/null +++ b/src/content/sponsors/jlg.md @@ -0,0 +1,7 @@ +--- +name: JLG +level: Platinum +url: https://www.jlg.com/ +logo: ../../assets/sponsors/jlg.svg +since: 2013 +--- diff --git a/src/content/sponsors/journalytic.md b/src/content/sponsors/journalytic.md new file mode 100644 index 00000000..9b80cf6f --- /dev/null +++ b/src/content/sponsors/journalytic.md @@ -0,0 +1,7 @@ +--- +name: Journalytic +level: Gold +url: https://journalytic.com/ +logo: ../../assets/sponsors/journalytic.svg +since: 2024 +--- diff --git a/src/content/sponsors/manitowoc.md b/src/content/sponsors/manitowoc.md new file mode 100644 index 00000000..375da173 --- /dev/null +++ b/src/content/sponsors/manitowoc.md @@ -0,0 +1,7 @@ +--- +name: Manitowoc +level: Bronze +url: https://www.manitowoc.com/ +logo: ../../assets/sponsors/manitowoc.png +since: 2024 +--- diff --git a/src/content/sponsors/orrstown.md b/src/content/sponsors/orrstown.md new file mode 100644 index 00000000..e66a649e --- /dev/null +++ b/src/content/sponsors/orrstown.md @@ -0,0 +1,7 @@ +--- +name: Orrstown +level: Bronze +url: https://www.orrstown.com/ +logo: ../../assets/sponsors/orrstown.png +since: 2024 +--- diff --git a/src/content/sponsors/the-workshope.md b/src/content/sponsors/the-workshope.md new file mode 100644 index 00000000..2dae0ce1 --- /dev/null +++ b/src/content/sponsors/the-workshope.md @@ -0,0 +1,7 @@ +--- +name: The WorkShope +level: Gold +url: https://www.theworkshope.com/ +logo: ../../assets/sponsors/workshope.png +since: 2021 +--- diff --git a/src/content/sponsors/vfw.md b/src/content/sponsors/vfw.md new file mode 100644 index 00000000..4f70753e --- /dev/null +++ b/src/content/sponsors/vfw.md @@ -0,0 +1,9 @@ +--- +name: VFW +sub: Chambersburg VFW Post 1599 +level: Platinum +url: https://www.vfw1599.org/ +logo: ../../assets/sponsors/vfw.png +active: false +since: 2022 +--- diff --git a/src/content/sponsors/volvo.md b/src/content/sponsors/volvo.md new file mode 100644 index 00000000..c493d659 --- /dev/null +++ b/src/content/sponsors/volvo.md @@ -0,0 +1,7 @@ +--- +name: Volvo +level: Gold +url: https://www.volvogroup.com/ +logo: ../../assets/sponsors/volvo.png +since: 2024 +--- diff --git a/src/content/sponsors/wellspan.md b/src/content/sponsors/wellspan.md new file mode 100644 index 00000000..f168b156 --- /dev/null +++ b/src/content/sponsors/wellspan.md @@ -0,0 +1,8 @@ +--- +name: Wellspan +level: Platinum +url: https://www.wellspan.org/ +logo: ../../assets/sponsors/wellspan.png +active: false +since: 2022 +--- diff --git a/src/content/sponsors/yb-welding.md b/src/content/sponsors/yb-welding.md new file mode 100644 index 00000000..6a09c771 --- /dev/null +++ b/src/content/sponsors/yb-welding.md @@ -0,0 +1,7 @@ +--- +name: Y.B. Welding +level: Gold +url: https://www.ybwelding.com/ +logo: ../../assets/sponsors/yb-dark.png +since: 2018 +--- diff --git a/src/data/events.ts b/src/data/events.ts new file mode 100644 index 00000000..dda3f524 --- /dev/null +++ b/src/data/events.ts @@ -0,0 +1,15 @@ +import { type PagePath, programs } from "@/data/site"; + +/** + * Where each `events` entry is published: entry id to the path of the route that renders it. + * + * An entry cannot know its own URL — `/openhouse` and `/programs/frc/kickoff` are hand-written + * routes, not a `[slug]` — and `/llms.txt` has to link the live ones. The key is a plain string + * so the check happens against the collection: an event with no route here throws at build. CI + * catches the other direction, a path that stops matching its route, by link-checking + * `dist/llms.txt` alongside the HTML. + */ +export const eventRoutes: ReadonlyMap = new Map([ + ["openhouse", "/openhouse/"], + ["frc-kickoff", `${programs.frc.href}kickoff/`], +]); diff --git a/src/data/ogCards.ts b/src/data/ogCards.ts new file mode 100644 index 00000000..07ffd223 --- /dev/null +++ b/src/data/ogCards.ts @@ -0,0 +1,40 @@ +import type { OgCard } from "@/lib/seo"; + +import ogDonate from "@/assets/og/donate.jpg"; +import ogEvents from "@/assets/og/events.jpg"; +import ogFll from "@/assets/og/fll.jpg"; +import ogFrc from "@/assets/og/frc.jpg"; +import ogPrograms from "@/assets/og/programs.jpg"; +import ogSponsors from "@/assets/og/sponsors.jpg"; + +/** + * The social cards a page can carry in place of the site-wide default (`site.ogImage`), each + * with the alt text that describes it. `tools/assets/og-cards.ts` renders the images; a card's + * alt changes in the same commit as its photo or title there. + */ +export const ogCards = { + donate: { + image: ogDonate, + alt: "The South Central STEM Collective logo over a rack of drill bits, captioned “Donate”", + }, + events: { + image: ogEvents, + alt: "The South Central STEM Collective logo over a collage of students at work, captioned “Events”", + }, + fll: { + image: ogFll, + alt: "The South Central STEM Collective logo over a student-built LEGO robot, captioned “FIRST LEGO League”", + }, + frc: { + image: ogFrc, + alt: "The South Central STEM Collective logo over the Biohazard drive team at competition, captioned “FIRST Robotics Competition”", + }, + programs: { + image: ogPrograms, + alt: "The South Central STEM Collective logo over students working in the workshop, captioned “Our programs”", + }, + sponsors: { + image: ogSponsors, + alt: "The South Central STEM Collective logo over a workbench of parts and notes, captioned “Sponsors”", + }, +} as const satisfies Record; diff --git a/src/data/programPhotos.ts b/src/data/programPhotos.ts new file mode 100644 index 00000000..6c848494 --- /dev/null +++ b/src/data/programPhotos.ts @@ -0,0 +1,26 @@ +import type { ImageMetadata } from "astro"; + +import fllRobots from "@/assets/fll/lego-robots.webp"; +import driveTeam from "@/assets/frc/frc-driveteam.webp"; +import students from "@/assets/sc2/students.webp"; +import { type ProgramKey, programs } from "@/data/site"; + +/** + * Each program's representative photograph and its alt text: the program cards, the program + * heroes, and the fallback hero of an event that carries no photo of its own. Kept apart from + * `site.ts`, which stays import-free so Node tools can load it directly. + */ +export const programPhotos = { + sc2: { + image: students, + alt: "South Central STEM Collective students at work in the Chambersburg workspace", + }, + frc: { + image: driveTeam, + alt: `Team ${String(programs.frc.teamNumber)} ${programs.frc.teamName}'s drive team standing with their robot at competition`, + }, + fll: { + image: fllRobots, + alt: "LEGO® robots built by South Central STEM Collective students", + }, +} as const satisfies Record; diff --git a/src/data/site.ts b/src/data/site.ts new file mode 100644 index 00000000..c108c671 --- /dev/null +++ b/src/data/site.ts @@ -0,0 +1,309 @@ +/** + * Every fact about the organization that is not page content, in one place. + * + * Rule of thumb: if it belongs in prose or would change per season, it belongs in + * `src/content/`; if it is an identifier, a URL, or an org fact, it belongs here. + */ + +/** The workspace's street line, which is also its mailing address. */ +const workspaceStreet = "20 South Main Street"; + +export const site = { + /** Never abbreviated in headings, and `STEM` is always capitalized (DESIGN.md §1). */ + name: "South Central STEM Collective", + /** Prose only, and only where the full name also appears on the page. */ + shortName: "SC2", + url: "https://scstem.org", + + /** The 501(c)(3) line, as it appears in the footer and structured data. */ + legal: + "SC2 is a 501(c)(3) non-profit providing STEM opportunities for students in and around Franklin County, PA.", + /** Employer Identification Number — what a donor's giving fund looks the organization up by. */ + ein: "86-2328794", + + description: + "The South Central STEM Collective runs FIRST robotics and hands-on STEM programs for ages 9–18 in Franklin County, Pennsylvania.", + /** `%s` is the page title. The homepage uses the bare site name instead. */ + titleTemplate: "%s | South Central STEM Collective", + + email: "info@scstem.org", + + /** + * The site-wide social card. Dimensions and alt travel with the path so `og:image:width`/ + * `height` are emitted for the fallback too — without them a scraper has to fetch all 466 KB + * just to learn the aspect ratio before it can lay the card out. + */ + ogImage: { + path: "/og/default.jpg", + width: 1200, + height: 630, + alt: + "The South Central STEM Collective logo over students building in the workshop, " + + "captioned “South Central STEM Collective”", + }, + + /** + * The FRC hero's looping footage, in `public/` rather than `src/assets/`: astro:assets has no + * video pipeline, so the encode is a manual ffmpeg step recorded in + * `docs/adr/0006-hero-video-encode.md` and the files ship as-is. + */ + heroVideo: { + mp4: "/video/biohazard/home-video.mp4", + webm: "/video/biohazard/home-video.webm", + }, + + /** The favicon/app-icon set in `public/`, projected into head links, the manifest, and JSON-LD. */ + icons: { + favicon: "/favicon.ico", + svg: "/icon.svg", + appleTouch: "/apple-touch-icon.png", + png192: "/icon-192.png", + png512: "/icon-512.png", + maskable512: "/icon-maskable-512.png", + }, + + location: { + /** How the workspace is named where a venue needs a name — an event's location, chiefly. */ + name: "South Central STEM Collective Workspace", + workspace: `${workspaceStreet}, Downtown Chambersburg`, + /** The street line alone, as a postal address takes it. */ + street: workspaceStreet, + locality: "Chambersburg", + region: "PA", + postalCode: "17201", + country: "US", + /** The service area, as used in copy and structured data. */ + areaServed: "Franklin County, Pennsylvania", + /** Everything we run happens here, so the calendar shows its times in this zone. */ + timeZone: "America/New_York", + }, + + social: { + facebook: "https://go.scstem.tech/facebook", + linkedin: "https://go.scstem.tech/linkedin", + github: "https://go.scstem.tech/github", + }, + + /** Where the `social` shortlinks resolve — schema.org `sameAs` matches an entity on these. */ + socialProfiles: { + facebook: "https://www.facebook.com/SCSTEM", + linkedin: "https://www.linkedin.com/company/scstem/", + github: "https://github.com/SCSTEM", + }, + + /** External destinations. */ + urls: { + donate: "https://www.paypal.com/US/fundraiser/charity/4486755", + wishlist: "https://wiki.scstem.org/donations/wishlist", + sponsorPacket: "https://wiki.scstem.org/donations/packet", + /** The bare form; `/get-involved` appends `?embedded=true` for the iframe it renders. */ + getInvolvedForm: + "https://docs.google.com/forms/d/e/1FAIpQLScTjT3LHFAq1mOfKFztgMOpUT8hFWz81dYlaaDa4B8lG6yr2Q/viewform", + wiki: "https://wiki.scstem.org", + directions: "https://wiki.scstem.org/workspace/#directions", + /** The parent organization every program belongs to. */ + firstInspires: "https://www.firstinspires.org/", + moreThanRobots: "https://info.firstinspires.org/morethanrobots", + /** The team's introduction video, linked out rather than embedded (no third-party player). */ + teamVideo: "https://youtu.be/147CgudTur8", + }, + + /** Public Google Calendar IDs. `functions/api/calendar/[name].ts` mirrors them verbatim. */ + calendars: { + frc: "Y19hYjljNWJlYTEwODgyYzAxYTAxOGNiZDUxYWIyMzcwYmY4NDk5NDZiZTRlMjUzNTAwZmZmMWQxMGZkY2M4NjFhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20", + sc2: "Y19wcDlkOXRrbGRrbThmdXZtcjMyZTBwZTgxc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t", + }, + + analytics: { + /** + * GA4, loaded by `Analytics.astro` as a plain gtag snippet after `load`. Cloudflare Web + * Analytics' token comes through `PUBLIC_CF_BEACON_TOKEN` in `astro.config.ts`'s env schema, + * set in the Pages dashboard. + */ + ga4MeasurementId: "G-3TPD3DLYBR", + }, +} as const; + +/** + * A page's site-root-relative path. Pages build to `/index.html`, which Cloudflare Pages + * serves at the trailing-slash form and 308-redirects to from the bare one, so an internal link + * carries the slash and the type rejects one without it. + */ +export type PagePath = "/" | `/${string}/`; + +/** A program's facts. Flat, like the content schemas. */ +interface Program { + /** The accent the program's pages wear; yellow when absent. */ + accent?: Exclude; + ages?: string; + href: PagePath; + name: string; + shortName: string; + /** The year the team formed. */ + since?: number; + teamName?: string; + /** The team's FIRST-issued number. */ + teamNumber?: number; +} + +/** + * @public + * + * The action accents `src/styles/tokens.css` declares (DESIGN.md §2). Yellow is the default and + * takes no attribute; the others are `data-accent` values. `@/lib/tokens` fails the build if the + * stylesheet and this list disagree. + */ +export const ACCENTS = ["yellow", "green", "orange"] as const; + +/** @public */ +export type Accent = (typeof ACCENTS)[number]; + +/** + * The programs the site is organized around; the key is the + * `program` enum in `src/content.config.ts`. Typed as a total record over `ProgramKey`, so a + * program accepted by the schema but missing here is a compile error rather than an `undefined` + * that surfaces as a runtime throw on a page. + */ +export const PROGRAM_KEYS = ["sc2", "frc", "fll"] as const; + +/** @public */ +export type ProgramKey = (typeof PROGRAM_KEYS)[number]; + +/** @public */ +export const programs = { + /** The org-wide program: events and pages that are not FRC- or FLL-specific. */ + sc2: { + name: "South Central STEM Collective", + shortName: "SC2", + href: "/programs/", + }, + frc: { + name: "FIRST Robotics Competition", + shortName: "FRC", + teamName: "Biohazard", + teamNumber: 4050, + /** Formed as a 4-H club, the team is also where the organization's own history starts. */ + since: 2012, + ages: "14–18", + accent: "green", + href: "/programs/frc/", + }, + fll: { + name: "FIRST LEGO League", + shortName: "FLL", + ages: "9–16", + accent: "orange", + href: "/programs/fll/", + }, +} as const satisfies Record; + +/** + * @public + * + * A program that wears its own accent; the org-wide `sc2` look is the default yellow. + */ +export type ProgramTheme = Exclude; + +/** A destination and the text that links to it. */ +interface Route { + readonly href: PagePath; + readonly label: string; +} + +/** A disclosure panel's row: the link plus the one-line description under it. */ +interface PanelLink extends Route { + readonly name: string; +} + +export interface NavLink extends Route { + /** Disclosure panel this entry opens in the header, in addition to being a link. */ + readonly panel?: readonly PanelLink[]; + /** + * Which chrome shows this entry, and how. The desktop header and the mobile sheet carry + * deliberately different sets (DESIGN.md §5) — Donate is a header link (`header`) and a + * pinned sheet button (`sheet-cta`), Calendar lives in the desktop Programs panel and at + * sheet top level — so the split is data, not a discrepancy between two hand-written lists. + */ + readonly surfaces: readonly ("header" | "sheet" | "sheet-cta")[]; +} + +/** + * Entries that other surfaces link to by name — the footer's columns, a page's call to action — + * are named here and spread into `primary` below, so a link never has to be found by index or + * re-typed at the call site. + */ +const calendar = { label: "Calendar", href: "/calendar/sc2/" } as const; +const donate = { label: "Donate", href: "/donate/" } as const; +const sponsors = { label: "Sponsors", href: "/sponsors/" } as const; +const about = { label: "About", href: "/about/" } as const; +const programsHub = { label: "Programs", href: programs.sc2.href } as const; +const robots = { label: "Robots", href: `${programs.frc.href}robots/` } as const; +const contact = { label: "Contact", href: "/contact/" } as const; + +/** The Programs disclosure in the desktop header. */ +const programsPanel = [ + { label: programs.fll.shortName, href: programs.fll.href, name: programs.fll.name }, + { label: programs.frc.shortName, href: programs.frc.href, name: programs.frc.name }, + { ...robots, name: "Our competition robots" }, + { ...calendar, name: "Upcoming events" }, +] as const; + +/** + * @public Consumed by the app shell and the 404 page. + * + * The site's route inventory. Adding a route is one edit here, not four across two components. + * The footer's columns stay curated in `Footer.astro` — they mix routes with external URLs and + * use full program names — but reference the entries here rather than re-typing an href. + */ +export const nav = { + primary: [ + { ...about, surfaces: ["header", "sheet"] }, + { ...programsHub, surfaces: ["header", "sheet"], panel: programsPanel }, + { ...sponsors, surfaces: ["header", "sheet"] }, + { ...calendar, surfaces: ["sheet"] }, + { ...donate, surfaces: ["header", "sheet-cta"] }, + ], + + about, + calendar, + contact, + donate, + programs: programsHub, + robots, + sponsors, + + /** The primary call to action, in the header and at the foot of the mobile sheet. */ + cta: { label: "Get involved", href: "/get-involved/" }, +} as const satisfies { + about: Route; + calendar: Route; + contact: Route; + cta: Route; + donate: Route; + primary: readonly NavLink[]; + programs: Route; + robots: Route; + sponsors: Route; +}; + +/** @public Consumed by `SocialLinks` and the footer's link columns. */ +export const socials = [ + { + label: "Facebook", + href: site.social.facebook, + profile: site.socialProfiles.facebook, + icon: "logo--facebook", + }, + { + label: "LinkedIn", + href: site.social.linkedin, + profile: site.socialProfiles.linkedin, + icon: "logo--linkedin", + }, + { + label: "GitHub", + href: site.social.github, + profile: site.socialProfiles.github, + icon: "logo--github", + }, +] as const; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 00000000..b0ff5f4e --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,103 @@ +--- +import type { Placement, SeoProps } from "@/lib/seo"; + +import Analytics from "@/components/Analytics.astro"; +import JsonLd from "@/components/JsonLd.astro"; +import Seo from "@/components/Seo.astro"; +import DrawingFrame from "@/components/ui/DrawingFrame.astro"; +import Footer from "@/components/ui/Footer.astro"; +import Navbar from "@/components/ui/Navbar.astro"; +import { programs, site } from "@/data/site"; +import { breadcrumbs, organization } from "@/lib/jsonld"; +import "@/styles/global.css"; + +/** + * The chrome every page shares. SEO correctness is built into the signature rather than left to + * each page: `title` and `description` are required, so a page cannot ship without them. + * + * The props are `Seo`'s, forwarded to it; `theme` names the page's program, whose accent lands on + * `` as `data-accent` (`src/styles/tokens.css`). `trail` is the page's breadcrumb trail, emitted + * here so no page hand-writes its own `BreadcrumbList`. + * + * Slots: the default slot is the page body; `head` takes per-page structured data. + */ +type Props = Omit & + Placement & { + /** The blueprint register (DESIGN.md §2): for 404s, special pages, and the wiki. */ + register?: "blueprint"; + }; + +const { trail = [], register, ...seo } = Astro.props; +const { theme } = seo; +const accent = theme === undefined ? undefined : programs[theme].accent; +--- + + + + + + + + + + + + + + + + + + {trail.length > 0 && } + + + + + + Skip to content + + +
+ {register === "blueprint" && } + +
+