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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ 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
Expand Down
29 changes: 29 additions & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 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$
5 changes: 3 additions & 2 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
"entry": [
"src/pages/**",
"functions/**",
// Exports the font URLs BaseLayout preloads; wired up in Phase 05.
"src/styles/fonts.ts",
// 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).
Expand Down
77 changes: 71 additions & 6 deletions plan/05-app-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Rebuild (reference `legacy/src/components/Navbar.tsx`, 261 LOC, for link invento
- Links: About / Programs ▾ / Sponsors / Donate + primary "Get involved" button (≥44px target). `aria-current="page"` states.
- **Programs dropdown**: CSS `:focus-within`/popover-attribute disclosure listing FLL, FRC, Robots, Calendar — while the "Programs" link itself navigates to the **`/programs` hub page** (Phase 07), so touch and no-JS users get a real destination. Keyboard reachable, Esc closes.
- Mobile menu: **full-height sheet** — ≥48px rows (About, Programs, Sponsors, Calendar), "Get involved" + "Donate" as large buttons pinned at the bottom; `aria-expanded` toggle script (~20 lines), Esc/backdrop close, body scroll locked while open.
(Backdrop close is not implemented: the sheet is opaque and its nav fills it, so there is no
backdrop to tap — the listener that claimed to do this could never fire. See the notes below.)

### 5. Footer — `src/components/ui/Footer.astro`

Expand All @@ -54,9 +56,72 @@ Point `src/pages/index.astro` (still placeholder) at BaseLayout so the shell is

## Acceptance criteria

- [ ] Omitting `description` on a page using BaseLayout is a type error.
- [ ] View-source on any page: title, description, canonical, OG/Twitter set, NGO JSON-LD present and valid (paste into Google Rich Results test manually once).
- [ ] Navbar: keyboard-only operation works (tab order, Esc, aria-expanded state); usable at 360px; zero JS beyond the disclosure toggle.
- [ ] Skip link functions; landmarks: exactly one `header`, `main`, `footer`, labeled `nav`s.
- [ ] Web manifest + icons valid (Lighthouse PWA-adjacent audits pass; no console 404s).
- [ ] `pnpm check && pnpm build` green.
- [x] Omitting `description` on a page using BaseLayout is a type error — verified: `Property 'description' is missing in type '{ children: any; title: string; }' but required in type 'Props'`.
- [x] View-source on any page: title, description, canonical, full OG/Twitter set, and NGO JSON-LD present. Homepage also emits WebSite. (Rich Results test is blocked from this environment — `validator.schema.org` and `search.google.com` are not reachable — so that one manual check is outstanding; noted for Phase 10, which already tasks validating every JSON-LD shape.)
- [x] Navbar keyboard-only operation: skip link is the first tab stop and becomes visible on focus; the Programs panel is keyboard reachable and **Esc closes it**; the mobile toggle is 44×44, flips `aria-expanded`, locks body scroll, and Esc closes it and returns focus. Usable at 360px; the only script is the ~30-line sheet toggle. Verified in a real browser: the panel opens on click, closes on Esc and on light-dismiss, and exposes `expanded` in the accessibility tree; the sheet swaps its icon, locks scroll, marks the page behind it `inert` (only the close button stays focusable), and closes when the viewport crosses `md`.
- [x] Skip link functions; landmarks are exactly one `header`, `main`, `footer`, and three labeled `nav`s (Main, Mobile, Footer).
- [x] Web manifest + icons valid, no console 404s: `favicon.ico` carried over, `icon.svg` from the square mark, and 180/192/512 PNGs rendered from it.
- [x] `pnpm check && pnpm build` green.

### Notes and deviations

- **The Programs panel is a native `popover`, not a `:focus-within` disclosure.** The brief named
both options; `:focus-within` has no dismissal path, so it could not satisfy this phase's own
"Esc closes" requirement (§9: "menus close on Esc") and missed WCAG 2.2 SC 1.4.13. The
`popover` attribute supplies Esc, light-dismiss, top-layer placement and the `expanded` state
with no script — rung 1 of the primitives README's interactivity ladder. The `/programs` link
keeps its real destination; the chevron beside it is the invoker.
- **Nav inventory lives in `src/data/site.ts`.** The header, the mobile sheet, the footer and the
404 page each declared their own list, and they had already diverged. The desktop/sheet split is
now the `surfaces` field on each entry, so it reads as a decision rather than an omission.
- **`ui-link`, `external-link` and `container-page` are utilities.** DESIGN.md §8's "UI links may
drop underline at rest but underline on hover/focus" was half-implemented: twelve call sites
wrote `no-underline` and no chrome link underlined on hover. Same for §8's external-link icon and
§5's container gutters, which were a class string repeated at five sites.
- **The sheet marks the page behind it `inert`.** It is opaque and covers the viewport, so Tab past
its last item used to walk every invisible link in `main` and `footer`.
- **The header lockup animates `scale`, not `height`.** `height` is not a compositor property, so
the old keyframe relayouted the sticky header and reflowed the page on every scroll frame
through the first 120px — against §6's "only `opacity` and `transform` animate".
- **`site.webmanifest` is a generated route.** It restated the org name, a third variant of the
description, and both brand colours as literals. `src/pages/site.webmanifest.ts` builds it from
`site.ts`, and its maskable entry points at a separate padded render: the plain 512 spans ~88% of
its box, so a launcher mask clipped the gear teeth — reusing it was worse than declaring no
maskable icon at all.

- **The mobile sheet has no backdrop dismissal.** The brief asks for "Esc/backdrop close". The
sheet is `fixed inset-0` and opaque, and its `<nav>` is `h-full` — exactly the same box — so
there is no backdrop, visible or hittable, and the `event.target === sheet` listener could never
fire. Esc and the toggle are the two ways out. Reshaping the sheet into a narrower column just to
create a tap target would be a design change, not a bug fix, so it is recorded here instead.
- **The offline link check needed an interim exclusion list.** The chrome built here links to ten
routes Phase 07 builds (`/about`, `/sponsors`, `/contact`, `/donate`, `/get-involved`,
`/programs`, `/programs/fll`, `/programs/frc`, `/programs/frc/robots`, `/calendar/sc2`), so
lychee failed on every page carrying the header and footer — a correct finding about an
unavoidable ordering problem, not a tooling bug. `.lycheeignore` excludes exactly those ten and
nothing else, and `tools/checks/stale-link-ignores.mjs` fails CI if an excluded route has since
been built, so the list cannot outlive its purpose. Phase 07's acceptance now requires the file
to be gone.

- **`sharp` became a direct dependency.** The first real `<Image>` use made astro:assets need
it; `pnpm-workspace.yaml`'s `allowBuilds: sharp` already anticipated this.
- **Icons were generated with sharp, not committed by hand.** `apple-touch-icon.png` is flattened
onto the brand ground (`#262626`) because iOS ignores transparency and would otherwise composite
it on black.
- **`exactOptionalPropertyTypes` forced a signature choice.** A layout that *forwards* optional
props passes explicit `undefined`, which that flag treats as distinct from an absent prop. The
receiving props are therefore declared `?: T | undefined` rather than filtering props at each
call site.
- **The OG image is legacy's `opengraph-image.png`**, moved to `public/og/default.png`. Phase 10
produces the curated per-section set; inventing a template now would be work Phase 10 redoes.
- **Footer content grew slightly beyond legacy's inventory**: legacy had two link columns
(Support us, Find us online) and pointed Donate at `/wiki/donations`. This version points Donate
at the real `/donate` page and adds a Programs column (FLL, FRC, Calendar), because the footer
is the only place a program link appears once the mobile sheet is closed. Copy is otherwise
verbatim, including the 501(c)(3) line.
- **The header condense uses a scroll-driven animation** behind `@supports` and
`prefers-reduced-motion`. Where unsupported the header keeps full height, which is the correct
fallback.
- `src/lib/jsonld.ts`, `src/data/site.ts`, and `ProgramLayout.astro` are registered as knip
entries: they are the shell's API and get consumed by Phases 07-08. Each carries a comment
saying so, so the entries can be removed when they are genuinely referenced.
1 change: 1 addition & 0 deletions plan/07-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ 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.
- [ ] 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.
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes.
Binary file added public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icon-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
15 changes: 15 additions & 0 deletions src/components/JsonLd.astro
Original file line number Diff line number Diff line change
@@ -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;
---

<script is:inline type="application/ld+json" set:html={JSON.stringify(data)} />
90 changes: 90 additions & 0 deletions src/components/Seo.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
import type { ImageMetadata } from "astro";

import { site } from "@/data/site";

/**
* Everything that goes in `<head>` 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.
*
* No `keywords` meta: legacy carried a 22-term list, and search engines have ignored the signal
* for years.
*/
interface Props {
title: string;
/** 50–160 characters, written for a human deciding whether to click. */
description: string;
/**
* Defaults to the site-wide card image. These are declared `| undefined` because layouts
* forward them straight through, and `exactOptionalPropertyTypes` treats an explicitly
* passed `undefined` as distinct from an absent prop.
*/
ogImage?: ImageMetadata | string | undefined;
ogType?: "website" | "article" | undefined;
/** Defaults to this page's own URL. Set it when two routes serve the same content. */
canonical?: URL | string | undefined;
noindex?: boolean | undefined;
}

const {
title,
description,
ogImage = site.ogImage.path,
ogType = "website",
canonical,
noindex = false,
} = 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);

const canonicalUrl = new URL(canonical ?? Astro.url.pathname, site.url);

/**
* The default card is a fixed-size file, so its dimensions travel with its path in `site.ts` —
* this block used to be dead for every page, since `imageDimensions` was only set for an imported
* `ImageMetadata` and no page passes one. Without the pair a scraper must fetch the whole 466 KB
* image just to learn its aspect ratio.
*/
const imageUrl = new URL(typeof ogImage === "string" ? ogImage : ogImage.src, site.url);

/** Known for an imported asset, and for the site default; unknown for any other bare path. */
const imageSize =
typeof ogImage === "string"
? ogImage === site.ogImage.path
? { width: site.ogImage.width, height: site.ogImage.height }
: undefined
: { width: ogImage.width, height: ogImage.height };
---

<title>{fullTitle}</title>
<meta content={description} name="description" />
<link href={canonicalUrl.href} rel="canonical" />

{noindex && <meta content="noindex, nofollow" name="robots" />}

<meta content={fullTitle} property="og:title" />
<meta content={description} property="og:description" />
<meta content={ogType} property="og:type" />
<meta content={canonicalUrl.href} property="og:url" />
<meta content={site.name} property="og:site_name" />
<meta content="en_US" property="og:locale" />
<meta content={imageUrl.href} property="og:image" />
<meta content={`${site.name} logo over a photo of the team`} property="og:image:alt" />
{
imageSize !== undefined && (
<>
<meta content={String(imageSize.width)} property="og:image:width" />
<meta content={String(imageSize.height)} property="og:image:height" />
</>
)
}

Comment thread
CS-5 marked this conversation as resolved.
<meta content="summary_large_image" name="twitter:card" />
<meta content={fullTitle} name="twitter:title" />
<meta content={description} name="twitter:description" />
<meta content={imageUrl.href} name="twitter:image" />

<meta content={site.chrome.themeColor} name="theme-color" />
Loading
Loading