From f6278120ed5221d58d7334bb0380db28b774999b Mon Sep 17 00:00:00 2001
From: zihao <31238371+sileneer@users.noreply.github.com>
Date: Sat, 17 Oct 2026 21:56:06 +0100
Subject: [PATCH 1/3] fix: apply code quality and UI/UX review findings
Code quality: memoize build-time content loaders, add path-contextual
error reporting and required-key shape assertions, remove dead markdown
helpers, guard the collection sort comparator, share one SiteData type,
validate calendar statuses at load, extend the image-reference check to
styles/*.css, harden serve.mjs path resolution, consolidate the thirteen
identical root layouts into one (site) route-group layout, extract a
shared JournalCard and reformat single-line page JSX.
UI/UX: restore the saved theme before first paint via a blocking inline
head script, add a branded not-found page, move the calendar single-column
breakpoint to 1000px, inert content/footer and close on outside pointer
or focus loss while the mobile menu is open, give whole-card links concise
accessible names, switch activity filters to radio semantics with a
result-count announcement, focus the calendar dates region only when it
scrolls, keep promoted:false member pages out of the sitemap, add meta
descriptions to detail and Contact pages, action-labelled theme toggle,
content-driven header/footer menus via menus.yml, ui.yml keys for
calendar aria labels and open seats, a no-JS calendar notice, and
announce event details only after explicit selection.
DESIGN.md and the change record are updated per the design contract.
---
DESIGN.md | 14 +-
docs/design/change-record.md | 11 +
web/app/(home)/page.tsx | 36 ---
.../{ => (playground)}/playground/layout.tsx | 0
.../{ => (playground)}/playground/page.tsx | 4 +-
.../(playground)/playground/playground.css | 1 +
web/app/(site)/(home)/page.tsx | 56 ++++
web/app/{ => (site)}/about/page.tsx | 9 +-
web/app/(site)/blogs/(index)/page.tsx | 22 ++
web/app/(site)/blogs/[slug]/page.tsx | 26 ++
web/app/(site)/calendar/page.tsx | 22 ++
web/app/(site)/committees/page.tsx | 37 +++
web/app/(site)/contact/page.tsx | 16 ++
web/app/(site)/events/(index)/page.tsx | 25 ++
web/app/(site)/events/[slug]/page.tsx | 27 ++
web/app/{(home) => (site)}/layout.tsx | 2 +
web/app/{ => (site)}/projects/page.tsx | 9 +-
web/app/(site)/team/(index)/page.tsx | 27 ++
web/app/(site)/team/[slug]/page.tsx | 40 +++
web/app/about/layout.tsx | 6 -
web/app/blogs/(index)/layout.tsx | 6 -
web/app/blogs/(index)/page.tsx | 15 --
web/app/blogs/[slug]/layout.tsx | 6 -
web/app/blogs/[slug]/page.tsx | 15 --
web/app/calendar/layout.tsx | 6 -
web/app/calendar/page.tsx | 15 --
web/app/committees/layout.tsx | 6 -
web/app/committees/page.tsx | 17 --
web/app/contact/layout.tsx | 6 -
web/app/contact/page.tsx | 9 -
web/app/events/(index)/layout.tsx | 6 -
web/app/events/(index)/page.tsx | 16 --
web/app/events/[slug]/layout.tsx | 6 -
web/app/events/[slug]/page.tsx | 19 --
web/app/not-found.tsx | 22 ++
web/app/playground/playground.css | 1 -
web/app/projects/layout.tsx | 6 -
web/app/sitemap.ts | 4 +-
web/app/team/(index)/layout.tsx | 6 -
web/app/team/(index)/page.tsx | 15 --
web/app/team/[slug]/layout.tsx | 6 -
web/app/team/[slug]/page.tsx | 21 --
web/components/ActivityGrid.tsx | 24 +-
web/components/CalendarApp.tsx | 77 ++++--
web/components/DesignPlayground.tsx | 124 +++++----
web/components/SiteDocument.tsx | 13 +-
web/components/SiteFrame.tsx | 61 +++--
web/components/SiteSections.tsx | 38 ++-
web/content/data/menus.yml | 14 +-
web/content/ui.yml | 26 +-
web/lib/calendar.ts | 28 +-
web/lib/content.ts | 255 +++++++++++++-----
web/lib/site.ts | 85 ++++--
web/scripts/check-content.mjs | 4 +-
web/scripts/serve.mjs | 9 +-
web/styles/claude.css | 10 +-
web/styles/site.css | 5 +-
57 files changed, 915 insertions(+), 477 deletions(-)
delete mode 100644 web/app/(home)/page.tsx
rename web/app/{ => (playground)}/playground/layout.tsx (100%)
rename web/app/{ => (playground)}/playground/page.tsx (80%)
create mode 100644 web/app/(playground)/playground/playground.css
create mode 100644 web/app/(site)/(home)/page.tsx
rename web/app/{ => (site)}/about/page.tsx (50%)
create mode 100644 web/app/(site)/blogs/(index)/page.tsx
create mode 100644 web/app/(site)/blogs/[slug]/page.tsx
create mode 100644 web/app/(site)/calendar/page.tsx
create mode 100644 web/app/(site)/committees/page.tsx
create mode 100644 web/app/(site)/contact/page.tsx
create mode 100644 web/app/(site)/events/(index)/page.tsx
create mode 100644 web/app/(site)/events/[slug]/page.tsx
rename web/app/{(home) => (site)}/layout.tsx (65%)
rename web/app/{ => (site)}/projects/page.tsx (57%)
create mode 100644 web/app/(site)/team/(index)/page.tsx
create mode 100644 web/app/(site)/team/[slug]/page.tsx
delete mode 100644 web/app/about/layout.tsx
delete mode 100644 web/app/blogs/(index)/layout.tsx
delete mode 100644 web/app/blogs/(index)/page.tsx
delete mode 100644 web/app/blogs/[slug]/layout.tsx
delete mode 100644 web/app/blogs/[slug]/page.tsx
delete mode 100644 web/app/calendar/layout.tsx
delete mode 100644 web/app/calendar/page.tsx
delete mode 100644 web/app/committees/layout.tsx
delete mode 100644 web/app/committees/page.tsx
delete mode 100644 web/app/contact/layout.tsx
delete mode 100644 web/app/contact/page.tsx
delete mode 100644 web/app/events/(index)/layout.tsx
delete mode 100644 web/app/events/(index)/page.tsx
delete mode 100644 web/app/events/[slug]/layout.tsx
delete mode 100644 web/app/events/[slug]/page.tsx
create mode 100644 web/app/not-found.tsx
delete mode 100644 web/app/playground/playground.css
delete mode 100644 web/app/projects/layout.tsx
delete mode 100644 web/app/team/(index)/layout.tsx
delete mode 100644 web/app/team/(index)/page.tsx
delete mode 100644 web/app/team/[slug]/layout.tsx
delete mode 100644 web/app/team/[slug]/page.tsx
diff --git a/DESIGN.md b/DESIGN.md
index c6ff08c..f0fc269 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -42,7 +42,7 @@ For a fix that preserves the rules, add a change record explaining the regressio
| Secondary text | `#5F5E57` | `#C4C2B8` |
| Rule/border | `#D0CEC5` | `#45453F` |
-- Warm Paper is the default. The appearance toggle persists the user's choice across navigation and reloads. Theme changes must not tint image/logo files.
+- Warm Paper is the default. The appearance toggle persists the user's choice across navigation and reloads. Theme changes must not tint image/logo files. The saved surface is restored before first paint by a blocking inline script in the document head that sets `data-surface` on ``; the toggle updates the same attribute. Never restore the theme in a post-paint effect only. The toggle's accessible name describes the action (`Switch to … appearance`), not the current state.
- Use Georgia/Times New Roman for editorial headings and Arial/Helvetica for body, controls and publication dates. Omit decorative monospaced captions and eyebrow labels from production and matching playground content. Local review controls also use the regular font. These are local font choices, not a claim about Claude's proprietary typography.
- Retain generous whitespace, warm surfaces, thin borders, orange framing and clear section hierarchy. Avoid introducing unrelated gradients, glass effects or a new font system without an explicit design decision.
- Share tokens and general styles in `web/styles/claude.css`; production page styles live in `web/styles/site.css`. Avoid duplicate component-specific palettes and scattered inline visual settings.
@@ -65,12 +65,13 @@ For a fix that preserves the rules, add a change record explaining the regressio
## Navigation and interactions
-- Main navigation is About, Explore, Projects, Calendar, Community and Journal, with a persistent Join action. Keep About in the footer as well. Preserve the existing destination URLs and active-page indication. Production navigation uses the existing menu pattern at 1100px and below so the expanded set of destinations never collides with the logo or appearance control.
+- Main navigation is About, Explore, Projects, Calendar, Community and Journal, with a persistent Join action. The set is content-driven: `web/content/data/menus.yml` `main` entries render in weight order, and entries marked `header: false` (Committees, Contact) stay out of the header while remaining valid destinations. The footer link set likewise comes from the `footer` list in `menus.yml`. Keep About in the footer as well. Preserve the existing destination URLs and active-page indication. Production navigation uses the existing menu pattern at 1100px and below so the expanded set of destinations never collides with the logo or appearance control.
+- While the mobile menu is open, the page content and footer are inert to keyboard and pointer focus, the menu closes on outside pointer down and on focus leaving the header, and Escape still returns focus to the toggle.
- Logo navigation returns home. Internal destinations go through `url()`; canonical URLs use `absoluteUrl()`.
- Join, Discord and application actions use their real configured URLs. External tabs use `rel="noopener noreferrer"`. Keep visible action labels explicit about their purpose.
-- A whole activity/member/story card is one clear link. Avoid nested links/buttons, duplicate tab stops and decorative elements that intercept clicks.
+- A whole activity/member/story card is one clear link with a concise accessible name (the card title via `aria-label`); the long description and pseudo-link text inside are not read as part of the link name. Avoid nested links/buttons, duplicate tab stops and decorative elements that intercept clicks.
- Mobile navigation opens from a labeled toggle, exposes its expanded state, closes on Escape and navigation, and returns focus to the toggle on Escape. It must not cover or block controls when closed.
-- Filters show the current selection with a filled surface and text emphasis, retain context and offer an understandable empty state. Keep hover and keyboard feedback visible in both themes. Do not make filtering dependent on hover.
+- Filters use radio semantics (`radiogroup`/`radio` with `aria-checked`), show the current selection with a filled surface and text emphasis, retain context and offer an understandable empty state. Only the result count is announced to assistive technology on change, not the whole card grid. Keep hover and keyboard feedback visible in both themes. Do not make filtering dependent on hover.
- Preserve standard browser Back behavior, link destinations and anchor navigation. Do not hijack scrolling, add autoplay video or make interactions depend on a pointer.
## Content and page behavior
@@ -87,9 +88,10 @@ For a fix that preserves the rules, add a change record explaining the regressio
- Calendar is an ongoing main page, never an archive-only destination. Its existing data, dates, times, venues and cancellation records remain intact unless the owner requests a schedule change.
- Do not add a summary row above the calendar grid: omit total/month session counts and the redundant `Club Calendar` badge. Retain month navigation, session names, details and the session list.
- Calendar opens on the current Cambridge date (Europe/London) at runtime, independently of the static build date or available event records. Previous/next controls move through consecutive months, including empty months and year boundaries; Current month returns to the live month. Keep the selected event within the visible month, with useful empty states. Default to the nearest event today or in the future; otherwise select the most recent past event. Prefer non-cancelled records for automatic selection, retaining explicit user selections and cancellation labels. Empty months have no selection. Omit the redundant Select an event for details sentence; retain only the functional mobile scrolling hint. Calendar weeks start on Monday, mark today separately from the selected event, and show session names with explicit cancelled labels. Each session is individually selectable, including multiple events on one date. Pair the grid with selected details first and a compact chronological event list beneath them in one coordinated layout. Long lists scroll in a keyboard-reachable region with a visible hint; show type and status separately. Multi-day events have a dated record for each day, retaining their daily time and venue.
-- On narrow screens, the calendar dates region may scroll horizontally with a visible hint; the document itself must not overflow. Keep names readable instead of hiding them or replacing them with counts. The region must be keyboard reachable.
+- On narrow screens, the calendar dates region may scroll horizontally with a visible hint; the document itself must not overflow. Keep names readable instead of hiding them or replacing them with counts. The region is keyboard reachable only while it can actually scroll (no dead tab stop at wide widths). The calendar switches to its single-column layout at 1000px and below so the grid never squeezes event names into unreadable slivers at intermediate widths. Changing the month announces the new month label; the event detail region announces only after an explicit user selection, not on automatic defaults. Without JavaScript the calendar shows a static notice instead of a perpetual loading message.
+- Unknown URLs render a branded 404 page (`web/app/not-found.tsx`) inside the standard shell with a link home, never the framework default page.
- Keep the Welcome section About link available at desktop, intermediate and mobile widths; place it below the copy when space is limited.
-- Reading pages use one primary heading, a comfortable prose width and useful return links. Preserve existing routes, canonical metadata, sitemap/robots and legacy redirect stubs.
+- Reading pages use one primary heading, a comfortable prose width and useful return links. Preserve existing routes, canonical metadata, sitemap/robots and legacy redirect stubs. Every index and detail page carries a meta description (page front matter or the entry's first paragraph). Member detail pages whose records set `promoted: false` keep their URLs but stay out of the sitemap until they are listed on Community.
## Accessibility and responsive acceptance
diff --git a/docs/design/change-record.md b/docs/design/change-record.md
index bad28e5..8e2f80a 100644
--- a/docs/design/change-record.md
+++ b/docs/design/change-record.md
@@ -187,3 +187,14 @@ Each entry describes its own review checkpoint. Later revisions supersede earlie
- Owner tested the port-4102 preview and asked to merge; the change squash-merged into `main` as [PR #32](https://github.com/Cambridge-AI-Build-Club/Cambridge-AI-Build-Club.github.io/pull/32) at `2f2a27f`. The PR `build` check passed and the `Deploy Next.js site to Pages` run ([38230306949](https://github.com/Cambridge-AI-Build-Club/Cambridge-AI-Build-Club.github.io/actions/runs/38230306949)) completed successfully on the merge commit.
- Live checks against the organisation root with cache-busting queries: Home, About, Community, Calendar, Committees and the playground returned HTTP 200; the shared recruitment summary renders on Committees; the playground payload carries the shared copy (`Stay curious. Keep building.`); `/images/team/andrew_choi.jpg` and the retained `/images/social/facebook.svg` served; the `/CUABC-Web/` legacy redirect stub resolves.
+
+### 16 October 2026 — code quality and UI/UX review fixes
+
+- Request: implement the full set of findings from a two-part codebase review (code quality and UI/UX), keeping the approved design intact.
+- Surfaces: content loaders and build tooling (`web/lib/*`, `web/scripts/*`), shared shell (`SiteFrame`, `SiteDocument`, `Shell`), calendar, activity/member/story cards, filters, navigation and footer data (`menus.yml`), activity card labels (`features.json`), interface copy (`ui.yml`), page metadata, sitemap, the calendar intermediate-width breakpoint, and a new branded 404 route.
+- Implementation intent:
+ - Code quality: memoize all build-time content loaders (content is immutable during a build); add path-contextual error reporting and required-key shape assertions at the loader boundary; type the page/collection front matter actually consumed instead of `String()`/`as` casts; remove dead helpers (`markdownifyStripTruncate`, `excerptHtmlTruncate`, `truncate`); guard the collection sort comparator against non-numeric weights; share one `SiteData` type between `lib/site.ts` and `SiteFrame`; pair activity artwork via a prebuilt map with the plural display title supplying the card link label; validate calendar statuses at load; scan `styles/` (including `.css`) in the image-reference check; harden the preview server's path resolution; consolidate the thirteen identical root layouts into one shared route-group layout; extract a shared `JournalCard`; reformat single-line page JSX for readability.
+ - UI/UX: restore the saved theme before first paint via a blocking inline head script (ending the per-navigation flash, since every navigation is a full page load); add a branded `not-found.tsx`; move the calendar single-column breakpoint from 900px to 1000px; make the open mobile menu inert the content/footer and close on outside pointer down and focus leaving the header; give whole-card links concise `aria-label` names; switch activity filters to radio semantics and announce only the result count; make the calendar dates region keyboard-focusable only when it can scroll; keep `promoted: false` member pages out of the sitemap; add meta descriptions to member/activity/story detail and Contact; label the theme toggle with the action it performs; source header/footer links from `menus.yml` with a `header: false` opt-out; move "open seats", calendar aria labels and the card link label pattern into `ui.yml`; add a no-JavaScript calendar notice; announce event details only after explicit selection. The desktop brand mark keeps its approved cropped presentation; swapping in a wide-format logo asset is recorded as an open item requiring an owner artwork decision.
+- Acceptance: `npm run build` (design guard, extended image-reference check, calendar tests, TypeScript, static export) passes; rendered copy unchanged except the items above; fresh 1440px/375px screenshots in both themes plus the 761–1000px calendar range; keyboard walk of the mobile menu, filters and calendar; no console, hydration or overflow regressions; the 404 route renders the site chrome.
+- Status: implemented; verification results to be recorded after the local build and preview checks.
+- Open item: the desktop header logo still centre-crops the square source image at render time; replacing it with a true wide-format asset needs an owner artwork decision.
diff --git a/web/app/(home)/page.tsx b/web/app/(home)/page.tsx
deleted file mode 100644
index 63e8151..0000000
--- a/web/app/(home)/page.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { PageMeta } from '@/components/PageMeta'
-import { Shell } from '@/components/Shell'
-import { ActivityGrid } from '@/components/ActivityGrid'
-import { ClaudeCollaboration } from '@/components/ClaudeCollaboration'
-import { Arrow, JoinSection, Recruitment, Welcome } from '@/components/SiteSections'
-import { firstParagraph, loadCollection, loadPage, loadUi, markdownifyStrip, url } from '@/lib/content'
-import { formatPublicationDate, loadActivities, loadHomeCopy, loadSiteData } from '@/lib/site'
-
-export default function HomePage() {
- const page = loadPage('index.md')
- const copy = loadHomeCopy()
- const site = loadSiteData()
- const blogs = loadCollection('blogs', 'weight').slice(0, 3)
- const ui = loadUi()
- return <>
- {copy.intro}
-
{ui.sections.find_starting_point}.
{ui.sections.ideas_worth_sharing}.
{copy.headline.join(' ')}
+
+
{ui.sections.find_starting_point}.
{ui.sections.ideas_worth_sharing}.
{ui.sections.find_starting_point}.
{ui.shared.meet_team_leads}.
: null}
+
{ui.sections.find_starting_point}.
+ {ui.activities.results.replace('{count}', String(visible.length))} +
+ )} +{item.description}
- Discover {item.title.toLowerCase()}s{ui.activities.no_match}
} diff --git a/web/components/CalendarApp.tsx b/web/components/CalendarApp.tsx index 9be51e6..732143d 100644 --- a/web/components/CalendarApp.tsx +++ b/web/components/CalendarApp.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { CalendarEvent } from '@/lib/calendar' import { clubDate, eventsForMonth, monthLayout, selectedEvent, shiftMonth } from '@/lib/calendar-dates' import { Icon } from '@/components/Icon' @@ -18,6 +18,12 @@ export function CalendarApp({ events, ui }: { events: CalendarEvent[]; ui: UiCop const [today, setToday] = useState{ui.calendar.loading}
+ useEffect(() => { + const region = datesRegion.current + if (!region) return + const measure = () => setDatesScrollable(region.scrollWidth > region.clientWidth) + measure() + const observer = new ResizeObserver(measure) + observer.observe(region) + return () => observer.disconnect() + }, [today]) - const currentMonth = today.slice(0, 7) + const currentMonth = today?.slice(0, 7) const monthKey = chosenMonth ?? currentMonth - const { label: monthName, firstDay, days } = monthLayout(monthKey) - const monthEvents = eventsForMonth(events, monthKey) + + const layout = useMemo(() => (monthKey ? monthLayout(monthKey) : undefined), [monthKey]) + const monthEvents = useMemo(() => (monthKey ? eventsForMonth(events, monthKey) : []), [events, monthKey]) + const eventsByDate = useMemo(() => { + const grouped = new Map{ui.calendar.loading}
+ } + + const { label: monthName, firstDay, days } = layout const selected = selectedEvent(monthEvents, selectedId, today) - const eventsByDate = new Map{ui.calendar.scroll_days}
{ui.calendar.scroll_more}
} - {monthEvents.length ?+ {data.ui.activities.results.replace('{count}', String(filteredEvents.length))} +
+ )} +{data.ui.activities.no_match}
} +{data.copy.welcome_copy}
{event.description}
- Discover {event.title.toLowerCase()}s{data.ui.activities.no_match}
} -{data.copy.welcome_copy}
{data.exploreIntro}
{data.ui.sections.make_time_body}
{data.communityIntro}
{data.ui.shared.join_discord}{member.role}