diff --git a/.gitignore b/.gitignore index e5086d2..3539b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # production /build +/.prerender-* # misc .DS_Store diff --git a/README.md b/README.md index 50fe8ec..7c997e1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # exploretech.la -The website for [exploretech.la](https://www.exploretech.la/): a React single-page app written in TypeScript, built with Vite and Tailwind, and hosted on GitHub Pages. +The website for [exploretech.la](https://www.exploretech.la/), built with React, TypeScript, Vite, and Tailwind. GitHub Pages serves generated HTML for each published route; React hydrates it for navigation and interactive controls. ## Development @@ -23,18 +23,20 @@ The development server runs at `http://127.0.0.1:3000` and fails clearly if that | `src/content/teams.ts` | Ordered team sections and the role each person holds in them | A roster or job title changes | | `src/content/speakers.ts` | Ordered historical speaking roles referencing the people registry | A past speaker's displayed title or order changes | | `src/content/events/` | One module per event year, plus `index.ts` listing them | A schedule, workshop, map, waiver or FAQ changes | +| `src/content/participation.ts` | Current program notices, school/volunteer/partner inquiry links, UCLA updates link | Verified program information or a contact destination changes | +| `src/content/pages.ts` | Published routes and their titles, descriptions, and canonical URLs | Page metadata changes | | `src/content/sections.ts` | Home page section ids used by header hash links | A home page anchor is added or renamed | | `src/features/` | Page rendering and feature-specific home or Ignite copy | A page layout or its local copy changes | | `src/components/` | Shared UI and media only | Something is genuinely used by more than one feature | | `src/styles/` | `theme.css` entry, tokens, reset, and per-area layers | The design changes | -| `src/constants/optimizedImages.ts` | Generated image map — never edit by hand | Never; run `npm run images` | +| `src/constants/optimizedImages.ts` | Generated image map, never edit by hand | Never; run `npm run images` | | `src/static/` | Original photographs and documents, and generated WebP output | You add a source image or document | | `scripts/` | Image pipeline, content check, browser smoke, test server, parity tools | Tooling changes | | `src/app/` | Entry, routes, analytics and scroll effects | A route is added or removed | Content is central on purpose: authors change `src/content` without knowing which component renders it, and features read content without owning it. -Home carousel and sponsor records live in `src/features/home/content/`. Team and speaker identities always live in the shared people registry. +Home gallery and sponsor records live in `src/features/home/content/`. Team and speaker identities always live in the shared people registry. ## Contributor tasks @@ -50,11 +52,11 @@ Edit the one entry in `src/content/people.ts`. Every team that lists them picks }, ``` -A person with no portrait simply omits `image`; their card still renders with name and role. +A person with no portrait omits `image`; their card shows initials, name, and role. ### Change a roster or a role -Edit `src/content/teams.ts`. Each member is a reference plus the title held *in that section*, so the same person can appear in several teams with different titles. Card order follows the array; tab order follows the section order. +Edit `src/content/teams.ts`. Each member is a reference plus the title held *in that section*, so the same person can appear in several teams with different titles. Card order follows the array. The department links and phone selector follow the section order. ```ts { personId: "benjamin-garcia", title: "Web Dev Lead" }, @@ -62,18 +64,32 @@ Edit `src/content/teams.ts`. Each member is a reference plus the title held *in `personId` is checked against the registry at compile time and again by `npm run content:check`. -### Change an event +### Publish verified program information -Edit the year module under `src/content/events/` (`2022.ts` is served at `/resources`, `2023.ts` at `/resources2023`, `2026.ts` at `/resources2026`, and `2021.ts` is the registration page at `/register`). Schedules, workshop cards, maps, waivers, FAQ entries and the feedback link are all data there. Archived workshop videos hold a bare YouTube id, never a watch URL, and always render behind the click-to-load facade. +Edit `src/content/participation.ts` for notices and inquiry actions shared by the home page, `/events`, `/get-involved`, and Ignite. Until the organization confirms new details, keep the explicit unpublished-details notice and the working email inquiry. + +Do not infer that registration is open or closed from an old form. Confirm dates, eligibility, transportation, meals, deadlines, and application destinations with the organization before publishing them. Keep school participation, UCLA volunteering, and partnership inquiries separate. The existing UCLA mailing list is for updates, not a school application or a volunteer application. + +Use the audience sections at `/get-involved#schools`, `#volunteer`, and `#partners` as the public entry points. Their mailto links use the shared contact address and a topic-specific subject. Check the destination without sending a message. + +### Change an archived event + +Edit the year module under `src/content/events/`. `/register` is the 2021 archive, `/resources` is the 2022 archive, and `/resources2023` and `/resources2026` serve their named years. Keep year labels consistent in headings and archive links. + +Schedules, workshop cards, maps, waivers, and historical FAQ entries live in these modules. Do not restore past registration, Zoom attendance, or feedback collection actions. Archived workshop videos use a bare YouTube id and their actual workshop title, and load only after activation. + +Local downloads use `DocumentLink` records with `name`, imported `src`, and `file: { format, bytes }`. Use the file's actual byte size, not a rounded display value. The shared component displays the format, decimal MB or kB size, and new-tab notice. Keep old PDFs and maps reachable at their existing URLs. + +The 2026 workshop session, time, and room fields come from the archived program. Change them only against that source. Workshop details use native disclosures, and filtering must leave a visible result count and a way to clear the query. ### Add an event year 1. Add `src/content/events/.ts` exporting an `EventContent`. -2. Register it in `src/content/events/index.ts`: the module in `EVENTS`, and its path, year and menu label in `EVENT_ROUTES`. - -That is the whole change. The router and the Resources menu are generated from `EVENT_ROUTES`, which is also why the menu label lives there: `/resources` serves the 2022 content under the label "exploretech 2021" it shipped with. +2. Register the module in `EVENTS` and its path, year, and matching archive label in `EVENT_ROUTES`. +3. Add the route's metadata in `src/content/pages.ts` if it is not derived from the event route list. +4. Run the content, build, and browser checks below. Confirm the generated route contains a heading and metadata with JavaScript disabled. -Old years stay published. They are history, not dead code. +`EVENT_ROUTES` supplies the archived event routes and archive links. `/events` is the program hub; a newer archive is not evidence of an upcoming event. ### Add or replace an image @@ -117,11 +133,11 @@ npm run format:check # Prettier (npm run format writes) npm test # Vitest unit and component regressions npm run content:check # References between people, teams, events and their assets npm run images:check # Generated image integrity and budgets -npm run build # Checks images, then builds into build/ +npm run build # Check images, build client assets, generate route HTML npm run preview # Serve the production build locally ``` -`npm run content:check` loads the content modules through Vite, so imported images and documents are resolved the way the app resolves them. It reports missing people, duplicate profiles or roles, shared profile links, missing assets, malformed URLs and video ids, and duplicate routes or workshop titles. It makes no network requests: a syntactically valid but dead external link still passes. +`npm run content:check` loads the content modules through Vite, so imported images and documents resolve as they do in the app. It reports missing people, duplicate profiles or roles, shared profile links, missing assets, malformed URLs and video ids, duplicate routes or workshop titles, mismatched archive years, incorrect download sizes, and broken participation inquiry destinations. TypeScript owns data shapes; this check owns cross-record and filesystem facts. It makes no network requests, so a syntactically valid but dead external link still passes. ### Browser smoke @@ -131,19 +147,33 @@ npm run build npm run test:browser ``` -The suite (`scripts/browser-smoke.spec.ts`, configured in `playwright.config.ts`) runs Chromium against the **built** site served by `scripts/serve-built-site.cjs`, which answers unknown paths with `404.html` exactly as GitHub Pages does. Point it at another build with `SMOKE_ROOT=/path/to/build`, or move it off port 4390 with `SMOKE_PORT`. +For a full cross-engine pass: + +```sh +npx playwright install chromium firefox webkit +npm run test:browser:all +``` + +CI keeps the faster Chromium gate. On macOS, the WebKit keyboard cases use the native Option+Tab link-navigation shortcut; the tests do not change your system or Safari settings. Geometry checks round to hundredths of a CSS pixel to avoid floating-point reporting noise. + +The suite in `scripts/browser-smoke.spec.ts`, configured in `playwright.config.ts`, runs Chromium against the built site served by `scripts/serve-built-site.cjs`. Known directory routes redirect to a trailing slash with their query preserved and return generated HTML with status 200. Unknown paths and the `/our_team` alias return `404.html` and exercise the Pages restore script. Missing assets return a plain 404, not the app shell. + +Point the suite at another generated build with `SMOKE_ROOT=/path/to/build`, or change port 4390 with `SMOKE_PORT`. The server starts fresh for each run to avoid accidentally checking a different build. + +Every cross-origin request is aborted and recorded. The suite does not contact analytics or video vendors, send email, or submit forms. It covers: -Every cross-origin request is aborted and recorded, so the suite is offline and reaches no analytics or video vendor. It covers: +- published routes, route-specific HTML and metadata without JavaScript, hydration errors, aliases, and the not-found page; +- query and hash preservation, first-tab skip navigation, phone menu dismissal, pushed-route focus, repeated hash links, and Back scroll restoration; +- the visible phone mission and overflow at 320, 390, 768, 1024, and 1440px; +- school, volunteer, and partner journeys ending at real inquiry links, shared program notices, explicit archives, and obsolete collection actions; +- all seven team grids, deferred portraits, and initials for missing photos; +- keyboard FAQ expansion with named panels, workshop disclosures and filtering, and event shortcuts; +- specific footer and video names, keyboard player activation, focus transfer, and reserved video geometry; +- 44px core controls, primary-action text contrast, and visible keyboard focus; +- download format and byte-size labels against actual served documents; +- manual gallery buttons and status, no autoplay with reduced motion or keyboard focus, slow images, failed images, and stale image loads. -- all 13 routes, the `/our_team` alias, its trailing slash, and the not-found page and title; -- deep links surviving the Pages 404-to-root restore with their query and hash intact; -- the phone menu opening, navigating, closing, and still landing on a hash section; -- a repeated same-hash click scrolling back; -- a pushed route starting at the top while Back leaves scrolling to the browser; -- all seven team sections, eager-then-lazy portraits, and cards with no portrait; -- archived videos requesting nothing before activation, keyboard activation, and unchanged geometry after it; -- every shared PDF and map link answering 200; -- the carousel holding the current slide until a slow one loads, and skipping a broken one without locking up. +These checks do not replace a visual review or a screen-reader pass. Before requesting review, inspect the changed pages at the listed widths, tab through each task, and check new external destinations without submitting anything. Add a case when you fix a bug a user could see; keep it in this one file. @@ -157,7 +187,7 @@ SITE_URL=http://127.0.0.1:4392 OUTPUT_DIR=/tmp/site-after BASELINE_DIR=/tmp/site node scripts/asset-parity.cjs /path/to/baseline/build build ``` -`WIDTHS=390,768,1440` narrows a capture. The default includes every Bootstrap-era breakpoint and its adjacent pixels. Read [the UI canon](docs/ui-canon.md) before changing shared controls or styling. +`WIDTHS=390,768,1440` narrows a capture. The default includes the historical responsive breakpoints and their adjacent pixels. Parity against an old design is not an acceptance gate for an intentional UX change. Read [the UI canon](docs/ui-canon.md) before changing shared controls or styling. ## CI and deployment @@ -171,11 +201,11 @@ node scripts/asset-parity.cjs /path/to/baseline/build build ## Runtime and hosting conventions -- Root `index.html` is the Vite entry and keeps the GitHub Pages query-to-route restoration script; `public/404.html` and `public/CNAME` are copied unchanged. +- Root `index.html` is the Vite entry and keeps the GitHub Pages query-to-route restoration script. `npm run build` runs the client build and `scripts/prerender.mjs`, using `src/app/prerender.tsx` and `src/content/pages.ts` to write each published route's `index.html`. Canonical URLs end with a slash except the root. `public/404.html` and `public/CNAME` remain the hosting fallback and domain configuration. - Public media keep their `static/media/..` URLs so shared PDF, map and image links survive a rebuild. JavaScript and CSS filenames may change. - Styling is Tailwind utilities plus the layered stylesheets in `src/styles`, entered from `theme.css`. Tailwind's Preflight is deliberately not imported: the site's geometry depends on the reset it shipped with, which `src/styles/reset.css` carries explicitly. Do not add `@import "tailwindcss"` back. - Fonts load from a stylesheet link in the HTML rather than a nested CSS import, which prevents an unstyled startup flash in WebKit. -- Initially visible images stay eager; offscreen content is lazy with reserved dimensions. The carousel waits for a selected image and skips failures. Archived videos load only after activation. +- Initially visible images stay eager; offscreen content is lazy with reserved dimensions. The gallery is manual, keeps the last loaded selection visible while another loads, and skips failures. Archived videos load only after activation. - `VITE_GOOGLE_ANALYTICS_TRACKING_ID` is optional and used only in production builds. Vite-prefixed variables are public client configuration, not a place for credentials. Analytics keeps the existing tracker, queues early commands, and defers vendor loading until idle, interaction, or its deadline. - GitHub Pages' cache headers and hosting configuration are unchanged. Longer immutable caching would need a separate hosting decision. - npm install-script decisions are version-scoped in `package.json`; the optional watcher source builds are denied and supported platforms use prebuilt packages. Review `npm install-scripts ls` before changing them. diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md index 083c255..d4358f8 100644 --- a/docs/refactor-plan.md +++ b/docs/refactor-plan.md @@ -1,4 +1,6 @@ -# Contributor refactor plan and acceptance ledger +# PR #108 refactor verification, historical record + +This records the original parity refactor against `f435aca`. PR #108 subsequently merged as `b57ae7d` and was deployed. The tables below retain that effort's original scope and measurements; they are not a current UI specification or working-tree status. For current controls, layouts, participation workflows, and checks, use the [README](../README.md) and [UI canon](ui-canon.md). The later UX pass intentionally replaces several preserved legacy behaviors. ## Mission diff --git a/docs/ui-canon.md b/docs/ui-canon.md index 8a04c9e..8372906 100644 --- a/docs/ui-canon.md +++ b/docs/ui-canon.md @@ -1,26 +1,34 @@ # UI canon -This is a styling-system migration, not a redesign. `src/styles/theme.css` is the only stylesheet entry. It loads Tailwind's theme and utilities, then explicit base and component layers. Preflight is excluded because it changes the site's heading, image, list, and button geometry. +The current UI prioritizes participation, readable archives, and keyboard access. The earlier visual-parity migration is recorded separately in `docs/refactor-plan.md`; its layouts and control APIs are not the current design contract. -## Shared controls +`src/styles/theme.css` is the only stylesheet entry. Tailwind's theme and utilities are combined with explicit base and component layers. Preflight remains excluded. Brand colors and font tokens live in `styles/tokens.css`; normal text and actions use dark navy rather than low-contrast turquoise text treatments. -- `ActionLink` renders a native anchor and preserves the existing Space-key behavior of action links. Use `action` with one of the site's five existing color treatments: `action-info`, `action-primary`, `action-warning`, `action-outline`, or `action-inverse`. `action-large` is the existing large size. The definitions live in `styles/controls.css`; do not add another button palette in a feature. -- `OutboundLink` owns outbound analytics, email redaction through the analytics module, and same-window navigation completion. Its props are native anchor props plus `eventLabel`. -- `Collapse` owns the menu and FAQ height transition. `disclosure` and `disclosure-transition` avoid Tailwind's unrelated `collapse` visibility utility. The 350ms CSS transition and the previously shipped 300ms fallback remain distinct on purpose. -- `Header` owns the single navigation layout and dropdown behavior. `site-nav-*` and `site-menu-*` are internal selectors, not another component library. -- `content-card-*` and `resource-list-*` supply the shared static card structure. Features compose native elements rather than passing variant props through wrappers. -- `People` and `YoutubeEmbed` own reserved media geometry and loading behavior. The home carousel owns its slide classes and timing separately. +## Native controls and shared behavior -## Values and breakpoints +- Use a native anchor for email/external destinations and a React Router `Link` for app routes or in-page navigation. Use a native button for an action. Do not suppress normal link keyboard behavior or resurrect `ActionLink`. +- Primary actions use `action action-primary`; secondary actions use `action action-outline`. Definitions live in `styles/controls.css`. Both have a minimum 44px target, visible keyboard focus, and brand-navy colors. Features must not introduce a competing action palette. +- `OutboundLink` adds outbound analytics and navigation completion to native anchor props. Give each destination a meaningful `eventLabel`; distinguish inquiries from newsletter signups or actual registrations. +- `Header` owns the shared navigation. Native disclosure buttons expose expanded state and associated links. Escape returns focus; phone navigation closes after selecting a destination. Labels and links describe programs and audiences rather than internal page structure. +- `Collapse` handles menu and FAQ visibility. `disclosure` avoids Tailwind's unrelated visibility utility. FAQ callers own native question buttons, `aria-expanded`, `aria-controls`, and named answer regions. Zero-duration transitions settle visibility and focus completion immediately instead of waiting for an animation fallback. +- The home photo gallery is manual. Arrow and indicator buttons have specific names, selected state, and 44px targets. No autoplay or hover-only pause behavior. Keep the current image until the requested image loads; reconcile cached image completion during hydration and skip failures without letting stale events replace a newer selection. +- `YoutubeEmbed` requires the actual workshop title. It reserves 16:9 space, requests no player before activation, and moves keyboard focus into the titled iframe after activation. +- People portraits are decorative when the visible name identifies the person. Use empty portrait alt text so a profile link announces the name once. Modified outbound clicks, including Alt-click, retain native browser behavior. +- Workshop cards use native `details`/`summary`, an explicitly labeled filter, a result count, and a clear-filter action. Do not hide essential maps or schedules inside the workshop list. +- `DocumentLink` displays the document name, verified local format/size metadata when available, and a new-tab notice. Never invent sizes for remote document collections. -`styles/tokens.css` owns the brand colors and section spacing. Breakpoints are 576, 768, 992, and 1200px, not Tailwind's defaults. Existing feature queries include 575.98px and an inclusive 768px mobile team rule; those boundaries remain deliberate. +## Layout and focus -Layout, sizing, spacing, and typography use Tailwind utilities through `@apply`. Exact existing values use arbitrary utilities where a default would change geometry. Motion, gradients, pseudo-elements, and state-specific effects remain ordinary CSS. Group utilities only when their properties do not override one another. +`page-container` provides a maximum 1120px content area plus 24px side gutters. `Section` supplies vertical rhythm only. Do not add global padding to every nested `section`. -The base reset and some control values derive from the former Bootstrap dependency. Their license is retained in `licenses/bootstrap.txt`; Bootstrap itself, its generic class API, React Bootstrap, and Sass are removed. +Navigation expands at 992px. The home hero stacks until 1100px. People use two phone columns, three from 640px, and four from 992px; the phone department picker replaces the desktop links below 768px. Check content fit at both sides of each affected breakpoint rather than selecting a breakpoint solely because it is a framework default. -## Verify a change +App owns one skip link, one `main`, and one footer outside `main`. Client route changes focus the new page heading; hash links focus their destination after a phone menu closes. The department selector opts into retaining focus during its route-based filtering with `data-preserve-route-focus`. POP navigation leaves scroll restoration to the browser. Static and client URLs share canonical metadata; pageview tracking normalizes their trailing slash without dropping query attribution. -Run the type, lint, content, image, build, and browser checks listed in the README. Compare phone, tablet, desktop, and both sides of affected breakpoints. A screenshot with motion disabled does not prove an animation: exercise the transition separately, including keyboard focus and interrupted open/close actions. +## Content and verification -The parity tool stores raw geometry, computed styles, content, links, image attributes, and screenshots. Its comparison ignores implementation class names and permits at most 0.05 CSS pixels of geometry rounding. It does not ignore differences in text, destinations, image order, loading policy, or computed style values. +`content/participation.ts` owns published status notices and the real inquiry destinations. Do not infer current registration availability from old forms. Archives must identify their year and must not invite current registration, waiver submission, Zoom attendance, or feedback collection for past events. + +Run the README checks, including generated HTML with JavaScript disabled, hydration/console checks, keyboard journeys, mobile overflow and 44px targets, contrast, reduced motion, and slow/failed/cached-image cases. Inspect screenshots for hierarchy and readability, not just presence of controls. Email delivery and third-party form/player behavior are not proven by local link checks. + +The base reset contains selected Bootstrap-derived rules; its complete license remains in `licenses/bootstrap.txt`. Bootstrap, React Bootstrap, and Sass are not runtime dependencies or styling APIs. diff --git a/docs/verification/ux/home-phone.png b/docs/verification/ux/home-phone.png new file mode 100644 index 0000000..8a0a99d Binary files /dev/null and b/docs/verification/ux/home-phone.png differ diff --git a/docs/verification/ux/resources-phone.png b/docs/verification/ux/resources-phone.png new file mode 100644 index 0000000..5b0950d Binary files /dev/null and b/docs/verification/ux/resources-phone.png differ diff --git a/docs/verification/ux/summary.json b/docs/verification/ux/summary.json new file mode 100644 index 0000000..b533173 --- /dev/null +++ b/docs/verification/ux/summary.json @@ -0,0 +1,109 @@ +{ + "baseCommit": "b57ae7d432f4eced178a91a46f0224852f16b2f6", + "branch": "improve-participation-and-accessibility", + "checks": { + "unitTests": 16, + "browserTests": { + "chromium": 46, + "firefox": 46, + "webkit": 46 + }, + "accessibility": { + "engine": "axe-core 4.13.0", + "scenes": 34, + "violations": 0, + "tags": [ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "wcag22aa" + ] + }, + "staticRoutes": 15, + "preservedPublicBinaryUrls": 102, + "people": 35, + "teamSections": 7, + "imageSources": 59, + "imageVariants": 86 + }, + "fixes": [ + "Visible phone mission and honest published-status notices", + "Working school, volunteer and partner inquiry paths; newsletter clearly separate", + "Program hub and year-consistent read-only archives", + "Correct historical Ignite duration, dates and workshop descriptions", + "No inert feedback controls or obsolete registration/Zoom instructions", + "Immediate event shortcuts, early maps/help, readable timetables and verified room/session data", + "Searchable compact workshop disclosures and labeled document sizes", + "Visible audience/people grids, compact department selector and tablet-safe header", + "Keyboard FAQ disclosures, named social links and video facades", + "Manual named gallery controls with cached/failed/stale image handling", + "One main/footer, skip link, deliberate route/hash focus and persistent selector focus", + "Accessible navy actions, visible focus,44px controls and reduced-motion behavior", + "Page-specific metadata/static HTML, sitemap/robots and no-JS canonical navigation", + "Accurate inquiry/action labels and canonical pageview normalization" + ], + "limitations": [ + "No future program dates, deadlines, enrollment state, or testimonials were invented; existing email inquiries are the completed fallback.", + "Email delivery and third-party form submissions/player playback were not performed.", + "Automated accessibility checks supplement, not replace, assistive-technology and physical-device testing.", + "macOS WebKit uses native Option+Tab link navigation; no OS or Safari settings were changed.", + "This is an intentional UX change, not a pixel-parity refactor." + ], + "phoneComparison": { + "width": 390, + "missionHeadline": { + "before": "hidden", + "afterY": 190.5 + }, + "audienceChoices": { + "beforeY": 2047.53125, + "afterY": 915.25 + }, + "maps2026": { + "beforeY": 9091.34375, + "afterY": 1065.96875 + }, + "firstTeamPortrait": { + "beforeY": 620.421875, + "afterY": 403.171875 + } + }, + "layout": [ + {"width":320,"route":"/","file":"320-_.png","title":"Technology experiences for LA high school students | exploretech.la","pageHeight":7443,"overflow":false,"heading":{"y":190.5,"width":272,"height":200,"text":"Helping Los Angeles high school students explore technology"},"maps":null,"firstPerson":{"y":4496.765625,"width":128,"height":128,"text":""},"audiences":{"y":1021.640625,"width":320,"height":900.6875,"text":"Get involved\nFor schools and students\n\nFind a program for your students and ask about the next oppor"},"primaryAction":{"y":661.640625,"width":227.140625,"height":44,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":320,"route":"/events","file":"320-_events.png","title":"Programs and event information | exploretech.la","pageHeight":2514,"overflow":false,"heading":{"y":101,"width":272,"height":36.796875,"text":"Events"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":579.21875,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":320,"route":"/get-involved","file":"320-_get-involved.png","title":"Schools, volunteers and partners | exploretech.la","pageHeight":4749,"overflow":false,"heading":{"y":101,"width":272,"height":36.796875,"text":"Get involved"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":742.203125,"width":230,"height":68.78125,"text":"Ask about school participation"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":320,"route":"/our_team/leadership","file":"320-_our_team_leadership.png","title":"Leadership team | exploretech.la","pageHeight":2916,"overflow":false,"heading":{"y":101,"width":272,"height":36.796875,"text":"Meet our team"},"maps":null,"firstPerson":{"y":403.171875,"width":128,"height":128,"text":""},"audiences":null,"primaryAction":null,"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":320,"route":"/resources2026","file":"320-_resources2026.png","title":"2026 event resources archive | exploretech.la","pageHeight":5316,"overflow":false,"heading":{"y":125,"width":272,"height":73.59375,"text":"exploretech.la 2026 archive"},"maps":{"y":1194.765625,"width":272,"height":413.953125,"text":"Maps\n\nVenue and bus maps retained from this event.\n\nBus Routes\nJPG, 59.9 kB\nopens in a new tab\nAcker"},"firstPerson":null,"audiences":null,"primaryAction":{"y":1747.5,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":15,"expandedWorkshops":0,"errors":[]}, + {"width":320,"route":"/ignite","file":"320-_ignite.png","title":"Ignite workshops for high school students | exploretech.la","pageHeight":7119,"overflow":false,"heading":{"y":165,"width":272,"height":36.796875,"text":"Ignite"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":652.46875,"width":228,"height":68.78125,"text":"Ask about the next Ignite series"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":320,"route":"/register","file":"320-_register.png","title":"2021 virtual event archive | exploretech.la","pageHeight":4092,"overflow":false,"heading":{"y":125,"width":272,"height":110.390625,"text":"exploretech.la 2021 virtual event archive"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":509.859375,"width":272,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":390,"route":"/","file":"390-_.png","title":"Technology experiences for LA high school students | exploretech.la","pageHeight":6810,"overflow":false,"heading":{"y":190.5,"width":342,"height":120,"text":"Helping Los Angeles high school students explore technology"},"maps":null,"firstPerson":{"y":4097.25,"width":160,"height":160,"text":""},"audiences":{"y":915.25,"width":390,"height":741.515625,"text":"Get involved\nFor schools and students\n\nFind a program for your students and ask about the next oppor"},"primaryAction":{"y":555.25,"width":227.140625,"height":44,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":390,"route":"/events","file":"390-_events.png","title":"Programs and event information | exploretech.la","pageHeight":2391,"overflow":false,"heading":{"y":101,"width":342,"height":36.796875,"text":"Events"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":579.21875,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":390,"route":"/get-involved","file":"390-_get-involved.png","title":"Schools, volunteers and partners | exploretech.la","pageHeight":4118,"overflow":false,"heading":{"y":101,"width":342,"height":36.796875,"text":"Get involved"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":686.125,"width":286.765625,"height":46.390625,"text":"Ask about school participation"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":390,"route":"/our_team/leadership","file":"390-_our_team_leadership.png","title":"Leadership team | exploretech.la","pageHeight":3076,"overflow":false,"heading":{"y":101,"width":342,"height":36.796875,"text":"Meet our team"},"maps":null,"firstPerson":{"y":403.171875,"width":160,"height":160,"text":""},"audiences":null,"primaryAction":null,"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":390,"route":"/resources2026","file":"390-_resources2026.png","title":"2026 event resources archive | exploretech.la","pageHeight":5009,"overflow":false,"heading":{"y":125,"width":342,"height":73.59375,"text":"exploretech.la 2026 archive"},"maps":{"y":1065.96875,"width":342,"height":413.953125,"text":"Maps\n\nVenue and bus maps retained from this event.\n\nBus Routes\nJPG, 59.9 kB\nopens in a new tab\nAcker"},"firstPerson":null,"audiences":null,"primaryAction":{"y":1618.703125,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":15,"expandedWorkshops":0,"errors":[]}, + {"width":390,"route":"/ignite","file":"390-_ignite.png","title":"Ignite workshops for high school students | exploretech.la","pageHeight":6943,"overflow":false,"heading":{"y":165,"width":342,"height":36.796875,"text":"Ignite"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":570,"width":290.71875,"height":46.390625,"text":"Ask about the next Ignite series"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":390,"route":"/register","file":"390-_register.png","title":"2021 virtual event archive | exploretech.la","pageHeight":3674,"overflow":false,"heading":{"y":125,"width":342,"height":73.59375,"text":"exploretech.la 2021 virtual event archive"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":473.0625,"width":342,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":768,"route":"/","file":"768-_.png","title":"Technology experiences for LA high school students | exploretech.la","pageHeight":5901,"overflow":false,"heading":{"y":190.5,"width":620.15625,"height":150,"text":"Helping Los Angeles high school students explore technology"},"maps":null,"firstPerson":{"y":4004.28125,"width":160,"height":160,"text":""},"audiences":{"y":926.0625,"width":768,"height":667.953125,"text":"Get involved\nFor schools and students\n\nFind a program for your students and ask about the next oppor"},"primaryAction":{"y":533.265625,"width":227.140625,"height":44,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":768,"route":"/events","file":"768-_events.png","title":"Programs and event information | exploretech.la","pageHeight":1558,"overflow":false,"heading":{"y":107.390625,"width":720,"height":36.796875,"text":"Events"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":532.3125,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":768,"route":"/get-involved","file":"768-_get-involved.png","title":"Schools, volunteers and partners | exploretech.la","pageHeight":2727,"overflow":false,"heading":{"y":107.390625,"width":720,"height":36.796875,"text":"Get involved"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":458.765625,"width":286.765625,"height":46.390625,"text":"Ask about school participation"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":768,"route":"/our_team/leadership","file":"768-_our_team_leadership.png","title":"Leadership team | exploretech.la","pageHeight":2199,"overflow":false,"heading":{"y":107.390625,"width":720,"height":36.796875,"text":"Meet our team"},"maps":null,"firstPerson":{"y":406.265625,"width":160,"height":160,"text":""},"audiences":null,"primaryAction":null,"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":768,"route":"/resources2026","file":"768-_resources2026.png","title":"2026 event resources archive | exploretech.la","pageHeight":3071,"overflow":false,"heading":{"y":131.390625,"width":720,"height":36.796875,"text":"exploretech.la 2026 archive"},"maps":{"y":442.5625,"width":348,"height":413.953125,"text":"Maps\n\nVenue and bus maps retained from this event.\n\nBus Routes\nJPG, 59.9 kB\nopens in a new tab\nAcker"},"firstPerson":null,"audiences":null,"primaryAction":{"y":968.90625,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":15,"expandedWorkshops":0,"errors":[]}, + {"width":768,"route":"/ignite","file":"768-_ignite.png","title":"Ignite workshops for high school students | exploretech.la","pageHeight":4863,"overflow":false,"heading":{"y":171.390625,"width":720,"height":36.796875,"text":"Ignite"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":473.921875,"width":290.71875,"height":46.390625,"text":"Ask about the next Ignite series"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":768,"route":"/register","file":"768-_register.png","title":"2021 virtual event archive | exploretech.la","pageHeight":2495,"overflow":false,"heading":{"y":131.390625,"width":720,"height":36.796875,"text":"exploretech.la 2021 virtual event archive"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":327.46875,"width":720,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1024,"route":"/","file":"1024-_.png","title":"Technology experiences for LA high school students | exploretech.la","pageHeight":5592,"overflow":false,"heading":{"y":190.5,"width":620.15625,"height":150,"text":"Helping Los Angeles high school students explore technology"},"maps":null,"firstPerson":{"y":3978.640625,"width":160,"height":160,"text":""},"audiences":{"y":957.265625,"width":1024,"height":375.546875,"text":"Get involved\nFor schools and students\n\nFind a program for your students and ask about the next oppor"},"primaryAction":{"y":533.265625,"width":227.140625,"height":44,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1024,"route":"/events","file":"1024-_events.png","title":"Programs and event information | exploretech.la","pageHeight":1545,"overflow":false,"heading":{"y":120.1875,"width":976,"height":47.09375,"text":"Events"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":518.203125,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1024,"route":"/get-involved","file":"1024-_get-involved.png","title":"Schools, volunteers and partners | exploretech.la","pageHeight":2642,"overflow":false,"heading":{"y":120.1875,"width":976,"height":47.09375,"text":"Get involved"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":505.921875,"width":286.765625,"height":46.390625,"text":"Ask about school participation"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1024,"route":"/our_team/leadership","file":"1024-_our_team_leadership.png","title":"Leadership team | exploretech.la","pageHeight":1935,"overflow":false,"heading":{"y":120.1875,"width":976,"height":47.09375,"text":"Meet our team"},"maps":null,"firstPerson":{"y":392.140625,"width":160,"height":160,"text":""},"audiences":null,"primaryAction":null,"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1024,"route":"/resources2026","file":"1024-_resources2026.png","title":"2026 event resources archive | exploretech.la","pageHeight":3110,"overflow":false,"heading":{"y":144.1875,"width":976,"height":47.09375,"text":"exploretech.la 2026 archive"},"maps":{"y":478.453125,"width":476,"height":410.984375,"text":"Maps\n\nVenue and bus maps retained from this event.\n\nBus Routes\nJPG, 59.9 kB\nopens in a new tab\nAcker"},"firstPerson":null,"audiences":null,"primaryAction":{"y":1003.8125,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":15,"expandedWorkshops":0,"errors":[]}, + {"width":1024,"route":"/ignite","file":"1024-_ignite.png","title":"Ignite workshops for high school students | exploretech.la","pageHeight":3950,"overflow":false,"heading":{"y":184.1875,"width":976,"height":47.09375,"text":"Ignite"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":509.8125,"width":290.71875,"height":46.390625,"text":"Ask about the next Ignite series"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1024,"route":"/register","file":"1024-_register.png","title":"2021 virtual event archive | exploretech.la","pageHeight":2512,"overflow":false,"heading":{"y":144.1875,"width":976,"height":47.09375,"text":"exploretech.la 2021 virtual event archive"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":350.5625,"width":976,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1440,"route":"/","file":"1440-_.png","title":"Technology experiences for LA high school students | exploretech.la","pageHeight":3971,"overflow":false,"heading":{"y":206.5,"width":540,"height":150,"text":"Helping Los Angeles high school students explore technology"},"maps":null,"firstPerson":{"y":2643.28125,"width":160,"height":160,"text":""},"audiences":{"y":665.265625,"width":1168,"height":357.171875,"text":"Get involved\nFor schools and students\n\nFind a program for your students and ask about the next oppor"},"primaryAction":{"y":549.265625,"width":227.140625,"height":44,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1440,"route":"/events","file":"1440-_events.png","title":"Programs and event information | exploretech.la","pageHeight":1376,"overflow":false,"heading":{"y":133,"width":1120,"height":55.1875,"text":"Events"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":559.9375,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1440,"route":"/get-involved","file":"1440-_get-involved.png","title":"Schools, volunteers and partners | exploretech.la","pageHeight":2514,"overflow":false,"heading":{"y":133,"width":1120,"height":55.1875,"text":"Get involved"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":547.65625,"width":286.765625,"height":46.390625,"text":"Ask about school participation"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1440,"route":"/our_team/leadership","file":"1440-_our_team_leadership.png","title":"Leadership team | exploretech.la","pageHeight":1761,"overflow":false,"heading":{"y":133,"width":1120,"height":55.1875,"text":"Meet our team"},"maps":null,"firstPerson":{"y":433.875,"width":160,"height":160,"text":""},"audiences":null,"primaryAction":null,"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1440,"route":"/resources2026","file":"1440-_resources2026.png","title":"2026 event resources archive | exploretech.la","pageHeight":2908,"overflow":false,"heading":{"y":157,"width":1120,"height":55.1875,"text":"exploretech.la 2026 archive"},"maps":{"y":512.171875,"width":548,"height":419,"text":"Maps\n\nVenue and bus maps retained from this event.\n\nBus Routes\nJPG, 59.9 kB\nopens in a new tab\nAcker"},"firstPerson":null,"audiences":null,"primaryAction":{"y":1053.5625,"width":239.140625,"height":46.390625,"text":"Ask about the next event"},"workshops":15,"expandedWorkshops":0,"errors":[]}, + {"width":1440,"route":"/ignite","file":"1440-_ignite.png","title":"Ignite workshops for high school students | exploretech.la","pageHeight":3856,"overflow":false,"heading":{"y":197,"width":1120,"height":55.1875,"text":"Ignite"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":543.53125,"width":290.71875,"height":46.390625,"text":"Ask about the next Ignite series"},"workshops":0,"expandedWorkshops":0,"errors":[]}, + {"width":1440,"route":"/register","file":"1440-_register.png","title":"2021 virtual event archive | exploretech.la","pageHeight":2384,"overflow":false,"heading":{"y":157,"width":1120,"height":55.1875,"text":"exploretech.la 2021 virtual event archive"},"maps":null,"firstPerson":null,"audiences":null,"primaryAction":{"y":371.46875,"width":1120,"height":46.390625,"text":"Ask about the next event"},"workshops":0,"expandedWorkshops":0,"errors":[]} + ] +} diff --git a/docs/verification/ux/team-phone.png b/docs/verification/ux/team-phone.png new file mode 100644 index 0000000..0833e80 Binary files /dev/null and b/docs/verification/ux/team-phone.png differ diff --git a/package.json b/package.json index f57a708..527d52f 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,12 @@ "format": "prettier --write src scripts *.json *.mjs *.ts .github/workflows/*.yml", "format:check": "prettier --check src scripts *.json *.mjs *.ts .github/workflows/*.yml", "prebuild": "npm run images:check", - "build": "vite build", + "build": "vite build && node scripts/prerender.mjs", "preview": "vite preview --host 127.0.0.1", "test": "vitest run", "test:watch": "vitest", - "test:browser": "playwright test", + "test:browser": "playwright test --project=chromium", + "test:browser:all": "playwright test", "visual:parity": "node scripts/visual-parity.cjs", "predeploy": "npm run build", "deploy": "gh-pages -d build" diff --git a/playwright.config.ts b/playwright.config.ts index e32e640..60427f2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,10 +4,9 @@ import { defineConfig, devices } from "@playwright/test"; * Browser smoke suite for the production build. * * The suite runs against `build/` served by `scripts/serve-built-site.cjs`, - * which reproduces GitHub Pages' 404-to-root restoration. Running it against - * the dev server would prove less: dev serves index.html for every path, so - * deep links and trailing slashes cannot fail there the way they can in - * production. + * which reproduces generated route directories and GitHub Pages' 404-to-root + * restoration. A dev-server SPA fallback cannot prove HTTP status, raw HTML, + * metadata, trailing-slash redirects, or hydration. * * npm run build && npm run test:browser * @@ -15,8 +14,7 @@ import { defineConfig, devices } from "@playwright/test"; * matrix, which stays a session tool. */ const port = Number(process.env.SMOKE_PORT ?? 4390); -// Normally the freshly built site; pointed at a saved baseline build when -// comparing current behaviour against the pre-refactor deployment. +// Use a separately generated build when investigating a build-specific failure. const root = process.env.SMOKE_ROOT ?? "build"; const host = "127.0.0.1"; const baseURL = `http://${host}:${port}`; @@ -48,11 +46,25 @@ export default defineConfig({ viewport: { width: 1440, height: 900 }, }, }, + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + viewport: { width: 1440, height: 900 }, + }, + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + viewport: { width: 1440, height: 900 }, + }, + }, ], webServer: { command: `node scripts/serve-built-site.cjs --root ${root} --port ${port} --host ${host}`, url: baseURL, - reuseExistingServer: !isCI, + reuseExistingServer: false, timeout: 30_000, stdout: "ignore", stderr: "pipe", diff --git a/scripts/browser-smoke.spec.ts b/scripts/browser-smoke.spec.ts index 442c5b3..595d5ad 100644 --- a/scripts/browser-smoke.spec.ts +++ b/scripts/browser-smoke.spec.ts @@ -5,27 +5,14 @@ import { type Page, type Route, } from "@playwright/test"; +import { + AUDIENCES, + PROGRAMS, + CONTACT_EMAIL, +} from "../src/content/participation"; -/** - * Durable browser smoke for the built site. - * - * Scope: the behaviour that has broken before and that unit tests cannot see — - * GitHub Pages deep-link restoration, the mobile menu, hash and history - * scrolling, team rendering and lazy portraits, the click-to-load video - * facade, shared document links, and the carousel's slow/failed image paths. - * - * Nothing here talks to a third party. Every request that is not same-origin - * is aborted and recorded, so "no YouTube before activation" and "no analytics - * beacons" are assertions rather than hopes. - */ - -declare global { - interface Window { - /** Arguments of each window.scrollTo call, installed by the history test. */ - __scrolls?: unknown[][]; - } -} - +// Run against generated HTML, not Vite's SPA fallback. Vendor requests are +// blocked even after video activation; no test submits a form or sends email. const TEAM_SECTIONS = [ { slug: "leadership", heading: "Leadership" }, { slug: "content", heading: "Content" }, @@ -34,44 +21,30 @@ const TEAM_SECTIONS = [ { slug: "external", heading: "External" }, { slug: "web-dev", heading: "Web Dev" }, { slug: "marketing", heading: "Marketing" }, -] as const; - +]; +const ARCHIVES = [ + { path: "/register", year: "2021" }, + { path: "/resources", year: "2022" }, + { path: "/resources2023", year: "2023" }, + { path: "/resources2026", year: "2026" }, +]; const ROUTES = [ - // The header also carries a `Home` class on the home page, hence `div`. - { path: "/", marker: "div.Home" }, - { path: "/register", marker: ".Register" }, - { path: "/resources", marker: ".Resources" }, - { path: "/resources2023", marker: ".Resources" }, - { path: "/resources2026", marker: ".Resources" }, - { path: "/ignite", marker: ".Ignite" }, - ...TEAM_SECTIONS.map((section) => ({ - path: `/our_team/${section.slug}`, - marker: ".Team", - })), -] as const; - -// The Google Fonts stylesheet is a deliberate first-party design choice and is -// merely blocked here; tracking and video vendors must never be reached. + "/", + "/events", + "/get-involved", + "/ignite", + ...ARCHIVES.map(({ path }) => path), + ...TEAM_SECTIONS.map(({ slug }) => `/our_team/${slug}`), +]; +const WIDTHS = [320, 390, 768, 1024, 1440]; const THIRD_PARTY = /google-analytics|googletagmanager|youtube|doubleclick/; - type Offline = { - /** Every cross-origin URL the page tried to reach, in order. */ readonly external: string[]; - /** Uncaught page errors. No route is allowed to produce one. */ readonly pageErrors: string[]; - /** Same-origin URL fragment whose response waits for `hold.release()`. */ hold: { fragment: string; wait: Promise; release: () => void } | null; - /** Same-origin URL fragment whose request fails, modelling a broken asset. */ fail: string | null; }; -/** - * Cut a page off from the internet and record what it reached for. - * - * The returned object stays live: setting `hold` or `fail` on it shapes - * later requests, which is how the carousel's slow and broken image paths are - * exercised without shipping a test-only hook into the app. - */ async function goOffline( page: Page, baseURL: string | undefined, @@ -83,12 +56,17 @@ async function goOffline( hold: null, fail: null, }; - page.on("pageerror", (error) => state.pageErrors.push(String(error))); - + page.on("console", (message) => { + if ( + message.type() === "error" && + /hydrat|server rendered|Minified React error/i.test(message.text()) + ) + state.pageErrors.push(message.text()); + }); await page.route("**/*", async (route: Route) => { const url = route.request().url(); - if (!url.startsWith(origin)) { + if (new URL(url).origin !== origin) { state.external.push(url); await route.abort(); return; @@ -97,386 +75,799 @@ async function goOffline( await route.abort("failed"); return; } - if (state.hold && url.includes(state.hold.fragment)) { - await state.hold.wait; - } + if (state.hold && url.includes(state.hold.fragment)) await state.hold.wait; await route.continue(); }); - return state; } -function activeTeamNav(page: Page) { - return page.locator(".team-navigation a.active"); +async function tabForward(page: Page, browserName: string) { + // macOS WebKit's native link-navigation shortcut includes controls that Tab skips. + // This respects the browser preference without changing the user's OS settings. + await page.keyboard.press( + browserName === "webkit" && process.platform === "darwin" + ? "Alt+Tab" + : "Tab", + ); +} + +async function expectNoOverflow(page: Page) { + const overflow = await page.evaluate(() => ({ + document: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + grids: Array.from( + document.querySelectorAll(".People"), + (grid) => grid.scrollWidth - grid.clientWidth, + ), + })); + expect(overflow.document, "page has horizontal overflow").toBeLessThanOrEqual( + 1, + ); + for (const width of overflow.grids) + expect(width, "people grid clips cards horizontally").toBeLessThanOrEqual( + 1, + ); } function activeSlideAlt(page: Page) { return page.locator(".carousel-item.active img").getAttribute("alt"); } +function slideButtons(page: Page) { + return page.locator(".carousel-indicators button"); +} -test.describe("routes", () => { - for (const route of ROUTES) { - test(`renders ${route.path}`, async ({ page, baseURL }) => { +test.describe("routes and generated HTML", () => { + for (const path of ROUTES) { + test(`renders and hydrates ${path}`, async ({ page, baseURL }) => { const offline = await goOffline(page, baseURL); - await page.goto(route.path); - - await expect(page.locator(route.marker)).toBeVisible(); + const response = await page.goto(path); + expect(response?.status()).toBe(200); + await expect(page.locator("main")).toHaveCount(1); + await expect(page.locator("main h1")).toHaveCount(1); + await expect(page.locator("main h1")).toBeVisible(); + await expect(page.getByRole("contentinfo")).toHaveCount(1); + await expect(page.locator("main footer")).toHaveCount(0); await expect(page.locator(".PageNotFound")).toHaveCount(0); - await expect(page.locator(".Header")).toBeVisible(); - // Restoration must leave the clean path in the address bar. - await expect(page).toHaveURL( - new RegExp(`${route.path.replace(/\//g, "\\/")}$`), - ); - expect(offline.pageErrors, `uncaught errors on ${route.path}`).toEqual( - [], - ); + expect(new URL(page.url()).pathname.replace(/\/$/, "") || "/").toBe(path); + // Exercise hydrated controls rather than merely waiting for static HTML. + await page + .getByRole("link", { name: /skip to (main )?content/i }) + .focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("main")).toBeFocused(); + expect(offline.pageErrors, path).toEqual([]); expect( offline.external.filter((url) => THIRD_PARTY.test(url)), - `third-party requests on ${route.path}`, + path, ).toEqual([]); }); } - test("unknown paths show the not-found page and restore the title", async ({ - page, + test("every published route has useful HTML and distinct metadata without JavaScript", async ({ + browser, baseURL, + request, }) => { + const context = await browser.newContext({ + baseURL, + javaScriptEnabled: false, + }); + const page = await context.newPage(); await goOffline(page, baseURL); - const response = await page.goto("/no-such-page"); - // GitHub Pages answers with its 404 document before the SPA restores. - expect(response?.status()).toBe(404); - await expect(page.locator(".PageNotFound")).toBeVisible(); - await expect(page).toHaveTitle("Page Not Found • exploretech.la"); - - await page.locator(".PageNotFound a").first().click(); - await expect(page.locator("div.Home")).toBeVisible(); - await expect(page).not.toHaveTitle("Page Not Found • exploretech.la"); + const titles = new Set(); + const descriptions = new Set(); + try { + for (const path of ROUTES) { + if (path !== "/") { + const redirect = await request.get( + `${path}?ref=static&value=one%20two`, + { maxRedirects: 0 }, + ); + expect(redirect.status(), path).toBe(301); + expect(redirect.headers().location).toBe( + `${path}/?ref=static&value=one%20two`, + ); + } + const response = await page.goto(path); + expect(response?.status(), path).toBe(200); + expect(await response?.text(), path).toMatch(/)/); + await expect(page.locator("main h1")).toBeVisible(); + const archive = ARCHIVES.find((entry) => entry.path === path); + if (archive) + await expect(page.locator("main h1")).toContainText(archive.year); + if (path === "/events") { + for (const program of Object.values(PROGRAMS)) + await expect( + page.getByText(program.status, { exact: true }), + ).toBeVisible(); + } + if (path === "/get-involved") { + for (const audience of Object.values(AUDIENCES)) + await expect( + page.getByRole("link", { + name: audience.actionLabel, + exact: true, + }), + ).toHaveAttribute("href", audience.inquiryHref); + } + const title = await page.title(); + const description = await page + .locator('meta[name="description"]') + .getAttribute("content"); + expect(title, path).toContain("exploretech.la"); + expect(description?.trim().length, path).toBeGreaterThan(30); + expect(titles.has(title), `duplicate title on ${path}`).toBe(false); + expect( + descriptions.has(description ?? ""), + `duplicate description on ${path}`, + ).toBe(false); + titles.add(title); + descriptions.add(description ?? ""); + const canonical = await page + .locator('link[rel="canonical"]') + .getAttribute("href"); + expect(canonical, path).toMatch(/^https:\/\/www\.exploretech\.la\//); + expect( + new URL(canonical ?? "").pathname.replace(/\/$/, "") || "/", + ).toBe(path); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( + "content", + title, + ); + await expect( + page.locator('meta[property="og:description"]'), + ).toHaveAttribute("content", description ?? ""); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute( + "content", + canonical ?? "", + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + /^https:\/\/.+\/placeholder\.png$/, + ); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + "content", + /summary/, + ); + } + await page.goto("/"); + await page + .getByRole("contentinfo") + .getByRole("link", { name: "Meet the team", exact: true }) + .click(); + await expect(page).toHaveURL(/\/our_team\/leadership\/$/); + await expect(page.locator(".Team h1")).toBeVisible(); + } finally { + await context.close(); + } }); - test("/our_team and its trailing slash land on leadership", async ({ + test("aliases and unknown paths retain the Pages restoration fallback", async ({ page, baseURL, + request, }) => { - await goOffline(page, baseURL); - - await page.goto("/our_team"); - await expect(page).toHaveURL(/\/our_team\/leadership$/); - await expect(activeTeamNav(page)).toHaveText("Leadership"); - - await page.goto("/our_team/"); - await expect(page).toHaveURL(/\/our_team\/leadership$/); - await expect(activeTeamNav(page)).toHaveText("Leadership"); + const offline = await goOffline(page, baseURL); + for (const path of ["/our_team", "/our_team/"]) { + expect((await request.get(path, { maxRedirects: 0 })).status()).toBe(404); + await page.goto(`${path}?ref=newsletter#top`); + await expect(page).toHaveURL( + /\/our_team\/leadership\/?\?ref=newsletter#top$/, + ); + await expect(page.locator(".team-navigation a.active")).toHaveText( + "Leadership", + ); + } + const missing = await request.get("/no-such-page?ref=archive"); + expect(missing.status()).toBe(404); + await page.goto("/no-such-page?ref=archive#top"); + await expect(page.locator(".PageNotFound")).toBeVisible(); + await expect(page).toHaveURL(/\/no-such-page\?ref=archive#top$/); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + "content", + "noindex,follow", + ); + const missingTitle = await page.title(); + await page + .locator(".PageNotFound") + .getByRole("link", { name: "Explore programs", exact: true }) + .click(); + await expect(page.locator(".Events")).toBeVisible(); + await expect(page).not.toHaveTitle(missingTitle); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + "content", + "index,follow", + ); + expect(offline.pageErrors).toEqual([]); }); - test("a deep link keeps its query and hash through the Pages restore", async ({ + test("a generated deep link preserves query and hash", async ({ page, baseURL, }) => { await goOffline(page, baseURL); const response = await page.goto("/our_team/design?ref=newsletter#top"); - expect(response?.status()).toBe(404); - - await expect(page.locator(".Team")).toBeVisible(); - await expect(activeTeamNav(page)).toHaveText("Design"); - await expect(page).toHaveURL(/\/our_team\/design\?ref=newsletter#top$/); + expect(response?.status()).toBe(200); + await expect(page).toHaveURL(/\/our_team\/design\/?\?ref=newsletter#top$/); + await expect(page.locator(".team-navigation a.active")).toHaveText( + "Design", + ); }); }); -test.describe("team", () => { - for (const section of TEAM_SECTIONS) { - test(`${section.slug} lists people`, async ({ page, baseURL }) => { - await goOffline(page, baseURL); - await page.goto(`/our_team/${section.slug}`); +test.describe("participation and archives", () => { + test("home audience paths reach real inquiry links, not applications or newsletter substitutes", async ({ + page, + baseURL, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await goOffline(page, baseURL); + for (const [id, audience] of Object.entries(AUDIENCES)) { + await page.goto("/"); + await page.locator(`main a[href="${audience.path}"]`).first().click(); + await expect(page).toHaveURL(new RegExp(`/get-involved#${id}$`)); + const section = page.locator(`#${id}`); + await expect(section).toBeVisible(); + const inquiry = section.getByRole("link", { + name: audience.actionLabel, + exact: true, + }); + await expect(inquiry).toHaveAttribute("href", audience.inquiryHref); + await expect(inquiry).toBeInViewport(); + const destination = new URL((await inquiry.getAttribute("href")) ?? ""); + expect(destination.protocol).toBe("mailto:"); + expect(destination.pathname).toBe(CONTACT_EMAIL); + expect(destination.searchParams.get("subject")).toBeTruthy(); + } + }); + test("program status and inquiry actions stay consistent", async ({ + page, + baseURL, + }) => { + await goOffline(page, baseURL); + await page.goto("/events"); + for (const program of Object.values(PROGRAMS)) { await expect( - page.getByRole("heading", { name: "Meet Our Team" }), + page.getByText(program.status, { exact: true }), ).toBeVisible(); - await expect(activeTeamNav(page)).toHaveCount(1); await expect( - page.locator(".Team h3", { hasText: section.heading }).first(), - ).toBeVisible(); + page.getByRole("link", { name: program.actionLabel, exact: true }), + ).toHaveAttribute("href", program.inquiryHref); + } + await page.goto("/ignite"); + await expect( + page.getByText(PROGRAMS.ignite.status, { exact: true }).first(), + ).toBeVisible(); + for (const inquiry of await page + .getByRole("link", { name: PROGRAMS.ignite.actionLabel, exact: true }) + .all()) { + await expect(inquiry).toHaveAttribute( + "href", + PROGRAMS.ignite.inquiryHref, + ); + } + await expect(page.locator("main")).toContainText(/2026/); + await expect(page.locator("main")).not.toContainText( + /currently accepting|four.week|4.week/i, + ); + await expect( + page.getByRole("link", { name: /apply|sign up|rsvp/i }), + ).toHaveCount(0); + }); + + for (const archive of ARCHIVES) { + test(`${archive.year} is an archive, not an active attendance or feedback flow`, async ({ + page, + baseURL, + }) => { + await goOffline(page, baseURL); + await page.goto(archive.path); + await expect(page.locator("main h1")).toContainText(archive.year); + await expect(page.locator("main h1")).toContainText(/archive/i); + await expect( + page.locator('main a[href*="zoom.us"], main a[href*="socio.events"]'), + ).toHaveCount(0); + await expect( + page + .locator("main") + .getByRole("button", { name: /feedback|register|apply/i }), + ).toHaveCount(0); + await expect( + page.locator("main").getByRole("link", { + name: /submit.*feedback|register now|apply now/i, + }), + ).toHaveCount(0); + await page.goto("/events"); + await expect( + page.locator(`main a[href="${archive.path}"]`).first(), + ).toContainText(archive.year); + }); + } + + test("event shortcuts and workshop filtering expose compact, recoverable details", async ({ + page, + baseURL, + }) => { + await goOffline(page, baseURL); + await page.goto("/resources2026"); + const navigation = page.getByRole("navigation", { name: "Event sections" }); + for (const [name, id] of [ + ["Schedule", "schedule"], + ["Workshops", "workshops"], + ["Maps", "maps"], + ["Help", "help"], + ]) { + await navigation.getByRole("link", { name, exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`#${id}$`)); + await expect(page.locator(`#${id}`)).toBeInViewport(); + await expect(page.locator(`#${id}`)).toBeFocused(); + } + const search = page.getByRole("searchbox", { + name: "Filter workshops and panels", + }); + const details = page.locator("#workshops details"); + const count = await details.count(); + expect(count).toBeGreaterThan(1); + await expect(details.first()).not.toHaveAttribute("open", ""); + await details.first().locator("summary").focus(); + await page.keyboard.press("Enter"); + await expect(details.first()).toHaveAttribute("open", ""); + await search.fill("no-such-workshop-xyz"); + await expect( + page.getByText("No workshops or panels match your search."), + ).toBeVisible(); + await expect(page.locator("#workshops [role=status]")).toContainText( + `0 of ${count}`, + ); + await page + .getByRole("button", { name: "Clear filter", exact: true }) + .click(); + await expect(search).toHaveValue(""); + await expect(details).toHaveCount(count); + await expect(page.locator("#workshops [role=status]")).toContainText( + `${count} of ${count}`, + ); + }); - const cards = page.locator(".person"); - const count = await cards.count(); - expect(count, `${section.slug} renders no people`).toBeGreaterThan(0); + for (const path of ["/register", "/resources"]) { + test(`FAQ keyboard expansion and named panels on ${path}`, async ({ + page, + baseURL, + }) => { + await goOffline(page, baseURL); + await page.goto(path); + const questions = page.locator( + ".FAQ button[aria-expanded][aria-controls]", + ); + expect(await questions.count()).toBeGreaterThan(0); + for (const question of await questions.all()) { + await expect(question).toHaveAccessibleName(/\S/); + const id = await question.getAttribute("aria-controls"); + if (!id) throw new Error("FAQ has no panel target"); + const panel = page.locator(`[id="${id}"]`); + await expect(question).toHaveAttribute("aria-expanded", "false"); + await question.focus(); + await page.keyboard.press("Enter"); + await expect(question).toHaveAttribute("aria-expanded", "true"); + await expect(panel).toBeVisible(); + await expect(panel).toHaveAttribute("role", "region"); + await expect(panel).toHaveAttribute( + "aria-labelledby", + (await question.getAttribute("id")) ?? "", + ); + await expect(panel).toHaveAccessibleName(/\S/); + await page.keyboard.press("Space"); + await expect(question).toHaveAttribute("aria-expanded", "false"); + await expect(panel).not.toBeVisible(); + } + }); + } +}); - // Every card carries text, including the members with no portrait. - const texts = await cards.evaluateAll((nodes) => - nodes.map((node) => (node.textContent ?? "").trim()), +test.describe("responsive layout and controls", () => { + for (const width of WIDTHS) { + test(`${width}px keeps the mission and all seven team grids readable`, async ({ + page, + baseURL, + }) => { + await page.setViewportSize({ width, height: 900 }); + await goOffline(page, baseURL); + await page.goto("/"); + await expect(page.locator("main h1")).toBeInViewport(); + await expect(page.locator("main h1")).toContainText( + /high school students/i, ); - expect( - texts.filter((text) => text.length === 0), - `blank cards in ${section.slug}`, - ).toEqual([]); - expect(await cards.locator("img").count()).toBeLessThanOrEqual(count); + await expectNoOverflow(page); + for (const section of TEAM_SECTIONS) { + await page.goto(`/our_team/${section.slug}`); + await expect( + page.getByRole("heading", { name: section.heading, exact: true }), + ).toBeVisible(); + await expect(page.locator(".team-navigation a.active")).toHaveText( + section.heading, + ); + const cards = page.locator(".person"); + expect(await cards.count()).toBeGreaterThan(0); + for (const text of await cards.allTextContents()) + expect(text.trim()).not.toBe(""); + await expectNoOverflow(page); + } + for (const path of [ + "/events", + "/get-involved", + "/ignite", + "/resources2026", + "/register", + ]) { + await page.goto(path); + await expectNoOverflow(page); + } }); } - test("portraits past the fold are lazy", async ({ page, baseURL }) => { + test("core controls meet touch size, primary contrast, and visible keyboard focus", async ({ + page, + baseURL, + browserName, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); await goOffline(page, baseURL); - await page.goto("/our_team/leadership"); + for (const path of [ + "/", + "/events", + "/get-involved", + "/ignite", + "/our_team/design", + "/resources2026", + "/register", + ]) { + await page.goto(path); + const controls = page.locator( + ".action, .site-nav-toggle, .team-navigation a, #team-department, .photo-gallery button, .FAQ button, main summary, main input[type=search], .section-links a, .participation-section > a.text-link", + ); + for (const control of await controls.all()) { + if (!(await control.isVisible())) continue; + const box = await control.boundingBox(); + expect( + Math.round((box?.width ?? 0) * 100) / 100, + `${path}: ${await control.innerText()}`, + ).toBeGreaterThanOrEqual(44); + expect( + Math.round((box?.height ?? 0) * 100) / 100, + `${path}: ${await control.innerText()}`, + ).toBeGreaterThanOrEqual(44); + } + for (const action of await page.locator(".action-primary").all()) { + if (!(await action.isVisible())) continue; + const ratio = await action.evaluate((node) => { + const style = getComputedStyle(node); + const luminance = (color: string) => { + const [r = 0, g = 0, b = 0] = (color.match(/[\d.]+/g) ?? []) + .slice(0, 3) + .map(Number) + .map((channel) => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const foreground = luminance(style.color); + const background = luminance(style.backgroundColor); + return ( + (Math.max(foreground, background) + 0.05) / + (Math.min(foreground, background) + 0.05) + ); + }); + expect(ratio, `${path}: primary text contrast`).toBeGreaterThanOrEqual( + 4.5, + ); + await tabForward(page, browserName); + await action.focus(); + expect( + await action.evaluate((node) => { + const style = getComputedStyle(node); + return ( + node.matches(":focus-visible") && + ((style.outlineStyle !== "none" && + parseFloat(style.outlineWidth) > 0) || + style.boxShadow !== "none") + ); + }), + `${path}: no visible keyboard focus`, + ).toBe(true); + } + } + }); - const loading = await page - .locator(".person img") - .evaluateAll((images) => - images.map((image) => (image as HTMLImageElement).loading), + test("the phone department selector reaches every team without a horizontal strip", async ({ + page, + baseURL, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await goOffline(page, baseURL); + await page.goto("/our_team/leadership"); + const department = page.getByRole("combobox", { name: "Department" }); + await department.focus(); + for (const section of TEAM_SECTIONS) { + await department.selectOption(section.slug); + await expect(page).toHaveURL(new RegExp(`/our_team/${section.slug}/?$`)); + await expect(page.locator(".team-content h2")).toHaveText( + section.heading, ); - expect(loading.length).toBeGreaterThan(2); - // The shared People component keeps the first two eager and defers the rest. - expect(loading.slice(0, 2)).toEqual(["eager", "eager"]); - expect(loading.slice(2).every((value) => value === "lazy")).toBe(true); + await expect(department).toHaveValue(section.slug); + await expect(department).toBeFocused(); + await expectNoOverflow(page); + } }); - test("a member without a portrait still renders", async ({ + test("missing portraits retain identity and offscreen portraits stay deferred", async ({ page, baseURL, }) => { await goOffline(page, baseURL); - let cardsWithoutImage = 0; + await page.goto("/our_team/leadership"); + const loading = await page + .locator(".person img") + .evaluateAll((images) => + images.map((image) => image.getAttribute("loading")), + ); + expect(loading).toContain("eager"); + expect(loading).toContain("lazy"); + for (const profile of await page.locator(".person-profile").all()) { + const name = (await profile.locator(".person-name").innerText()) + .replace(/\s+/g, " ") + .trim(); + const role = (await profile.locator(".person-title").innerText()) + .replace(/\s+/g, " ") + .trim(); + await expect(profile).toHaveAccessibleName(`${name} ${role}`); + } + let missing = 0; for (const section of TEAM_SECTIONS) { await page.goto(`/our_team/${section.slug}`); - await expect(page.locator(".person").first()).toBeVisible(); - cardsWithoutImage += await page.locator(".person:not(:has(img))").count(); + for (const card of await page.locator(".person:not(:has(img))").all()) { + missing += 1; + await expect(card.locator(".person-name")).toHaveText(/\S/); + await expect(card.locator(".person-image")).toHaveText(/\S/); + } } - // Two roster members deliberately have no photo; their cards must survive. - expect(cardsWithoutImage).toBeGreaterThan(0); + expect(missing).toBeGreaterThan(0); }); }); -test.describe("navigation", () => { - test("dropdown arrow keys retain the first and last item boundaries", async ({ +test.describe("keyboard navigation and history", () => { + test("desktop menus support native keyboard navigation and return focus on Escape", async ({ page, baseURL, + browserName, }) => { await goOffline(page, baseURL); await page.goto("/"); - const trigger = page.locator("#navbar-home"); - await trigger.focus(); - await trigger.press("ArrowUp"); - await expect(trigger).toHaveAttribute("aria-expanded", "false"); - await expect(trigger).toBeFocused(); - await trigger.press("ArrowDown"); - const links = page.locator(".site-menu-link"); - await expect(links.first()).toBeFocused(); - await page.keyboard.press("ArrowUp"); - await expect(links.first()).toBeFocused(); - for (let index = 0; index < 5; index++) - await page.keyboard.press("ArrowDown"); - await expect(links.last()).toBeFocused(); + const about = page.getByRole("button", { name: "About", exact: true }); + await about.focus(); + await page.keyboard.press("Enter"); + await expect(about).toHaveAttribute("aria-expanded", "true"); + await tabForward(page, browserName); + await expect( + page.getByRole("link", { name: "Our story", exact: true }), + ).toBeFocused(); + await tabForward(page, browserName); + await expect( + page.getByRole("link", { name: "Our Team", exact: true }), + ).toBeFocused(); await page.keyboard.press("Escape"); - await expect(trigger).toHaveAttribute("aria-expanded", "false"); - await expect(trigger).toBeFocused(); + await expect(about).toHaveAttribute("aria-expanded", "false"); + await expect(about).toBeFocused(); }); - test.describe("on a phone", () => { - test.use({ viewport: { width: 390, height: 844 } }); - - test("the menu opens, navigates and closes", async ({ page, baseURL }) => { - await goOffline(page, baseURL); - await page.goto("/"); - - const toggle = page.locator(".site-nav-toggle"); - const collapse = page.locator(".site-nav-panel"); - await expect(toggle).toBeVisible(); - - await toggle.click(); - await expect(collapse).toHaveClass(/show/); - - await collapse.getByRole("link", { name: "Our Team" }).click(); - await expect(page).toHaveURL(/\/our_team\/leadership$/); - await expect(collapse).not.toHaveClass(/show/); - }); - - test("a hash link closes the menu and still reaches its section", async ({ - page, - baseURL, - }) => { - await goOffline(page, baseURL); - await page.goto("/"); + test("skip link is first, phone menu closes on navigation and Escape", async ({ + page, + baseURL, + browserName, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await goOffline(page, baseURL); + await page.goto("/"); + const skip = page.getByRole("link", { name: /skip to (main )?content/i }); + await tabForward(page, browserName); + await expect(skip).toBeFocused(); + await expect(skip).toBeInViewport(); + await page.keyboard.press("Enter"); + await expect(page.locator("main")).toBeFocused(); + const toggle = page.locator(".site-nav-toggle"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await page.keyboard.press("Escape"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toBeFocused(); + await toggle.click(); + await page + .locator(".site-nav-panel") + .getByRole("button", { name: "About", exact: true }) + .click(); + await page + .locator(".site-nav-panel") + .getByRole("link", { name: /Our Team/i }) + .click(); + await expect(page).toHaveURL(/\/our_team\/leadership\/?$/); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(page.locator("main h1")).toBeFocused(); + }); - const collapse = page.locator(".site-nav-panel"); + test("repeated home hashes scroll again after the phone menu closes", async ({ + page, + baseURL, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await goOffline(page, baseURL); + await page.goto("/"); + const about = page.locator("#about"); + for (let index = 0; index < 2; index += 1) { + await page.evaluate(() => window.scrollTo(0, 0)); await page.locator(".site-nav-toggle").click(); - await expect(collapse).toHaveClass(/show/); - - await collapse.locator("#navbar-home").click(); - await collapse.getByRole("link", { name: "About", exact: true }).click(); - - await expect(collapse).not.toHaveClass(/show/); - // The final scroll happens after the menu finishes closing, so the - // section must be at the top once the collapse animation is done. + await page + .locator(".site-nav-panel") + .getByRole("button", { name: "About", exact: true }) + .click(); + await page.locator('.site-nav-panel a[href="/#about"]').click(); + await expect(page.locator(".site-nav-toggle")).toHaveAttribute( + "aria-expanded", + "false", + ); + await expect(page).toHaveURL(/#about$/); await expect .poll(() => - page - .locator("#about") - .evaluate((node) => Math.abs(node.getBoundingClientRect().top)), + about.evaluate((node) => Math.abs(node.getBoundingClientRect().top)), ) - .toBeLessThan(80); - }); + .toBeLessThan(100); + } }); - test("repeating the same hash scrolls back to the section", async ({ + test("reduced-motion phone hash focus does not wait for an animation timeout", async ({ page, baseURL, }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ reducedMotion: "reduce" }); await goOffline(page, baseURL); await page.goto("/"); - - const about = page.locator("#about"); - await expect(about).toHaveCount(1); - - const openAbout = async () => { - await page.locator("#navbar-home").click(); - await page.getByRole("link", { name: "About", exact: true }).click(); - }; - - await openAbout(); - await expect(page).toHaveURL(/#about$/); - await expect - .poll(() => - about.evaluate((node) => Math.abs(node.getBoundingClientRect().top)), - ) - .toBeLessThan(80); - - await page.evaluate(() => window.scrollTo(0, 0)); - await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); - - // Same hash again: the location does not change, so the app has to scroll - // deliberately rather than rely on the browser. - await openAbout(); + await page.locator(".site-nav-toggle").click(); + await expect(page.locator(".site-nav-panel")).toHaveClass(/\bshow\b/); + await page.clock.install(); + await page + .locator(".site-nav-panel") + .getByRole("button", { name: "About", exact: true }) + .click(); + await page.locator('.site-nav-panel a[href="/#about"]').click(); + // No clock advancement: the old 300ms fallback would leave this unsettled. + await expect(page.locator(".site-nav-panel")).not.toHaveClass( + /disclosure-transition|\bshow\b/, + ); + await expect(page.locator("#about")).toBeFocused(); await expect .poll(() => - about.evaluate((node) => Math.abs(node.getBoundingClientRect().top)), + page + .locator("#about") + .evaluate((node) => Math.abs(node.getBoundingClientRect().top)), ) - .toBeLessThan(80); + .toBeLessThan(40); }); - test("a new page starts at the top and Back does not", async ({ + test("pushed pages focus their heading at the top; Back restores the reading position", async ({ page, baseURL, }) => { await goOffline(page, baseURL); await page.goto("/"); - await expect(page.locator("div.Home")).toBeVisible(); - - // Record the app's own scrolling. The spy survives client navigation - // because these are same-document history moves, and it separates "the - // app scrolled you to the top" from "the browser restored a position", - // which a scroll offset alone cannot. - await page.evaluate(() => { - const scrolls: unknown[][] = []; - window.__scrolls = scrolls; - const original = window.scrollTo.bind(window); - window.scrollTo = (x?: number | ScrollToOptions, y?: number) => { - if (typeof x === "number") { - scrolls.push([x, y ?? 0]); - original(x, y ?? 0); - } else { - scrolls.push([x]); - original(x); - } - }; - }); await page.evaluate(() => window.scrollTo(0, 1200)); await expect - .poll(() => page.evaluate(() => Math.round(window.scrollY))) + .poll(() => page.evaluate(() => window.scrollY)) .toBeGreaterThan(600); - + const previous = await page.evaluate(() => window.scrollY); + // Trigger the link without Playwright scrolling the header into view first. await page - .locator(".Header") - .getByRole("link", { name: "Our Team" }) - .click(); - await expect(page.locator(".Team")).toBeVisible(); - // A pushed route starts at the top of the new page. + .locator('.Header a[href="/our_team/leadership"]') + .evaluate((node) => { + if (node instanceof HTMLElement) node.click(); + }); + await expect(page.locator("main h1")).toBeFocused(); await expect - .poll(() => page.evaluate(() => Math.round(window.scrollY))) + .poll(() => page.evaluate(() => window.scrollY)) .toBeLessThan(100); - expect(await page.evaluate(() => window.__scrolls)).toContainEqual([0, 0]); - - await page.evaluate(() => { - window.__scrolls?.splice(0); - }); await page.goBack(); await expect(page.locator("div.Home")).toBeVisible(); - await page.waitForTimeout(500); - - // Going back must leave scrolling to the browser's restoration. - const scrollsOnBack = await page.evaluate(() => window.__scrolls); - expect(scrollsOnBack, "the app scrolled during a Back navigation").toEqual( - [], - ); + await expect + .poll(() => page.evaluate(() => window.scrollY)) + .toBeGreaterThan(600); + expect( + Math.abs((await page.evaluate(() => window.scrollY)) - previous), + ).toBeLessThan(100); }); }); test.describe("media", () => { - test("archived videos load nothing until activated, and keep their box", async ({ + test("footer links and archived video buttons have specific names; activation keeps geometry and moves focus", async ({ page, baseURL, }) => { const offline = await goOffline(page, baseURL); await page.goto("/resources"); - - const facade = page.locator(".video-facade").first(); - await expect(facade).toBeVisible(); - expect( - offline.external.filter((url) => url.includes("youtube")), - "a video requested YouTube before activation", - ).toEqual([]); + for (const link of await page + .getByRole("contentinfo") + .getByRole("link") + .all()) + await expect(link).toHaveAccessibleName(/\S/); + for (const summary of await page + .locator("#workshops details summary") + .all()) + await summary.click(); + const facades = page.locator(".video-facade"); + expect(await facades.count()).toBeGreaterThan(1); + const names = []; + for (const facade of await facades.all()) { + await expect(facade).toHaveAccessibleName(/^Play video: .+/); + await expect(facade).not.toHaveAccessibleName(/Embedded youtube/i); + names.push(await facade.getAttribute("aria-label")); + } + expect(new Set(names).size).toBe(names.length); + expect(offline.external.filter((url) => url.includes("youtube"))).toEqual( + [], + ); await expect(page.locator("iframe")).toHaveCount(0); - + const facade = facades.first(); const frame = page.locator(".video-frame").first(); const before = await frame.boundingBox(); - - // Keyboard activation: the facade is a real button, not a clickable div. await facade.focus(); - await expect(facade).toBeFocused(); await page.keyboard.press("Enter"); - const iframe = page.locator(".video-frame iframe").first(); - await expect(iframe).toHaveCount(1); await expect(iframe).toHaveAttribute("src", /youtube\.com\/embed\//); - + await expect(iframe).toHaveAttribute("title", /\S/); + await expect(iframe).toBeFocused(); const after = await frame.boundingBox(); - if (!before || !after) throw new Error("the video frame has no box"); + if (!before || !after) throw new Error("video frame has no reserved box"); expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1); expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1); - // Activation is the only thing that may reach YouTube, and it is blocked here. + expect(Math.abs(before.width / before.height - 16 / 9)).toBeLessThan(0.02); await expect .poll(() => offline.external.some((url) => url.includes("youtube"))) .toBe(true); + expect(offline.pageErrors).toEqual([]); }); - test("shared documents and maps resolve", async ({ + test("shared documents retain working URLs, format and truthful size labels", async ({ page, baseURL, request, }) => { await goOffline(page, baseURL); const checked = new Set(); - - for (const path of [ - "/resources", - "/resources2023", - "/resources2026", - "/register", - ]) { + for (const { path } of ARCHIVES) { await page.goto(path); - const hrefs = await page - .locator('a[href*="/static/media/"]') - .evaluateAll((links) => - links.map((link) => (link as HTMLAnchorElement).href), - ); - expect(hrefs.length, `no document links on ${path}`).toBeGreaterThan(0); - - for (const href of hrefs) { + const links = page.locator('main a[href*="/static/media/"]'); + expect(await links.count(), path).toBeGreaterThan(0); + for (const link of await links.all()) { + const href = await link.getAttribute("href"); + if (!href) throw new Error("document has no URL"); + await expect(link).toContainText(/PDF|JPG|PNG/); + await expect(link).toContainText(/\d+(?:\.\d+)?\s*(?:MB|kB)/); + await expect(link).toContainText(/opens in a new tab/i); if (checked.has(href)) continue; checked.add(href); const response = await request.head(href); - expect(response.status(), `${href} on ${path}`).toBe(200); - expect(Number(response.headers()["content-length"])).toBeGreaterThan(0); + expect(response.status(), `${path}: ${href}`).toBe(200); + const bytes = Number(response.headers()["content-length"]); + expect(bytes).toBeGreaterThan(0); + const label = + bytes >= 1_000_000 + ? `${(bytes / 1_000_000).toFixed(1)} MB` + : `${(bytes / 1000).toFixed(1)} kB`; + await expect(link).toContainText(label); } } - - // The test server must not dress a missing asset up as the SPA shell. const missing = await request.get( "/static/media/not-a-real-file.abcdef12.pdf", ); @@ -485,18 +876,8 @@ test.describe("media", () => { }); }); -test.describe("carousel", () => { +test.describe("manual gallery", () => { type Slide = { readonly alt: string; readonly asset: string }; - - /** - * Read the slide list from a throwaway context. - * - * Slides are responsive, and which srcset candidate the browser picks is - * not known before it requests one, so slides are identified by the shared - * part of their filename rather than by one URL. The throwaway context also - * leaves the real page's HTTP cache empty, which is what lets the held and - * failed requests reach the route handler at all. - */ async function readSlides( browser: Browser, baseURL: string | undefined, @@ -509,9 +890,14 @@ test.describe("carousel", () => { await expect(page.locator(".carousel-item.active img")).toBeVisible(); return await page.locator(".carousel-item img").evaluateAll((images) => images.map((image) => { - const slide = image as HTMLImageElement; - const file = new URL(slide.src).pathname.split("/").pop() ?? ""; - return { alt: slide.alt, asset: file.split(/-\d+w\./)[0] }; + const file = + new URL(image.getAttribute("src") ?? "", location.href).pathname + .split("/") + .pop() ?? ""; + return { + alt: image.getAttribute("alt") ?? "", + asset: file.split(/-\d+w\./)[0] ?? file, + }; }), ); } finally { @@ -519,75 +905,120 @@ test.describe("carousel", () => { } } - test("a slow slide keeps the current one visible until it loads", async ({ + for (const reducedMotion of ["reduce", "no-preference"] satisfies Array< + "reduce" | "no-preference" + >) { + test(`keyboard dots select manually with no autoplay (${reducedMotion})`, async ({ + page, + baseURL, + }) => { + await page.emulateMedia({ reducedMotion }); + await goOffline(page, baseURL); + await page.goto("/"); + const buttons = slideButtons(page); + expect(await buttons.count()).toBeGreaterThan(2); + for (const button of await buttons.all()) + await expect(button).toHaveAccessibleName(/\S/); + const initial = await activeSlideAlt(page); + await page.clock.install(); + await page.clock.fastForward(20_000); + expect(await activeSlideAlt(page)).toBe(initial); + await buttons.nth(1).focus(); + await page.keyboard.press("Enter"); + await expect.poll(() => activeSlideAlt(page)).not.toBe(initial); + await expect(buttons.nth(1)).toHaveAttribute("aria-pressed", "true"); + const selected = await activeSlideAlt(page); + await page.clock.fastForward(20_000); + expect(await activeSlideAlt(page)).toBe(selected); + if (reducedMotion === "reduce") { + expect( + await page + .locator(".photo-gallery") + .evaluate( + (node) => + node + .getAnimations({ subtree: true }) + .filter((animation) => animation.playState === "running") + .length, + ), + ).toBe(0); + } + await page + .getByRole("button", { name: "Previous photo", exact: true }) + .click(); + await expect.poll(() => activeSlideAlt(page)).toBe(initial); + await page + .getByRole("button", { name: "Next photo", exact: true }) + .click(); + await expect.poll(() => activeSlideAlt(page)).toBe(selected); + await expect(page.locator(".gallery-status")).toContainText( + selected ?? "", + ); + }); + } + + test("a slow selection keeps the visible slide and a stale load cannot replace a newer selection", async ({ browser, baseURL, }) => { const slides = await readSlides(browser, baseURL); - expect(slides.length).toBeGreaterThan(2); - + const first = slides[0]; + const slow = slides[2]; + const newer = slides[3]; + if (!first || !slow || !newer) + throw new Error("gallery needs four authored slides for this regression"); const context = await browser.newContext({ baseURL }); const page = await context.newPage(); const offline = await goOffline(page, baseURL); const { promise, resolve } = Promise.withResolvers(); - offline.hold = { - fragment: slides[2].asset, - wait: promise, - release: resolve, - }; - // `load` would wait for the held image, so settle on the parsed document. - await page.goto("/", { waitUntil: "domcontentloaded" }); - await expect(page.locator(".carousel-item.active img")).toBeVisible(); - - const firstAlt = await activeSlideAlt(page); - expect(firstAlt).toBe(slides[0].alt); - - await page.locator(".carousel-indicators li").nth(2).click(); - // The requested slide has not arrived, so the visible one must not change. - await page.waitForTimeout(1000); - expect(await activeSlideAlt(page)).toBe(firstAlt); - - offline.hold.release(); - await expect - .poll(() => activeSlideAlt(page), { timeout: 10_000 }) - .toBe(slides[2].alt); - expect(offline.pageErrors).toEqual([]); - await context.close(); + offline.hold = { fragment: slow.asset, wait: promise, release: resolve }; + try { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect.poll(() => activeSlideAlt(page)).toBe(first.alt); + await slideButtons(page).nth(2).click(); + await page.waitForTimeout(500); + expect(await activeSlideAlt(page)).toBe(first.alt); + await slideButtons(page).nth(3).click(); + await expect.poll(() => activeSlideAlt(page)).toBe(newer.alt); + resolve(); + await page.waitForLoadState("load"); + await page.waitForTimeout(500); + expect(await activeSlideAlt(page)).toBe(newer.alt); + await slideButtons(page).nth(2).click(); + await expect.poll(() => activeSlideAlt(page)).toBe(slow.alt); + expect(offline.pageErrors).toEqual([]); + } finally { + resolve(); + await context.close(); + } }); - test("a broken slide is skipped and the carousel keeps working", async ({ + test("a broken slide is skipped without locking manual selection", async ({ browser, baseURL, }) => { const slides = await readSlides(browser, baseURL); - expect(slides.length).toBeGreaterThan(2); - + const first = slides[0]; + const broken = slides[1]; + const next = slides[2]; + if (!first || !broken || !next) + throw new Error( + "gallery needs three authored slides for this regression", + ); const context = await browser.newContext({ baseURL }); const page = await context.newPage(); const offline = await goOffline(page, baseURL); - offline.fail = slides[1].asset; - await page.goto("/"); - - const firstAlt = await activeSlideAlt(page); - await page.locator(".carousel-indicators li").nth(1).click(); - - // The broken slide is passed over rather than displayed or retried. - await expect - .poll(() => activeSlideAlt(page), { timeout: 5_000 }) - .toBe(slides[2].alt); - const seen: (string | null)[] = []; - for (let sample = 0; sample < 15; sample += 1) { - seen.push(await activeSlideAlt(page)); - await page.waitForTimeout(200); + offline.fail = broken.asset; + try { + await page.goto("/"); + await slideButtons(page).nth(1).click(); + await expect.poll(() => activeSlideAlt(page)).toBe(next.alt); + expect(await activeSlideAlt(page)).not.toBe(broken.alt); + await slideButtons(page).nth(0).click(); + await expect.poll(() => activeSlideAlt(page)).toBe(first.alt); + expect(offline.pageErrors).toEqual([]); + } finally { + await context.close(); } - expect(seen, "the broken slide was displayed").not.toContain(slides[1].alt); - - // Selection still works afterwards: the failure did not lock the carousel. - await page.locator(".carousel-indicators li").nth(0).click(); - await expect - .poll(() => activeSlideAlt(page), { timeout: 5_000 }) - .toBe(firstAlt); - expect(offline.pageErrors).toEqual([]); - await context.close(); }); }); diff --git a/scripts/content-check.mjs b/scripts/content-check.mjs index febee12..a7c3fba 100755 --- a/scripts/content-check.mjs +++ b/scripts/content-check.mjs @@ -74,6 +74,36 @@ function checkLinks(where, links, options) { if (link.name.trim() === "") report(at, "name is empty; it is the visible link text"); checkUrl(`${at}.src`, link.src, options); + if (link.file) { + const clean = link.src.split(/[?#]/)[0]; + const onDisk = clean.startsWith("/@fs/") + ? clean.slice(4) + : path.join(repoRoot, clean); + if (fs.existsSync(onDisk)) { + const bytes = fs.statSync(onDisk).size; + if (bytes !== link.file.bytes) + report( + at, + `file size label is ${link.file.bytes} bytes; asset is ${bytes}`, + ); + const extension = path + .extname(onDisk) + .slice(1) + .toUpperCase() + .replace(/^JPEG$/, "JPG"); + if (extension !== link.file.format) + report( + at, + `format label ${link.file.format} does not match ${extension}`, + ); + } + } else if ( + options?.local || + link.src.startsWith("/src/") || + link.src.startsWith("/@fs/") + ) { + report(at, "local downloads need file format and byte size labels"); + } if (seen.has(link.src)) report(at, `same target as ${where}[${seen.get(link.src)}]`); else seen.set(link.src, index); @@ -153,17 +183,14 @@ function checkEvent(year, event) { const where = `EVENTS["${year}"]`; if (event.year !== year) report(where, `year field "${event.year}" does not match its key`); - checkUrl(`${where}.programHref`, event.programHref); + checkLinks(`${where}.program`, [event.program]); checkLinks(`${where}.maps`, event.maps, { local: true }); if (event.waivers) checkLinks(`${where}.waivers`, event.waivers, { local: true }); - if (event.wristbandImage) { - checkUrl(`${where}.wristbandImage.src`, event.wristbandImage.src, { + if (event.wristbandImage) + checkLinks(`${where}.wristbandImage`, [event.wristbandImage], { local: true, }); - } - if (event.feedback.kind === "link") - checkUrl(`${where}.feedback.href`, event.feedback.href); const scheduleKeys = new Set(); for (const entry of event.schedule) { @@ -203,15 +230,6 @@ function checkEvent(year, event) { const keys = new Set(event.faq.items.map((item) => item.key)); if (keys.size !== event.faq.items.length) report(`${where}.faq`, "duplicate FAQ keys"); - if ( - event.faq.initialOpenKey !== null && - !keys.has(event.faq.initialOpenKey) - ) { - report( - `${where}.faq.initialOpenKey`, - `${event.faq.initialOpenKey} matches no entry`, - ); - } for (const item of event.faq.items) { if (item.question.trim() === "" || item.answer.trim() === "") { report( @@ -238,7 +256,12 @@ function checkEvents(events, eventRoutes, registration) { report("EVENT_ROUTES", `route "${route}" is not an absolute path`); if (navLabel.trim() === "") report("EVENT_ROUTES", `"${route}" has no menu label`); - else if (labels.has(navLabel)) + if (!navLabel.includes(year)) + report( + "EVENT_ROUTES", + `"${route}" label does not identify its ${year} archive`, + ); + if (labels.has(navLabel)) report("EVENT_ROUTES", `duplicate menu label "${navLabel}"`); labels.add(navLabel); if (!Object.hasOwn(events, year)) @@ -250,26 +273,37 @@ function checkEvents(events, eventRoutes, registration) { report(`EVENTS["${year}"]`, "no EVENT_ROUTES entry reaches it"); } - if (!registration.alert.to.startsWith("/")) { - report( - "REGISTRATION_2021.alert.to", - `must be an in-app route, got "${registration.alert.to}"`, - ); + checkLinks("REGISTRATION_2021.documents", registration.documents); + checkLinks("REGISTRATION_2021.waivers", registration.waivers); +} + +function checkParticipation({ + PROGRAMS, + AUDIENCES, + CONTACT_EMAIL, + UCLA_UPDATES_URL, +}) { + for (const [id, entry] of Object.entries({ ...PROGRAMS, ...AUDIENCES })) { + const where = `participation.${id}`; + const inquiry = new URL(entry.inquiryHref); + if (inquiry.protocol !== "mailto:" || inquiry.pathname !== CONTACT_EMAIL) + report(where, "inquiry must reach the shared contact email"); + if (!inquiry.searchParams.get("subject")?.trim()) + report(where, "inquiry needs a useful subject"); + if (!entry.actionLabel.trim()) report(where, "inquiry link has no label"); } - for (const [field, value] of Object.entries(registration.importantLinks)) { - if (Array.isArray(value)) - checkLinks(`REGISTRATION_2021.importantLinks.${field}`, value); - else if (value?.src) - checkUrl(`REGISTRATION_2021.importantLinks.${field}.src`, value.src); + for (const [id, program] of Object.entries(PROGRAMS)) { + if (!program.status.trim()) + report(`PROGRAMS.${id}`, "missing published-status notice"); } - checkLinks("REGISTRATION_2021.waivers.forms", registration.waivers.forms, { - local: true, - }); - // The combined-forms link is an external short URL, not a bundled PDF. - checkUrl( - "REGISTRATION_2021.waivers.allForms.src", - registration.waivers.allForms.src, - ); + for (const [id, audience] of Object.entries(AUDIENCES)) { + if (audience.path !== `/get-involved#${id}`) + report( + `AUDIENCES.${id}`, + "audience path must reach its participation section", + ); + } + checkUrl("UCLA_UPDATES_URL", UCLA_UPDATES_URL); } async function loadContent() { @@ -294,7 +328,10 @@ async function loadContent() { "/src/content/speakers.ts", ); const events = await server.ssrLoadModule("/src/content/events/index.ts"); - return { people, teamSections, pastSpeakers, ...events }; + const participation = await server.ssrLoadModule( + "/src/content/participation.ts", + ); + return { people, teamSections, pastSpeakers, ...events, ...participation }; } finally { await server.close(); } @@ -312,6 +349,7 @@ try { checkPeople(content.people); checkTeams(content.teamSections, content.people, content.pastSpeakers); checkEvents(content.EVENTS, content.EVENT_ROUTES, content.REGISTRATION_2021); +checkParticipation(content); if (problems.length > 0) { console.error(`Content check failed with ${problems.length} problem(s):\n`); diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs new file mode 100644 index 0000000..4b1ab59 --- /dev/null +++ b/scripts/prerender.mjs @@ -0,0 +1,110 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { build } from "vite"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const output = path.join(root, "build"); +const template = await fs.readFile(path.join(output, "index.html"), "utf8"); +if ( + !template.includes('
') || + !template.includes("") +) { + throw new Error( + "The built HTML must contain one empty #root and a title for static rendering.", + ); +} +const escapeHtml = (value) => + String(value).replace( + /[&<>"']/g, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + character + ], + ); +const temporary = await fs.mkdtemp(path.join(root, ".prerender-")); + +try { + await build({ + root, + logLevel: "warn", + build: { + ssr: "src/app/prerender.tsx", + outDir: temporary, + emptyOutDir: true, + copyPublicDir: false, + rolldownOptions: { output: { entryFileNames: "entry.mjs" } }, + }, + }); + const { + renderPage, + SITE_PAGES, + SHARE_IMAGE, + SHARE_IMAGE_ALT, + SITE_ORIGIN, + canonicalUrl, + } = await import(pathToFileURL(path.join(temporary, "entry.mjs")).href); + const emittedPaths = new Set(); + for (const page of SITE_PAGES) { + if ( + !/^\/(?:[a-z0-9_-]+(?:\/[a-z0-9_-]+)*)?$/.test(page.path) || + emittedPaths.has(page.path) + ) { + throw new Error(`Invalid or duplicate static page path: ${page.path}`); + } + emittedPaths.add(page.path); + const canonical = canonicalUrl(page); + const head = [ + `<title>${escapeHtml(page.title)}`, + ``, + ``, + ``, + ...Object.entries({ + "og:type": "website", + "og:site_name": "exploretech.la", + "og:title": page.title, + "og:description": page.description, + "og:url": canonical, + "og:image": SHARE_IMAGE, + "og:image:alt": SHARE_IMAGE_ALT, + }).map( + ([key, value]) => + ``, + ), + ...Object.entries({ + "twitter:card": "summary_large_image", + "twitter:title": page.title, + "twitter:description": page.description, + "twitter:image": SHARE_IMAGE, + "twitter:image:alt": SHARE_IMAGE_ALT, + }).map( + ([key, value]) => ``, + ), + ].join("\n "); + const html = template + .replace(/.*?<\/title>/s, head) + .replace( + '<div id="root"></div>', + `<div id="root" data-page="${escapeHtml(page.path)}">${renderPage(page.path === "/" ? "/" : `${page.path}/`)}</div>`, + ); + const directory = path.join(output, page.path.slice(1)); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, "index.html"), html); + } + const locations = SITE_PAGES.map( + (page) => ` <url><loc>${escapeHtml(canonicalUrl(page))}</loc></url>`, + ).join("\n"); + await fs.writeFile( + path.join(output, "sitemap.xml"), + `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${locations}\n</urlset>\n`, + ); + await fs.writeFile( + path.join(output, "robots.txt"), + `User-agent: *\nAllow: /\nSitemap: ${SITE_ORIGIN}/sitemap.xml\n`, + ); + console.log( + `Prerendered ${SITE_PAGES.length} public routes with page-specific metadata, sitemap and robots.txt`, + ); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} diff --git a/scripts/serve-built-site.cjs b/scripts/serve-built-site.cjs index 985bfaf..647f7c2 100755 --- a/scripts/serve-built-site.cjs +++ b/scripts/serve-built-site.cjs @@ -2,16 +2,14 @@ /** * Static server for the production build that behaves like GitHub Pages. * - * The deployed site is a single-page app served from the root of the - * `gh-pages` branch. Pages has no SPA rewrite: a deep path such as - * `/our_team/design` is a real 404, and the published `404.html` turns that - * path into `/?/our_team/design` so the restore script in `index.html` can - * hand it back to the router. A plain "serve index.html for everything" - * development server hides that hop, so deep links, trailing slashes and the - * not-found page all look healthier locally than in production. + * Known routes have generated directory index files. Pages redirects their + * extensionless paths to a trailing slash and serves useful HTML with 200. + * Unknown paths and legacy aliases still use the published 404-to-root + * restoration script. A development SPA fallback hides both behaviors. * * This server therefore: * - serves real files from the build directory, + * - redirects generated directory paths to their slash URL, preserving query, * - answers unknown document paths with `404.html` and a 404 status, * - answers missing files under the emitted asset prefixes with a plain 404 * instead of HTML, so a broken image or PDF URL fails loudly, @@ -99,10 +97,10 @@ function createSiteServer(options = {}) { return http.createServer((req, res) => { let pathname; + let url; try { - pathname = decodeURIComponent( - new URL(req.url, "http://localhost").pathname, - ); + url = new URL(req.url, "http://localhost"); + pathname = decodeURIComponent(url.pathname); } catch { res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Bad request URI"); @@ -118,12 +116,13 @@ function createSiteServer(options = {}) { } let status = 200; - // GitHub Pages serves index.html for a directory that contains one. - let file = - readableFile(resolved) || - (pathname.endsWith("/") - ? readableFile(path.join(resolved, "index.html")) - : null); + const index = readableFile(path.join(resolved, "index.html")); + if (index && !pathname.endsWith("/")) { + res.writeHead(301, { Location: `${url.pathname}/${url.search}` }); + res.end(); + return; + } + let file = readableFile(resolved) || index; if (!file) { if (isAssetPath(pathname)) { diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 6b4b706..68168c7 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -63,3 +63,35 @@ it("records a new route with its query but not another view for an anchor", asyn ["/our_team/leadership?source=local"], ]); }); + +it("groups static directory URLs and client routes without duplicate pageviews", async () => { + await renderPath("/events/"); + await act(async () => { + await router.navigate("/events"); + }); + await act(async () => { + await router.navigate("/events?source=school"); + }); + expect(vi.mocked(GA.trackPageView).mock.calls).toEqual([ + ["/events"], + ["/events?source=school"], + ]); +}); + +it("keeps metadata consistent with the unknown-team leadership fallback", async () => { + await renderPath("/our_team/leadership"); + const title = document.title; + const canonical = document.head.querySelector<HTMLLinkElement>( + 'link[rel="canonical"]', + )?.href; + await act(async () => { + await router.navigate("/our_team/not-a-department"); + }); + expect(container.querySelector(".team-content h2")?.textContent).toBe( + "Leadership", + ); + expect(document.title).toBe(title); + expect( + document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href, + ).toBe(canonical); +}); diff --git a/src/app/App.tsx b/src/app/App.tsx index 1592707..88b072a 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -8,50 +8,86 @@ import { } from "react-router-dom"; import GA from "../util/GoogleAnalytics"; import Header from "../components/common/Header"; +import Footer from "../components/Footer"; import Home from "../features/home/Home"; import RegistrationPage from "../features/registration/RegistrationPage"; +import Events from "../features/events/Events"; import EventPage from "../features/events/EventPage"; +import Participation from "../features/participation/Participation"; import Team from "../features/team/Team"; import PageNotFound from "../components/PageNotFound"; import Ignite from "../features/ignite/Ignite"; - import { EVENT_ROUTES } from "../content/events"; +import { normalizePath } from "../content/pages"; +import { focusHashTarget, focusPageHeading } from "./navigation"; +import PageMetadata from "./PageMetadata"; + export default function App() { const { pathname, search, hash, key } = useLocation(); const navigationType = useNavigationType(); const initialNavigation = useRef(true); - const redirecting = /^\/our_team\/?$/i.test(pathname); + const analyticsPath = normalizePath(pathname); + const redirecting = analyticsPath === "/our_team"; useEffect(() => { - // Alias routes never represent a displayed page. Track only their destination. - if (!redirecting && GA.init()) GA.trackPageView(pathname + search); - }, [pathname, search, redirecting]); + if (!redirecting && GA.init()) GA.trackPageView(analyticsPath + search); + }, [analyticsPath, search, redirecting]); useEffect(() => { + if (redirecting) return; const initial = initialNavigation.current; initialNavigation.current = false; if (!initial && navigationType === "POP") return; - if (hash) document.getElementById(hash.slice(1))?.scrollIntoView(); - else window.scrollTo(0, 0); - }, [pathname, hash, key, navigationType]); + if (hash) focusHashTarget(hash); + else if (!initial) { + // Route-based selectors can opt into retaining their own keyboard focus. + const active = document.activeElement; + if ( + active instanceof HTMLElement && + active.dataset.preserveRouteFocus === "true" && + document.getElementById("main-content")?.contains(active) + ) + return; + window.scrollTo(0, 0); + focusPageHeading(); + } + }, [pathname, hash, key, navigationType, redirecting]); return ( <div className="App"> + <PageMetadata /> + <a className="skip-link" href="#main-content"> + Skip to main content + </a> <Header /> - <Routes> - <Route path="/" element={<Home />} /> - <Route path="/register" element={<RegistrationPage />} /> - {EVENT_ROUTES.map(({ route, year }) => ( - <Route key={route} path={route} element={<EventPage year={year} />} /> - ))} - <Route - path="/our_team" - element={<Navigate replace to="/our_team/leadership" />} - /> - <Route path="/our_team/:section" element={<Team />} /> - <Route path="/ignite" element={<Ignite />} /> - <Route path="*" element={<PageNotFound />} /> - </Routes> + <main id="main-content" tabIndex={-1}> + <Routes> + <Route path="/" element={<Home />} /> + <Route path="/events" element={<Events />} /> + <Route path="/get-involved" element={<Participation />} /> + <Route path="/register" element={<RegistrationPage />} /> + {EVENT_ROUTES.map(({ route, year }) => ( + <Route + key={route} + path={route} + element={<EventPage key={year} year={year} />} + /> + ))} + <Route + path="/our_team" + element={ + <Navigate + replace + to={{ pathname: "/our_team/leadership", search, hash }} + /> + } + /> + <Route path="/our_team/:section" element={<Team />} /> + <Route path="/ignite" element={<Ignite />} /> + <Route path="*" element={<PageNotFound />} /> + </Routes> + </main> + <Footer /> </div> ); } diff --git a/src/app/PageMetadata.tsx b/src/app/PageMetadata.tsx new file mode 100644 index 0000000..d1b66a5 --- /dev/null +++ b/src/app/PageMetadata.tsx @@ -0,0 +1,60 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { + canonicalUrl, + getPageMetadata, + SHARE_IMAGE, + SHARE_IMAGE_ALT, +} from "../content/pages"; + +function setMeta(attribute: "name" | "property", key: string, content: string) { + let element = document.head.querySelector<HTMLMetaElement>( + `meta[${attribute}="${key}"]`, + ); + if (!element) { + element = document.createElement("meta"); + element.setAttribute(attribute, key); + document.head.appendChild(element); + } + element.content = content; +} + +export default function PageMetadata() { + const { pathname } = useLocation(); + useEffect(() => { + const page = getPageMetadata(pathname); + const url = canonicalUrl(page); + document.title = page.title; + setMeta("name", "description", page.description); + setMeta( + "name", + "robots", + page.indexable ? "index,follow" : "noindex,follow", + ); + for (const [key, value] of Object.entries({ + "og:type": "website", + "og:site_name": "exploretech.la", + "og:title": page.title, + "og:description": page.description, + "og:url": url, + "og:image": SHARE_IMAGE, + "og:image:alt": SHARE_IMAGE_ALT, + })) + setMeta("property", key, value); + setMeta("name", "twitter:card", "summary_large_image"); + setMeta("name", "twitter:title", page.title); + setMeta("name", "twitter:description", page.description); + setMeta("name", "twitter:image", SHARE_IMAGE); + setMeta("name", "twitter:image:alt", SHARE_IMAGE_ALT); + let canonical = document.head.querySelector<HTMLLinkElement>( + 'link[rel="canonical"]', + ); + if (!canonical) { + canonical = document.createElement("link"); + canonical.rel = "canonical"; + document.head.appendChild(canonical); + } + canonical.href = url; + }, [pathname]); + return null; +} diff --git a/src/app/main.tsx b/src/app/main.tsx index 404d286..fcf3652 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,12 +1,19 @@ -import { createRoot } from "react-dom/client"; +import { createRoot, hydrateRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; +import { normalizePath } from "../content/pages"; import "../styles/theme.css"; const root = document.getElementById("root"); if (!root) throw new Error("The application root is missing from index.html"); -createRoot(root).render( +const app = ( <BrowserRouter> <App /> - </BrowserRouter>, + </BrowserRouter> ); +if (root.dataset.page === normalizePath(window.location.pathname)) { + hydrateRoot(root, app); +} else { + // The Pages 404 recovery can serve the home shell for a different route. + createRoot(root).render(app); +} diff --git a/src/app/navigation.ts b/src/app/navigation.ts new file mode 100644 index 0000000..58f6b50 --- /dev/null +++ b/src/app/navigation.ts @@ -0,0 +1,23 @@ +export function focusHashTarget(hash: string): boolean { + let id = hash.replace(/^#/, ""); + try { + id = decodeURIComponent(id); + } catch { + return false; + } + const target = document.getElementById(id); + if (!target) return false; + if (!target.hasAttribute("tabindex")) target.tabIndex = -1; + target.focus({ preventScroll: true }); + target.scrollIntoView(); + return true; +} + +export function focusPageHeading() { + const target = + document.querySelector<HTMLElement>("#main-content h1") ?? + document.getElementById("main-content"); + if (!target) return; + target.tabIndex = -1; + target.focus({ preventScroll: true }); +} diff --git a/src/app/prerender.tsx b/src/app/prerender.tsx new file mode 100644 index 0000000..918564f --- /dev/null +++ b/src/app/prerender.tsx @@ -0,0 +1,20 @@ +import { renderToString } from "react-dom/server"; +import { StaticRouter } from "react-router-dom"; +import App from "./App"; + +export { + SITE_PAGES, + SHARE_IMAGE, + SHARE_IMAGE_ALT, + SITE_ORIGIN, + getPageMetadata, + canonicalUrl, +} from "../content/pages"; + +export function renderPage(path: string): string { + return renderToString( + <StaticRouter location={path}> + <App /> + </StaticRouter>, + ); +} diff --git a/src/components/ActionLink.tsx b/src/components/ActionLink.tsx deleted file mode 100644 index 7d84ecb..0000000 --- a/src/components/ActionLink.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { AnchorHTMLAttributes } from "react"; - -/** - * React Bootstrap rendered `<Button href>` through SafeAnchor, which swallowed the - * Space key so the page never scrolled while a call-to-action link had focus. - * Native anchors scroll instead, so the behavior is kept explicitly here. - */ - -export default function ActionLink({ - onKeyDown, - ...props -}: AnchorHTMLAttributes<HTMLAnchorElement>) { - return ( - <a - {...props} - onKeyDown={(event) => { - if (event.key === " ") event.preventDefault(); - onKeyDown?.(event); - }} - /> - ); -} diff --git a/src/components/Collapse.tsx b/src/components/Collapse.tsx index b76f0f6..4ca8c8c 100644 --- a/src/components/Collapse.tsx +++ b/src/components/Collapse.tsx @@ -15,11 +15,8 @@ const STATUS_CLASS: Record<Status, string> = { exited: "disclosure", }; -/** - * Bootstrap animates `.collapsing` height over 350ms, but React Bootstrap also armed a - * 300ms fallback timer and settled on whichever finished first — so the panel snapped - * to its final height at 300ms. Both are kept so the motion is unchanged. - */ +// Normal motion settles on transitionend, with a bounded safety timeout. +// Without a transition, visibility and focus completion must settle immediately. const FALLBACK_MS = 300; function outerHeight(node: HTMLElement): number { @@ -99,6 +96,13 @@ export default function Collapse({ if (event.target === node) finish(); }; + const durations = window + .getComputedStyle(node) + .transitionDuration.split(","); + if (durations.every((duration) => (parseFloat(duration) || 0) === 0)) { + finish(); + return; + } timer = window.setTimeout(finish, FALLBACK_MS); node.addEventListener("transitionend", handleTransitionEnd); diff --git a/src/components/DocumentLink.tsx b/src/components/DocumentLink.tsx new file mode 100644 index 0000000..998e219 --- /dev/null +++ b/src/components/DocumentLink.tsx @@ -0,0 +1,33 @@ +import type { DocumentLink as Document } from "../content/types"; + +export default function DocumentLink({ + document, + className = "text-link", +}: { + document: Document; + className?: string; +}) { + const { file } = document; + const size = file + ? file.bytes >= 1_000_000 + ? `${(file.bytes / 1_000_000).toFixed(1)} MB` + : `${(file.bytes / 1_000).toFixed(1)} kB` + : null; + + return ( + <a + href={document.src} + target="_blank" + rel="noopener noreferrer" + className={className} + > + <span>{document.name}</span> + {file && ( + <span className="document-meta"> + {file.format}, {size} + </span> + )} + <span className="document-meta">opens in a new tab</span> + </a> + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index dd06767..9073d24 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,70 +1,74 @@ +import { Link } from "react-router-dom"; import { FaFacebook, FaInstagram, FaLinkedin, FaHeart } from "react-icons/fa"; - import OutboundLink from "./OutboundLink"; import LogoWithIcons from "../static/svg/logo-with-icons-white.svg"; - -const facebookLink = "https://www.facebook.com/exploretech.la/"; -const instagramLink = "https://www.instagram.com/exploretech.la/"; -const linkedinLink = "https://www.linkedin.com/company/exploretech-la/"; +import { CONTACT_EMAIL } from "../content/participation"; export default function Footer() { return ( - <section className="Section Footer"> - <div className="footer-inner"> - <div className="footer-item logo"> - <img - src={LogoWithIcons} - className="logo-with-icons" - alt="logo-with-icons" - /> + <footer className="Footer on-dark"> + <div className="page-container footer-inner"> + <div className="footer-brand"> + <Link to="/" aria-label="exploretech.la home"> + <img src={LogoWithIcons} className="logo-with-icons" alt="" /> + </Link> + <p>Technology outreach led by UCLA students.</p> + <p className="footer-credit"> + Made with <FaHeart aria-label="love" role="img" /> in LA + </p> </div> - <div className="footer-item social-media"> - <p>FIND US ON</p> + <nav aria-label="Footer programs"> + <h2>Programs</h2> + <Link to="/events">Annual event and archives</Link> + <Link to="/ignite">Ignite workshops</Link> + <Link to="/our_team/leadership">Meet the team</Link> + </nav> + <nav aria-label="Footer participation"> + <h2>Get involved</h2> + <Link to="/get-involved#schools">Schools and students</Link> + <Link to="/get-involved#volunteer">UCLA volunteers</Link> + <Link to="/get-involved#partners">Partners and sponsors</Link> + </nav> + <div className="footer-contact"> + <h2>Contact us</h2> + <OutboundLink + className="contact-address" + href={`mailto:${CONTACT_EMAIL}`} + eventLabel="Footer Email" + > + {CONTACT_EMAIL} + </OutboundLink> <div className="social-media-list"> <OutboundLink className="social-media-link" - href={facebookLink} + href="https://www.facebook.com/exploretech.la/" target="_blank" - eventLabel="facebook" + aria-label="exploretech.la on Facebook (opens in a new tab)" + eventLabel="Facebook" > - <FaFacebook size="2em" /> + <FaFacebook size="1.5em" aria-hidden="true" focusable="false" /> </OutboundLink> <OutboundLink className="social-media-link" - href={instagramLink} + href="https://www.instagram.com/exploretech.la/" target="_blank" - eventLabel="instagram" + aria-label="exploretech.la on Instagram (opens in a new tab)" + eventLabel="Instagram" > - <FaInstagram size="2em" /> + <FaInstagram size="1.5em" aria-hidden="true" focusable="false" /> </OutboundLink> <OutboundLink className="social-media-link" - href={linkedinLink} + href="https://www.linkedin.com/company/exploretech-la/" target="_blank" - eventLabel="linkedin" + aria-label="exploretech.la on LinkedIn (opens in a new tab)" + eventLabel="LinkedIn" > - <FaLinkedin size="2em" /> + <FaLinkedin size="1.5em" aria-hidden="true" focusable="false" /> </OutboundLink> </div> </div> - <div className="footer-item email"> - <p>REACH US AT</p> - <p> - <OutboundLink - href="mailto:exploretechla@cs.ucla.edu" - target="_blank" - eventLabel="email_us_footer" - > - exploretechla@cs.ucla.edu - </OutboundLink> - </p> - </div> - <div className="footer-item"> - <p> - Made with <FaHeart className="FaHeart" /> in LA - </p> - </div> </div> - </section> + </footer> ); } diff --git a/src/components/OutboundLink.test.tsx b/src/components/OutboundLink.test.tsx new file mode 100644 index 0000000..e7e0723 --- /dev/null +++ b/src/components/OutboundLink.test.tsx @@ -0,0 +1,55 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { vi } from "vitest"; +import OutboundLink from "./OutboundLink"; +import GA from "../util/GoogleAnalytics"; + +vi.mock("../util/GoogleAnalytics", () => ({ + default: { trackOutbound: vi.fn() }, +})); + +it("leaves Alt-click to the browser while recording the outbound action", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let prevented: boolean | undefined; + const observe = (event: Event) => { + prevented = event.defaultPrevented; + // Observe React's decision, then prevent jsdom from attempting navigation. + event.preventDefault(); + }; + document.addEventListener("click", observe, { once: true }); + try { + act(() => + root.render( + <OutboundLink + href="https://example.com/document.pdf" + eventLabel="Document" + > + Read document + </OutboundLink>, + ), + ); + const anchor = container.querySelector("a"); + if (!anchor) throw new Error("Outbound link did not render"); + act(() => + anchor.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + altKey: true, + }), + ), + ); + expect(prevented).toBe(false); + expect(GA.trackOutbound).toHaveBeenCalledWith( + "Document", + expect.any(Function), + ); + } finally { + document.removeEventListener("click", observe); + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + } +}); diff --git a/src/components/OutboundLink.tsx b/src/components/OutboundLink.tsx index 75962ba..4165a33 100644 --- a/src/components/OutboundLink.tsx +++ b/src/components/OutboundLink.tsx @@ -25,6 +25,7 @@ export default function OutboundLink({ !event.ctrlKey && !event.shiftKey && !event.metaKey && + !event.altKey && event.button !== 1; if (sameWindow && href) { event.preventDefault(); diff --git a/src/components/PageNotFound.tsx b/src/components/PageNotFound.tsx index bb14e77..ab758a4 100644 --- a/src/components/PageNotFound.tsx +++ b/src/components/PageNotFound.tsx @@ -1,23 +1,24 @@ -import { useEffect } from "react"; - -import LinkWithArrow from "./common/LinkWithArrow"; +import { Link } from "react-router-dom"; export default function PageNotFound() { - useEffect(() => { - const previousTitle = document.title; - document.title = "Page Not Found • exploretech.la"; - return () => { - document.title = previousTitle; - }; - }, []); - return ( - <section className="PageNotFound"> - <h3>Sorry, this page isn't available.</h3> + <section className="PageNotFound page-container page-header"> + <h1>This page could not be found</h1> <p> - The link you followed may be broken, or the page may have been removed. + The address may have changed. You can explore our programs or find the + right contact below. </p> - <LinkWithArrow text="Go back to exploretech.la" to="/" /> + <div className="action-row"> + <Link className="action action-primary" to="/events"> + Explore programs + </Link> + <Link className="action action-outline" to="/get-involved"> + Get involved + </Link> + <Link className="text-link" to="/"> + Return home + </Link> + </div> </section> ); } diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index 01ecf49..2c223cc 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -1,40 +1,45 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type MouseEvent } from "react"; import { Link, useLocation, useNavigationType } from "react-router-dom"; -import HomePageSections from "../../content/sections"; +import Collapse from "../Collapse"; +import { focusHashTarget } from "../../app/navigation"; +import { normalizePath } from "../../content/pages"; +import GA from "../../util/GoogleAnalytics"; import LogoWithIcons from "../../static/svg/logo-with-icons-navy.svg"; import CompassLogo from "../../static/svg/logo-compass.svg"; -import Collapse from "../Collapse"; -import { EVENT_ROUTES } from "../../content/events"; -const menus = [ - { - id: "home", - title: "Home", +type MenuId = "programs" | "involved" | "about"; +const menus = { + programs: { + title: "Programs", links: [ - ["Home", "/"], - ["About", `/#${HomePageSections.ABOUT.name}`], - ["Get Involved", `/#${HomePageSections.GET_INVOLVED.name}`], - ["Speakers", `/#${HomePageSections.SPEAKERS.name}`], - ["Sponsors", `/#${HomePageSections.SPONSORS.name}`], + ["Annual event and archives", "/events"], + ["Ignite workshops", "/ignite"], ], }, - { id: "events", title: "Events", links: [["Ignite", "/ignite"]] }, - { - id: "resources", - title: "Resources", - links: [...EVENT_ROUTES] - .reverse() - .map(({ route, navLabel }) => [navLabel, route] as const), + involved: { + title: "Get involved", + links: [ + ["UCLA volunteers", "/get-involved#volunteer"], + ["Partners and sponsors", "/get-involved#partners"], + ["All participation options", "/get-involved"], + ], }, -] as const; + about: { + title: "About", + links: [ + ["Our story", "/#about"], + ["Our Team", "/our_team/leadership"], + ], + }, +} as const; -type MenuId = (typeof menus)[number]["id"]; export default function Header() { const location = useLocation(); const navigationType = useNavigationType(); const [expanded, setExpanded] = useState(false); const [menu, setMenu] = useState<MenuId | null>(null); const header = useRef<HTMLElement>(null); + const toggle = useRef<HTMLButtonElement>(null); const pendingHash = useRef<string | null>(null); useEffect(() => { @@ -44,44 +49,82 @@ export default function Header() { }, [location.key, location.hash, navigationType]); useEffect(() => { - if (!menu) return; - const closeOutside = (event: MouseEvent) => { + if (!expanded && !menu) return; + const closeOutside = (event: globalThis.MouseEvent) => { if ( event.target instanceof Node && !header.current?.contains(event.target) - ) + ) { + pendingHash.current = null; setMenu(null); + setExpanded(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setMenu(null); + // Safari can leave focus outside a button that was opened with a pointer. + if (expanded && toggle.current?.getClientRects().length) { + pendingHash.current = null; + setExpanded(false); + toggle.current.focus(); + } else if (menu) document.getElementById(`nav-${menu}`)?.focus(); }; document.addEventListener("click", closeOutside); - return () => document.removeEventListener("click", closeOutside); - }, [menu]); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("click", closeOutside); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [expanded, menu]); - const selectLink = (href: string) => { - const hash = new URL(href, window.location.href).hash; - pendingHash.current = hash; + const selectLink = ( + href: string, + label: string, + event: MouseEvent<HTMLAnchorElement>, + ) => { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + pendingHash.current = new URL(href, window.location.href).hash; setMenu(null); setExpanded(false); - // Same-hash links may not change location, so scroll even without a route effect. - if (!expanded && hash) - requestAnimationFrame(() => - document.getElementById(hash.slice(1))?.scrollIntoView(), - ); + GA.trackEvent({ category: "Navigation", action: "Navigate", label }); }; - const renderMenu = (item: (typeof menus)[number]) => ( - <div - className={`site-menu site-nav-item${menu === item.id ? " show" : ""}`} - key={item.id} - onKeyDown={(event) => { - if (event.key === "Escape") { - setMenu(null); - document.getElementById(`navbar-${item.id}`)?.focus(); - event.preventDefault(); - } - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - if (event.key === "ArrowUp" && menu !== item.id) return; + const isCurrent = (href: string) => { + const [path, hash] = href.split("#"); + return ( + normalizePath(location.pathname) === normalizePath(path) && + (!hash || location.hash === `#${hash}`) + ); + }; + + const renderMenu = (id: MenuId) => { + const item = menus[id]; + const open = menu === id; + return ( + <div + className="site-menu site-nav-item" + key={id} + onBlur={(event) => { + if ( + event.relatedTarget instanceof Node && + !event.currentTarget.contains(event.relatedTarget) + ) + setMenu(null); + }} + onKeyDown={(event) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + if (event.key === "ArrowUp" && !open) return; event.preventDefault(); - setMenu(item.id); + setMenu(id); const parent = event.currentTarget; const backwards = event.key === "ArrowUp"; requestAnimationFrame(() => { @@ -91,118 +134,106 @@ export default function Header() { const index = links.findIndex( (link) => link === document.activeElement, ); - const next = Math.max( - 0, - Math.min(links.length - 1, index + (backwards ? -1 : 1)), - ); - links[next]?.focus(); + links[ + Math.max( + 0, + Math.min(links.length - 1, index + (backwards ? -1 : 1)), + ) + ]?.focus(); }); - } - }} - onBlur={(event) => { - if ( - event.relatedTarget instanceof Node && - !event.currentTarget.contains(event.relatedTarget) - ) - setMenu(null); - }} - > - <a - aria-haspopup="true" - aria-expanded={menu === item.id} - id={`navbar-${item.id}`} - href="#" - className="site-menu-toggle site-nav-link" - role="button" - onClick={(event) => { - event.preventDefault(); - setMenu((current) => (current === item.id ? null : item.id)); - }} - onKeyDown={(event) => { - if (event.key === " ") { - event.preventDefault(); - setMenu((current) => (current === item.id ? null : item.id)); - } }} > - {item.title} - </a> - {menu === item.id && ( - <div - className="site-menu-items show" - aria-labelledby={`navbar-${item.id}`} + <button + type="button" + className="site-menu-toggle site-nav-link" + id={`nav-${id}`} + aria-expanded={open} + aria-controls={`nav-${id}-links`} + onClick={(event) => { + event.currentTarget.focus(); + setMenu((current) => (current === id ? null : id)); + }} > + {item.title} + <span aria-hidden="true" className="nav-chevron" /> + </button> + <div id={`nav-${id}-links`} className="site-menu-items" hidden={!open}> {item.links.map(([label, href]) => ( <Link key={href} - className="site-menu-link" to={href} - onClick={() => selectLink(href)} + className="site-menu-link" + aria-current={isCurrent(href) ? "page" : undefined} + onClick={(event) => selectLink(href, label, event)} > {label} </Link> ))} </div> - )} - </div> - ); + </div> + ); + }; return ( - <nav - ref={header} - className={`Section Header${location.pathname === "/" ? " Home" : ""} site-nav site-nav-responsive site-nav-light`} - > - <Link className="site-brand" to="/" onClick={() => selectLink("/")}> - <img src={CompassLogo} className="logo-compass" alt="logo-compass" /> - <img - src={LogoWithIcons} - className="logo-with-icons" - alt="logo-with-icons" - /> - </Link> - <button - type="button" - aria-label="Toggle navigation" - aria-controls="site-navigation" - aria-expanded={expanded} - className={`site-nav-toggle${expanded ? "" : " collapsed"}`} - onClick={() => { - pendingHash.current = null; - setExpanded((value) => !value); - }} - > - <span className="site-nav-toggle-icon" /> - </button> - <Collapse - in={expanded} - id="site-navigation" - className="nav-align-end site-nav-panel" - onExited={() => { - if (pendingHash.current) - document - .getElementById(pendingHash.current.slice(1)) - ?.scrollIntoView(); - pendingHash.current = null; - }} - > - <div className="site-nav-items"> + <nav ref={header} className="Header site-nav" aria-label="Main navigation"> + <div className="page-container site-nav-inner"> + <Link + className="site-brand" + to="/" + aria-label="exploretech.la home" + onClick={(event) => selectLink("/", "Home", event)} + > + <img src={CompassLogo} className="logo-compass" alt="" /> + <img src={LogoWithIcons} className="logo-with-icons" alt="" /> + <span className="brand-name" aria-hidden="true"> + exploretech.la + </span> + </Link> + <button + ref={toggle} + type="button" + aria-label="Toggle navigation" + aria-controls="site-navigation" + aria-expanded={expanded} + className="site-nav-toggle" + onClick={(event) => { + event.currentTarget.focus(); + pendingHash.current = null; + setMenu(null); + setExpanded((value) => !value); + }} + > + <span className="site-nav-toggle-icon" aria-hidden="true" /> + <span className="visually-hidden">Menu</span> + </button> + <Collapse + in={expanded} + id="site-navigation" + className="site-nav-panel" + onExited={() => { + if (pendingHash.current) focusHashTarget(pendingHash.current); + pendingHash.current = null; + }} + > <div className="site-nav-items"> - {renderMenu(menus[0])} - <div className="site-nav-item"> - {" "} - <Link - className="site-nav-link" - to="/our_team" - onClick={() => selectLink("/our_team")} - > - Our Team - </Link>{" "} - </div> - {renderMenu(menus[1])} - {renderMenu(menus[2])} + {renderMenu("programs")} + <Link + to="/get-involved#schools" + className="site-nav-link" + aria-current={ + isCurrent("/get-involved#schools") ? "page" : undefined + } + onClick={(event) => + selectLink("/get-involved#schools", "For schools", event) + } + > + For schools + </Link> + {renderMenu("involved")} + {renderMenu("about")} </div> - </div> - </Collapse> + </Collapse> + </div> </nav> ); } diff --git a/src/components/common/LinkWithArrow.tsx b/src/components/common/LinkWithArrow.tsx deleted file mode 100644 index 12f9b60..0000000 --- a/src/components/common/LinkWithArrow.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Link } from "react-router-dom"; -import { MdKeyboardArrowRight } from "react-icons/md"; - -interface LinkWithArrowProps { - readonly text: string; - readonly to: string; -} - -export default function LinkWithArrow({ text, to }: LinkWithArrowProps) { - return ( - <div className="LinkWithArrow"> - <Link to={to}> - <span className="link-text">{text}</span> - <MdKeyboardArrowRight className="MdKeyboardArrowRight" /> - </Link> - </div> - ); -} diff --git a/src/components/common/People.tsx b/src/components/common/People.tsx index a1838a3..21c98cb 100644 --- a/src/components/common/People.tsx +++ b/src/components/common/People.tsx @@ -8,69 +8,63 @@ interface PeopleProps { readonly eagerCount?: number; } -/** - * Horizontal-scrolling card list shared by team sections, speakers and - * panelists. Cards with a profile link become tracked outbound anchors. - */ export default function People({ people, className, eagerCount = 2, }: PeopleProps) { - if (people.length === 0) { - return null; - } + if (people.length === 0) return null; return ( - <div className={className ? `People ${className}` : "People"}> - {people.map((person, index) => { - const { name, title, descriptions, image, link } = person; + <ul className={className ? `People ${className}` : "People"}> + {people.map(({ name, title, descriptions, image, link }, index) => { const body = ( <> {image ? ( <img src={image} className="person-image" - alt={name} + alt="" width="160" height="160" loading={index < eagerCount ? "eager" : "lazy"} decoding="async" /> ) : ( - <div className="person-image" aria-hidden="true" /> + <div className="person-image person-initials" aria-hidden="true"> + {name + .split(/\s+/) + .map((part) => part[0]) + .slice(0, 2) + .join("")} + </div> )} - <h5 className="person-name">{name}</h5> + <h3 className="person-name">{name}</h3> {title ? <p className="person-title">{title}</p> : null} - {descriptions - ? descriptions.map((description, descriptionIndex) => ( - <p className="person-description" key={descriptionIndex}> - {description} - </p> - )) - : null} + {descriptions?.map((description, descriptionIndex) => ( + <p className="person-description" key={descriptionIndex}> + {description} + </p> + ))} </> ); - - if (link) { - return ( - <OutboundLink - href={link} - target="_blank" - eventLabel={name} - key={name} - > - <div className="person">{body}</div> - </OutboundLink> - ); - } - return ( - <div className="person" key={name}> - {body} - </div> + <li className="person" key={name}> + {link ? ( + <OutboundLink + className="person-profile" + href={link} + target="_blank" + eventLabel={name} + > + {body} + </OutboundLink> + ) : ( + body + )} + </li> ); })} - </div> + </ul> ); } diff --git a/src/components/common/YoutubeEmbed.tsx b/src/components/common/YoutubeEmbed.tsx index 4c64b68..fece4bc 100644 --- a/src/components/common/YoutubeEmbed.tsx +++ b/src/components/common/YoutubeEmbed.tsx @@ -1,22 +1,27 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; interface YoutubeEmbedProps { readonly embedId: string; - readonly title?: string; + readonly title: string; } /** No player or remote thumbnail request until the visitor chooses to play. */ -export default function YoutubeEmbed({ - embedId, - title = "Embedded youtube", -}: YoutubeEmbedProps) { +export default function YoutubeEmbed({ embedId, title }: YoutubeEmbedProps) { const [activated, setActivated] = useState(false); + const playerRef = useRef<HTMLIFrameElement>(null); + const focusPlayer = useRef(false); + + useEffect(() => { + if (activated && focusPlayer.current) playerRef.current?.focus(); + }, [activated]); return ( <div className="video-responsive"> <div className="video-frame"> {activated ? ( <iframe + ref={playerRef} + tabIndex={0} src={`https://www.youtube.com/embed/${embedId}?autoplay=1`} frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" @@ -27,7 +32,10 @@ export default function YoutubeEmbed({ <button type="button" className="video-facade" - onClick={() => setActivated(true)} + onClick={(event) => { + focusPlayer.current = event.detail === 0; + setActivated(true); + }} aria-label={`Play video: ${title}`} title={title} > diff --git a/src/content/events/2021.ts b/src/content/events/2021.ts index f7f9cf1..8e7a175 100644 --- a/src/content/events/2021.ts +++ b/src/content/events/2021.ts @@ -1,60 +1,21 @@ import type { DocumentLink } from "../types"; import type { ScheduleDay, FaqContent } from "./types"; -import { WAIVER_FORMS, WAIVER_INTRO, WAIVER_NOTE } from "./waivers"; +import { WAIVER_FORMS } from "./waivers"; -/** - * exploretech.la 2021, the virtual event still served at /register. - * It is the only two-day event and the only page with the Socio platform links, - * so it keeps its own shape instead of pretending to be an EventContent. - */ +/** The historical two-day virtual event remains reachable at /register. */ export interface RegistrationContent { readonly title: string; - readonly alert: { - readonly heading: string; - readonly note: string; - readonly linkLabel: string; - readonly to: string; - /** Analytics label recorded when the alert link is followed. */ - readonly trackingLabel: string; - }; readonly schedule: readonly [ScheduleDay, ScheduleDay]; - readonly importantLinks: { - readonly intro: string; - readonly platform: DocumentLink; - readonly documents: readonly DocumentLink[]; - readonly support: { - readonly heading: string; - readonly paragraphs: readonly string[]; - readonly liveHeading: string; - readonly liveBody: string; - readonly zoom: DocumentLink; - readonly hoursLabel: string; - readonly hours: string; - }; - }; + readonly documents: readonly DocumentLink[]; readonly faq: FaqContent; - readonly waivers: { - readonly intro: string; - readonly note: string; - readonly forms: readonly DocumentLink[]; - readonly allFormsHeading: string; - readonly allFormsBody: string; - readonly allForms: DocumentLink; - }; + readonly waivers: readonly DocumentLink[]; } export const REGISTRATION_2021: RegistrationContent = { - title: "Event Information", - alert: { - heading: "Missed our event? Want to rewatch workshops and panels?", - note: "Checkout our past workshops and resources from previous exploretech.la main events on our Resources Page.", - linkLabel: "exploretech.la Recordings and Resources", - to: "/resources", - trackingLabel: "Resources", - }, + title: "exploretech.la 2021 virtual event archive", schedule: [ { - title: "Day 1 (Saturday, April 10th)", + title: "Day 1, Saturday, April 10, 2021", items: [ { key: 1, time: "9:30 AM", name: "Virtual Platform Opens" }, { key: 2, time: "10:00 AM", name: "Opening Ceremony" }, @@ -65,7 +26,7 @@ export const REGISTRATION_2021: RegistrationContent = { ], }, { - title: "Day 2 (Sunday, April 11th)", + title: "Day 2, Sunday, April 11, 2021", items: [ { key: 1, time: "9:30 AM", name: "Virtual Platform Opens" }, { key: 2, time: "10:00 AM", name: "Opening Briefing" }, @@ -76,102 +37,59 @@ export const REGISTRATION_2021: RegistrationContent = { ], }, ], - importantLinks: { - intro: "Below are some of the important links you need for the event!", - platform: { - name: "Socio Virtual Platform Access", - src: "https://tinyurl.com/exploretechla2021", + documents: [ + { + name: "exploretech.la 2021 Program", + src: "https://drive.google.com/file/d/1icRfJae9wy8Kld8qSUxsY0pbsyIu9nh2/view?usp=sharing", }, - documents: [ - { - name: "exploretech.la 2021 Program", - src: "https://drive.google.com/file/d/1icRfJae9wy8Kld8qSUxsY0pbsyIu9nh2/view?usp=sharing", - }, - { - name: "Student Event Day Guide", - src: "https://docs.google.com/document/d/14_eHYyuf4V9BnUUEUijrYEYVxUTPfAUFZrey7blTc9s/edit?usp=sharing", - }, - { - name: "Workshop/Panel Schedule", - src: "https://tinyurl.com/contentschedule2021", - }, - ], - support: { - heading: "Event Support", - paragraphs: [ - "If you are experiencing any technical difficulties or have any questions about the event, please first refer to the FAQs below. ", - " If your question is not answered below, please reach out to us at exploretechla@cs.ucla.edu for assistance! ", - ], - liveHeading: "Live Event Day Support", - liveBody: - "For the duration of our event, attendees will be able to join a Zoom meeting (link below) to ask any questions live to an exploretech.la 2021 Staff Member.", - zoom: { - name: "Zoom Help Room Link", - src: "https://tinyurl.com/exploretechla2021help", - }, - hoursLabel: "Zoom Meeting Hours:", - hours: "Saturday and Sunday, 9:30AM to 1:30PM", + { + name: "2021 Student Event Day Guide", + src: "https://docs.google.com/document/d/14_eHYyuf4V9BnUUEUijrYEYVxUTPfAUFZrey7blTc9s/edit?usp=sharing", }, - }, - faq: { - kind: "registration", - initialOpenKey: 11, - intro: { - heading: "Still interested in attending?", - body: 'If you are still interested in attending, fill out the waivers at the bottom of the page (under "Waivers") and email them to us at exploretechla@cs.ucla.edu ASAP so we can get you registered!', + { + name: "2021 Workshop/Panel Schedule", + src: "https://tinyurl.com/contentschedule2021", }, + ], + faq: { items: [ - { - key: 11, - question: - "I'm still interested in attending but registration is closed! Can I still attend? ", - answer: - 'Yes you can! Just fill out all of the waivers under the "Waivers" secrtion below and email them to us at exploretechla@cs.ucla.edu ASAP so we can get you registered!', - }, { key: 0, - question: - "Is there a registration fee? Are there any associated costs with the program (ie: need to pay for an account on the virtual platform?) ", + question: "Was there a registration fee?", answer: - "This event is completely free for all attendees! All you will need is a computer and stable internet to access the event.", + "The 2021 event was free for attendees. A computer and stable internet connection were needed to access the virtual event.", }, { key: 1, - question: "What prior background is needed? ", + question: "What prior background was needed?", answer: - "No experience needed! This event is meant to help you explore the different fields of CS and tech!", + "No prior experience was needed. The event introduced students to different fields of computer science and technology.", }, { key: 3, - question: "How would virtual format work? (boxes, platform, etc) ", + question: "How did the virtual format work?", answer: - "The event will be hosted completely online. exploretech.boxes will be sent to each attendee (while supplies last) as well as instructions on how to access the virtual platform prior to the event. Attendees will receive more information once registration is closed.", + "The event took place entirely online using the Socio virtual platform. The event plan included exploretech.boxes with workshop materials for attendees, while supplies lasted, and platform access instructions sent before the event.", }, { key: 4, - question: "What is an exploretech.box?", + question: "What was an exploretech.box?", answer: - "An exploretech.box is a box that will hold all of the supplemental workshop materials and swag for the event. We will ship these to our attendees prior to the event. Supplies will be limited, but not having a box will not prevent you from attending our event.", + "An exploretech.box held supplemental workshop materials and swag. Boxes were planned for shipment to attendees before the event, while supplies lasted. A box was not required to attend.", }, { key: 6, - question: "What is the College Buddy Chat?", + question: "What was the College Buddy Chat?", answer: - "Each day you will have the opportunity to talk with one to two UCLA undergraduates about their life at UCLA and in STEM. Feel free to ask them any questions you may have about going from high school to college and pursuing a STEM degree!", + "Each day included an opportunity to talk with one or two UCLA undergraduates about life at UCLA, STEM, and the transition from high school to college.", }, ], }, - waivers: { - intro: WAIVER_INTRO, - note: WAIVER_NOTE, - forms: WAIVER_FORMS, - allFormsHeading: - "All forms can also be found here, in both PDF and Google Docs formats (for easier editing):", - allFormsBody: - "To edit the Google Docs, please make a copy of the waiver into your own Drive and fill it out there. Simply save as PDF to upload to the registration form.", - allForms: { - name: "All Forms (PDFs and Google Docs)", + waivers: [ + { + name: "2021 waiver archive, PDFs and Google Docs", src: "https://tinyurl.com/exploretechla2021-waivers", }, - }, + ...WAIVER_FORMS, + ], }; diff --git a/src/content/events/2022.ts b/src/content/events/2022.ts index 776b278..2b23feb 100644 --- a/src/content/events/2022.ts +++ b/src/content/events/2022.ts @@ -13,7 +13,11 @@ import { WAIVER_FORMS } from "./waivers"; */ export const EVENT_2022: EventContent = { year: "2022", - programHref: eventProgram, + program: { + name: "2022 event program", + src: eventProgram, + file: { format: "PDF", bytes: 29500282 }, + }, schedule: [ { key: 1, name: "Opening Ceremony", time: "9:00AM - 9:30AM" }, { key: 2, name: "Session 1", time: "9:30AM - 10:15AM" }, @@ -23,12 +27,28 @@ export const EVENT_2022: EventContent = { { key: 6, name: "Closing Ceremony", time: "1:00PM - 1:30PM" }, ], maps: [ - { name: "Bus Routes", src: busRoutes }, - { name: "Ackerman Union 2F", src: ackermanUnion2F }, - { name: "Ackerman Union 3F", src: ackermanUnion3F }, + { + name: "Bus Routes", + src: busRoutes, + file: { format: "JPG", bytes: 59864 }, + }, + { + name: "Ackerman Union 2F", + src: ackermanUnion2F, + file: { format: "JPG", bytes: 46939 }, + }, + { + name: "Ackerman Union 3F", + src: ackermanUnion3F, + file: { format: "JPG", bytes: 18761 }, + }, ], - workshopsPlacement: "after-wristbands", - wristbandImage: { src: wristbands, alt: "Wristband coordination" }, + wristbandImage: { + name: "2022 wristband coordination map", + src: wristbands, + alt: "Wristband coordination", + file: { format: "PNG", bytes: 54090 }, + }, workshops: [ { heading: "Workshops", @@ -302,95 +322,79 @@ export const EVENT_2022: EventContent = { }, ], faq: { - kind: "resources", - initialOpenKey: null, items: [ - { - key: 1, - question: - "I'm still interested in attending but registration is closed! Can I still attend?", - answer: - 'Yes you can! Just fill out all of the waivers under the "Waivers" section below and email them to us at exploretechla@cs.ucla.edu ASAP so we can get you registered!', - }, { key: 2, - question: - "Is there a registration fee? Are there any associated costs with the program (i.e: need to pay for an account on the virtual platform?)", - answer: - "This event is completely free for all attendees! All you will need is a computer and stable internet to access the event.", + question: "Was there a registration fee?", + answer: "The 2022 event was free for attendees.", }, { key: 3, - question: "What prior background is needed?", + question: "What prior background was needed?", answer: - "No experience needed! This event is meant to help you explore the different fields of CS and tech!", + "No prior experience was needed. The event introduced different fields of computer science and technology.", }, { key: 4, - question: "When does the event start/end?", + question: "When did the event start and end?", answer: - "The Opening Ceremony will begin at 9AM and the Closing Ceremony will begin at 1:00PM. Refer to the program for more schedule info!", + "The archived schedule lists the opening ceremony at 9 AM and the closing ceremony from 1 PM to 1:30 PM.", }, { key: 5, - question: "How does the raffle work?", + question: "How did the raffle work?", answer: - "Workshops will give 1 ticket to students if they ask a good question or complete some activity. Panels will also give 1 ticket to all students who attend. Hold onto your ticket until the Closing Ceremony, where a raffle will be held and prizes will be given!", + "The event plan awarded one ticket for asking a good question or completing a workshop activity, and one ticket for attending a panel. The raffle took place during the closing ceremony.", }, { key: 6, - question: "Where is bus pick-up?", + question: "Where was bus pick-up?", answer: - "Bus pick-up will be at the same place as the drop-off area (across Intramural Field) as detailed in the logistics packet sent to your school.", + "The event logistics placed bus pick-up at the drop-off area across from Intramural Field.", }, { key: 7, - question: - "How many high school students and how many high schools are participating in our events today?", - answer: "There are 8 schools with a total of 371 students! Woo!", + question: "How many schools and students participated?", + answer: "The 2022 event page recorded eight schools and 371 students.", }, { key: 8, - question: "How does the mentorship session + lunch work?", + question: "How did mentorship and lunch work?", answer: - "Each student’s wristband has a number on it indicating which mentor group they are in. Mentors will hold up a sign with their group number on it and students will find their mentors accordingly. Mentor groups will take turns getting lunch and they can use any space in the AGB (around the chairs/on the floor/etc) to gather around and start the mentorship session.", - note: "Group 3 students will start finding their mentors 15 minutes prior to the end of Session 3 and will get their lunch first.", + "Students used the number on their wristband to find their mentor group. Mentors held signs with group numbers. Groups took turns getting lunch and gathered in the Ackerman Grand Ballroom for mentorship.", + note: "The schedule directed Group 3 students to find their mentors 15 minutes before the end of Session 3 and get lunch first.", }, { key: 9, - question: "Can students go to any workshop they want to?", + question: "Could students choose their workshops?", answer: - "Yes! However, some workshops are limited by room size/workshop material capacity so they will be closed once capacity is reached on a first-come, first-served basis, and students will be directed to other workshops instead.", + "Students could choose workshops, subject to room and material capacity. Admission was first-come, first-served, with students directed to other workshops when a room reached capacity.", }, { key: 10, - question: "Are there restrooms in the venue?", - answer: "Restrooms are on the third floor of Ackerman Union.", + question: "Where were the venue restrooms?", + answer: + "The event guide listed restrooms on the third floor of Ackerman Union.", }, { key: 12, - question: "Who is organizing this event?", + question: "Who organized this event?", answer: - "This event is being organized by exploretech.la, a group of UCLA undergraduate students! Learn more about us on our website!", + "exploretech.la, a group of UCLA undergraduate students, organized the event.", }, { key: 13, - question: "What are the raffle prizes?", + question: "What were the raffle prizes?", answer: - "3 big prizes (including airpods!) are being given out during the Closing Ceremony, while additional digital prizes will be awarded to students who fill out the Feedback Survey.", + "Three big prizes, including AirPods, were given out during the closing ceremony.", }, { key: 14, - question: "How many students are at UCLA?", - answer: "There are over 40,000 students at UCLA", + question: "How many students were at UCLA?", + answer: + "The 2022 event FAQ listed UCLA's enrollment as over 40,000 students.", }, ], }, waivers: WAIVER_FORMS, - // The deployed page reuses the "Wristbands" heading above the feedback form. - feedbackHeading: "Wristbands", - feedback: { - kind: "link", - href: "https://tinyurl.com/exploretechla2022feedback", - }, }; diff --git a/src/content/events/2023.ts b/src/content/events/2023.ts index 3ce984c..44f9e4d 100644 --- a/src/content/events/2023.ts +++ b/src/content/events/2023.ts @@ -10,8 +10,10 @@ import type { EventContent } from "./types"; */ export const EVENT_2023: EventContent = { year: "2023", - programHref: - "https://drive.google.com/file/d/1QBPh_9WEnlVxcw18xKHrCtPD4Wv9tfrj/view?usp=sharing", + program: { + name: "2023 event program", + src: "https://drive.google.com/file/d/1QBPh_9WEnlVxcw18xKHrCtPD4Wv9tfrj/view?usp=sharing", + }, schedule: [ { key: 1, name: "Opening Ceremony", time: "9:00AM - 9:30AM" }, { key: 2, name: "Session 1", time: "9:30AM - 10:15AM" }, @@ -21,11 +23,22 @@ export const EVENT_2023: EventContent = { { key: 6, name: "Closing Ceremony", time: "1:00PM - 1:30PM" }, ], maps: [ - { name: "Bus Routes", src: busRoutes }, - { name: "Ackerman Union 2F", src: ackermanUnion2F }, - { name: "Ackerman Union 3F", src: ackermanUnion3F }, + { + name: "Bus Routes", + src: busRoutes, + file: { format: "JPG", bytes: 59864 }, + }, + { + name: "Ackerman Union 2F", + src: ackermanUnion2F, + file: { format: "JPG", bytes: 46939 }, + }, + { + name: "Ackerman Union 3F", + src: ackermanUnion3F, + file: { format: "JPG", bytes: 18761 }, + }, ], - workshopsPlacement: "before-maps", workshops: [ { heading: "Workshops", @@ -149,10 +162,10 @@ export const EVENT_2023: EventContent = { }, { kind: "described", - title: "Natural Language Proccessing", + title: "Natural Language Processing", instructors: "Ashish Basetty and Jazlin Ong", description: - "Every used Siri? ChatGPT? Google Translate? Come to this workshop to learn how a computer thinking in 0’s and 1’s can learn to speak like a human. Learn more about how human language works, and how we can come up with rules to teach it to a machine.", + "Ever used Siri? ChatGPT? Google Translate? Come to this workshop to learn how a computer thinking in 0’s and 1’s can learn to speak like a human. Learn more about how human language works, and how we can come up with rules to teach it to a machine.", links: [ { name: "Slides", @@ -209,7 +222,7 @@ export const EVENT_2023: EventContent = { title: "Careers in Computer Science", instructors: "Isha Rajput and Emily Nham", description: - "Want to know what it's like working in tech? Meetcurrent students and graduates pursuing Computer Science at places like Google, Facebook, Atlassian, and CreditKarma! Come learn about what they do and how they ended up where they are!", + "Want to know what it's like working in tech? Meet current students and graduates pursuing Computer Science at places like Google, Facebook, Atlassian, and CreditKarma! Come learn about what they do and how they ended up where they are!", links: [ { name: "Slides", @@ -223,7 +236,7 @@ export const EVENT_2023: EventContent = { }, { kind: "described", - title: "Divieristy 101", + title: "Diversity 101", instructors: "Aparna Hariharan and Jason Lozada", description: "Join the conversation with our esteemed guests regarding Diversity in Computer Science and what their experiences are like as minorities in this field.", @@ -241,5 +254,4 @@ export const EVENT_2023: EventContent = { ], }, ], - feedback: { kind: "coming-soon", disabled: false }, }; diff --git a/src/content/events/2026.ts b/src/content/events/2026.ts index 1af2fcf..0e58d27 100644 --- a/src/content/events/2026.ts +++ b/src/content/events/2026.ts @@ -8,10 +8,15 @@ import type { EventContent } from "./types"; /** * exploretech.la 2026, served at /resources2026. * Panels are authored after the workshops; both render as described cards. + * Session times and rooms are transcribed from Event_Program_2026.pdf, pages 3, 5, 7, and 8. */ export const EVENT_2026: EventContent = { year: "2026", - programHref: eventProgram, + program: { + name: "2026 event program", + src: eventProgram, + file: { format: "PDF", bytes: 41255757 }, + }, schedule: [ { key: 1, name: "Opening Ceremony", time: "9:55AM - 10:10AM" }, { key: 2, name: "Session 1", time: "10:15AM - 10:50AM" }, @@ -21,12 +26,22 @@ export const EVENT_2026: EventContent = { { key: 6, name: "Closing Ceremony", time: "1:10PM - 1:30PM" }, ], maps: [ - { name: "Bus Routes", src: busRoutes }, - { name: "Ackerman Union 2F", src: ackermanUnion2F }, - { name: "Ackerman Union 3F", src: ackermanUnion3F }, + { + name: "Bus Routes", + src: busRoutes, + file: { format: "JPG", bytes: 59864 }, + }, + { + name: "Ackerman Union 2F", + src: ackermanUnion2F, + file: { format: "JPG", bytes: 46939 }, + }, + { + name: "Ackerman Union 3F", + src: ackermanUnion3F, + file: { format: "JPG", bytes: 18761 }, + }, ], - mapsLinkRel: "noopener noreferrer", - workshopsPlacement: "before-maps", workshops: [ { heading: "Workshops", @@ -34,6 +49,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Tower Stacking Game", + slot: { + session: "Session 3", + time: "11:50 AM - 12:30 PM", + location: "Ackerman Union 3517", + }, instructors: "Emma Qiu and Eli Drewry", description: "Students will compete in groups to see who can create the tallest free-standing tower with limited materials (popsicle sticks, tape, rope, etc). This challenge tests their communication, strategy, and discovering the importance of architecture while getting a chance to win prizes.", @@ -44,6 +64,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Intro to Game Development", + slot: { + session: "Session 1", + time: "10:15 AM - 10:50 AM", + location: "Ackerman Union 3508", + }, instructors: "Joshua Jin and Paul Macapinlac", description: "Learn how the snake game works in this fun workshop! Uncover the secrets of Python and learn to customize your snake. Before you know it, you’ll be a pro game dev with a fully customized snake game.", @@ -54,6 +79,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Battle Bots", + slot: { + session: "Session 3", + time: "11:50 AM - 12:30 PM", + location: "Ackerman Union 2412", + }, instructors: "Sofia Matos and Benjamin Garcia", description: "Drive XRP Robots on a field to take down your opponents in a game of robot soccer! After interacting with code, you will be able to drive and dribble past other robots to score using teamwork and strategy.", @@ -63,7 +93,12 @@ export const EVENT_2026: EventContent = { }, { kind: "described", - title: "Squid Games Several-Legged Pentathalon (STEM Edition)", + title: "Squid Games Several-Legged Pentathlon (STEM Edition)", + slot: { + session: "Session 3", + time: "11:50 AM - 12:30 PM", + location: "Ackerman Union 3508", + }, instructors: "Matthew Chen and Paul Macapinlac", description: "Inspired by the hit Netflix series “Squid Games,” collaborate with up to 3 of your friends to participate in the STEM Games Several-Legged Pentathlon, where you will be tasked to solve STEM puzzles and activities in an interactive race against time.", @@ -77,6 +112,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "AI Photobooth", + slot: { + session: "Session 3", + time: "11:50 AM - 12:30 PM", + location: "Ackerman Union 2408", + }, instructors: "Ridhima Seth and Joshua Jin", description: "Do you want to learn more about AI image generation? Come to AI Photobooth! This workshop will show you the basics of how an AI image generator works, and then we’ll let you try it for yourself!", @@ -87,6 +127,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "COVID-19 Simulator", + slot: { + session: "Session 2", + time: "11:00 AM - 11:40 AM", + location: "Ackerman Union 2412", + }, instructors: "Clemente Irarrazaval and Eli Drewry", description: "Students will get an introduction into the field of game development by designing a COVID-19 spread simulator. Using Scratch they’ll explore conditionals, loops, and movement while working through obstacles. Students work in teams to build, test, and modify their own simulation-based game, also encouraging teamwork.", @@ -97,6 +142,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "AI Music", + slot: { + session: "Session 2", + time: "11:00 AM - 11:40 AM", + location: "Ackerman Union 3517", + }, instructors: "Matthew Chen and Benjamin Garcia", description: "The purpose of this workshop is to teach students how to use suno.com, an AI music website that allows users to create any form of music by simply entering into the prompt bar.", @@ -107,6 +157,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Bridge Building Competition", + slot: { + session: "Session 1", + time: "10:15 AM - 10:50 AM", + location: "Ackerman Union 2412", + }, instructors: "Atharv Panditrao and Clemente Irarrazaval", description: "Students will design and build a small bridge using limited materials. They will learn basic structural concepts, apply problem-solving skills, and work collaboratively to create a bridge that can hold the greatest load. Students compete in teams and test their designs in a fun final challenge.", @@ -117,6 +172,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Live Coding Music", + slot: { + session: "Session 3", + time: "11:50 AM - 12:30 PM", + location: "Bruin Reception Room", + }, instructors: "Ana Laura Santana and Atharv Panditrao", description: "Sometimes learning code can seem like it’s exclusively professional or something you will never see in your career. But in truth, coding can be useful for all types of passions beyond a corporate life, which is what gives it its beauty. In this workshop, you will learn how to become a DJ using only code!", @@ -127,6 +187,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Prompt Engineering", + slot: { + session: "Session 2", + time: "11:00 AM - 11:40 AM", + location: "Ackerman Union 3508", + }, instructors: "Alexander Chien and Arjun Asudani", description: "Join our prompt engineering workshop to learn how to make the most out of your AI credits. Today’s chat models have vast capabilities, but accessing these skills can be more nuanced than just a straightforward question. We’ll cover prompting methods you can use to ensure you get the results you want. Let’s harness the power of AI!", @@ -140,6 +205,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "How Machines can learn like humans (Neural Networks)", + slot: { + session: "Session 2", + time: "11:00 AM - 11:40 AM", + location: "Ackerman Union 2408", + }, instructors: "Ana Laura Santana and Emma Qiu", description: "Students will learn the basics of neural networks, and how they are present in our everyday lives. They will also get hands-on experience with neural networks by loading data and training their own model. In the end, students will gain a conceptual understanding and practical skills in AI, which has become increasingly influential in the tech world.", @@ -150,6 +220,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "AI Guess Who", + slot: { + session: "Session 1", + time: "10:15 AM - 10:50 AM", + location: "Ackerman Union 3517", + }, instructors: "Ridhima Seth and Amy Lloyd", description: "Want to master Guess Who? with logic and strategy? Learn how decision trees and probability help you ask the smartest questions, minimize guesswork, and consistently win. This session makes algorithmic thinking fun and practical—sharpen your problem-solving skills while gaining a competitive edge in both games and real-world decision-making!", @@ -160,6 +235,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Game Theory", + slot: { + session: "Session 1", + time: "10:15 AM - 10:50 AM", + location: "Ackerman Union 2408", + }, instructors: "Arjun Asudani and Alexander Chien", description: "What would you do if your success depended on someone else’s choice? This interactive workshop introduces game theory through fast-paced games and real-life scenarios. You’ll explore why people compete, when cooperation fails, and how small incentives can completely change outcomes.", @@ -178,6 +258,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "Careers in Tech Panel", + slot: { + session: "Session 1", + time: "10:15 AM - 10:50 AM", + location: "Bruin Reception Room", + }, instructors: "Meet Modi / Tammy Sujaritchai and Sofia Matos", description: "Curious about what a career in tech actually looks like? Join us for a discussion with students and professionals as they share their experiences, career paths, and advice for breaking into the industry. Learn about different roles in tech, what skills matter most, and how to start exploring your own path.", @@ -195,6 +280,11 @@ export const EVENT_2026: EventContent = { { kind: "described", title: "CS/Tech in College Panel", + slot: { + session: "Session 2", + time: "11:00 AM - 11:40 AM", + location: "Bruin Reception Room", + }, instructors: "Meet Modi / Tammy Sujaritchai and Amy Lloyd", description: "What is it really like to study computer science or tech in college? In this panel, students will talk about classes, projects, internships, clubs, and how they found their place in tech. Come ask questions, hear honest advice, and get a better sense of what the college experience can look like.", @@ -212,5 +302,4 @@ export const EVENT_2026: EventContent = { ], }, ], - feedback: { kind: "coming-soon", disabled: true }, }; diff --git a/src/content/events/index.ts b/src/content/events/index.ts index eee89c7..6863eec 100644 --- a/src/content/events/index.ts +++ b/src/content/events/index.ts @@ -23,7 +23,7 @@ export const EVENT_ROUTES: readonly { readonly year: EventYear; readonly navLabel: string; }[] = [ - { route: "/resources", year: "2022", navLabel: "exploretech 2021" }, - { route: "/resources2023", year: "2023", navLabel: "exploretech 2023" }, - { route: "/resources2026", year: "2026", navLabel: "exploretech 2026" }, + { route: "/resources", year: "2022", navLabel: "2022 event archive" }, + { route: "/resources2023", year: "2023", navLabel: "2023 event archive" }, + { route: "/resources2026", year: "2026", navLabel: "2026 event archive" }, ]; diff --git a/src/content/events/types.ts b/src/content/events/types.ts index 43eaf77..9d32724 100644 --- a/src/content/events/types.ts +++ b/src/content/events/types.ts @@ -14,6 +14,11 @@ export interface ScheduleDay { export type WorkshopCard = { readonly title: string; readonly links: readonly DocumentLink[]; + readonly slot?: { + readonly session: string; + readonly time: string; + readonly location: string; + }; } & ( | { readonly kind: "archivedVideo"; @@ -38,27 +43,15 @@ export interface FaqEntry { readonly note?: string; } export interface FaqContent { - readonly kind: "registration" | "resources"; - readonly initialOpenKey: number | null; readonly items: readonly FaqEntry[]; - /** Extra lead-in shown only on the registration page, above the first question. */ - readonly intro?: { readonly heading: string; readonly body: string }; } -export type Feedback = - | { readonly kind: "link"; readonly href: string } - | { readonly kind: "coming-soon"; readonly disabled: boolean }; export interface EventContent { readonly year: string; - readonly programHref: string; + readonly program: DocumentLink; readonly schedule: readonly ScheduleEntry[]; readonly maps: readonly DocumentLink[]; - /** 2026 added rel="noopener noreferrer" to its map buttons; 2022/2023 did not. */ - readonly mapsLinkRel?: "noopener noreferrer"; readonly workshops: readonly WorkshopSection[]; - readonly workshopsPlacement: "before-maps" | "after-wristbands"; - readonly wristbandImage?: { readonly src: string; readonly alt: string }; + readonly wristbandImage?: DocumentLink & { readonly alt: string }; readonly faq?: FaqContent; readonly waivers?: readonly DocumentLink[]; - readonly feedbackHeading?: string; - readonly feedback: Feedback; } diff --git a/src/content/events/waivers.ts b/src/content/events/waivers.ts index 6a8dfcd..1cb0251 100644 --- a/src/content/events/waivers.ts +++ b/src/content/events/waivers.ts @@ -5,17 +5,23 @@ import studentVerification from "../../static/pdf/exploretech_la_2022_Student_Ve import type { DocumentLink } from "../types"; /** - * The same three signed forms are offered on /register and on /resources. - * Both pages link the identical PDFs, so they are authored once here. + * Historic PDFs retained on /register and /resources. Their filenames and + * document dates are 2022, including the copies linked from the 2021 page. */ export const WAIVER_FORMS: readonly DocumentLink[] = [ - { name: "Liability Waiver", src: liabilityWaiver }, - { name: "Multimedia Release Form", src: multimediaRelease }, - { name: "Student Verification Form", src: studentVerification }, + { + name: "2022 Liability Waiver", + src: liabilityWaiver, + file: { format: "PDF", bytes: 67111 }, + }, + { + name: "2022 Multimedia Release Form", + src: multimediaRelease, + file: { format: "PDF", bytes: 64140 }, + }, + { + name: "2022 Student Verification Form", + src: studentVerification, + file: { format: "PDF", bytes: 79227 }, + }, ]; - -export const WAIVER_INTRO = - "Please download and sign the following waivers. They are a required part of your registration."; - -export const WAIVER_NOTE = - "Note: For high school students, the waivers require a parent/guardian signature."; diff --git a/src/content/pages.ts b/src/content/pages.ts new file mode 100644 index 0000000..ea0e133 --- /dev/null +++ b/src/content/pages.ts @@ -0,0 +1,99 @@ +import { EVENT_ROUTES } from "./events"; +import { teamSections } from "./teams"; + +export const SITE_ORIGIN = "https://www.exploretech.la"; +export const SHARE_IMAGE = `${SITE_ORIGIN}/placeholder.png`; +export const SHARE_IMAGE_ALT = + "Participants collaborating on laptops at an exploretech.la workshop"; + +export interface PageMetadata { + readonly path: string; + readonly title: string; + readonly description: string; + readonly indexable: boolean; +} + +export const SITE_PAGES: readonly PageMetadata[] = [ + { + path: "/", + title: + "Technology experiences for LA high school students | exploretech.la", + description: + "Explore hands-on technology programs led by UCLA students. Find information for schools, UCLA volunteers, and community partners.", + indexable: true, + }, + { + path: "/events", + title: "Programs and event information | exploretech.la", + description: + "Explore the annual exploretech.la event and Ignite, ask about upcoming opportunities, and find clearly labeled past event resources.", + indexable: true, + }, + { + path: "/get-involved", + title: "Schools, volunteers and partners | exploretech.la", + description: + "Find the right contact for school participation, UCLA volunteering, or partnering with exploretech.la. Learn what to ask and how to get involved.", + indexable: true, + }, + { + path: "/ignite", + title: "Ignite workshops for high school students | exploretech.la", + description: + "Learn about Ignite's beginner-friendly technology workshops, explore past projects, and ask about the next series.", + indexable: true, + }, + { + path: "/register", + title: "2021 virtual event archive | exploretech.la", + description: + "Historical information and documents from exploretech.la's 2021 virtual event. This archive is not current registration or event support.", + indexable: true, + }, + ...EVENT_ROUTES.map(({ route, year }) => ({ + path: route, + title: `${year} event resources archive | exploretech.la`, + description: `Archived ${year} exploretech.la schedules, workshops, recordings and documents. Find current participation information through the programs hub.`, + indexable: true, + })), + ...teamSections.map((team) => ({ + path: `/our_team/${team.id}`, + title: `${team.navLabel} team | exploretech.la`, + description: `Meet the ${team.navLabel.toLowerCase()} team behind exploretech.la, UCLA's student-led technology outreach organization.`, + indexable: true, + })), +]; + +export function normalizePath(pathname: string): string { + let path = pathname; + // Keep encoded separators inside their segment rather than creating new routes. + try { + path = decodeURI(pathname); + } catch { + /* Malformed URLs remain a not-found path. */ + } + return path.replace(/\/+$/, "").toLowerCase() || "/"; +} + +export function getPageMetadata(pathname: string): PageMetadata { + const path = normalizePath(pathname); + const teamSlug = path.match(/^\/our_team\/([^/]+)$/)?.[1]; + const usesTeamFallback = + path === "/our_team" || + (teamSlug !== undefined && + !teamSections.some((team) => team.id === teamSlug)); + const resolved = usesTeamFallback ? "/our_team/leadership" : path; + return ( + SITE_PAGES.find((page) => page.path === resolved) ?? { + path, + title: "Page not found | exploretech.la", + description: + "This page could not be found. Explore our programs, participation information, and team.", + indexable: false, + } + ); +} + +export function canonicalUrl(page: PageMetadata): string { + return `${SITE_ORIGIN}${page.path === "/" ? "/" : `${page.path}/`}`; +} diff --git a/src/content/participation.ts b/src/content/participation.ts new file mode 100644 index 0000000..479957c --- /dev/null +++ b/src/content/participation.ts @@ -0,0 +1,58 @@ +export const CONTACT_EMAIL = "exploretechla@cs.ucla.edu"; + +function inquiry(subject: string): string { + return `mailto:${CONTACT_EMAIL}?subject=${encodeURIComponent(subject)}`; +} + +/** Published website status, not a claim that the organization has closed recruitment. */ +export const PROGRAMS = { + annual: { + title: "Annual exploretech.la event", + description: + "Hands-on workshops, panels, and an exhibition hall for high school students and school groups.", + status: "Next event details are not published here yet.", + actionLabel: "Ask about the next event", + inquiryHref: inquiry("Next exploretech.la annual event"), + archivePath: "/resources2026", + archiveLabel: "2026 event resources", + }, + ignite: { + title: "Ignite", + description: + "A beginner-friendly workshop series for high school students to explore technology through projects.", + status: "Details for the next Ignite series are not published here yet.", + actionLabel: "Ask about the next Ignite series", + inquiryHref: inquiry("Next Ignite series"), + }, +} as const; + +export const AUDIENCES = { + schools: { + title: "For schools and students", + description: + "Find a program for your students and ask about the next opportunity to take part.", + path: "/get-involved#schools", + actionLabel: "Ask about school participation", + inquiryHref: inquiry("School participation enquiry"), + }, + volunteer: { + title: "For UCLA volunteers", + description: + "Help deliver workshops, organize events, or support high school students as they explore technology.", + path: "/get-involved#volunteer", + actionLabel: "Ask about volunteering", + inquiryHref: inquiry("UCLA volunteer enquiry"), + }, + partners: { + title: "For partners and sponsors", + description: + "Explore opportunities to support workshops, share industry experience, or contribute to an event.", + path: "/get-involved#partners", + actionLabel: "Discuss a partnership", + inquiryHref: inquiry("exploretech.la partnership enquiry"), + }, +} as const; + +/** This existing list is for UCLA updates, not a substitute for a school application. */ +export const UCLA_UPDATES_URL = + "https://mailchi.mp/e1197feb6276/ucla-student-mailing-list"; diff --git a/src/content/types.ts b/src/content/types.ts index 145f4e1..3d73b07 100644 --- a/src/content/types.ts +++ b/src/content/types.ts @@ -15,6 +15,10 @@ export interface PersonCard extends PersonProfile { export interface DocumentLink { readonly name: string; readonly src: string; + readonly file?: { + readonly format: "PDF" | "JPG" | "PNG"; + readonly bytes: number; + }; } export type TeamSectionId = diff --git a/src/features/events/EventPage.tsx b/src/features/events/EventPage.tsx index a95582a..93edc9e 100644 --- a/src/features/events/EventPage.tsx +++ b/src/features/events/EventPage.tsx @@ -1,129 +1,88 @@ -import Footer from "../../components/Footer"; +import { Link } from "react-router-dom"; +import DocumentLink from "../../components/DocumentLink"; +import OutboundLink from "../../components/OutboundLink"; import { EVENTS } from "../../content/events"; -import type { EventYear, Feedback } from "../../content/events/types"; -import { WAIVER_INTRO, WAIVER_NOTE } from "../../content/events/waivers"; -import ActionLink from "../../components/ActionLink"; +import type { EventYear } from "../../content/events/types"; +import { CONTACT_EMAIL, PROGRAMS } from "../../content/participation"; import Faq from "./Faq"; import Maps from "./Maps"; import Schedule from "./Schedule"; import Waivers from "./Waivers"; import Workshops from "./Workshops"; -function FeedbackButton({ feedback }: { feedback: Feedback }) { - if (feedback.kind === "link") { - return ( - <ActionLink - href={feedback.href} - target="_blank" - className="action action-info action-large" - > - Feedback Form - </ActionLink> - ); - } - return ( - <button - disabled={feedback.disabled} - type="button" - className="action action-info action-large" - > - Feedback Form (Coming Soon) - </button> - ); -} - -/** - * One page for every archived annual resources route; the year's differences all - * come from `src/content/events/<year>.ts`. - */ export default function EventPage({ year }: { year: EventYear }) { const event = EVENTS[year]; - const workshops = <Workshops sections={event.workshops} />; return ( - <div className="Resources"> - <div className="resources-title"> - <h1>Resources</h1> - <div className="pill-divider" /> - <div className="Section resources-text"> - <h5> - Welcome students and teachers! Confused about the schedule? Lost and - need a map? Or maybe just curious about what workshops are happening - and when? This webpage contains important resources you need for the - exploretech.la {event.year} event! If you can't find what you need, - feel free to contact{" "} - <a - href="mailto:exploretechla@cs.ucla.edu" - target="_blank" - rel="noopener noreferrer" - > - exploretechla@cs.ucla.edu - </a>{" "} - for more questions. - </h5> - </div> - <div className="Section full-length-button"> - <ActionLink - href={event.programHref} - target="_blank" - className="action action-outline action-large" - > - Click here to view the full event program - </ActionLink> - </div> + <div className="Resources page-container"> + <header className="page-header"> + <Link className="text-link" to="/events"> + All events and archives + </Link> + <h1>exploretech.la {event.year} archive</h1> + <p className="status-notice"> + This is the {event.year} event archive, not a current event schedule + or registration page. {PROGRAMS.annual.status} + </p> + <nav className="section-links" aria-label="Event sections"> + <Link to="#schedule">Schedule</Link> + <Link to="#workshops">Workshops</Link> + <Link to="#maps">Maps</Link> + <Link to="#help">Help</Link> + </nav> + <DocumentLink + document={event.program} + className="action action-outline" + /> + </header> + <div className="event-logistics"> <Schedule items={event.schedule} /> - {event.workshopsPlacement === "before-maps" && workshops} - <Maps maps={event.maps} linkRel={event.mapsLinkRel} /> - <div className="Section resources-title"> - <h2>Wristbands</h2> - <div className="pill-divider" /> - <h5> - <b>Make sure you have your wristband on the entire time!</b> - </h5> - <div className="resources-text"> - <h5> - All high school students are divided into 3 groups, each will get - a different colored wristband. During each session, 1 group will - be at the exhibition hall and the other 2 groups will be at the - workshops. Group 1 (green colored-wristband) will be at the - exhibition hall during Session 1, Group 2 (blue) during Session 2, - and Group 3 (red) during Session 3. - </h5> - </div> - {event.wristbandImage && ( + <Maps maps={event.maps} /> + </div> + <section + id="help" + className="event-section" + aria-labelledby="event-help-heading" + > + <h2 id="event-help-heading">Help and next events</h2> + <p> + Questions about these resources? Email{" "} + <a className="text-link" href={`mailto:${CONTACT_EMAIL}`}> + {CONTACT_EMAIL} + </a> + . + </p> + <OutboundLink + href={PROGRAMS.annual.inquiryHref} + eventLabel="Annual event inquiry" + className="action action-primary" + > + {PROGRAMS.annual.actionLabel} + </OutboundLink> + </section> + <Workshops sections={event.workshops} /> + <section className="event-section" aria-labelledby="wristbands-heading"> + <h2 id="wristbands-heading">Wristband groups</h2> + <p> + At the {event.year} event, students were divided into three groups. + Group 1 wore green wristbands and visited the exhibition hall during + Session 1, Group 2 wore blue during Session 2, and Group 3 wore red + during Session 3. The other groups attended workshops. + </p> + {event.wristbandImage && ( + <div> <img - style={{ marginTop: "50px" }} + className="wristband-image" src={event.wristbandImage.src} alt={event.wristbandImage.alt} + loading="lazy" /> - )} - </div> - {event.workshopsPlacement === "after-wristbands" && workshops} - {event.faq && <Faq content={event.faq} />} - {event.waivers && ( - <Waivers - forms={event.waivers} - intro={WAIVER_INTRO} - note={WAIVER_NOTE} - /> - )} - <div className="Section resources-title"> - {event.feedbackHeading && <h2>{event.feedbackHeading}</h2>} - <div className="pill-divider" /> - <div className="resources-text"> - <h5> - <b> - Thanks for coming to our event! Got feedback? Let us know by - filling out this form below! - </b> - </h5> + <DocumentLink document={event.wristbandImage} /> </div> - </div> - <div className="Section full-length-button"> - <FeedbackButton feedback={event.feedback} /> - </div> - </div> - <Footer /> + )} + </section> + {event.faq && <Faq content={event.faq} />} + {event.waivers && <Waivers forms={event.waivers} />} </div> ); } diff --git a/src/features/events/Events.tsx b/src/features/events/Events.tsx new file mode 100644 index 0000000..8637810 --- /dev/null +++ b/src/features/events/Events.tsx @@ -0,0 +1,81 @@ +import { Link } from "react-router-dom"; +import OutboundLink from "../../components/OutboundLink"; +import { EVENT_ROUTES } from "../../content/events"; +import { PROGRAMS } from "../../content/participation"; + +export default function Events() { + return ( + <div className="Events page-container"> + <header className="page-header"> + <h1>Events</h1> + <p> + Explore technology with UCLA students through our annual event and + Ignite workshops. + </p> + </header> + <div className="program-grid"> + <section aria-labelledby="annual-event-heading"> + <h2 id="annual-event-heading">{PROGRAMS.annual.title}</h2> + <p>{PROGRAMS.annual.description}</p> + <p className="status-notice">{PROGRAMS.annual.status}</p> + <OutboundLink + href={PROGRAMS.annual.inquiryHref} + eventLabel="Annual event inquiry" + className="action action-primary" + > + {PROGRAMS.annual.actionLabel} + </OutboundLink> + <p> + <Link className="text-link" to={PROGRAMS.annual.archivePath}> + Browse the 2026 event archive + </Link> + </p> + </section> + <section aria-labelledby="ignite-program-heading"> + <h2 id="ignite-program-heading">{PROGRAMS.ignite.title}</h2> + <p>{PROGRAMS.ignite.description}</p> + <p className="status-notice">{PROGRAMS.ignite.status}</p> + <OutboundLink + href={PROGRAMS.ignite.inquiryHref} + eventLabel="Ignite inquiry" + className="action action-outline" + > + {PROGRAMS.ignite.actionLabel} + </OutboundLink> + <p> + <Link className="text-link" to="/ignite"> + About Ignite and past workshops + </Link> + </p> + </section> + </div> + <section + className="event-section" + aria-labelledby="event-archives-heading" + > + <h2 id="event-archives-heading">Past event archives</h2> + <p> + Historical schedules, workshop resources, recordings, and maps. These + pages are not current registration instructions. + </p> + <ul className="archive-links"> + {EVENT_ROUTES.map(({ route, year }) => ( + <li key={year}> + <Link className="text-link" to={route}> + {year} event archive + </Link> + </li> + ))} + <li> + <Link className="text-link" to="/register"> + 2021 virtual event archive + </Link> + </li> + </ul> + <Link className="action action-outline" to="/get-involved"> + Find your way to get involved + </Link> + </section> + </div> + ); +} diff --git a/src/features/events/Faq.tsx b/src/features/events/Faq.tsx index 442e162..c68b38e 100644 --- a/src/features/events/Faq.tsx +++ b/src/features/events/Faq.tsx @@ -1,89 +1,55 @@ -import { useState } from "react"; - +import { useId, useState } from "react"; import type { FaqContent } from "../../content/events/types"; import Collapse from "../../components/Collapse"; -/** - * One accordion, two presentations: the registration page uses plain text inside - * smaller headings, the resources pages wrap every question and answer in `h5`. - */ export default function Faq({ content }: { content: FaqContent }) { - const [openKey, setOpenKey] = useState<number | null>(content.initialOpenKey); - const registration = content.kind === "registration"; + const [openKey, setOpenKey] = useState<number | null>(null); + const id = useId(); return ( - <section className="Section FAQ"> - <div className="faq-content"> - <div className="faq-content faq-centered-title"> - <h2 className="title">FAQs</h2> - <div className="pill-divider" /> - {registration ? ( - <p> - If your question is not answered below, please reach out to us at - exploretechla@cs.ucla.edu for more information! - </p> - ) : ( - <p> - If your question is not answered below, please reach out to us at{" "} - <a - href="mailto:exploretechla@cs.ucla.edu" - target="_blank" - rel="noopener noreferrer" - > - exploretechla@cs.ucla.edu - </a>{" "} - for more information! - </p> - )} - {content.intro && ( - <> - <h3>{content.intro.heading}</h3> - <p>{content.intro.body}</p> - </> - )} - </div> - <div className="faq-student"> - <div className="faq-section-title"> - {registration ? ( - <h4 className="title">Student FAQs</h4> - ) : ( - <h3 className="title">Student FAQs</h3> - )} - <div className="pill-divider" /> - </div> - <div className="accordion"> - {content.items.map((item) => ( - <div className="content-card" key={item.key}> - {/* - The shipped header is a plain div with a click handler; it was never - focusable or keyboard-operable. Preserved rather than quietly changed. - */} - <div - className="content-card-header" + <section className="event-section FAQ" aria-labelledby={`${id}-heading`}> + <h2 id={`${id}-heading`}>Archived event FAQs</h2> + <p> + These answers describe this past event. They do not apply to future + programs. + </p> + <div className="faq-list"> + {content.items.map((item) => { + const questionId = `${id}-question-${item.key}`; + const panelId = `${id}-panel-${item.key}`; + const open = openKey === item.key; + return ( + <div className="faq-item" key={item.key}> + <h3> + <button + id={questionId} + type="button" + aria-expanded={open} + aria-controls={panelId} onClick={() => setOpenKey((current) => current === item.key ? null : item.key, ) } > - {registration ? ( - <b>{item.question}</b> - ) : ( - <h5> - <b>{item.question}</b> - </h5> - )} + {item.question} + <span aria-hidden="true">{open ? "−" : "+"}</span> + </button> + </h3> + <Collapse in={open}> + <div + id={panelId} + role="region" + aria-labelledby={questionId} + className="faq-answer" + > + <p>{item.answer}</p> + {item.note && <p>Note: {item.note}</p>} </div> - <Collapse in={openKey === item.key}> - <div className="content-card-body"> - {registration ? item.answer : <h5>{item.answer}</h5>} - {item.note && <p>Note: {item.note}</p>} - </div> - </Collapse> - </div> - ))} - </div> - </div> + </Collapse> + </div> + ); + })} </div> </section> ); diff --git a/src/features/events/Maps.tsx b/src/features/events/Maps.tsx index 51823ce..af8d45c 100644 --- a/src/features/events/Maps.tsx +++ b/src/features/events/Maps.tsx @@ -1,37 +1,22 @@ -import type { DocumentLink } from "../../content/types"; -import ActionLink from "../../components/ActionLink"; +import type { DocumentLink as Document } from "../../content/types"; +import DocumentLink from "../../components/DocumentLink"; -export default function Maps({ - maps, - linkRel, -}: { - maps: readonly DocumentLink[]; - linkRel?: string; -}) { +export default function Maps({ maps }: { maps: readonly Document[] }) { return ( - <section className="Section Maps"> - <div className="maps-content"> - <div className="maps-title"> - <h2>Maps</h2> - <div className="pill-divider" /> - </div> - <h5> - Below are links to view all the maps you may need throughout the day! - </h5> - <div className="buttons"> - {maps.map((map) => ( - <ActionLink - key={map.name} - href={map.src} - target="_blank" - rel={linkRel} - className="action action-outline action-large" - > - {map.name} - </ActionLink> - ))} - </div> - </div> + <section + id="maps" + className="event-section Maps" + aria-labelledby="maps-heading" + > + <h2 id="maps-heading">Maps</h2> + <p>Venue and bus maps retained from this event.</p> + <ul className="document-list"> + {maps.map((map) => ( + <li key={map.name}> + <DocumentLink document={map} className="action action-outline" /> + </li> + ))} + </ul> </section> ); } diff --git a/src/features/events/Schedule.tsx b/src/features/events/Schedule.tsx index 23713df..370fffe 100644 --- a/src/features/events/Schedule.tsx +++ b/src/features/events/Schedule.tsx @@ -1,28 +1,46 @@ import type { ScheduleEntry } from "../../content/events/types"; +export function Timetable({ + items, + caption, +}: { + items: readonly ScheduleEntry[]; + caption: string; +}) { + return ( + <table className="event-timetable"> + <caption className="visually-hidden">{caption}</caption> + <thead> + <tr> + <th scope="col">Time</th> + <th scope="col">Activity</th> + </tr> + </thead> + <tbody> + {items.map((entry) => ( + <tr key={entry.key}> + <th scope="row">{entry.time}</th> + <td>{entry.name}</td> + </tr> + ))} + </tbody> + </table> + ); +} + export default function Schedule({ items, }: { items: readonly ScheduleEntry[]; }) { return ( - <section className="Section Schedule"> - <div className="schedule-content"> - <div className="schedule-content schedule-title"> - <h2 className="title">Schedule</h2> - <div className="pill-divider" /> - </div> - <div className="schedule-text"> - <p> - {items.map((entry) => ( - <h4 key={entry.key}> - <b>{entry.time} </b> - {entry.name} - </h4> - ))} - </p> - </div> - </div> + <section + id="schedule" + className="event-section Schedule" + aria-labelledby="schedule-heading" + > + <h2 id="schedule-heading">Schedule</h2> + <Timetable items={items} caption="Archived event schedule" /> </section> ); } diff --git a/src/features/events/Waivers.tsx b/src/features/events/Waivers.tsx index dc4384b..e72445e 100644 --- a/src/features/events/Waivers.tsx +++ b/src/features/events/Waivers.tsx @@ -1,46 +1,24 @@ -import type { ReactNode } from "react"; +import type { DocumentLink as Document } from "../../content/types"; +import DocumentLink from "../../components/DocumentLink"; -import type { DocumentLink } from "../../content/types"; -import ActionLink from "../../components/ActionLink"; - -/** - * Shared by /resources and /register. `children` carries the extra - * "all forms" block that only the registration page shows. - */ -export default function Waivers({ - forms, - intro, - note, - children, -}: { - forms: readonly DocumentLink[]; - intro: string; - note: string; - children?: ReactNode; -}) { +export default function Waivers({ forms }: { forms: readonly Document[] }) { return ( - <section className="Waivers"> - <div className="waivers-title"> - <h2 className="title">Waivers</h2> - <div className="pill-divider" /> - <p>{intro}</p> - <p> - <em>{note}</em> - </p> - </div> - <div className="buttons"> + <section + className="event-section Waivers" + aria-labelledby="waivers-heading" + > + <h2 id="waivers-heading">Archived waiver documents</h2> + <p> + Historical reference only. Do not submit these forms to register for a + future event. + </p> + <ul className="document-list"> {forms.map((form) => ( - <div className="button" key={form.name}> - <ActionLink - href={form.src} - className="action action-info action-large" - > - {form.name} - </ActionLink> - </div> + <li key={form.name}> + <DocumentLink document={form} /> + </li> ))} - </div> - {children} + </ul> </section> ); } diff --git a/src/features/events/Workshops.tsx b/src/features/events/Workshops.tsx index da0dd1e..c4c8d65 100644 --- a/src/features/events/Workshops.tsx +++ b/src/features/events/Workshops.tsx @@ -1,17 +1,8 @@ +import { useId, useState } from "react"; +import DocumentLink from "../../components/DocumentLink"; import YoutubeEmbed from "../../components/common/YoutubeEmbed"; import type { WorkshopCard, WorkshopSection } from "../../content/events/types"; -/** Cards have always been laid out two per deck row, in authored order. */ -function rowsOfTwo( - cards: readonly WorkshopCard[], -): readonly (readonly WorkshopCard[])[] { - const rows: WorkshopCard[][] = []; - for (let index = 0; index < cards.length; index += 2) { - rows.push(cards.slice(index, index + 2)); - } - return rows; -} - function Card({ card, heading, @@ -20,47 +11,46 @@ function Card({ heading: WorkshopSection["heading"]; }) { return ( - <div className="text-center content-card"> - <div className="content-card-body"> - <div className="content-card-title heading-five"> - <b>{card.title}</b> - </div> + <details className="workshop-card"> + <summary> + <span className="workshop-title">{card.title}</span> + {card.slot && ( + <span className="workshop-slot"> + {card.slot.session} · {card.slot.time} + <br /> + {card.slot.location} + </span> + )} + </summary> + <div className="workshop-body"> {card.kind === "archivedVideo" ? ( <> - {card.subtitle && ( - <div className="mb-2 muted-copy content-card-subtitle heading-six"> - {card.subtitle} - </div> - )} - <YoutubeEmbed embedId={card.embedId} /> - {card.caption && ( - <p className="content-card-text">{card.caption}</p> - )} + {card.subtitle && <p>{card.subtitle}</p>} + <YoutubeEmbed embedId={card.embedId} title={card.title} /> + {card.caption && <p>{card.caption}</p>} </> ) : ( - card.instructors && ( - <p className="content-card-text"> - {heading === "Panels" ? "Moderators" : "Instructors"}:{" "} - {card.instructors} - </p> - ) - )} - </div> - <div className="resource-list-flush resource-list"> - {card.kind === "described" && card.description && ( - <div className="resource-list-item"> - <p className="content-card-text">{card.description}</p> - </div> + <> + {card.instructors && ( + <p> + <strong> + {heading === "Panels" ? "Moderators" : "Instructors"}: + </strong>{" "} + {card.instructors} + </p> + )} + {card.description && <p>{card.description}</p>} + </> )} - {card.links.map((link) => ( - <div className="resource-list-item" key={link.name}> - <a className="content-card-link" href={link.src}> - {link.name} - </a> - </div> - ))} + <ul className="document-list"> + {card.links.map((link) => ( + <li key={link.name}> + <DocumentLink document={link} /> + </li> + ))} + </ul> </div> - </div> + </details> ); } @@ -69,29 +59,80 @@ export default function Workshops({ }: { sections: readonly WorkshopSection[]; }) { + const [query, setQuery] = useState(""); + const searchId = useId(); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filteredSections = sections.map((section) => ({ + ...section, + cards: section.cards.filter((card) => { + const description = + card.kind === "described" + ? `${card.instructors ?? ""} ${card.description ?? ""}` + : `${card.subtitle ?? ""} ${card.caption ?? ""}`; + const slot = card.slot + ? `${card.slot.session} ${card.slot.time} ${card.slot.location}` + : ""; + return `${card.title} ${description} ${slot}` + .toLocaleLowerCase() + .includes(normalizedQuery); + }), + })); + const total = sections.reduce( + (count, section) => count + section.cards.length, + 0, + ); + const count = filteredSections.reduce( + (sum, section) => sum + section.cards.length, + 0, + ); + return ( - <section className="Section Workshops"> - {sections.map((section) => ( - <div className="workshops-content" key={section.heading}> - <div className="workshops-title"> - <h2>{section.heading}</h2> - <div className="pill-divider" /> - </div> - <div className="workshops-cards"> - {rowsOfTwo(section.cards).map((row, index) => ( - <div className="card-row content-card-row" key={index}> - {row.map((card) => ( + <section + id="workshops" + className="event-section Workshops" + aria-labelledby="workshops-heading" + > + <h2 id="workshops-heading">Workshops and panels</h2> + <div className="workshop-filter"> + <label htmlFor={searchId}>Filter workshops and panels</label> + <input + id={searchId} + type="search" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search topic, instructor, session, or room" + /> + {query && ( + <button + type="button" + className="action action-outline" + onClick={() => setQuery("")} + > + Clear filter + </button> + )} + </div> + <p role="status"> + {count} of {total} workshops and panels + </p> + {count === 0 && <p>No workshops or panels match your search.</p>} + {filteredSections.map( + (section) => + section.cards.length > 0 && ( + <div className="workshop-section" key={section.heading}> + <h3>{section.heading}</h3> + <div className="workshop-grid"> + {section.cards.map((card) => ( <Card + key={card.title} card={card} heading={section.heading} - key={card.title} /> ))} </div> - ))} - </div> - </div> - ))} + </div> + ), + )} </section> ); } diff --git a/src/features/home/Home.tsx b/src/features/home/Home.tsx index 39ec5e2..d40dc18 100644 --- a/src/features/home/Home.tsx +++ b/src/features/home/Home.tsx @@ -1,4 +1,7 @@ -import Footer from "../../components/Footer"; +import { Link } from "react-router-dom"; + +import OutboundLink from "../../components/OutboundLink"; +import { PROGRAMS } from "../../content/participation"; import About from "./components/About"; import GetInvolved from "./components/GetInvolved"; import Hero from "./components/Hero"; @@ -9,11 +12,44 @@ export default function Home() { return ( <div className="Home"> <Hero /> - <About /> <GetInvolved /> + <section + className="home-programs page-container" + aria-labelledby="home-programs-title" + > + <h2 id="home-programs-title" className="section-heading"> + Our programs + </h2> + <div className="program-grid"> + {Object.entries(PROGRAMS).map(([id, program]) => ( + <article key={id}> + <h3>{program.title}</h3> + <p>{program.description}</p> + <p className="status-notice">{program.status}</p> + <div className="section-links"> + <Link + className="text-link" + to={id === "annual" ? "/events" : "/ignite"} + > + {id === "annual" + ? "Explore the annual event" + : "Explore Ignite"} + </Link> + <OutboundLink + className="text-link" + href={program.inquiryHref} + eventLabel={program.actionLabel} + > + {program.actionLabel} + </OutboundLink> + </div> + </article> + ))} + </div> + </section> + <About /> <Speakers /> <Sponsors /> - <Footer /> </div> ); } diff --git a/src/features/home/components/About.test.tsx b/src/features/home/components/About.test.tsx index 881fac0..c881308 100644 --- a/src/features/home/components/About.test.tsx +++ b/src/features/home/components/About.test.tsx @@ -1,6 +1,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; +import { vi } from "vitest"; import About from "./About"; @@ -17,7 +18,7 @@ const click = (element: Element | null | undefined) => { }; const select = (index: number) => { - click(container.querySelectorAll(".carousel-indicators li")[index]); + click(container.querySelectorAll(".carousel-indicators button")[index]); }; const fire = (element: HTMLImageElement, type: "load" | "error") => { @@ -26,15 +27,7 @@ const fire = (element: HTMLImageElement, type: "load" | "error") => { }); }; -/** Longer than the 600ms slide, so the carousel has settled on a slide. */ -const settle = () => { - act(() => { - vi.advanceTimersByTime(700); - }); -}; - beforeEach(() => { - vi.useFakeTimers(); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -53,7 +46,6 @@ afterEach(() => { root.unmount(); }); container.remove(); - vi.useRealTimers(); }); it("skips a failed selected image and keeps backward navigation usable", () => { @@ -64,10 +56,8 @@ it("skips a failed selected image and keeps backward navigation usable", () => { }); select(2); fire(images[2], "error"); - settle(); expect(activeImage()).toBe(images[3]); click(container.querySelector(".carousel-control-prev")); - settle(); expect(activeImage()).toBe(images[1]); }); @@ -76,10 +66,36 @@ it("does not let older image events override the newer selection", () => { select(1); select(3); fire(images[1], "load"); - settle(); expect(activeImage()).toBe(images[0]); fire(images[0], "error"); fire(images[3], "load"); - settle(); expect(activeImage()).toBe(images[3]); }); + +it("reconciles cached image successes and failures without waiting for new events", () => { + const complete = vi + .spyOn(HTMLImageElement.prototype, "complete", "get") + .mockReturnValue(true); + const width = vi + .spyOn(HTMLImageElement.prototype, "naturalWidth", "get") + .mockImplementation(function (this: HTMLImageElement) { + return this.alt === "Dev Tools Workshop" ? 0 : 640; + }); + try { + act(() => + root.render( + <MemoryRouter key="already-complete"> + <About /> + </MemoryRouter>, + ), + ); + images = Array.from(container.querySelectorAll(".carousel-item img")); + select(1); + expect(activeImage()).toBe(images[2]); + click(container.querySelector(".carousel-control-prev")); + expect(activeImage()).toBe(images[0]); + } finally { + complete.mockRestore(); + width.mockRestore(); + } +}); diff --git a/src/features/home/components/About.tsx b/src/features/home/components/About.tsx index 95c3ecc..8bec0d2 100644 --- a/src/features/home/components/About.tsx +++ b/src/features/home/components/About.tsx @@ -1,6 +1,5 @@ -import { Component } from "react"; +import { Component, createRef } from "react"; import { Link } from "react-router-dom"; -import { MdKeyboardArrowRight } from "react-icons/md"; import GA from "../../../util/GoogleAnalytics"; import OutboundLink from "../../../components/OutboundLink"; @@ -14,7 +13,7 @@ const FoundingStoryLink = // Match the two-column layout without sending desktop images to narrow screens. const AboutImageSizes = - "(min-width: 1100px) 480px, (min-width: 768px) 46vw, 100vw"; + "(min-width: 1100px) 520px, (min-width: 640px) 640px, 100vw"; interface AboutState { readonly activeIndex: number; @@ -29,41 +28,45 @@ export default class About extends Component< private readonly loadedSlides = new Set<number>(); private readonly failedSlides = new Set<number>(); + private readonly imageRefs = CarouselSlides.map(() => + createRef<HTMLImageElement>(), + ); + + componentDidMount() { + // Prerendered images may finish before hydration attaches load/error handlers. + // Reconcile the whole set first so an already-failed neighbour is skipped too. + this.imageRefs.forEach((ref, index) => { + const image = ref.current; + if (!image?.complete) return; + if (image.naturalWidth > 0) this.loadedSlides.add(index); + else this.failedSlides.add(index); + }); + if (this.loadedSlides.size > 0) this.setState({ primed: true }); + if (this.failedSlides.has(this.state.activeIndex)) + this._onSelect(this.state.activeIndex); + } state: AboutState = { activeIndex: 0, primed: false, requestedIndex: null }; render() { return ( - <section className="Section About" id={HomePageSections.ABOUT.name}> + <section + className="About page-container" + id={HomePageSections.ABOUT.name} + > <div className="about-content about-org"> <div className="left-column text"> - <div className="section-title"> - <h3 className="title">What is exploretech.la?</h3> - <div className="pill-divider" /> - </div> - <div className="about-description"> - <p> - exploretech.la is a UCLA Samueli School of Engineering student - organization that hosts an annual event that aims to inspire - high school students from underserved communities in the Greater - Los Angeles Area to explore computer science, engineering, and - technology. - </p> - <p> - In partnership with industry and academic organizations, our - event introduces students to the various applications of tech - through our panels, interactive workshops, and exhibition hall. - </p> - <p> - Every year, exploretech.la welcomes around 500 students to our - in-person event. The event will take place in early April, and - we would love for you to{" "} - <a className="in-text-link" href="#get-involved"> - attend - </a> - ! - </p> - </div> + <h2 className="section-heading"> + Technology students can try for themselves + </h2> + <p> + Our past events have included virtual reality demonstrations, + Scratch projects, and developer tools workshops. Students meet + engineers and UCLA volunteers, ask questions, and try new skills. + </p> + <Link className="text-link" to="/resources2026"> + Browse past workshops and resources + </Link> </div> {this._renderCarousel()} </div> @@ -74,43 +77,36 @@ export default class About extends Component< loading="lazy" decoding="async" className="team-photo left-column" - alt="Our Team" + alt="The exploretech.la team at the 2022 event" /> <div className="right-column text"> - <div className="section-title"> - <h3 className="title">Who is exploretech.la?</h3> - <div className="pill-divider" /> - </div> + <h2 className="section-heading">Run by UCLA students since 2017</h2> <p> - Founded in 2017 as student organization at UCLA, exploretech.la is - run by students for students. We apply our insights as recent high - school graduates to create engaging and impactful learning - experiences for our younger peers. We believe that every student - should have equal access to STEM education and opportunities. + exploretech.la is a UCLA Samueli School of Engineering student + organization. We work with industry and academic organizations to + give high school students access to STEM experiences. </p> - <div className="current-team"> + <div className="section-links"> <Link - to="/our_team" + className="text-link" + to="/our_team/leadership" onClick={() => GA.trackEvent({ - category: "Outbound", + category: "Home", action: "Click", - label: "current_team", + label: "Meet the team", }) } > - <p>Check out our current full team</p> - <MdKeyboardArrowRight className="MdKeyboardArrowRight" /> + Meet the team </Link> - </div> - <div className="founding-story"> <OutboundLink + className="text-link" href={FoundingStoryLink} target="_blank" eventLabel="founding_story" > - <p>Read about our founding story</p> - <MdKeyboardArrowRight className="MdKeyboardArrowRight" /> + Read our founding story </OutboundLink> </div> </div> @@ -179,6 +175,7 @@ export default class About extends Component< key: img.alt, content: ( <img + ref={this.imageRefs[index]} {...img} alt={img.alt} sizes={AboutImageSizes} @@ -201,7 +198,13 @@ export default class About extends Component< className="right-column" items={items} activeIndex={activeIndex} - interval={primed && requestedIndex === null ? 5000 : null} + status={ + this.failedSlides.size === total + ? "Photos could not load. You can still browse our past workshop resources." + : requestedIndex !== null + ? `Loading photo ${requestedIndex + 1} of ${total}.` + : `Photo ${activeIndex + 1} of ${total}: ${CarouselSlides[activeIndex].alt}` + } onSelect={this._onSelect} /> ); diff --git a/src/features/home/components/Carousel.tsx b/src/features/home/components/Carousel.tsx index 3fc34c6..f304dc9 100644 --- a/src/features/home/components/Carousel.tsx +++ b/src/features/home/components/Carousel.tsx @@ -1,28 +1,4 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type KeyboardEvent, - type ReactNode, - type TouchEvent, -} from "react"; - -/** - * The one carousel this site uses: the About section's photo strip. - * - * It reproduces Bootstrap 4's `.carousel.slide` markup and its class - * choreography (`carousel-item-next` -> reflow -> `carousel-item-left`), so the - * existing stylesheet drives the same 600ms slide. Slide contents are supplied - * by the caller, which also owns which slide is selected; this component owns - * only the transition, autoplay, keyboard, hover and touch behaviour. - */ - -const SWIPE_THRESHOLD = 40; - -/** dom-helpers padded its emulated transition end by 5ms; keep that slack. */ -const TRANSITION_END_PADDING_MS = 5; +import type { ReactNode } from "react"; export interface CarouselSlideItem { readonly key: string; @@ -33,271 +9,79 @@ interface CarouselProps { readonly className: string; readonly items: readonly CarouselSlideItem[]; readonly activeIndex: number; - /** Autoplay delay in milliseconds, or null to leave autoplay off. */ - readonly interval: number | null; + readonly status: string; readonly onSelect: (index: number) => void; } -type SlideDirection = "next" | "prev"; - -interface SlideState { - readonly from: number; - readonly to: number; - readonly direction: SlideDirection; - /** `start` positions the incoming slide; `active` runs the transition. */ - readonly phase: "start" | "active"; -} - -function useEventCallback<A extends unknown[]>( - fn: (...args: A) => void, -): (...args: A) => void { - const ref = useRef(fn); - useLayoutEffect(() => { - ref.current = fn; - }); - return useCallback((...args: A) => ref.current(...args), []); -} - -function transitionDuration(node: Element): number { - const value = window.getComputedStyle(node).transitionDuration || ""; - const multiplier = value.indexOf("ms") === -1 ? 1000 : 1; - return parseFloat(value) * multiplier || 0; -} - -function isVisible(element: HTMLElement | null): boolean { - const parent = element?.parentElement; - if (!element || !parent) return false; - return ( - window.getComputedStyle(element).display !== "none" && - window.getComputedStyle(element).visibility !== "hidden" && - window.getComputedStyle(parent).display !== "none" - ); -} - +/** The caller keeps the last loaded image visible while a selection loads. */ export default function Carousel({ className, items, activeIndex, - interval, + status, onSelect, }: CarouselProps) { - const elementRef = useRef<HTMLDivElement | null>(null); - const enteringRef = useRef<HTMLDivElement | null>(null); - const pendingDirectionRef = useRef<SlideDirection | null>(null); - - const [renderedActiveIndex, setRenderedActiveIndex] = useState(activeIndex); - const [slide, setSlide] = useState<SlideState | null>(null); - const [pausedOnHover, setPausedOnHover] = useState(false); - const [pausedOnTouch, setPausedOnTouch] = useState(false); - - // Adopt a new selection during render so the outgoing and incoming slides - // commit together, exactly as the Bootstrap carousel did. - if (slide === null && activeIndex !== renderedActiveIndex) { - setSlide({ - from: renderedActiveIndex, - to: activeIndex, - direction: - pendingDirectionRef.current ?? - (activeIndex > renderedActiveIndex ? "next" : "prev"), - phase: "start", - }); - setRenderedActiveIndex(activeIndex); - } - - const isSliding = slide !== null; - const count = items.length; - - const prev = useCallback(() => { - if (isSliding) return; - const target = renderedActiveIndex - 1; - pendingDirectionRef.current = "prev"; - onSelect(target < 0 ? count - 1 : target); - }, [isSliding, renderedActiveIndex, onSelect, count]); - - // Stable so the autoplay interval is not torn down on every render. - const next = useEventCallback(() => { - if (isSliding) return; - const target = renderedActiveIndex + 1; - pendingDirectionRef.current = "next"; - onSelect(target >= count ? 0 : target); - }); - - const nextWhenVisible = useEventCallback(() => { - if (!document.hidden && isVisible(elementRef.current)) next(); - }); - - useLayoutEffect(() => { - if (slide === null || slide.phase !== "start") return; - pendingDirectionRef.current = null; - // Force layout between the positioning class and the moving class so the - // browser animates instead of jumping. - if (enteringRef.current) void enteringRef.current.offsetHeight; - setSlide({ ...slide, phase: "active" }); - }, [slide]); - - useEffect(() => { - if (slide === null || slide.phase !== "active") return undefined; - const node = enteringRef.current; - if (!node) { - setSlide(null); - return undefined; - } - const finish = () => setSlide(null); - const handle = window.setTimeout( - finish, - transitionDuration(node) + TRANSITION_END_PADDING_MS, - ); - node.addEventListener("transitionend", finish); - return () => { - window.clearTimeout(handle); - node.removeEventListener("transitionend", finish); - }; - }, [slide]); - - const shouldPlay = - interval !== null && !pausedOnHover && !pausedOnTouch && !isSliding; - - useEffect(() => { - if (!shouldPlay) return undefined; - const handle = window.setInterval( - document.visibilityState ? nextWhenVisible : next, - interval ?? undefined, - ); - return () => window.clearInterval(handle); - }, [shouldPlay, next, interval, nextWhenVisible]); - - const touchStartXRef = useRef(0); - const touchDeltaXRef = useRef(0); - const touchUnpauseRef = useRef<number | undefined>(undefined); - useEffect(() => () => window.clearTimeout(touchUnpauseRef.current), []); - - const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { - if ( - event.target instanceof HTMLElement && - /input|textarea/i.test(event.target.tagName) - ) - return; - if (event.key === "ArrowLeft") { - event.preventDefault(); - prev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - next(); - } - }; - - const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => { - touchStartXRef.current = event.touches[0].clientX; - touchDeltaXRef.current = 0; - setPausedOnTouch(true); - }; - - const handleTouchMove = (event: TouchEvent<HTMLDivElement>) => { - touchDeltaXRef.current = - event.touches.length > 1 - ? 0 - : event.touches[0].clientX - touchStartXRef.current; - }; - - const handleTouchEnd = () => { - const touchDeltaX = touchDeltaXRef.current; - // A tap below the swipe threshold leaves autoplay paused, as before. - if (Math.abs(touchDeltaX) <= SWIPE_THRESHOLD) return; - if (touchDeltaX > 0) prev(); - else next(); - window.clearTimeout(touchUnpauseRef.current); - touchUnpauseRef.current = window.setTimeout( - () => setPausedOnTouch(false), - interval ?? undefined, - ); - }; - - const slideClassName = (index: number): string => { - if (slide === null) { - return index === renderedActiveIndex - ? "active carousel-item" - : "carousel-item"; - } - const order = `carousel-item-${slide.direction}`; - const moving = `carousel-item-${slide.direction === "next" ? "left" : "right"}`; - if (index === slide.to) { - return slide.phase === "start" - ? `${order} carousel-item` - : `${order} ${moving} carousel-item`; - } - if (index === slide.from) { - return slide.phase === "start" - ? "active carousel-item" - : `active ${moving} carousel-item`; - } - return "carousel-item"; - }; + if (items.length === 0) return null; return ( <div - ref={elementRef} - className={`${className} carousel slide`} - onKeyDown={handleKeyDown} - onMouseOver={() => setPausedOnHover(true)} - onMouseOut={() => setPausedOnHover(false)} - onTouchStart={handleTouchStart} - onTouchMove={handleTouchMove} - onTouchEnd={handleTouchEnd} + className={`${className} photo-gallery`} + role="region" + aria-roledescription="carousel" + aria-label="Photos from past exploretech.la events" > - <ol className="carousel-indicators"> - {items.map((item, index) => ( - <li - key={item.key} - className={index === renderedActiveIndex ? "active" : undefined} - onClick={() => onSelect(index)} - /> - ))} - </ol> <div className="carousel-inner"> {items.map((item, index) => ( <div key={item.key} - ref={slide !== null && index === slide.to ? enteringRef : undefined} - className={slideClassName(index)} + className={`carousel-item${index === activeIndex ? " active" : ""}`} + aria-hidden={index !== activeIndex} > {item.content} </div> ))} </div> - <a - className="carousel-control-prev" - role="button" - href="#" - onClick={(event) => { - event.preventDefault(); - prev(); - }} - onKeyDown={(event) => { - if (event.key !== " ") return; - event.preventDefault(); - prev(); - }} - > - <span aria-hidden="true" className="carousel-control-prev-icon" /> - <span className="visually-hidden">Previous</span> - </a> - <a - className="carousel-control-next" - role="button" - href="#" - onClick={(event) => { - event.preventDefault(); - next(); - }} - onKeyDown={(event) => { - if (event.key !== " ") return; - event.preventDefault(); - next(); - }} + <div className="gallery-controls"> + <button + type="button" + className="carousel-control-prev" + aria-label="Previous photo" + onClick={() => + onSelect((activeIndex + items.length - 1) % items.length) + } + > + <span aria-hidden="true">←</span> + </button> + <div className="carousel-indicators" aria-label="Choose a photo"> + {items.map((item, index) => ( + <button + key={item.key} + type="button" + aria-label={`Show photo ${index + 1}: ${item.key}`} + aria-pressed={index === activeIndex} + onClick={() => onSelect(index)} + > + <span aria-hidden="true" /> + </button> + ))} + </div> + <button + type="button" + className="carousel-control-next" + aria-label="Next photo" + onClick={() => onSelect((activeIndex + 1) % items.length)} + > + <span aria-hidden="true">→</span> + </button> + </div> + <p + className="gallery-status" + role="status" + aria-live="polite" + aria-atomic="true" > - <span aria-hidden="true" className="carousel-control-next-icon" /> - <span className="visually-hidden">Next</span> - </a> + {status} + </p> </div> ); } diff --git a/src/features/home/components/GetInvolved.tsx b/src/features/home/components/GetInvolved.tsx index 6a88b13..4c5f73e 100644 --- a/src/features/home/components/GetInvolved.tsx +++ b/src/features/home/components/GetInvolved.tsx @@ -1,125 +1,28 @@ -import { MdKeyboardArrowRight } from "react-icons/md"; +import { Link } from "react-router-dom"; -import OutboundLink from "../../../components/OutboundLink"; -import images from "../../../constants/optimizedImages"; +import { AUDIENCES } from "../../../content/participation"; import HomePageSections from "../../../content/sections"; -// Three cards side by side inside the 1040px section on desktop, a single -// 18rem card in the horizontal scroller below that. -const CardImageSizes = - "(min-width: 1100px) 340px, (min-width: 768px) 33vw, 18rem"; - -const MailingListSignUp = - "https://mailchi.mp/e1197feb6276/ucla-student-mailing-list"; - export default function GetInvolved() { return ( <section - className="Section GetInvolved" + className="GetInvolved page-container" id={HomePageSections.GET_INVOLVED.name} + aria-labelledby="get-involved-title" > - <div className="cards-container"> - <div className="cards-header"> - <h3>Get Involved</h3> - <div className="pill-divider" /> - </div> - <div className="content-card-row"> - <div className="high-schools content-card"> - <img - className="content-card-image" - {...images["images/teals-vr.jpg"]} - sizes={CardImageSizes} - loading="lazy" - decoding="async" - /> - <OutboundLink - target="_blank" - eventLabel="high_school_interest_form" - > - <div className="content-card-overlay"> - <div className="card-content"> - <div className="content-card-title heading-five"> - For High Schools - </div> - <p className="content-card-text">Inspire your students</p> - <div className="get-involved-cta"> - <MdKeyboardArrowRight className="MdKeyboardArrowRight" /> - <p className="cta-text content-card-text"> - Interest Form Out Soon - </p> - </div> - </div> - <div className="gradient-back" /> - </div> - </OutboundLink> - </div> - <div className="companies content-card"> - <img - className="content-card-image" - {...images["images/taboola-booth.jpg"]} - sizes={CardImageSizes} - loading="lazy" - decoding="async" - /> - <OutboundLink - href="mailto:exploretechla@cs.ucla.edu" - target="_blank" - eventLabel="email_us_companies" - > - <div className="content-card-overlay"> - <div className="card-content"> - <div className="content-card-title heading-five"> - For Companies - </div> - <p className="full-content content-card-text"> - Become an industry partner - </p> - <p className="short-content content-card-text"> - Partner with us - </p> - <div className="get-involved-cta"> - <MdKeyboardArrowRight className="MdKeyboardArrowRight" /> - <p className="cta-text content-card-text">Email us</p> - </div> - </div> - <div className="gradient-back" /> - </div> - </OutboundLink> - </div> - <div className="ucla-students content-card"> - <img - className="content-card-image" - {...images["images/speaker-connie.jpg"]} - sizes={CardImageSizes} - loading="lazy" - decoding="async" - /> - <OutboundLink - href={MailingListSignUp} - target="_blank" - eventLabel="ucla_student_mailing_list" - > - <div className="content-card-overlay"> - <div className="card-content"> - <div className="content-card-title heading-five"> - For UCLA Students - </div> - <p className="content-card-text">Join our team</p> - <div className="get-involved-cta"> - <MdKeyboardArrowRight className="MdKeyboardArrowRight" /> - <p className="cta-text short-content content-card-text"> - Get our newsletter - </p> - <p className="cta-text full-content content-card-text"> - Sign up for our newsletter - </p> - </div> - </div> - <div className="gradient-back" /> - </div> - </OutboundLink> - </div> - </div> + <h2 id="get-involved-title" className="section-heading"> + Get involved + </h2> + <div className="audience-grid"> + {Object.entries(AUDIENCES).map(([id, audience]) => ( + <Link className="audience-card" key={id} to={audience.path}> + <h3>{audience.title}</h3> + <p>{audience.description}</p> + <span className="audience-card-action"> + {audience.actionLabel} <span aria-hidden="true">→</span> + </span> + </Link> + ))} </div> </section> ); diff --git a/src/features/home/components/Hero.tsx b/src/features/home/components/Hero.tsx index 52ef8cd..02147a3 100644 --- a/src/features/home/components/Hero.tsx +++ b/src/features/home/components/Hero.tsx @@ -1,67 +1,59 @@ import { Link } from "react-router-dom"; -import GA from "../../../util/GoogleAnalytics"; -import HomePageSections from "../../../content/sections"; +import OutboundLink from "../../../components/OutboundLink"; +import images from "../../../constants/optimizedImages"; +import { PROGRAMS } from "../../../content/participation"; import LogoWithIconGroup from "../../../static/svg/logo-navy-with-icon-group-white.svg"; -import ActionLink from "../../../components/ActionLink"; - -const trackLearnMore = () => - GA.trackEvent({ category: "Hero", action: "Click", label: "Learn More" }); +import GA from "../../../util/GoogleAnalytics"; export default function Hero() { return ( - <section className="Section Hero"> - <img - src={LogoWithIconGroup} - className="logo-with-icon-group" - alt="logo-with-icon-group" - /> - <div className="hero-content"> - <h2 className="h1-title">Schedule is out now!</h2> - <h5 className="h1-title"> - Check out our 2026 schedule page for the latest event details and - updates. - </h5> - <p className="h1-title"> - <b>Any questions or partnerships?</b> - </p> - <p> - Contact our executive directors Spoorthy Nalumachu and Clara Zhang:{" "} - <a - href="mailto:exploretechla@cs.ucla.edu" - target="_blank" - rel="noopener noreferrer" - > - exploretechla@cs.ucla.edu - </a>{" "} - </p> - - <div className="hero-cta"> - <Link - className="action action-info" - to="/resources2026" - onClick={() => - GA.trackEvent({ - category: "Hero", - action: "Click", - label: "Register Today", - }) - } - > - Event Info - </Link> - <ActionLink - className="action action-info" - href={`#${HomePageSections.ABOUT.name}`} - onClick={trackLearnMore} - onKeyDown={(event) => { - if (event.key !== " ") return; - trackLearnMore(); - }} - > - Learn More - </ActionLink> + <section className="Hero"> + <div className="hero-layout page-container"> + <div className="hero-content"> + <img + src={LogoWithIconGroup} + className="logo-with-icon-group" + alt="exploretech.la" + /> + <h1>Helping Los Angeles high school students explore technology</h1> + <p> + We are UCLA students bringing hands-on computer science and + engineering experiences to high school students from underserved + communities. + </p> + <p className="status-notice">{PROGRAMS.annual.status}</p> + <div className="hero-cta section-links"> + <OutboundLink + className="action action-primary" + href={PROGRAMS.annual.inquiryHref} + eventLabel={PROGRAMS.annual.actionLabel} + > + {PROGRAMS.annual.actionLabel} + </OutboundLink> + <Link + className="text-link" + to="/events" + onClick={() => + GA.trackEvent({ + category: "Hero", + action: "Click", + label: "Explore programs", + }) + } + > + Explore our programs + </Link> + </div> </div> + <img + {...images["images/workshop-audience.jpg"]} + className="hero-photo" + alt="High school students taking part in an exploretech.la workshop" + sizes="(min-width: 1200px) 480px, (min-width: 1100px) 42vw, (min-width: 640px) 640px, 100vw" + fetchPriority="high" + decoding="async" + /> </div> </section> ); diff --git a/src/features/home/components/Speakers.tsx b/src/features/home/components/Speakers.tsx index 1b98003..76dbc08 100644 --- a/src/features/home/components/Speakers.tsx +++ b/src/features/home/components/Speakers.tsx @@ -5,11 +5,13 @@ import { people } from "../../../content/people"; export default function Speakers() { return ( - <section className="Section Speakers" id={HomePageSections.SPEAKERS.name}> + <section + className="Speakers page-container" + id={HomePageSections.SPEAKERS.name} + > <div className="speakers-container"> <div className="speakers-header"> - <h3>Past Speakers</h3> - <div className="pill-divider" /> + <h2 className="section-heading">Past speakers</h2> </div> <People people={pastSpeakers.map(({ personId, title }) => ({ diff --git a/src/features/home/components/Sponsors.tsx b/src/features/home/components/Sponsors.tsx index d49ba39..ae17bc6 100644 --- a/src/features/home/components/Sponsors.tsx +++ b/src/features/home/components/Sponsors.tsx @@ -1,4 +1,4 @@ -import { MdKeyboardArrowRight } from "react-icons/md"; +import { Link } from "react-router-dom"; import OutboundLink from "../../../components/OutboundLink"; import HomePageSections from "../../../content/sections"; @@ -32,11 +32,15 @@ function renderLogo({ name, website, ...image }: SponsorLogo, sizes: string) { export default function Sponsors() { return ( - <section className="Section Sponsors" id={HomePageSections.SPONSORS.name}> + <section + className="Sponsors page-container" + id={HomePageSections.SPONSORS.name} + > <div className="sponsors-container"> <div className="sponsors-header"> - <h3>Our Sponsors</h3> - <div className="pill-divider" /> + <h2 className="section-heading"> + Organizations that have supported exploretech.la + </h2> </div> <div className="large-logos"> {LargeLogoSponsors.map((logo) => renderLogo(logo, LargeLogoSizes))} @@ -45,17 +49,9 @@ export default function Sponsors() { {SmallLogoSponsors.map((logo) => renderLogo(logo, SmallLogoSizes))} </div> <div className="sponsor-contact"> - <h5>Interested in sponsoring?</h5> - <div className="sponsor-cta"> - <OutboundLink - href="mailto:exploretechla@cs.ucla.edu" - target="_blank" - eventLabel="email_us_sponsors" - > - <p>Email Us!</p> - <MdKeyboardArrowRight className="MdKeyboardArrowRight" /> - </OutboundLink> - </div> + <Link className="action action-outline" to="/get-involved#partners"> + Discuss a partnership + </Link> </div> </div> </section> diff --git a/src/features/home/content/sponsors.ts b/src/features/home/content/sponsors.ts index e210efb..4c455bc 100644 --- a/src/features/home/content/sponsors.ts +++ b/src/features/home/content/sponsors.ts @@ -17,12 +17,12 @@ export interface SponsorLogo { export const LargeLogoSponsors: readonly SponsorLogo[] = [ { - name: "google-logo", + name: "Google", ...images["sponsors/google-logo.png"], website: "https://about.google/", }, { - name: "ucla-cs", + name: "UCLA Computer Science", ...images["sponsors/ucla-cs.png"], website: "https://www.cs.ucla.edu/", }, @@ -30,39 +30,39 @@ export const LargeLogoSponsors: readonly SponsorLogo[] = [ export const SmallLogoSponsors: readonly SponsorLogo[] = [ { - name: "balsamiq", + name: "Balsamiq", ...images["sponsors/balsamiq.png"], website: "https://balsamiq.com/", }, { - name: "ucla_samueli_engineering", + name: "UCLA Samueli School of Engineering", ...images["sponsors/ucla-samueli-engineering.png"], website: "https://samueli.ucla.edu/", }, { - name: "yubico", + name: "Yubico", ...images["sponsors/yubico.png"], website: "https://www.yubico.com/", }, { - name: "northrop-grumman", + name: "Northrop Grumman", ...images["sponsors/northrop-grumman.png"], website: "https://www.northropgrumman.com/", }, { - name: "disney", + name: "Disney", ...images["sponsors/disney.png"], website: "https://thewaltdisneycompany.com/", }, { - name: "capcom", + name: "Capcom", src: capcomLogo, width: 5592, height: 1024, website: "https://www.capcom.com/us/", }, { - name: "cloud-girls", + name: "Cloud Girls", ...images["sponsors/cloud-girls.png"], website: "https://cloudgirls.org/", }, diff --git a/src/features/ignite/Ignite.tsx b/src/features/ignite/Ignite.tsx index df4e398..2fbd7c3 100644 --- a/src/features/ignite/Ignite.tsx +++ b/src/features/ignite/Ignite.tsx @@ -1,16 +1,62 @@ -import Footer from "../../components/Footer"; -import PageNotFound from "../../components/PageNotFound"; +import { Link } from "react-router-dom"; +import OutboundLink from "../../components/OutboundLink"; +import { PROGRAMS } from "../../content/participation"; import images from "../../constants/optimizedImages"; -// Workshop cards sit three-up on desktop; the capstone and apply photos are -// single large images inside their own containers. -const WorkshopImageSizes = "(min-width: 992px) 33vw, 100vw"; -const CapstoneImageSizes = "(min-width: 1000px) 900px, 90vw"; -const ApplyImageSizes = "(min-width: 992px) 50vw, 100vw"; +const WorkshopImageSizes = + "(min-width: 1200px) 340px, (min-width: 992px) 30vw, (min-width: 768px) 45vw, 100vw"; +const ProjectImageSizes = "(min-width: 992px) 540px, 100vw"; -const ApplicationForm = "https://forms.gle/DKUZARUPjWoxrUif6"; +const PAST_WORKSHOPS = [ + { + year: 2025, + workshops: [ + { + title: "Introduction to Python", + description: + "Beginner Python concepts and practical programming projects.", + image: "images/ignite/python.jpg", + }, + { + title: "Game development with Roblox", + description: + "A speed-run parkour game in Roblox Studio, with level building and game mechanics scripted in Lua.", + image: "images/ignite/gamedevroblox.jpg", + }, + { + title: "Web development", + description: + "Website building with HTML and CSS, leading to a custom website capstone project.", + image: "images/ignite/webdev.jpg", + }, + ], + }, + { + year: 2024, + workshops: [ + { + title: "Introduction to Python", + description: + "Beginner Python concepts and practical programming projects.", + image: "images/ignite/PXL_20250215_212102361.MP.jpg", + }, + { + title: "Game development with Unity", + description: + "An introduction to game development using the Unity engine.", + image: "images/ignite/gamdev.jpeg", + }, + { + title: "Machine learning", + description: + "An introduction to machine learning, a field that uses data to train models to recognize patterns.", + image: "images/ignite/ML.png", + }, + ], + }, +] as const; -// Every image on this page is below the header section, so they are all lazy. +// Photos appear after the program introduction and cohort details. function lazyPhoto(key: keyof typeof images, sizes: string) { return { ...images[key], @@ -20,246 +66,148 @@ function lazyPhoto(key: keyof typeof images, sizes: string) { } as const; } -const IGNITE_ENABLED: boolean = true; // gate - export default function Ignite() { - if (!IGNITE_ENABLED) { - return <PageNotFound />; - } - return ( <div className="Ignite"> - <section className="ignite-section header-section"> - <div className="content-wrapper"> - <h1 className="ignite-title">Ignite</h1> - <div className="ignite-description"> + <section className="ignite-intro" aria-labelledby="ignite-title"> + <div className="page-container"> + <header className="page-header"> + <h1 id="ignite-title">{PROGRAMS.ignite.title}</h1> + <p>{PROGRAMS.ignite.description}</p> + </header> + <div className="status-notice ignite-next"> + <h2>Next Ignite series</h2> + <p>{PROGRAMS.ignite.status}</p> <p> - Ignite is a 4-week series of beginner-friendly workshops offering - an in-depth exploration of current trending topics in technology. - It is designed to inspire and encourage high school students to - pursue a career in the STEM/Tech field. + Email the team to ask about dates, eligibility, accessibility, and + how to participate. </p> - </div> - <div className="header-buttons"> - <button - type="button" - className="btn-apply action action-primary" - onClick={() => window.open(ApplicationForm, "_blank")} + <OutboundLink + href={PROGRAMS.ignite.inquiryHref} + eventLabel="Ignite inquiry" + className="action action-primary" > - Apply Now! - </button> - <button - type="button" - className="btn-previous action action-inverse" - onClick={() => - document - .getElementById("workshops-2025") - ?.scrollIntoView({ behavior: "smooth" }) - } - > - Previous Workshop - </button> + {PROGRAMS.ignite.actionLabel} + </OutboundLink> </div> + <nav className="section-links" aria-label="Ignite page sections"> + <Link to="#cohort-2026">2026 cohort details</Link> + <Link to="#workshops-2025">2025 workshops</Link> + <Link to="#workshops-2024">2024 workshops</Link> + <Link to="#past-projects">Past projects</Link> + </nav> </div> </section> - <section className="ignite-section structure-section"> - <div className="content-wrapper"> - <h2 className="section-header">Program Structure</h2> - <div className="program-cards-container"> - <div className="program-card"> - <h3 className="content-card-title">Week 1: Foundation</h3> - <p className="card-description"> - Building core skills and understanding key concepts. - </p> - </div> - <div className="program-card"> - <h3 className="content-card-title">Week 2: Development</h3> - <p className="card-description"> - Hands-on learning and practical application. - </p> + <section id="cohort-2026" aria-labelledby="cohort-2026-heading"> + <div className="page-container"> + <h2 id="cohort-2026-heading" className="section-heading"> + 2026 cohort details + </h2> + <p className="ignite-section-description"> + The published 2026 schedule listed three Saturday sessions at UCLA. + These are historical details, not the schedule or arrangements for + the next series. + </p> + {/* Source: Ignite 26 RSVP metadata, https://forms.gle/DKUZARUPjWoxrUif6. + Keep the past form out of the current participation workflow. */} + <dl className="ignite-facts"> + <div> + <dt>2026 session dates</dt> + <dd>January 31, February 7, and February 14, 2026</dd> </div> - <div className="program-card"> - <h3 className="content-card-title">Week 3: Integration</h3> - <p className="card-description"> - Advanced concepts and team collaboration. - </p> + <div> + <dt>2026 daily schedule</dt> + <dd> + <ul> + <li>Web development: 10:00 to 11:30 a.m.</li> + <li>Lunch break: 11:30 a.m. to 12:30 p.m.</li> + <li>Game development: 12:30 to 2:00 p.m.</li> + </ul> + </dd> </div> - <div className="program-card"> - <h3 className="content-card-title"> - Week 4: - <br /> - Capstone - </h3> - <p className="card-description"> - Project development and final presentation. - </p> + <div> + <dt>2026 cost and logistics</dt> + <dd> + Participation was free. Transportation to UCLA and lunch were + not provided. The 2026 information listed an on-site food court + where participants could buy lunch. + </dd> </div> - </div> + </dl> </div> </section> - <section - id="workshops-2025" - className="ignite-section workshop-section theme-light" - > - <div className="content-wrapper"> - <h2 className="section-header">2025 Workshops</h2> - <div className="workshop-cards-container"> - <div className="workshop-card"> - <h3 className="content-card-title">Introduction to Python</h3> - <p className="card-description"> - Dive into one of the most versatile and widely used programming - languages. This track will teach you essential Python concepts - and guide you through practical projects, perfect for beginners. - </p> - <div className="card-image"> - <img - {...lazyPhoto("images/ignite/python.jpg", WorkshopImageSizes)} - alt="Introduction to Python" - /> - </div> - </div> - <div className="workshop-card"> - <h3 className="content-card-title"> - Game Development with Roblox - </h3> - <p className="card-description"> - Design and develop an exciting speed run parkour game using - Roblox Studio. From building dynamic levels to scripting game - mechanics with Lua, you'll gain hands-on experience in game - creation. - </p> - <div className="card-image"> - <img - {...lazyPhoto( - "images/ignite/gamedevroblox.jpg", - WorkshopImageSizes, - )} - alt="Game Development with Roblox" - /> - </div> - </div> - <div className="workshop-card"> - <h3 className="content-card-title">Web Development</h3> - <p className="card-description"> - Learn how websites are built using HTML and CSS. Build your - skills throughout the sessions and create your very own custom - website as a capstone project. - </p> - <div className="card-image"> - <img - {...lazyPhoto("images/ignite/webdev.jpg", WorkshopImageSizes)} - alt="Web Development" - /> - </div> + {PAST_WORKSHOPS.map(({ year, workshops }) => ( + <section + key={year} + id={`workshops-${year}`} + className="ignite-workshops" + aria-labelledby={`workshops-${year}-heading`} + > + <div className="page-container"> + <h2 id={`workshops-${year}-heading`} className="section-heading"> + {year} workshops + </h2> + <p className="ignite-section-description"> + Topics from the {year} series. Future workshop offerings may + differ. + </p> + <div className="ignite-workshop-grid"> + {workshops.map((workshop) => ( + <article className="ignite-workshop-card" key={workshop.title}> + <h3>{workshop.title}</h3> + <p>{workshop.description}</p> + <img + {...lazyPhoto(workshop.image, WorkshopImageSizes)} + alt={workshop.title} + /> + </article> + ))} </div> </div> - </div> - </section> + </section> + ))} - <section className="ignite-section workshop-section theme-light"> - <div className="content-wrapper"> - <h2 className="section-header">2024 Workshops</h2> - <div className="workshop-cards-container"> - <div className="workshop-card"> - <h3 className="content-card-title">Introduction to Python</h3> - <p className="card-description"> - Dive into one of the most versatile and widely used programming - languages. This track will teach you essential Python concepts - and guide you through practical projects, perfect for beginners. - </p> - <div className="card-image"> - <img - {...lazyPhoto( - "images/ignite/PXL_20250215_212102361.MP.jpg", - WorkshopImageSizes, - )} - alt="Introduction to Python" - /> - </div> - </div> - <div className="workshop-card"> - <h3 className="content-card-title"> - Game Development with Unity - </h3> - <p className="card-description"> - Design and develop an exciting speed run parkour game using - Roblox Studio. From building dynamic levels to scripting game - mechanics with Lua, you'll gain hands-on experience in game - creation. - </p> - <div className="card-image"> - <img - {...lazyPhoto( - "images/ignite/gamdev.jpeg", - WorkshopImageSizes, - )} - alt="Game Development with Unity" - /> - </div> - </div> - <div className="workshop-card"> - <h3 className="content-card-title">Machine Learning</h3> - <p className="card-description"> - Learn how websites are built using HTML and CSS. Build your - skills throughout the sessions and create your very own custom - website as a capstone project. - </p> - <div className="card-image"> - <img - {...lazyPhoto("images/ignite/ML.png", WorkshopImageSizes)} - alt="Machine Learning" - /> - </div> - </div> - </div> - </div> - </section> - - <section className="ignite-section capstone-section theme-light"> - <div className="content-wrapper"> - <h2 className="section-header">Capstone Project</h2> - <p className="section-subtitle"> - The program culminates in a capstone project... + <section id="past-projects" aria-labelledby="past-projects-heading"> + <div className="page-container"> + <h2 id="past-projects-heading" className="section-heading"> + Past projects and collaboration + </h2> + <p className="ignite-section-description"> + Past workshops combined practice with project work. The 2025 web + development track included a custom website capstone. These photos + show project presentations and students working together. </p> - <div className="capstone-image-container"> - <img - {...lazyPhoto("images/ignite/collab1.jpg", CapstoneImageSizes)} - alt="Capstone Project Presentation" - /> - </div> - </div> - </section> - - <section className="ignite-section footer-apply-section"> - <div className="content-wrapper split-layout"> - <div className="left-text-col"> - <h1 className="section-header align-left">Apply Now!</h1> - <p className="section-subtitle-small"> - Applications for the next IGNITE are open... - </p> - <button - type="button" - className="btn-apply-large action action-primary" - onClick={() => window.open(ApplicationForm, "_blank")} - > - Apply - </button> - </div> - - <div className="right-image-col"> - <div className="apply-image"> + <div className="ignite-project-grid"> + <figure> <img - {...lazyPhoto("images/ignite/cllab2.jpg", ApplyImageSizes)} - alt="Students Workshop" + {...lazyPhoto("images/ignite/collab1.jpg", ProjectImageSizes)} + alt="An Ignite capstone project presentation" /> - </div> + <figcaption>Sharing project work</figcaption> + </figure> + <figure> + <img + {...lazyPhoto("images/ignite/cllab2.jpg", ProjectImageSizes)} + alt="Students working together during an Ignite workshop" + /> + <figcaption>Working together during a workshop</figcaption> + </figure> + </div> + <div className="ignite-contact"> + <h2>Interested in taking part?</h2> + <p>{PROGRAMS.ignite.status}</p> + <OutboundLink + href={PROGRAMS.ignite.inquiryHref} + eventLabel="Ignite inquiry" + className="action action-primary" + > + {PROGRAMS.ignite.actionLabel} + </OutboundLink> </div> </div> </section> - - <Footer /> </div> ); } diff --git a/src/features/participation/Participation.tsx b/src/features/participation/Participation.tsx new file mode 100644 index 0000000..d482483 --- /dev/null +++ b/src/features/participation/Participation.tsx @@ -0,0 +1,175 @@ +import { Link } from "react-router-dom"; +import OutboundLink from "../../components/OutboundLink"; +import { + AUDIENCES, + CONTACT_EMAIL, + UCLA_UPDATES_URL, +} from "../../content/participation"; + +export default function Participation() { + return ( + <div className="Participation"> + <header className="page-header page-container"> + <h1>Get involved</h1> + <p className="page-lead"> + Find the right contact for your school, volunteer interest, or + organization. + </p> + <nav className="section-links" aria-label="Participation options"> + <Link to="#schools">Schools and students</Link> + <Link to="#volunteer">UCLA volunteers</Link> + <Link to="#partners">Partners and sponsors</Link> + </nav> + </header> + + <div className="page-container participation-sections"> + <section + id="schools" + className="participation-section" + aria-labelledby="schools-heading" + > + <h2 id="schools-heading">For schools and students</h2> + <p> + Teachers and school coordinators can ask about bringing a group. + Students and families can contact us to learn which participation + options are available. + </p> + <OutboundLink + className="action action-primary" + href={AUDIENCES.schools.inquiryHref} + eventLabel="School Participation Enquiry" + > + {AUDIENCES.schools.actionLabel} + </OutboundLink> + <div className="status-notice"> + <p> + Check the program information, then ask the team to confirm dates + and arrangements before planning a visit. + </p> + <Link className="text-link" to="/events"> + Compare the programs + </Link> + </div> + <h3>What to include in your enquiry</h3> + <ul> + <li>Your school or organization and the year groups interested.</li> + <li> + An approximate group size, if you are planning a school visit. + </li> + <li> + Questions about eligibility, permissions, transport, lunch, or + accessibility arrangements. + </li> + </ul> + <p> + The team can confirm dates and arrangements before you plan a visit. + Please do not send student waivers or personal information using + instructions from a past event archive. + </p> + </section> + + <section + id="volunteer" + className="participation-section" + aria-labelledby="volunteer-heading" + > + <h2 id="volunteer-heading">Volunteer with the UCLA team</h2> + <p> + Help create workshop content, design event materials, organize + activities, build the website, or support outreach. You can explore + the existing departments before contacting us. + </p> + <div className="action-row"> + <OutboundLink + className="action action-primary" + href={AUDIENCES.volunteer.inquiryHref} + eventLabel="UCLA Volunteer Enquiry" + > + {AUDIENCES.volunteer.actionLabel} + </OutboundLink> + <OutboundLink + className="action action-outline" + href={UCLA_UPDATES_URL} + target="_blank" + eventLabel="UCLA Newsletter Signup" + > + Get UCLA email updates + <span className="visually-hidden"> (opens in a new tab)</span> + </OutboundLink> + </div> + <Link + className="text-link inline-flex min-h-11 items-center" + to="/our_team/leadership" + > + Meet the team and explore departments + </Link> + <h3>Start a conversation</h3> + <ul> + <li> + Tell us what you are interested in contributing and any relevant + experience. + </li> + <li> + Ask about available roles, recruitment timing, and the expected + commitment. + </li> + </ul> + <p> + This enquiry is separate from joining the mailing list. The team + will confirm the current recruitment process. + </p> + </section> + + <section + id="partners" + className="participation-section" + aria-labelledby="partners-heading" + > + <h2 id="partners-heading">Partner with exploretech.la</h2> + <p> + Our programs connect high school students with UCLA students and + people working in technology. Contact us to discuss how your + organization could contribute. + </p> + <OutboundLink + className="action action-primary" + href={AUDIENCES.partners.inquiryHref} + eventLabel="Partnership Enquiry" + > + {AUDIENCES.partners.actionLabel} + </OutboundLink> + <h3>Ways to discuss helping</h3> + <ul> + <li> + Share experience through a panel, mentoring, or an exhibition. + </li> + <li>Contribute expertise or materials to a hands-on workshop.</li> + <li>Ask about financial or in-kind support for an event.</li> + </ul> + <p> + Include your organization, a contact person, the kind of support you + have in mind, and your timeframe. The team can discuss suitable + opportunities and arrangements with you. + </p> + </section> + + <section + className="participation-contact" + aria-labelledby="participation-contact-heading" + > + <h2 id="participation-contact-heading">Prefer to write directly?</h2> + <p> + The enquiry links open your email app. You can also copy this + address into the email service you use: + </p> + <a + className="contact-address text-link" + href={`mailto:${CONTACT_EMAIL}`} + > + {CONTACT_EMAIL} + </a> + </section> + </div> + </div> + ); +} diff --git a/src/features/registration/ImportantLinks.tsx b/src/features/registration/ImportantLinks.tsx deleted file mode 100644 index 0529c6a..0000000 --- a/src/features/registration/ImportantLinks.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { RegistrationContent } from "../../content/events/2021"; -import ActionLink from "../../components/ActionLink"; - -export default function ImportantLinks({ - links, -}: { - links: RegistrationContent["importantLinks"]; -}) { - const { support } = links; - - return ( - <section className="ImportantLinks"> - <div className="links-title"> - <h2 className="title">Important Links</h2> - <div className="pill-divider" /> - <p>{links.intro}</p> - </div> - <div className="button"> - <ActionLink - href={links.platform.src} - className="action action-warning action-large" - > - {links.platform.name} - </ActionLink> - </div> - <div className="buttons"> - {links.documents.map((document) => ( - <div className="button" key={document.name}> - <ActionLink - href={document.src} - className="action action-info action-large" - > - {document.name} - </ActionLink> - </div> - ))} - </div> - <div className="links-text"> - <h2 className="title">{support.heading}</h2> - <div className="pill-divider" /> - {support.paragraphs.map((paragraph) => ( - <p key={paragraph}>{paragraph}</p> - ))} - <br /> - <h4>{support.liveHeading}</h4> - <p>{support.liveBody}</p> - <div className="button"> - <ActionLink - href={support.zoom.src} - className="action action-info action-large" - > - {support.zoom.name} - </ActionLink> - </div> - <br /> - <p> - <b>{support.hoursLabel}</b> {support.hours} - </p> - </div> - </section> - ); -} diff --git a/src/features/registration/RegistrationAlert.tsx b/src/features/registration/RegistrationAlert.tsx deleted file mode 100644 index e555827..0000000 --- a/src/features/registration/RegistrationAlert.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Link } from "react-router-dom"; - -import type { RegistrationContent } from "../../content/events/2021"; -import GA from "../../util/GoogleAnalytics"; - -export default function RegistrationAlert({ - alert, -}: { - alert: RegistrationContent["alert"]; -}) { - return ( - <div className="RegistrationAlert"> - <h3> - <b>{alert.heading}</b> - </h3> - <div className="notes"> - <h5>{alert.note}</h5> - </div> - <div className="buttons"> - <div className="button"> - <Link - className="action action-inverse action-large" - to={alert.to} - onClick={() => - GA.trackEvent({ - category: "RegistrationAlert", - action: "Click", - label: alert.trackingLabel, - }) - } - > - {alert.linkLabel} - </Link> - </div> - </div> - </div> - ); -} diff --git a/src/features/registration/RegistrationPage.tsx b/src/features/registration/RegistrationPage.tsx index 96d3988..7d7548c 100644 --- a/src/features/registration/RegistrationPage.tsx +++ b/src/features/registration/RegistrationPage.tsx @@ -1,57 +1,59 @@ -import Footer from "../../components/Footer"; -import images from "../../constants/optimizedImages"; +import { Link } from "react-router-dom"; +import DocumentLink from "../../components/DocumentLink"; +import OutboundLink from "../../components/OutboundLink"; import { REGISTRATION_2021 } from "../../content/events/2021"; -import ActionLink from "../../components/ActionLink"; +import { PROGRAMS } from "../../content/participation"; import Faq from "../events/Faq"; import Waivers from "../events/Waivers"; -import ImportantLinks from "./ImportantLinks"; -import RegistrationAlert from "./RegistrationAlert"; import TwoDaySchedule from "./TwoDaySchedule"; -/** The 2021 event information page, served at /register. */ export default function RegistrationPage() { const content = REGISTRATION_2021; - const { waivers } = content; - return ( - <div className="Register"> - <div className="banner"> - {/* - The banner is the first thing on the page, so it stays eager and - keeps its intrinsic dimensions to reserve the space it needs. - */} - <img - {...images["images/banner.png"]} - sizes="100vw" - decoding="async" - className="banner" - alt="banner" - /> - </div> - <RegistrationAlert alert={content.alert} /> - <div className="registration-title"> + <div className="Register page-container"> + <header className="page-header"> + <Link className="text-link" to="/events"> + All events and archives + </Link> <h1>{content.title}</h1> - </div> + <p>April 10 and 11, 2021 · Online</p> + <p className="status-notice"> + This page preserves the 2021 virtual event. It is not a registration + page for an upcoming event. {PROGRAMS.annual.status} + </p> + <OutboundLink + href={PROGRAMS.annual.inquiryHref} + eventLabel="Annual event inquiry" + className="action action-primary" + > + {PROGRAMS.annual.actionLabel} + </OutboundLink> + <nav className="section-links" aria-label="Archive sections"> + <a href="#documents">Program and documents</a> + <a href="#schedule">Schedule</a> + </nav> + </header> + <section + id="documents" + className="event-section" + aria-labelledby="documents-heading" + > + <h2 id="documents-heading">2021 program and documents</h2> + <p> + Retained for historical reference. Instructions in these documents + applied to the 2021 event only. + </p> + <ul className="document-list"> + {content.documents.map((document) => ( + <li key={document.name}> + <DocumentLink document={document} /> + </li> + ))} + </ul> + </section> <TwoDaySchedule days={content.schedule} /> - <ImportantLinks links={content.importantLinks} /> <Faq content={content.faq} /> - <Waivers forms={waivers.forms} intro={waivers.intro} note={waivers.note}> - <div className="waivers-text"> - <p> - <b>{waivers.allFormsHeading}</b> - </p> - <p>{waivers.allFormsBody}</p> - <div className="button"> - <ActionLink - href={waivers.allForms.src} - className="action action-outline action-large" - > - {waivers.allForms.name} - </ActionLink> - </div> - </div> - </Waivers> - <Footer /> + <Waivers forms={content.waivers} /> </div> ); } diff --git a/src/features/registration/TwoDaySchedule.tsx b/src/features/registration/TwoDaySchedule.tsx index 5208757..790fdba 100644 --- a/src/features/registration/TwoDaySchedule.tsx +++ b/src/features/registration/TwoDaySchedule.tsx @@ -1,42 +1,25 @@ import type { ScheduleDay } from "../../content/events/types"; +import { Timetable } from "../events/Schedule"; -function Column({ day, side }: { day: ScheduleDay; side: "left" | "right" }) { - return ( - <div className={`${side}-column text`}> - <div className="section-title"> - <h3 className="title">{day.title}</h3> - <div className="pill-divider" /> - </div> - <div className="schedule-text"> - <p> - {day.items.map((entry) => ( - <h4 key={entry.key}> - <b>{entry.time}:</b> {entry.name} - </h4> - ))} - </p> - </div> - </div> - ); -} - -/** The 2021 event ran over two days, so its schedule renders as two columns. */ export default function TwoDaySchedule({ days, }: { days: readonly [ScheduleDay, ScheduleDay]; }) { return ( - <section className="Section Schedule"> - <div className="schedule-content"> - <div className="schedule-content schedule-title"> - <h2 className="title">Schedule</h2> - <div className="pill-divider" /> - </div> - <div className="schedule-content schedule-column-container"> - <Column day={days[0]} side="left" /> - <Column day={days[1]} side="right" /> - </div> + <section + id="schedule" + className="event-section Schedule" + aria-labelledby="schedule-heading" + > + <h2 id="schedule-heading">Schedule</h2> + <div className="event-logistics"> + {days.map((day) => ( + <div key={day.title}> + <h3>{day.title}</h3> + <Timetable items={day.items} caption={day.title} /> + </div> + ))} </div> </section> ); diff --git a/src/features/team/Team.tsx b/src/features/team/Team.tsx index 29372b6..eb36c7a 100644 --- a/src/features/team/Team.tsx +++ b/src/features/team/Team.tsx @@ -1,120 +1,70 @@ -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; -import Footer from "../../components/Footer"; import People from "../../components/common/People"; import { teamCards, teamSections, teamSectionsById } from "../../content/teams"; -import type { TeamSectionId } from "../../content/types"; - -interface SectionLayout { - /** Second class on the <section>, after "Section". */ - readonly sectionClass: string; - /** Wrapper div around header + cards; absent for the newest two sections. */ - readonly containerClass?: string; - readonly headerClass: string; - readonly elementId?: string; -} - -/** - * Legacy DOM shape per section. The five original sections wrap their header - * and cards in a "<name>-container" div and use a "<name>-header"; Web Dev and - * Marketing were added later without a container and share a generic header - * class. Existing stylesheets select on these exact names. - */ -const sectionLayouts: Record<TeamSectionId, SectionLayout> = { - leadership: { - sectionClass: "Leadership", - containerClass: "leadership-container", - headerClass: "leadership-header", - elementId: "leadership", - }, - content: { - sectionClass: "Content", - containerClass: "content-container", - headerClass: "content-header", - }, - design: { - sectionClass: "Design", - containerClass: "design-container", - headerClass: "design-header", - }, - operations: { - sectionClass: "Operations", - containerClass: "operations-container", - headerClass: "operations-header", - }, - external: { - sectionClass: "External", - containerClass: "external-container", - headerClass: "external-header", - }, - "web-dev": { - sectionClass: "WebDev", - headerClass: "team-section-header", - }, - marketing: { - sectionClass: "Marketing", - headerClass: "team-section-header", - }, -}; +import { normalizePath } from "../../content/pages"; export default function Team() { const location = useLocation(); - - // Section comes from the URL: /our_team and /our_team/<slug>. - const pathParts = location.pathname.split("/").filter(Boolean); + const navigate = useNavigate(); + const pathParts = normalizePath(location.pathname).split("/").filter(Boolean); const lastPart = pathParts[pathParts.length - 1]; const activeSlug = lastPart === "our_team" ? "leadership" : lastPart || "leadership"; - - // Unknown slugs render Leadership without marking any tab active. - const section = - teamSections.find((candidate) => candidate.id === activeSlug) ?? - teamSectionsById.leadership; - const layout = sectionLayouts[section.id]; - - const sectionBody = ( - <> - <div className={layout.headerClass}> - <h3>{section.heading}</h3> - <div className="pill-divider" /> - </div> - <People people={teamCards(section.id)} /> - </> + const selectedSection = teamSections.find( + (candidate) => candidate.id === activeSlug, ); + // Unknown slugs still show Leadership without selecting a department link. + const section = selectedSection ?? teamSectionsById.leadership; return ( - <div className="Team"> - <div className="team-title"> - <h1>Meet Our Team</h1> - <div className="pill-divider"></div> + <div className="Team page-container"> + <header className="page-header team-title"> + <h1>Meet our team</h1> + <p>The UCLA students behind exploretech.la.</p> + </header> + <div className="team-department-picker"> + <label htmlFor="team-department">Department</label> + <select + id="team-department" + data-preserve-route-focus="true" + value={selectedSection?.id ?? ""} + onChange={(event) => navigate(`/our_team/${event.target.value}`)} + > + {!selectedSection && ( + <option value="" disabled> + Choose a department + </option> + )} + {teamSections.map((candidate) => ( + <option key={candidate.id} value={candidate.id}> + {candidate.navLabel} + </option> + ))} + </select> </div> - - <div className="team-navigation"> + <nav className="team-navigation" aria-label="Team departments"> {teamSections.map((candidate) => ( <Link key={candidate.id} to={`/our_team/${candidate.id}`} - className={candidate.id === activeSlug ? "active" : ""} + aria-current={candidate.id === activeSlug ? "page" : undefined} + className={candidate.id === activeSlug ? "active" : undefined} > {candidate.navLabel} </Link> ))} - </div> - - <div className="team-content"> - <section - className={`Section ${layout.sectionClass}`} - id={layout.elementId} - > - {layout.containerClass ? ( - <div className={layout.containerClass}>{sectionBody}</div> - ) : ( - sectionBody - )} - </section> - </div> - - <Footer /> + </nav> + <section + className="team-content" + aria-labelledby="team-section-title" + id={section.id} + > + <h2 id="team-section-title" className="section-heading"> + {section.heading} + </h2> + <People people={teamCards(section.id)} /> + </section> </div> ); } diff --git a/src/styles/app-shell.css b/src/styles/app-shell.css index 19d7cf7..2e95919 100644 --- a/src/styles/app-shell.css +++ b/src/styles/app-shell.css @@ -1,173 +1,443 @@ -/* - * Site shell: the divider, document behaviour, section geometry and the two pieces of - * chrome that wrap every route. Section padding is the one piece of layout that is exactly - * a token lookup at every breakpoint, so it is expressed with Tailwind utilities over the - * spacing tokens instead of four hand-written media blocks. - */ - -/* Shared pill divider */ -.pill-divider { - @apply h-2 w-16 mb-6 rounded-sm bg-aqua; -} - -/* Document */ html { - scroll-behavior: smooth; + scroll-behavior: auto; } - -/* Section geometry and scrollbars */ -.App section, -.App .Section { - @apply py-section-y px-section-xs sm:px-section-sm md:px-section-md lg:px-section-lg; +body { + color: #17333e; } - -.App ::-webkit-scrollbar { - @apply h-[2px]; +.App { + min-height: 100vh; } - -.App ::-webkit-scrollbar-track { - background: #ebebeb; - @apply rounded-[1px]; +#main-content { + min-height: 55vh; } - -.App ::-webkit-scrollbar-thumb { +#main-content:focus:not(:focus-visible) { + outline: none; +} +.page-container { + width: 100%; + max-width: 1168px; + margin-inline: auto; + padding-inline: 24px; +} +.Section { + padding-block: clamp(40px, 6vw, 64px); +} +.page-header { + padding-block: clamp(32px, 5vw, 64px); +} +h1 { + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.15; + font-weight: 600; + color: var(--color-navy); +} +h2 { + font-size: clamp(1.5rem, 2.5vw, 2rem); + line-height: 1.25; + font-weight: 600; + color: var(--color-navy); +} +h3 { + font-size: 1.2rem; + line-height: 1.35; + font-weight: 600; +} +p, +li { + line-height: 1.65; +} +.page-header > p, +.page-lead { + max-width: 68ch; + font-size: 1.125rem; +} +.section-heading { + margin-bottom: 24px; +} +.text-link, +main a:not(.action), +.contact-address { + color: var(--color-navy); + text-decoration: underline; + text-underline-offset: 0.2em; +} +.text-link:hover, +main a:not(.action):hover { + color: #083f54; +} +.status-notice { + padding: 16px 20px; + border-left: 4px solid var(--color-navy); + border-radius: 0 8px 8px 0; + background: #e8f4f4; + color: #17333e; + line-height: 1.6; +} +.status-notice p:last-child { + margin-bottom: 0; +} +.section-links, +.action-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.section-links { + margin-block: 24px; +} +.section-links > a { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 8px 12px; + border-radius: 6px; + font-weight: 500; +} +.pill-divider { + width: 64px; + height: 6px; + margin-block: 12px 24px; + border-radius: 3px; background: var(--color-aqua); - @apply rounded-[1px]; } - -@media (max-width: 575.98px) { - .App { - overflow-x: hidden; - } +:where([id]) { + scroll-margin-top: 24px; } - -@media (min-width: 576px) { - .App ::-webkit-scrollbar { - @apply h-[4px]; - } - - .App ::-webkit-scrollbar-track { - @apply rounded-[2px]; - } - - .App ::-webkit-scrollbar-thumb { - @apply rounded-[2px]; - } +.contact-address { + overflow-wrap: anywhere; } - -@media (min-width: 768px) { - .App ::-webkit-scrollbar { - @apply hidden; - } +.skip-link { + position: absolute; + z-index: 1000; + left: 16px; + top: 12px; + transform: translateY(-180%); + min-height: 44px; + padding: 10px 16px; + background: var(--color-navy); + color: #fff; + border-radius: 6px; } - -/* Site header */ -.Header.Home { - background-color: var(--color-aqua); +.skip-link:focus { + transform: none; + color: #fff; + outline: 3px solid #fff; + outline-offset: 2px; + box-shadow: 0 0 0 5px var(--color-navy); } -.Header.Section { - @apply pt-[1rem]; - @apply pb-[1rem]; +/* One navigation, expanded only when its content fits. */ +.Header { + position: relative; + z-index: 20; + border-bottom: 1px solid #d4e4e5; + background: #fff; + color: var(--color-navy); } - -.Header .site-nav-items { - @apply pt-[0.5rem]; +.site-nav-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + column-gap: 16px; + padding-block: 12px; } - -.Header .site-nav-items .site-nav-item, -.Header .site-nav-items .site-nav-link, -.Header .site-nav-items .site-menu { - @apply ml-[0.5rem]; - @apply mr-[0.5rem]; +.site-brand { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 44px; + text-decoration: none; + color: var(--color-navy); } - -.Header .logo-compass { - @apply block w-[2.5rem] h-[auto]; +.site-brand .logo-compass { + width: 40px; + height: 40px; } - -.Header .logo-with-icons { - @apply hidden max-w-[14rem] h-[auto]; +.site-brand .logo-with-icons { + display: none; + width: 220px; + height: auto; +} +.brand-name { + font-weight: 500; + font-size: 1.1rem; +} +.site-nav-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 10px; + border: 1px solid var(--color-navy); + border-radius: 8px; + background: #fff; + color: var(--color-navy); +} +.site-nav-toggle-icon, +.site-nav-toggle-icon::before, +.site-nav-toggle-icon::after { + display: block; + width: 22px; + height: 2px; + background: currentColor; +} +.site-nav-toggle-icon { + position: relative; +} +.site-nav-toggle-icon::before, +.site-nav-toggle-icon::after { + content: ""; + position: absolute; + left: 0; +} +.site-nav-toggle-icon::before { + top: -7px; +} +.site-nav-toggle-icon::after { + top: 7px; +} +.site-nav-panel { + flex-basis: 100%; + width: 100%; +} +.site-nav-items { + display: flex; + flex-direction: column; + gap: 4px; + padding-block: 12px 4px; +} +.site-nav-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 44px; + width: 100%; + padding: 10px 12px; + color: var(--color-navy); + background: transparent; + border: 0; + border-radius: 6px; + font: inherit; + font-weight: 500; + text-align: left; + text-decoration: none; +} +.site-nav-link:hover, +.site-menu-link:hover { + color: #083f54; + background: #e8f4f4; + text-decoration: none; +} +.site-nav-link[aria-current], +.site-menu-link[aria-current] { + text-decoration: underline; + text-underline-offset: 0.25em; +} +.nav-chevron { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); +} +.site-menu-toggle[aria-expanded="true"] .nav-chevron { + transform: rotate(225deg); +} +.site-menu { + position: relative; +} +.site-menu-items { + margin: 4px 8px 8px; + padding: 4px 8px; + border-left: 2px solid var(--color-aqua); +} +.site-menu-link { + display: flex; + align-items: center; + min-height: 44px; + padding: 10px 12px; + color: var(--color-navy); + border-radius: 4px; + line-height: 1.4; + text-decoration: none; } - @media (min-width: 576px) { - .Header .site-nav-items { - @apply pt-[0]; + .site-brand .logo-compass, + .brand-name { + display: none; + } + .site-brand .logo-with-icons { + display: block; } } - -@media (min-width: 768px) { - .Header .logo-compass { - @apply hidden; +@media (min-width: 992px) { + .site-nav-inner { + flex-wrap: nowrap; } - - .Header .logo-with-icons { - @apply block; + .site-nav-toggle { + display: none; + } + .Header .site-nav-panel { + display: block; + flex-basis: auto; + width: auto; + height: auto !important; + overflow: visible; + } + .site-nav-items { + flex-direction: row; + align-items: center; + gap: 4px; + padding: 0; + } + .site-nav-link { + width: auto; + white-space: nowrap; + } + .site-menu-items { + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 240px; + width: max-content; + max-width: 320px; + margin: 0; + padding: 8px; + background: #fff; + border: 1px solid #bdd1d8; + border-radius: 10px; + box-shadow: 0 12px 28px rgba(11, 84, 111, 0.14); + } + .site-menu:last-child .site-menu-items { + left: auto; + right: 0; } } -/* Site footer */ +/* One footer outside the page's main landmark. */ .Footer { - @apply p-[48px_0]; - color: #ffffff; - background-color: var(--color-muted-blue); + margin-top: 48px; + padding-block: 44px; + background: var(--color-navy); + color: #fff; } - -.Footer .footer-inner { - @apply flex; - @apply flex-col justify-between items-center max-w-[1200px] m-[auto]; +.footer-inner { + display: grid; + gap: 32px; } - -.Footer .footer-inner .footer-item { - @apply text-center; +.Footer h2 { + font-size: 1.1rem; + color: #fff; + margin-bottom: 8px; } - -.Footer .footer-inner .footer-item:not(:last-child) { - @apply pb-[2.5rem]; +.Footer a { + color: #fff; } - -.Footer .footer-inner .footer-item.logo { - @apply hidden; +.Footer nav a { + display: flex; + align-items: center; + min-height: 44px; + padding-block: 8px; + text-decoration: underline; + text-underline-offset: 0.2em; } - -.Footer .footer-inner .footer-item.email p:last-child { - @apply mb-[0]; +.Footer .logo-with-icons { + width: 220px; + max-width: 100%; + height: auto; } - -.Footer .footer-inner .footer-item .logo-with-icons { - @apply w-[14rem] h-[auto]; +.footer-brand > a { + display: inline-flex; + align-items: center; + min-height: 44px; + margin-bottom: 16px; } - -.Footer .footer-inner .footer-item .social-media-list { - @apply flex justify-center; +.footer-brand p { + max-width: 30ch; } - -.Footer .footer-inner .footer-item .social-media-list .social-media-link { - @apply m-[0_8px]; +.footer-credit { + font-size: 0.9rem; } - -.Footer .footer-inner .footer-item a { - color: #ffffff; - text-decoration: none; +.footer-credit svg { + vertical-align: middle; } - -.Footer .FaHeart { - fill: red; - @apply mb-[2px]; +.footer-contact > a { + display: flex; + align-items: center; + min-height: 44px; } - -@media (min-width: 576px) { - .Footer .footer-inner { - @apply flex-row p-[0_24px]; +.social-media-list { + display: flex; + gap: 8px; + margin-top: 12px; +} +.social-media-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 50%; +} +.social-media-link:hover { + color: var(--color-navy); + background: #fff; +} +@media (min-width: 640px) { + .footer-inner { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - - .Footer .footer-inner .footer-item { - @apply pb-[0]; +} +@media (min-width: 1100px) { + .footer-inner { + grid-template-columns: 1.2fr 1fr 1fr 1.4fr; } } -@media (min-width: 992px) { - .Footer .footer-inner .footer-item.logo { - @apply block; +.participation-sections { + display: grid; + gap: 32px; +} +.participation-section { + padding: clamp(24px, 4vw, 40px); + border: 1px solid #c8dce0; + border-radius: 16px; + background: #fff; +} +.participation-section p, +.participation-section li { + max-width: 75ch; +} +.participation-section h3 { + margin-top: 24px; +} +.participation-section .status-notice { + margin-block: 24px; +} +.participation-section > .action { + margin-top: 8px; +} +.participation-contact { + padding-block: 8px 24px; +} +.PageNotFound { + min-height: 55vh; +} +@media (max-width: 479px) { + .action-row { + align-items: stretch; + flex-direction: column; + } + .action-row > .action { + width: 100%; + } + .participation-section { + padding: 20px; } } diff --git a/src/styles/controls.css b/src/styles/controls.css index 62ee27d..4127b01 100644 --- a/src/styles/controls.css +++ b/src/styles/controls.css @@ -1,766 +1,116 @@ -/* - * Rules in this file are derived from Bootstrap v4.5.2 (https://getbootstrap.com/), - * Copyright 2011-2020 The Bootstrap Authors and Twitter, Inc., MIT licensed - * (https://github.com/twbs/bootstrap/blob/main/LICENSE). - * - * They are the subset the shipped markup actually depended on, extracted from the compiled - * baseline rather than re-imported, so the site keeps its exact reset and control geometry - * without carrying the framework. - */ -/* Buttons */ +/* Shared native controls. Brand and exact reset attribution: licenses/bootstrap.txt. */ .action { - @apply inline-block font-normal text-[#212529] text-center align-middle select-none bg-transparent border border-solid border-transparent px-3 py-[0.375rem] text-[1rem] leading-[1.5] rounded-[0.25rem]; + @apply inline-flex items-center justify-center gap-2 font-medium text-center align-middle select-none; + min-height: 44px; + padding: 10px 18px; + border: 2px solid transparent; + border-radius: 0.5rem; + font-size: 1rem; + line-height: 1.4; + text-decoration: none; + white-space: normal; transition: - color 0.15s ease-in-out, - background-color 0.15s ease-in-out, - border-color 0.15s ease-in-out, - box-shadow 0.15s ease-in-out; -} - -@media (prefers-reduced-motion: reduce) { - .action { - transition: none; - } + background-color 160ms ease, + border-color 160ms ease; } - .action:hover { - color: #212529; text-decoration: none; } - -.action:focus { - outline: 0; - box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); -} - -.action:disabled { - opacity: 0.65; -} - .action:not(:disabled) { cursor: pointer; } - -.action-primary { - color: #fff; - background-color: #007bff; - border-color: #007bff; -} - -.action-primary:hover { - color: #fff; - background-color: #0069d9; - border-color: #0062cc; -} - -.action-primary:focus { - color: #fff; - background-color: #0069d9; - border-color: #0062cc; - box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5); -} - -.action-primary:disabled { - color: #fff; - background-color: #007bff; - border-color: #007bff; -} - -.action-primary:not(:disabled):active { - color: #fff; - background-color: #0062cc; - border-color: #005cbf; -} - -.action-primary:not(:disabled):active:focus { - box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5); -} - -.action-info { - color: #fff; - background-color: #17a2b8; - border-color: #17a2b8; -} - -.action-info:hover { - color: #fff; - background-color: #138496; - border-color: #117a8b; -} - -.action-info:focus { - color: #fff; - background-color: #138496; - border-color: #117a8b; - box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5); +.action:disabled { + opacity: 0.6; + cursor: not-allowed; } - -.action-info:disabled { +.action-primary { color: #fff; - background-color: #17a2b8; - border-color: #17a2b8; + background-color: var(--color-navy); + border-color: var(--color-navy); } - -.action-info:not(:disabled):active { +.action-primary:hover, +.action-primary:active { color: #fff; - background-color: #117a8b; - border-color: #10707f; -} - -.action-info:not(:disabled):active:focus { - box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5); -} - -.action-warning { - color: #212529; - background-color: #ffc107; - border-color: #ffc107; -} - -.action-warning:hover { - color: #212529; - background-color: #e0a800; - border-color: #d39e00; -} - -.action-warning:focus { - color: #212529; - background-color: #e0a800; - border-color: #d39e00; - box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5); -} - -.action-warning:disabled { - color: #212529; - background-color: #ffc107; - border-color: #ffc107; -} - -.action-warning:not(:disabled):active { - color: #212529; - background-color: #d39e00; - border-color: #c69500; -} - -.action-warning:not(:disabled):active:focus { - box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5); + background-color: #083f54; + border-color: #083f54; } - .action-outline { - color: #17a2b8; - border-color: #17a2b8; -} - -.action-outline:hover { - color: #fff; - background-color: #17a2b8; - border-color: #17a2b8; -} - -.action-outline:focus { - box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); -} - -.action-outline:disabled { - color: #17a2b8; + color: var(--color-navy); background-color: transparent; + border-color: var(--color-navy); +} +.action-outline:hover, +.action-outline:active { + color: var(--color-navy); + background-color: #e5f3f3; +} +:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { + outline: 3px solid var(--color-navy); + outline-offset: 4px; +} +.on-dark + :where( + a, + button, + input, + select, + textarea, + summary, + [tabindex] + ):focus-visible { + outline-color: #fff; +} +:where(input, select, textarea) { + max-width: 100%; + min-height: 44px; + padding: 8px 12px; + border: 1px solid #536d77; + border-radius: 0.5rem; + background: #fff; + color: #17333e; + font: inherit; + font-size: 1rem; +} +button, +summary { + touch-action: manipulation; +} +summary { + min-height: 44px; + cursor: pointer; } - -.action-outline:not(:disabled):active { - color: #fff; - background-color: #17a2b8; - border-color: #17a2b8; -} - -.action-outline:not(:disabled):active:focus { - box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5); -} - -.action-inverse { - color: #f8f9fa; - border-color: #f8f9fa; -} - -.action-inverse:hover { - color: #212529; - background-color: #f8f9fa; - border-color: #f8f9fa; -} - -.action-inverse:focus { - box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); -} - -.action-inverse:disabled { - color: #f8f9fa; - background-color: transparent; -} - -.action-inverse:not(:disabled):active { - color: #212529; - background-color: #f8f9fa; - border-color: #f8f9fa; -} - -.action-inverse:not(:disabled):active:focus { - box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); -} - -.action-large { - @apply p-[0.5rem_1rem] text-[1.25rem] leading-[1.5] rounded-[0.3rem]; +[hidden] { + display: none !important; } - -/* Disclosure (menu and accordion) transitions */ .disclosure:not(.show) { - @apply hidden; + display: none; } - .disclosure-transition { - @apply relative h-[0]; - overflow: hidden; - transition: height 0.35s ease; -} - -@media (prefers-reduced-motion: reduce) { - .disclosure-transition { - transition: none; - } -} - -/* Dropdown */ -.site-menu { - @apply relative; -} - -.site-menu-toggle { - @apply whitespace-nowrap; -} - -.site-menu-toggle::after { - @apply inline-block ml-[0.255em]; - vertical-align: 0.255em; - content: ""; - border-top: 0.3em solid; - border-right: 0.3em solid transparent; - border-bottom: 0; - border-left: 0.3em solid transparent; -} - -.site-menu-items { - @apply absolute top-[100%] left-[0] z-[1000] hidden; - float: left; - @apply min-w-[10rem] p-[0.5rem_0] m-[0.125rem_0_0] text-[1rem]; - color: #212529; - @apply text-left; - list-style: none; - background-color: #fff; - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, 0.15); - @apply rounded-[0.25rem]; -} - -.site-menu-link { - @apply block w-[100%] p-[0.25rem_1.5rem]; - clear: both; - @apply font-normal; - color: #212529; - text-align: inherit; - @apply whitespace-nowrap; - background-color: transparent; - border: 0; -} - -.site-menu-link:hover, -.site-menu-link:focus { - color: #16181b; - text-decoration: none; - background-color: #f8f9fa; -} - -.site-menu-link.active, -.site-menu-link:active { - color: #fff; - text-decoration: none; - background-color: #007bff; -} - -.site-menu-items.show { - @apply block; -} - -/* Nav */ -.site-nav-link { - @apply block p-[0.5rem_1rem]; -} - -.site-nav-link:hover, -.site-nav-link:focus { - text-decoration: none; -} - -/* Navbar */ -.site-nav { - @apply relative flex; - @apply flex-wrap items-center justify-between p-[0.5rem_1rem]; -} - -.site-brand { - @apply inline-block pt-[0.3125rem]; - @apply pb-[0.3125rem] mr-[1rem] text-[1.25rem] leading-[inherit] whitespace-nowrap; -} - -.site-brand:hover, -.site-brand:focus { - text-decoration: none; -} - -.site-nav-items { - @apply flex; - @apply flex-col pl-[0] mb-[0]; - list-style: none; -} - -.site-nav-items .site-nav-link { - @apply pr-[0]; - @apply pl-[0]; -} - -.site-nav-items .site-menu-items { - @apply static; - float: none; -} - -.site-nav-panel { - flex-basis: 100%; - @apply grow-[1] items-center; -} - -.site-nav-toggle { - @apply p-[0.25rem_0.75rem] text-[1.25rem] leading-[1]; - background-color: transparent; - border: 1px solid transparent; - @apply rounded-[0.25rem]; -} - -.site-nav-toggle:hover, -.site-nav-toggle:focus { - text-decoration: none; -} - -.site-nav-toggle-icon { - @apply inline-block w-[1.5em] h-[1.5em] align-middle; - content: ""; - background: no-repeat center center; - background-size: 100% 100%; -} - -@media (min-width: 576px) { - .site-nav-responsive { - flex-flow: row nowrap; - @apply justify-start; - } - - .site-nav-responsive .site-nav-items { - @apply flex-row; - } - - .site-nav-responsive .site-nav-items .site-menu-items { - @apply absolute; - } - - .site-nav-responsive .site-nav-items .site-nav-link { - @apply pr-[0.5rem]; - @apply pl-[0.5rem]; - } - - .site-nav-responsive .site-nav-panel { - display: flex !important; - flex-basis: auto; - } - - .site-nav-responsive .site-nav-toggle { - @apply hidden; - } -} - -.site-nav-light .site-brand { - color: rgba(0, 0, 0, 0.9); -} - -.site-nav-light .site-brand:hover, -.site-nav-light .site-brand:focus { - color: rgba(0, 0, 0, 0.9); -} - -.site-nav-light .site-nav-items .site-nav-link { - color: rgba(0, 0, 0, 0.5); -} - -.site-nav-light .site-nav-items .site-nav-link:hover, -.site-nav-light .site-nav-items .site-nav-link:focus { - color: rgba(0, 0, 0, 0.7); -} - -.site-nav-light .site-nav-items .show > .site-nav-link, -.site-nav-light .site-nav-items .active > .site-nav-link, -.site-nav-light .site-nav-items .site-nav-link.show, -.site-nav-light .site-nav-items .site-nav-link.active { - color: rgba(0, 0, 0, 0.9); -} - -.site-nav-light .site-nav-toggle { - color: rgba(0, 0, 0, 0.5); - border-color: rgba(0, 0, 0, 0.1); -} - -.site-nav-light .site-nav-toggle-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); -} - -/* Card */ -.content-card { - @apply relative flex; - @apply flex-col min-w-[0]; - word-wrap: break-word; - background-color: #fff; - background-clip: border-box; - border: 1px solid rgba(0, 0, 0, 0.125); - @apply rounded-[0.25rem]; -} - -.content-card > .resource-list { - border-top: inherit; - border-bottom: inherit; -} - -.content-card > .resource-list:first-child { - border-top-width: 0; - border-top-left-radius: calc(0.25rem - 1px); - border-top-right-radius: calc(0.25rem - 1px); -} - -.content-card > .resource-list:last-child { - border-bottom-width: 0; - border-bottom-right-radius: calc(0.25rem - 1px); - border-bottom-left-radius: calc(0.25rem - 1px); -} - -.content-card > .content-card-header + .resource-list { - border-top: 0; -} - -.content-card-body { - flex: 1 1 auto; - @apply min-h-[1px] p-[1.25rem]; -} - -.content-card-title { - @apply mb-[0.75rem]; -} - -.content-card-subtitle { - @apply mt-[-0.375rem]; - @apply mb-[0]; -} - -.content-card-text:last-child { - @apply mb-[0]; -} - -.content-card-link:hover { - text-decoration: none; -} - -.content-card-link + .content-card-link { - @apply ml-[1.25rem]; -} - -.content-card-header { - @apply p-[0.75rem_1.25rem] mb-[0]; - background-color: rgba(0, 0, 0, 0.03); - border-bottom: 1px solid rgba(0, 0, 0, 0.125); -} - -.content-card-header:first-child { - @apply rounded-[calc(0.25rem_-_1px)_calc(0.25rem_-_1px)_0_0]; -} - -.content-card-overlay { - @apply absolute top-[0] right-[0] bottom-[0] left-[0] p-[1.25rem] rounded-[calc(0.25rem_-_1px)]; -} - -.content-card-image { - @apply shrink-[0] w-[100%]; - border-top-left-radius: calc(0.25rem - 1px); - border-top-right-radius: calc(0.25rem - 1px); -} - -.content-card-row .content-card { - @apply mb-[15px]; -} - -@media (min-width: 576px) { - .content-card-row { - @apply flex; - flex-flow: row wrap; - @apply mr-[-15px]; - @apply ml-[-15px]; - } - - .content-card-row .content-card { - flex: 1 0 0%; - @apply mr-[15px]; - @apply mb-[0]; - @apply ml-[15px]; - } -} - -/* Accordion */ -.accordion { - overflow-anchor: none; -} - -.accordion > .content-card { + position: relative; + height: 0; overflow: hidden; + transition: height 0.25s ease; } - -.accordion > .content-card:not(:last-of-type) { - border-bottom: 0; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} - -.accordion > .content-card:not(:first-of-type) { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.accordion > .content-card > .content-card-header { - @apply rounded-[0] mb-[-1px]; -} - -/* List group */ -.resource-list { - @apply flex; - @apply flex-col pl-[0] mb-[0] rounded-[0.25rem]; -} - -.resource-list-item { - @apply relative block p-[0.75rem_1.25rem]; - background-color: #fff; - border: 1px solid rgba(0, 0, 0, 0.125); -} - -.resource-list-item:first-child { - border-top-left-radius: inherit; - border-top-right-radius: inherit; -} - -.resource-list-item:last-child { - border-bottom-right-radius: inherit; - border-bottom-left-radius: inherit; -} - -.resource-list-item + .resource-list-item { - border-top-width: 0; -} - -.resource-list-flush { - @apply rounded-[0]; -} - -.resource-list-flush > .resource-list-item { - border-width: 0 0 1px; -} - -.resource-list-flush > .resource-list-item:last-child { - border-bottom-width: 0; -} - -/* Carousel */ -.carousel { - @apply relative; -} - -.carousel-inner { - @apply relative w-[100%]; - overflow: hidden; -} - -.carousel-inner::after { - @apply block; - clear: both; - content: ""; -} - -.carousel-item { - @apply relative hidden; - float: left; - @apply w-[100%] mr-[-100%]; - backface-visibility: hidden; - transition: transform 0.6s ease-in-out; -} - -@media (prefers-reduced-motion: reduce) { - .carousel-item { - transition: none; - } -} - -.carousel-item.active, -.carousel-item-next, -.carousel-item-prev { - @apply block; -} - -.carousel-item-next:not(.carousel-item-left), -.active.carousel-item-right { - transform: translateX(100%); -} - -.carousel-item-prev:not(.carousel-item-right), -.active.carousel-item-left { - transform: translateX(-100%); -} - -.carousel-control-prev, -.carousel-control-next { - @apply absolute top-[0] bottom-[0] z-[1] flex items-center justify-center w-[15%]; - color: #fff; - @apply text-center; - opacity: 0.5; - transition: opacity 0.15s ease; -} - -@media (prefers-reduced-motion: reduce) { - .carousel-control-prev, - .carousel-control-next { - transition: none; - } -} - -.carousel-control-prev:hover, -.carousel-control-prev:focus, -.carousel-control-next:hover, -.carousel-control-next:focus { - color: #fff; - text-decoration: none; - outline: 0; - opacity: 0.9; -} - -.carousel-control-prev { - @apply left-[0]; -} - -.carousel-control-next { - @apply right-[0]; -} - -.carousel-control-prev-icon, -.carousel-control-next-icon { - @apply inline-block w-[20px] h-[20px]; - background: no-repeat 50%/100% 100%; -} - -.carousel-control-prev-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e"); -} - -.carousel-control-next-icon { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e"); -} - -.carousel-indicators { - @apply absolute right-[0] bottom-[0] left-[0] z-[15] flex justify-center pl-[0] mr-[15%]; - @apply ml-[15%]; - list-style: none; -} - -.carousel-indicators li { - box-sizing: content-box; - flex: 0 1 auto; - @apply w-[30px] h-[3px] mr-[3px]; - @apply ml-[3px]; - text-indent: -999px; - cursor: pointer; - background-color: #fff; - background-clip: padding-box; - border-top: 10px solid transparent; - border-bottom: 10px solid transparent; - opacity: 0.5; - transition: opacity 0.6s ease; -} - -@media (prefers-reduced-motion: reduce) { - .carousel-indicators li { - transition: none; - } -} - -.carousel-indicators .active { - opacity: 1; -} - -/* - * Utility classes the markup still uses and Tailwind does not reproduce identically. - * `mb-2` and `text-center` were dropped: Tailwind generates the same declarations for those - * literals. `sr-only` stays because Tailwind's version hides text with `clip-path` instead of - * `clip`, which is a different computed style from the one this site shipped. - */ .visually-hidden { - @apply absolute w-[1px] h-[1px] p-[0] m-[-1px]; + position: absolute; + width: 1px; + height: 1px; + padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); - @apply whitespace-nowrap; + white-space: nowrap; border: 0; } - -.nav-align-end { - justify-content: flex-end !important; -} - -.muted-copy { - color: #6c757d !important; -} - -@media print { +@media (prefers-reduced-motion: reduce) { + html, + html:focus-within { + scroll-behavior: auto; + } *, *::before, *::after { - text-shadow: none !important; - box-shadow: none !important; - } - - a:not(.action) { - text-decoration: underline; - } - - img { - page-break-inside: avoid; - } - - p, - h2, - h3 { - orphans: 3; - widows: 3; - } - - h2, - h3 { - page-break-after: avoid; - } - - @page { - size: a3; - } - - body { - min-width: 992px !important; - } - - .site-nav { - @apply hidden; + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; } } diff --git a/src/styles/events.css b/src/styles/events.css index 014be54..675f482 100644 --- a/src/styles/events.css +++ b/src/styles/events.css @@ -1,210 +1,158 @@ -/* Not found */ .PageNotFound { - @apply flex; - @apply flex-col items-center text-center; + @apply flex flex-col items-center text-center; } -.PageNotFound h3 { - @apply mb-[2rem]; +.Events, +.Resources, +.Register { + @apply pb-12; } -/* Registration */ -.RegistrationAlert { - background-color: var(--color-muted-blue); - @apply flex; - @apply flex-col items-center text-center m-[auto] pt-[32px]; - @apply pb-[32px]; +.event-section { + @apply py-6; } -.RegistrationAlert h3, -.RegistrationAlert h5 { - color: white; - @apply pr-[10px]; - @apply pl-[10px]; +.event-section h2, +.program-grid h2 { + @apply mb-4; } -.RegistrationAlert .buttons { - @apply pt-[10px] flex; - @apply flex-col items-center; +.event-section h3 { + @apply mb-3; } -.RegistrationAlert .buttons .button { - @apply pt-[10px]; - @apply pb-[10px]; +.event-section p, +.program-grid p { + @apply mb-4; } -.RegistrationAlert .notes { - @apply pt-[10px]; +.event-logistics, +.program-grid { + @apply grid gap-6; } -.Waivers { - background-color: #f5f5f5; - @apply flex; - @apply flex-col items-center m-[auto] pb-[32px]; +.program-grid > section { + @apply min-w-0 border-t-2 border-[#1a496b] py-6; } -.Waivers .waivers-title, -.Waivers .waivers-text { - @apply flex; - @apply flex-col items-center; +.archive-links, +.document-list { + @apply m-0 mb-4 grid list-none gap-3 p-0; } -.Waivers .buttons { - @apply pt-[20px] flex; - @apply flex-col items-center pb-[20px]; +.document-list a, +.page-header > .action { + @apply flex flex-wrap justify-start gap-x-2 gap-y-1 text-left; + overflow-wrap: anywhere; } -.Waivers .buttons .button { - @apply pt-[10px]; - @apply pb-[10px]; +.document-meta { + @apply text-sm font-normal; } -.ImportantLinks { - background-color: #f5f5f5; - @apply flex; - @apply flex-col items-center m-[auto] pb-[32px]; +.event-timetable { + @apply w-full border-collapse text-left text-sm; } -.ImportantLinks .links-title { - @apply flex; - @apply flex-col items-center; +.event-timetable th, +.event-timetable td { + @apply border-b border-[#c8d4db] px-2 py-3 align-top; } -.ImportantLinks .links-text { - @apply flex; - @apply flex-col items-center pt-[30px]; +.event-timetable th { + @apply font-semibold; } -.ImportantLinks .buttons { - @apply pt-[20px] flex; - @apply flex-col items-center pb-[20px]; +.event-timetable thead { + @apply bg-[#edf5f6]; } -.ImportantLinks .buttons .button { - @apply pt-[10px]; - @apply pb-[10px]; +.event-timetable tbody th { + @apply w-[45%]; } -.FAQ { - background-color: white; +.workshop-filter { + @apply flex flex-wrap items-center gap-3; } -.FAQ .faq-content { - @apply flex; - @apply flex-col justify-between max-w-[1200px] m-[auto]; +.workshop-filter label { + @apply w-full font-semibold; } -.FAQ .faq-content:not(:last-child) { - @apply pb-[32px]; +.workshop-filter input { + @apply min-h-11 min-w-0 flex-1 rounded border border-[#607582] bg-white px-3 py-2 text-base; } -.FAQ .faq-content .faq-centered-title { - @apply flex-col items-center; +.Workshops [role="status"] { + @apply mt-3; } -.FAQ .faq-content .left-column.text, -.FAQ .faq-content .right-column.text { - @apply pt-[32px]; +.workshop-section + .workshop-section { + @apply mt-6; } -.FAQ .faq-student { - @apply pb-[32px]; +.workshop-grid { + @apply grid items-start gap-3; } -.Schedule { - background-color: white; +.workshop-card { + @apply min-w-0 rounded border border-[#c8d4db] bg-white; } -.Schedule .schedule-content { - @apply flex; - @apply flex-col justify-between max-w-[1200px] m-[auto]; +.workshop-card summary { + @apply min-h-11 cursor-pointer rounded p-4 text-left; } -.Schedule .schedule-content:not(:last-child) { - @apply pb-[32px]; +.workshop-card summary::marker { + color: #1a496b; } -.Schedule .schedule-content.schedule-column-container { - @apply flex-col; +.workshop-title { + @apply font-semibold; } -.Schedule .schedule-content .schedule-title { - @apply items-center text-center; +.workshop-slot { + @apply mt-2 block text-sm; } -.Schedule .schedule-content .left-column.text, -.Schedule .schedule-content .right-column.text { - @apply pt-[32px]; +.workshop-body { + @apply border-t border-[#c8d4db] p-4 text-left; } -.Register .registration-title { - @apply flex; - @apply flex-col items-center max-w-[1200px] m-[auto] pt-[32px]; - @apply pb-[32px]; +.workshop-body .document-list { + @apply mb-0; } -.Register .banner { - @apply w-[100vw] h-[auto]; +.workshop-body p { + @apply leading-relaxed; } -/* Resources */ -.Workshops .workshops-content { - @apply flex; - @apply flex-col justify-between items-center max-w-[1200px] m-[auto]; +.faq-item { + @apply border-b border-[#c8d4db]; } -.Workshops .workshops-content .workshops-cards { - @apply flex; - @apply flex-col w-[75vw] pb-[50px]; +.faq-item h3 { + @apply m-0 text-base; } -.Workshops .workshops-content .workshops-title { - @apply flex pb-[32px]; - @apply flex-col items-center; +.faq-item button { + @apply flex min-h-11 w-full items-center justify-between gap-4 bg-transparent py-4 text-left font-semibold; + border: 0; + color: var(--color-navy); } -.Workshops .workshops-content .card-row { - @apply pt-[15px]; - @apply pb-[15px]; +.faq-answer { + @apply pb-2; } -.Maps { - background-color: #f5f5f5; +.wristband-image { + @apply mb-3 block h-auto max-w-full; } -.Maps .maps-content { - @apply flex; - @apply flex-col justify-between items-center max-w-[1200px] m-[auto]; -} - -.Maps .maps-content .maps-title { - @apply flex pb-[32px]; - @apply flex-col items-center; -} - -.Maps .maps-content .buttons { - @apply pt-[20px] grid gap-y-[10px]; - grid-template-columns: auto; - @apply pb-[20px] w-[60vw]; -} - -.Resources .resources-title { - @apply flex; - @apply flex-col items-center max-w-[1200px] m-[auto] pt-[32px]; - @apply pb-[32px]; -} - -.Resources .resources-text { - @apply flex; - @apply flex-col items-center text-center pt-[30px]; -} - -.Resources .banner { - @apply w-[100vw] h-[auto]; -} - -.Resources .full-length-button { - @apply pt-[20px] grid gap-y-[10px]; - grid-template-columns: auto; - @apply pb-[20px] w-[100%]; +@media (min-width: 768px) { + .event-logistics, + .program-grid, + .workshop-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/src/styles/home.css b/src/styles/home.css index bf10a7c..8ccac00 100644 --- a/src/styles/home.css +++ b/src/styles/home.css @@ -1,564 +1,243 @@ -/* Hero */ +/* Home: mission, participation routes, then program and organization detail. */ .Hero { - @apply h-[450px] flex justify-center items-center; - @apply flex-col; background-color: var(--color-aqua); color: var(--color-navy); } -.Hero .logo-with-icon-group { - @apply w-[20rem] h-[auto] mb-[2rem] pl-[24px]; - @apply pr-[24px]; -} - -.Hero .hero-content { - @apply flex; - @apply flex-col justify-center items-center text-center; -} - -.Hero .hero-content .h1-title { - @apply hidden; -} - -.Hero .hero-content .hero-cta { - @apply mt-[1rem] flex; - @apply flex-row; -} - -.Hero .hero-content .hero-cta .action:not(:last-child) { - @apply mr-[1rem]; -} - -.Hero p { - @apply m-[0]; -} - -@media (min-width: 576px) { - .Hero .logo-with-icon-group { - @apply w-[25rem]; - } - - .Hero .hero-content .h1-title { - @apply block; - } -} - -@media (min-width: 768px) { - .Hero { - @apply flex-row; - } - - .Hero .logo-with-icon-group { - @apply mr-[3rem]; - @apply mb-[0] pl-[0]; - @apply pr-[0]; - } - - .Hero .hero-content { - @apply justify-start items-start text-left max-w-[400px]; - } +.Hero .hero-layout { + @apply grid gap-6 py-8; } -@media (min-width: 992px) { - .Hero .logo-with-icon-group { - @apply w-[30rem] mr-[4rem]; - } +.Hero .logo-with-icon-group { + @apply w-40 h-auto mb-5; } -/* About */ -.About .about-content { - @apply flex justify-between items-center max-w-[1040px] m-[auto]; +.Hero h1 { + @apply text-[2rem] leading-tight mb-4; + max-width: 24ch; } -.About .about-content:not(:last-child) { - @apply pb-[32px]; +.Hero .hero-content > p { + @apply mb-4; + max-width: 60ch; } -.About .about-content.about-org { - flex-direction: column-reverse; +.Hero .hero-photo { + @apply w-full object-cover rounded-lg; + height: clamp(180px, 35vw, 300px); + object-position: center; } -.About .about-content.about-team { - @apply flex-col; +.Home .GetInvolved, +.Home .home-programs, +.Home .About, +.Home .Speakers, +.Home .Sponsors { + @apply py-8; } -.About .about-content .left-column.text, -.About .about-content .right-column.text { - @apply pt-[32px]; +.audience-grid, +.program-grid { + @apply grid gap-4; } -.About .about-content .founding-story a, -.About .about-content .current-team a { - @apply flex; - @apply flex-row items-center; +.audience-card { + @apply flex flex-col gap-3 p-5 rounded-lg; + border: 1px solid #c4d8df; + background: #f2fafb; + color: var(--color-navy); text-decoration: none; - color: var(--color-muted-blue); + min-width: 0; } -.About .about-content .founding-story a:hover .MdKeyboardArrowRight, -.About .about-content .current-team a:hover .MdKeyboardArrowRight { - fill: white; - background-color: var(--color-muted-blue); - border: 2px solid var(--color-muted-blue); +.audience-card h3, +.program-grid h3 { + @apply text-xl mb-0; } -.About .about-content .founding-story a p, -.About .about-content .current-team a p { - @apply mb-[0]; - @apply mr-[0.5rem]; +.audience-card p { + @apply mb-0; } -.About .about-content img { - @apply max-w-[100%]; - @apply max-h-[100vh] h-[auto]; +.audience-card-action { + @apply font-semibold mt-auto; + text-decoration: underline; + text-underline-offset: 0.2em; } -.About .MdKeyboardArrowRight { - fill: var(--color-muted-blue); - border: 2px solid #e2e2e2; - @apply rounded-[50%] w-[1.5rem] min-w-[1.5rem] h-[1.5rem]; - transition: all 0.3s ease-in-out; - -webkit-transition: all 0.3s ease-in-out; /** Chrome & Safari **/ - -moz-transition: all 0.3s ease-in-out; /** Firefox **/ - -o-transition: all 0.3s ease-in-out; /** Opera **/ +.audience-card:hover { + border-color: var(--color-navy); } -@media (min-width: 768px) { - .About .about-content { - @apply justify-between; - } - - .About .about-content:not(:last-child) { - @apply pb-[64px]; - } - - .About .about-content.about-org, - .About .about-content.about-team { - @apply flex-row; - } - - .About .about-content .left-column, - .About .about-content .right-column { - @apply w-[46%]; - } - - .About .about-content .left-column.text, - .About .about-content .right-column.text { - @apply pt-[0px]; - } +.audience-card:focus-visible, +.photo-gallery button:focus-visible { + outline: 3px solid var(--color-navy); + outline-offset: 3px; } -/* Get involved cards */ -.GetInvolved { - background-color: #f5f5f5; +.program-grid article { + @apply py-4; + border-top: 2px solid var(--color-aqua); } -.GetInvolved .cards-container { - @apply m-[0_auto] max-w-[1040px]; +.program-grid h3 { + @apply mb-3; } -.GetInvolved .cards-container .cards-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; +.About .about-content { + @apply grid gap-6 items-center; } -.GetInvolved .cards-container .content-card-row { - overflow-x: scroll; - @apply whitespace-nowrap pb-[1.5rem]; - transform: scaleX(-1) rotateZ(180deg); +.About .about-content + .about-content { + @apply mt-12; } -.GetInvolved .cards-container .content-card { - @apply inline-block w-[18rem] mb-[0]; - transform: scaleX(-1) rotateZ(180deg); +.About .team-photo { + @apply w-full h-auto rounded-lg; } -@media (max-width: 575.98px) { - .GetInvolved .cards-container .content-card:not(:last-child) { - @apply mr-[0.5rem]; - } +.photo-gallery { + min-width: 0; } -.GetInvolved .cards-container .content-card.high-schools .content-card-image { - object-position: 25% 80%; +.photo-gallery .carousel-inner { + @apply grid overflow-hidden rounded-lg; + aspect-ratio: 4 / 3; + background: #e9f2f4; } -.GetInvolved .cards-container .content-card.companies .content-card-image { - object-position: 44% 50%; -} - -.GetInvolved .cards-container .content-card.ucla-students .content-card-image { - object-position: 15% 50%; -} - -.GetInvolved .cards-container .content-card .content-card-image { - @apply h-[512px] object-cover; -} - -.GetInvolved .cards-container .content-card .content-card-overlay { - @apply flex; - @apply flex-col justify-end; - color: #ffffff; -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content { - @apply z-[2]; -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .content-card-title { - @apply mb-[0.25rem]; -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .content-card-text { - @apply mb-[0.4rem]; -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .get-involved-cta { - @apply flex; - @apply flex-row items-center; -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .get-involved-cta - .cta-text { - opacity: 1; - @apply mb-[0]; - @apply ml-[8px]; - transition: all 0.3s ease-in-out; - -webkit-transition: all 0.3s ease-in-out; /** Chrome & Safari **/ - -moz-transition: all 0.3s ease-in-out; /** Firefox **/ - -o-transition: all 0.3s ease-in-out; /** Opera **/ -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .short-content { - @apply hidden; -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .full-content { - @apply block; -} - -.GetInvolved - .cards-container - .content-card - .content-card-overlay - .gradient-back { - @apply absolute bottom-[0px] left-[0px] w-[100%] h-[224px] z-[1]; - background: -moz-linear-gradient( - top, - rgba(137, 255, 241, 0) 0%, - rgba(0, 0, 0, 0.75) 100% - ); /* FF3.6+ */ - background: -webkit-gradient( - linear, - left top, - left bottom, - color-stop(0%, rgba(137, 255, 241, 0)), - color-stop(100%, rgba(0, 0, 0, 0.75)) - ); /* Chrome,Safari4+ */ - background: -webkit-linear-gradient( - top, - rgba(137, 255, 241, 0) 0%, - rgba(0, 0, 0, 0.75) 100% - ); /* Chrome10+,Safari5.1+ */ - background: -o-linear-gradient( - top, - rgba(137, 255, 241, 0) 0%, - rgba(0, 0, 0, 0.75) 100% - ); /* Opera 11.10+ */ - background: -ms-linear-gradient( - top, - rgba(137, 255, 241, 0) 0%, - rgba(0, 0, 0, 0.75) 100% - ); /* IE10+ */ - background: linear-gradient( - to bottom, - rgba(137, 255, 241, 0) 0%, - rgba(0, 0, 0, 0.75) 100% - ); /* W3C */ -} - -.GetInvolved .cards-container .content-card .MdKeyboardArrowRight { - fill: #ffffff; - background-color: var(--color-muted-blue); - border: 2px solid var(--color-muted-blue); - @apply rounded-[50%] w-[1.5rem] min-w-[1.5rem] h-[1.5rem]; - transition: all 0.3s ease-in-out; - -webkit-transition: all 0.3s ease-in-out; /** Chrome & Safari **/ - -moz-transition: all 0.3s ease-in-out; /** Firefox **/ - -o-transition: all 0.3s ease-in-out; /** Opera **/ -} - -@media (min-width: 576px) { - .GetInvolved .cards-container .content-card-row { - @apply block; - } - - .GetInvolved .cards-container .content-card:first-child { - @apply ml-[0]; - } - - .GetInvolved .cards-container .content-card:last-child { - @apply mr-[0]; - } - - .GetInvolved - .cards-container - .content-card:hover - .content-card-overlay - .card-content - .get-involved-cta - .cta-text { - opacity: 1; - } - - .GetInvolved - .cards-container - .content-card:hover - .content-card-overlay - .card-content - .get-involved-cta - .MdKeyboardArrowRight { - background-color: var(--color-muted-blue); - border: 2px solid var(--color-muted-blue); - } - - .GetInvolved .cards-container .content-card.high-schools .content-card-image { - object-position: 20% 80%; - } - - .GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .get-involved-cta - .cta-text { - opacity: 0; - } - - .GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .get-involved-cta - .MdKeyboardArrowRight { - background-color: transparent; - border: 2px solid rgba(255, 255, 255, 0.3); - } +.photo-gallery .carousel-item { + grid-area: 1 / 1; + visibility: hidden; + min-width: 0; + min-height: 0; } -@media (min-width: 768px) { - .GetInvolved .cards-container .content-card-row { - @apply flex; - overflow-x: visible; - } - - .GetInvolved .cards-container .content-card { - @apply flex w-[100%]; - } - - .GetInvolved .cards-container .content-card.high-schools .content-card-image { - object-position: 30% 80%; - } - - .GetInvolved .cards-container .content-card.companies .content-card-image { - object-position: 40% 50%; - } - - .GetInvolved - .cards-container - .content-card.ucla-students - .content-card-image { - object-position: 12% 50%; - } - - .GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .short-content { - @apply block; - } - - .GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .full-content { - @apply hidden; - } +.photo-gallery .carousel-item.active { + visibility: visible; } -@media (min-width: 992px) { - .GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .short-content { - @apply hidden; - } - - .GetInvolved - .cards-container - .content-card - .content-card-overlay - .card-content - .full-content { - @apply block; - } +.photo-gallery .carousel-item img { + @apply w-full h-full object-cover; } -/* Speakers */ -.Speakers p { - @apply mb-[0.25rem]; +.photo-gallery .gallery-controls, +.photo-gallery .carousel-indicators { + @apply flex items-center justify-center; } -.Speakers a { - text-decoration: none !important; +.photo-gallery .gallery-controls { + @apply mt-3 flex-wrap; } -.Speakers .speakers-container { - @apply max-w-[1040px] m-[auto]; +.photo-gallery .carousel-indicators { + flex-basis: 100%; + order: 2; } -.Speakers .speakers-container .speakers-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; +.photo-gallery .carousel-control-next { + margin-left: auto; } -.Speakers .speakers-container:not(:last-child) { - @apply pb-[64px]; +.photo-gallery button { + @apply inline-flex items-center justify-center p-0; + width: 44px; + height: 44px; + border: 1px solid transparent; + border-radius: 4px; + background: white; + color: var(--color-navy); + cursor: pointer; } -@media (min-width: 768px) { - .Speakers .speakers-container .speakers-header { - @apply mb-[0]; - } +.photo-gallery .carousel-control-prev, +.photo-gallery .carousel-control-next { + border-color: #a4b7c1; + font-size: 1.5rem; } -/* Sponsors */ -.Sponsors { - background-color: #f5f5f5; +.photo-gallery .carousel-indicators button span { + @apply block w-3 h-3 rounded-full; + border: 2px solid var(--color-navy); } -.Sponsors .sponsors-container { - @apply flex; - @apply flex-col justify-center items-center max-w-[1040px] m-[auto]; +.photo-gallery .carousel-indicators button[aria-pressed="true"] span { + background: var(--color-navy); } -.Sponsors .sponsors-container .sponsors-header { - @apply flex; - @apply flex-col items-center; +.photo-gallery button:hover { + background: #e9f2f4; } -.Sponsors .sponsors-container .logo-container { - @apply m-[1rem]; +.photo-gallery .gallery-status { + @apply text-sm text-center mt-2 mb-0; + min-height: 2.8em; } -.Sponsors .sponsors-container .large-logos, -.Sponsors .sponsors-container .small-logos { - @apply flex; - flex-flow: row wrap; - @apply justify-center items-center pb-[32px]; +.Sponsors .large-logos, +.Sponsors .small-logos { + @apply flex flex-wrap justify-center items-center gap-6 py-5; } -.Sponsors .sponsors-container .large-logos .logo { - @apply w-[14rem] h-[auto]; +.Sponsors .logo-container { + min-width: 0; } -.Sponsors .sponsors-container .small-logos .logo { - @apply w-[8rem] h-[auto]; +.Sponsors .logo-container a { + @apply flex items-center justify-center; + min-height: 44px; } -.Sponsors .sponsors-container .sponsor-contact { - @apply flex; - @apply flex-col justify-center items-center; +.Sponsors .large-logos .logo { + @apply w-56 max-w-full h-auto; } -.Sponsors .sponsors-container .sponsor-contact h5 { - @apply mb-[1rem]; +.Sponsors .small-logos .logo { + @apply w-32 max-w-full h-auto; } -.Sponsors .sponsors-container .sponsor-contact .sponsor-cta a { - @apply flex; - @apply flex-row items-center; - text-decoration: none; - color: var(--color-muted-blue); +.Sponsors .sponsor-contact { + @apply mt-6; } -.Sponsors - .sponsors-container - .sponsor-contact - .sponsor-cta:hover - .MdKeyboardArrowRight { - fill: white; - background-color: var(--color-muted-blue); - border: 2px solid var(--color-muted-blue); -} +@media (min-width: 640px) { + .Hero h1 { + font-size: 2.5rem; + } -.Sponsors .sponsors-container .sponsor-contact .sponsor-cta p { - @apply mb-[0]; - @apply mr-[0.5rem]; + .Home .GetInvolved, + .Home .home-programs, + .Home .About, + .Home .Speakers, + .Home .Sponsors { + @apply py-12; + } } -.Sponsors .MdKeyboardArrowRight { - fill: var(--color-muted-blue); - border: 2px solid #e2e2e2; - @apply rounded-[50%] w-[1.5rem] min-w-[1.5rem] h-[1.5rem]; - transition: all 0.3s ease-in-out; - -webkit-transition: all 0.3s ease-in-out; /** Chrome & Safari **/ - -moz-transition: all 0.3s ease-in-out; /** Firefox **/ - -o-transition: all 0.3s ease-in-out; /** Opera **/ +@media (min-width: 992px) { + .audience-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .program-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + @apply gap-8; + } } -@media (min-width: 768px) { - .Sponsors .sponsors-container .logo-container { - @apply m-[2rem]; +@media (min-width: 1100px) { + .Hero .hero-layout, + .About .about-content { + grid-template-columns: 1fr 1fr; + @apply gap-10; } - .Sponsors .sponsors-container .large-logos .logo { - @apply w-[16rem]; + .Hero .hero-layout { + @apply items-center py-12; } - .Sponsors .sponsors-container .small-logos .logo { - @apply w-[12rem]; + .Hero .hero-photo { + @apply h-full; + max-height: 460px; } } diff --git a/src/styles/ignite.css b/src/styles/ignite.css index 377258f..7e2fcbb 100644 --- a/src/styles/ignite.css +++ b/src/styles/ignite.css @@ -1,310 +1,128 @@ -/* Ignite page shell */ .Ignite { - @apply w-[100%] min-h-[100vh]; + @apply w-full; + color: #173b48; + background: #ffffff; } -.Ignite .ignite-section { - @apply w-[100%] p-[80px_20px] flex justify-center; +.Ignite > section { + @apply py-section-y; } -.Ignite .ignite-section .content-wrapper { - @apply w-[100%] max-w-[1140px] flex; - @apply flex-col items-center text-center; +.Ignite .ignite-intro { + background: #e9f7f7; } -/* Header */ -.Ignite .header-section { - @apply pt-[100px]; - @apply pb-[80px]; - color: white; - /* NEW — solid blue background */ - background: #2ca6bc; +.Ignite .ignite-next { + @apply max-w-[760px]; } -.Ignite .header-section .ignite-title { - @apply text-[7rem] font-bold; - letter-spacing: -2px; - @apply mb-[42px] leading-[1]; +.Ignite .ignite-next h2, +.Ignite .ignite-contact h2 { + @apply text-[1.5rem] leading-[1.3] mb-3; + color: var(--color-navy); } -.Ignite .header-section .ignite-description { - @apply max-w-[800px] text-[1.25rem] leading-[1.5] mb-[40px] font-normal; - color: #d0ecf0; +.Ignite .ignite-next p, +.Ignite .ignite-contact p { + @apply leading-[1.65] mb-4; } -.Ignite .header-section .header-buttons { - @apply flex gap-[20px]; +.Ignite .ignite-section-description { + @apply max-w-[760px] leading-[1.7] mb-6; } -.Ignite .header-section .header-buttons .btn-apply { - background-color: #07556f; - border: none; - @apply p-[12px_18px] text-[1.1rem] font-normal rounded-[1rem]; +.Ignite .ignite-facts { + @apply grid gap-5 m-0; } -.Ignite .header-section .header-buttons .btn-previous { - border: 2px solid #06546f; - color: #06546f; - @apply p-[12px_18px] text-[1.1rem]; - background: transparent; - @apply rounded-[1rem] font-normal; +.Ignite .ignite-facts > div { + @apply min-w-0 p-5 rounded-xl; + background: #f0f7f8; + border: 1px solid #bfd9dd; } -.Ignite .header-section .header-buttons .btn-previous:hover { - background: rgba(255, 255, 255, 0.1); +.Ignite .ignite-facts dt { + @apply font-semibold mb-3; + color: var(--color-navy); } -/* Program structure */ -.Ignite .structure-section { - /* NEW — blue → white gradient */ - background: linear-gradient(to bottom, #2ca6bc 0%, #ddf6f8 100%); +.Ignite .ignite-facts dd { + @apply m-0 leading-[1.7]; } -.Ignite .structure-section .section-header { - @apply text-[3.5rem] font-bold mb-[40px]; - color: white; +.Ignite .ignite-facts ul { + @apply m-0 pl-5; } -.Ignite .structure-section .program-cards-container { - @apply flex; - @apply flex-col gap-[20px] w-[100%]; +.Ignite .ignite-facts li + li { + @apply mt-2; } -@media (min-width: 768px) { - .Ignite .structure-section .program-cards-container { - @apply flex-row gap-[14px]; - } -} - -.Ignite .structure-section .program-card { - background: rgba(255, 255, 255, 0.2); - @apply rounded-[16px] p-[20px] flex; - @apply flex-col; - flex: 1; - @apply min-h-[0]; - backdrop-filter: blur(10px); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - transition: all 0.3s ease; - opacity: 1; +.Ignite .ignite-workshops { + background: #e9f7f7; } -.Ignite .structure-section .program-card .content-card-title { - @apply text-[1.5rem] font-bold; - color: white; - @apply mb-[10px] leading-[1.2]; +.Ignite .ignite-workshop-grid, +.Ignite .ignite-project-grid { + @apply grid grid-cols-1 gap-6; } -.Ignite .structure-section .program-card .card-description { - @apply text-[1rem]; - color: rgba(255, 255, 255, 0.9); - @apply mb-[8px] leading-[1.5]; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - @apply min-h-[3em]; +.Ignite .ignite-workshop-card { + @apply min-w-0 p-5 rounded-xl flex flex-col; + background: #ffffff; + border: 1px solid #bfd9dd; } -.Ignite .structure-section .program-card .card-image { - @apply w-[100%] h-[240px] rounded-[12px]; - overflow: hidden; - @apply mt-[auto]; - background: rgba(0, 0, 0, 0.1); - border: 3px solid white; - @apply shrink-[0] mt-[0]; +.Ignite .ignite-workshop-card h3 { + @apply text-[1.25rem] leading-[1.4] mb-3; + color: var(--color-navy); } -.Ignite .structure-section .program-card .card-image img { - @apply w-[100%] h-[100%] object-cover block; +.Ignite .ignite-workshop-card p { + @apply leading-[1.7] mb-5; } -.Ignite .structure-section .program-cards-container:hover .program-card { - opacity: 0.6; - transform: scale(0.98); +.Ignite .ignite-workshop-card img { + @apply block w-full h-auto mt-auto rounded-lg; } -.Ignite .structure-section .program-cards-container .program-card:hover { - opacity: 1; - transform: translateY(-8px) scale(1.02); - background: rgba(255, 255, 255, 0.3); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); - @apply z-[10] relative; +.Ignite .ignite-project-grid figure { + @apply min-w-0 m-0; } -/* Archived workshops */ -.Ignite .workshop-section { - /* NEW — solid light background */ - background: #ddf6f8; +.Ignite .ignite-project-grid img { + @apply block w-full h-auto rounded-xl; } -.Ignite .workshop-section .section-header { - @apply text-[3rem] font-bold mb-[40px]; - color: #06546f; +.Ignite figcaption { + @apply text-[0.9375rem] leading-[1.6] pt-3; } -.Ignite .workshop-section .workshop-cards-container { - @apply flex; - @apply flex-col gap-[20px] w-[100%]; +.Ignite .ignite-contact { + @apply mt-10 pt-8; + border-top: 1px solid #bfd9dd; } @media (min-width: 768px) { - .Ignite .workshop-section .workshop-cards-container { - @apply flex-row gap-[14px]; + .Ignite .ignite-workshop-grid, + .Ignite .ignite-project-grid { + @apply grid-cols-2; } } -.Ignite .workshop-section .workshop-card { - background: #1cb0c6; - @apply rounded-[16px] p-[20px] flex; - @apply flex-col; - flex: 1; - @apply min-h-[450px]; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - transition: all 0.3s ease; - opacity: 1; -} - -.Ignite .workshop-section .workshop-card .content-card-title { - @apply text-[1.5rem] font-bold; - color: white; - @apply mb-[10px] leading-[1.2]; -} - -.Ignite .workshop-section .workshop-card .card-description { - @apply text-[1rem]; - color: rgba(255, 255, 255, 0.9); - @apply mb-[8px] leading-[1.5]; -} - -.Ignite .workshop-section .workshop-card .card-image { - @apply w-[100%] h-[240px] rounded-[12px]; - overflow: hidden; - @apply mt-[auto]; - background: rgba(0, 0, 0, 0.1); - border: 3px solid white; - @apply shrink-[0] mt-[0]; -} - -.Ignite .workshop-section .workshop-card .card-image img { - @apply w-[100%] h-[100%] object-cover block; -} - -.Ignite .workshop-section .workshop-cards-container:hover .workshop-card { - opacity: 0.6; - transform: scale(0.98); -} - -.Ignite .workshop-section .workshop-cards-container .workshop-card:hover { - opacity: 1; - transform: translateY(-8px) scale(1.02); - background: #2ca6bc; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); - @apply z-[10] relative; -} - -/* Capstone */ -.Ignite .capstone-section { - /* NEW — light grey background */ - background: #f5f5f5; -} - -.Ignite .capstone-section .section-header { - @apply text-[3rem] font-bold mb-[10px]; - color: #06546f; -} - -.Ignite .capstone-section .section-subtitle { - color: #6796a5; - @apply mb-[30px]; -} - -.Ignite .capstone-section .capstone-image-container { - @apply w-[90%] max-w-[900px] m-[0_auto] rounded-[16px]; - overflow: hidden; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); -} - -.Ignite .capstone-section .capstone-image-container img { - @apply w-[100%] h-[auto] block; -} - -/* Apply */ -.Ignite .footer-apply-section { - color: white; - @apply pt-[80px]; - @apply pb-[60px]; - /* NEW — gradient from light blue to darker teal */ - background: linear-gradient(to bottom, #a8d8e8 0%, #2ca6bc 100%); -} - -.Ignite .footer-apply-section .content-wrapper { - @apply flex-col; -} - -@media (min-width: 768px) { - .Ignite .footer-apply-section .content-wrapper { - flex-direction: row !important; - @apply items-start justify-between; +@media (min-width: 992px) { + .Ignite .ignite-facts, + .Ignite .ignite-workshop-grid { + @apply grid-cols-3; } } -.Ignite .footer-apply-section .split-layout { - @apply flex; - @apply flex-col gap-[40px] items-stretch; -} - -.Ignite .footer-apply-section .left-text-col { - @apply flex; - @apply flex-col justify-start items-start text-left; -} - -.Ignite .footer-apply-section .left-text-col .align-left { - @apply text-left; - @apply text-[4rem] font-bold mb-[15px] leading-[1]; - color: #ffffff; -} - -.Ignite .footer-apply-section .left-text-col .section-subtitle-small { - @apply text-[1.1rem] mt-[15px]; - @apply mb-[30px]; - color: white; -} - -.Ignite .footer-apply-section .left-text-col .btn-apply-large { - background-color: #07556f; - border: none; - @apply p-[12px_40px] text-[1.1rem] font-normal rounded-[8px] mt-[10px]; - color: white; - cursor: pointer; - @apply w-[100%]; -} - -.Ignite .footer-apply-section .right-image-col { - @apply w-[100%] flex items-center justify-center; -} - -.Ignite .footer-apply-section .right-image-col .apply-image { - @apply w-[100%] rounded-[20px]; - overflow: hidden; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); -} - -.Ignite .footer-apply-section .right-image-col .apply-image img { - @apply w-[100%] h-[auto] block; -} - -@media (min-width: 768px) { - .Ignite .footer-apply-section .left-text-col { - width: 50% !important; - } - - .Ignite .footer-apply-section .right-image-col { - width: 50% !important; - } - - .Ignite .footer-apply-section .btn-apply-large { - width: 100% !important; +@media (prefers-reduced-motion: reduce) { + .Ignite *, + .Ignite *::before, + .Ignite *::after { + animation: none; + transition: none; + scroll-behavior: auto; } } diff --git a/src/styles/reset.css b/src/styles/reset.css index 6a33777..297d717 100644 --- a/src/styles/reset.css +++ b/src/styles/reset.css @@ -144,9 +144,7 @@ h2, h3, h4, h5, -h6, -.heading-five, -.heading-six { +h6 { margin-bottom: 0.5rem; font-weight: 500; line-height: 1.2; @@ -168,12 +166,10 @@ h4 { font-size: 1.5rem; } -h5, -.heading-five { +h5 { font-size: 1.25rem; } -h6, -.heading-six { +h6 { font-size: 1rem; } diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index a6966b7..8922869 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -1,136 +1,119 @@ -/* Arrow link */ -.LinkWithArrow { - @apply flex; - @apply flex-row items-center; +.People .person-profile:focus-visible { + outline: 3px solid var(--color-navy); + outline-offset: 3px; } -.LinkWithArrow a { - @apply flex; - @apply flex-row items-center; - text-decoration: none; -} - -.LinkWithArrow a:hover { - text-decoration: none; -} - -.LinkWithArrow a:hover .MdKeyboardArrowRight { - fill: white; - background-color: var(--color-muted-blue); - border: 2px solid var(--color-muted-blue); -} - -.LinkWithArrow a .link-text { - @apply mb-[0]; - @apply mr-[0.5rem]; - color: var(--color-muted-blue); -} - -.LinkWithArrow .MdKeyboardArrowRight { - fill: var(--color-muted-blue); - border: 2px solid #e2e2e2; - @apply rounded-[50%] w-[1.5rem] min-w-[1.5rem] h-[1.5rem]; - transition: all 0.3s ease-in-out; - -webkit-transition: all 0.3s ease-in-out; /** Chrome & Safari **/ - -moz-transition: all 0.3s ease-in-out; /** Firefox **/ - -o-transition: all 0.3s ease-in-out; /** Opera **/ -} - -/* Person cards */ +/* Every person is visible without horizontal scrolling, including on phones. */ .People { - @apply flex; - @apply flex-row; - overflow-x: scroll; - @apply pb-[1rem]; - transform: scaleX(-1) rotateZ(180deg); + @apply grid gap-x-4 gap-y-6 p-0 m-0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + list-style: none; } -.People p { - @apply mb-[0.25rem]; +.People .person { + @apply text-center; + min-width: 0; + color: var(--color-navy); + overflow-wrap: anywhere; } -.People a { - text-decoration: none !important; +.People .person-profile { + @apply block h-full rounded-md; + color: inherit; + text-decoration: none; } -.People .person { - @apply w-[14rem] h-[100%] p-[1rem_0.5rem] text-center; - color: black; - transition: color 0.5s; - transform: scaleX(-1) rotateZ(180deg); +.People .person-profile:hover .person-name { + text-decoration: underline; + text-underline-offset: 0.2em; } -.People .person .person-image { - @apply w-[10rem] h-[10rem] rounded-[50%] mb-[1.25rem] object-cover; +.People .person-image { + @apply block w-full h-auto rounded-full object-cover mx-auto mb-3; + max-width: 160px; + aspect-ratio: 1; object-position: center; - transition: filter 0.3s ease; } -.People .person .person-title { - @apply text-[small]; +.People .person-initials { + @apply flex items-center justify-center font-semibold; + background: #e0f1f3; + font-size: clamp(1.5rem, 5vw, 2.5rem); } -.People .person .person-description { - @apply text-[small]; +.People .person-name { + @apply text-base leading-snug mb-1; } -@media (min-width: 576px) { - .People .person:hover { - color: var(--color-muted-blue); - transition: color 0.5s; - } +.People .person-title, +.People .person-description { + @apply text-sm leading-normal mb-1; } -@media (min-width: 768px) { +@media (min-width: 640px) { .People { - transform: none; - @apply justify-center flex-wrap pb-[0]; + grid-template-columns: repeat(3, minmax(0, 1fr)); + @apply gap-6; + } + + .People .person-name { + @apply text-lg; } +} - .People .person { - transform: none; +@media (min-width: 992px) { + .People { + grid-template-columns: repeat(4, minmax(0, 1fr)); } } -/* Click-to-load video facade */ +/* The facade reserves the player's exact geometry without loading YouTube. */ .video-responsive { - @apply w-[300px] max-w-[100%]; + @apply w-full; } .video-responsive .video-frame { - @apply relative h-[0] pb-[56.25%]; - overflow: hidden; - background-color: #000000; + @apply relative; + aspect-ratio: 16 / 9; + background: #111; } .video-responsive .video-frame iframe, .video-responsive .video-frame .video-facade { - @apply absolute left-[0] top-[0] w-[100%] h-[100%]; + @apply absolute left-0 top-0 w-full h-full; + border: 0; } .video-responsive .video-facade { - @apply flex items-center justify-center p-[0]; - border: 0; + @apply flex items-center justify-center p-0; + min-height: 44px; + min-width: 44px; cursor: pointer; - background: radial-gradient(circle at center, #2f2f2f 0%, #000000 100%); + background: #202020; +} + +.video-responsive .video-facade:focus-visible, +.video-responsive iframe:focus-visible { + outline: 3px solid white; + outline-offset: -6px; } -.video-responsive .video-facade:hover .video-facade-play, -.video-responsive .video-facade:focus .video-facade-play { - background-color: #ff0000; +.video-responsive .video-facade:hover .video-facade-play { + background: #c00; } .video-responsive .video-facade-play { - @apply relative block w-[54px] h-[38px] rounded-[10px]; - background-color: rgba(33, 33, 33, 0.85); - transition: background-color 0.2s ease-in-out; + @apply relative block rounded-lg; + width: 60px; + height: 44px; + background: #c00; } .video-responsive .video-facade-play::after { content: ""; - @apply absolute top-[50%] left-[50%]; + @apply absolute top-1/2 left-1/2; transform: translate(-50%, -50%); border-style: solid; border-width: 9px 0 9px 15px; - border-color: transparent transparent transparent #ffffff; + border-color: transparent transparent transparent white; } diff --git a/src/styles/team.css b/src/styles/team.css index 0e49f2c..edf1e26 100644 --- a/src/styles/team.css +++ b/src/styles/team.css @@ -1,131 +1,79 @@ -/* Team page shell */ .Team { - @apply flex; - @apply flex-col items-center min-h-[100vh]; - background-color: #f5f5f5; + @apply pb-12; } .Team .team-title { - @apply text-center w-[100%]; - background-color: white; - @apply p-[2rem_0] mb-[2rem]; + @apply mb-5; } -.Team .team-title h1 { - @apply text-[2.5rem]; - color: #333; - @apply mb-[1rem]; +.Team .team-title p { + @apply mb-0; } -.Team .team-title .pill-divider { - @apply w-[60px] h-[4px]; - background-color: var(--color-muted-blue); - @apply m-[0_auto] rounded-[2px]; +.Team .team-department-picker { + @apply mb-6; } -.Team .team-navigation { - @apply flex gap-[1rem] mb-[2rem]; - @apply flex-wrap justify-center p-[0_1rem]; -} - -.Team .team-navigation a { - @apply p-[0.75rem_1.5rem]; - border: 2px solid var(--color-muted-blue); - background-color: transparent; - color: var(--color-muted-blue); - @apply rounded-[25px] text-[1rem] font-medium; - cursor: pointer; - transition: all 0.3s ease; - text-decoration: none; - @apply inline-block; +.Team .team-department-picker label { + @apply block font-semibold mb-2; } -.Team .team-navigation a:hover { - background-color: var(--color-muted-blue); - color: white; +.Team .team-department-picker select { + @apply w-full rounded-md px-3 py-2 text-base; + min-height: 44px; + border: 1px solid #536d77; + background: white; + color: var(--color-navy); + appearance: none; + padding-right: 36px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%230b546f' stroke-width='2'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; } -.Team .team-navigation a.active { - background-color: var(--color-muted-blue); - color: white; +.Team .team-navigation { + @apply hidden; } -.Team .team-section-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; +.Team .team-content > h2 { + @apply mb-4; } -.Team .team-content { - @apply w-[100%] max-w-[1200px] p-[0_1rem]; - flex: 1; - @apply relative; +.Team .team-navigation a, +.Team .team-department-picker select { + scroll-margin-top: 6rem; } -.Team > .Footer { - align-self: stretch; - @apply w-[100%]; +.Team .team-navigation a:focus-visible, +.Team .team-department-picker select:focus-visible { + outline: 3px solid var(--color-navy); + outline-offset: 3px; } -@media (max-width: 768px) { - .Team .team-navigation a { - @apply p-[0.5rem_1rem] text-[0.9rem]; +@media (min-width: 768px) { + .Team .team-department-picker { + @apply hidden; } -} - -/* Department containers */ -.Leadership { - background-color: #f5f5f5; -} - -.Leadership .leadership-container { - @apply max-w-[1200px] m-[auto]; -} - -.Leadership .leadership-container .leadership-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; -} - -.Content .content-container { - @apply max-w-[1200px] m-[auto]; -} - -.Content .content-container .content-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; -} -.Design { - background-color: #f5f5f5; -} - -.Design .design-container { - @apply max-w-[1200px] m-[auto]; -} - -.Design .design-container .design-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; -} - -.Operations { - background-color: #f5f5f5; -} - -.Operations .operations-container { - @apply max-w-[1200px] m-[auto]; -} + .Team .team-navigation { + @apply flex flex-wrap gap-2 mb-6; + } -.Operations .operations-container .operations-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; -} + .Team .team-navigation a { + @apply inline-flex items-center rounded-md px-4 py-2 font-medium; + min-height: 44px; + border: 1px solid #9aaeb8; + color: var(--color-navy); + text-decoration: none; + } -.External .external-container { - @apply max-w-[1200px] m-[auto]; -} + .Team .team-navigation a:hover { + background: #e9f2f4; + } -.External .external-container .external-header { - @apply flex; - @apply flex-col items-center mb-[1rem]; + .Team .team-navigation a.active { + color: white; + background: var(--color-navy); + border-color: var(--color-navy); + } }