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.headline.join(' ')}

-

{copy.intro}

-
{ui.shared.join_club} {ui.shared.see_what_we_do}
-
-
- - -
-
-

{ui.sections.find_starting_point}.

{ui.shared.explore_club}
- - - {blogs.length > 0 &&

{ui.sections.ideas_worth_sharing}.

{ui.shared.read_journal}
} - -
- -} diff --git a/web/app/playground/layout.tsx b/web/app/(playground)/playground/layout.tsx similarity index 100% rename from web/app/playground/layout.tsx rename to web/app/(playground)/playground/layout.tsx diff --git a/web/app/playground/page.tsx b/web/app/(playground)/playground/page.tsx similarity index 80% rename from web/app/playground/page.tsx rename to web/app/(playground)/playground/page.tsx index 5fe6eab..5fb20a5 100644 --- a/web/app/playground/page.tsx +++ b/web/app/(playground)/playground/page.tsx @@ -1,12 +1,12 @@ import { DesignPlayground, type PlaygroundData } from '@/components/DesignPlayground' -import { loadPage, loadUi, markdownifyStrip, url } from '@/lib/content' +import { loadPage, loadUi, markdownifyStrip, requireString, url } from '@/lib/content' import { loadActivities, loadHomeCopy, loadRecruitment, loadSiteData, loadVisibleMembers } from '@/lib/site' export default function PlaygroundPage() { const site = loadSiteData() const data: PlaygroundData = { copy: loadHomeCopy(), copyright: site.copyright, signup: site.signup, discord: site.discord, email: site.email, - hero: url(String(loadPage('index.md').intro_image)), logo: site.logo, + hero: url(requireString(loadPage('index.md').intro_image, 'intro_image', 'pages/index.md')), logo: site.logo, claudeLogo: url('/images/brand/claude-official.svg'), events: loadActivities(), members: loadVisibleMembers(), recruitment: loadRecruitment(), links: { ...site.links, journal: url('/blogs/') }, diff --git a/web/app/(playground)/playground/playground.css b/web/app/(playground)/playground/playground.css new file mode 100644 index 0000000..f28670e --- /dev/null +++ b/web/app/(playground)/playground/playground.css @@ -0,0 +1 @@ +@import '../../../styles/claude.css'; diff --git a/web/app/(site)/(home)/page.tsx b/web/app/(site)/(home)/page.tsx new file mode 100644 index 0000000..227c0c8 --- /dev/null +++ b/web/app/(site)/(home)/page.tsx @@ -0,0 +1,56 @@ +import { PageMeta } from '@/components/PageMeta' +import { Shell } from '@/components/Shell' +import { ActivityGrid } from '@/components/ActivityGrid' +import { ClaudeCollaboration } from '@/components/ClaudeCollaboration' +import { Arrow, JoinSection, JournalCard, Recruitment, Welcome } from '@/components/SiteSections' +import { absoluteUrl, loadCollection, loadPage, loadUi, requireString, url } from '@/lib/content' +import { 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() + const hero = url(requireString(page.intro_image, 'intro_image', 'pages/index.md')) + return <> + + +
+
+

{copy.headline.join(' ')}

+

{copy.intro}

+ +
+
+ + +
+
+
+
+

{ui.sections.find_starting_point}.

+ {ui.shared.explore_club} +
+ +
+ + + {blogs.length > 0 && ( +
+
+

{ui.sections.ideas_worth_sharing}.

+ {ui.shared.read_journal} +
+
+ {blogs.map((blog) => )} +
+
+ )} + +
+ +} diff --git a/web/app/about/page.tsx b/web/app/(site)/about/page.tsx similarity index 50% rename from web/app/about/page.tsx rename to web/app/(site)/about/page.tsx index f5d20b8..6780721 100644 --- a/web/app/about/page.tsx +++ b/web/app/(site)/about/page.tsx @@ -5,7 +5,10 @@ import { loadPage, loadProjects, loadUi } from '@/lib/content' export default function AboutPage() { const page = loadPage('about.md') const project = loadProjects().find((entry) => entry.slug === 'cbc-world') - return - {project && } - + const heading = typeof page.project_heading === 'string' ? page.project_heading : undefined + return ( + + {project && } + + ) } diff --git a/web/app/(site)/blogs/(index)/page.tsx b/web/app/(site)/blogs/(index)/page.tsx new file mode 100644 index 0000000..adbda44 --- /dev/null +++ b/web/app/(site)/blogs/(index)/page.tsx @@ -0,0 +1,22 @@ +import { PageMeta } from '@/components/PageMeta' +import { Shell } from '@/components/Shell' +import { JoinSection, JournalCard, PageIntro } from '@/components/SiteSections' +import { loadCollection, loadPage, loadUi, markdownifyStrip } from '@/lib/content' + +export default function BlogsPage() { + const page = loadPage('blogs.md') + const blogs = loadCollection('blogs', 'weight') + const ui = loadUi() + return <> + + + +
+
+ {blogs.map((blog) => )} +
+
+ +
+ +} diff --git a/web/app/(site)/blogs/[slug]/page.tsx b/web/app/(site)/blogs/[slug]/page.tsx new file mode 100644 index 0000000..1abce22 --- /dev/null +++ b/web/app/(site)/blogs/[slug]/page.tsx @@ -0,0 +1,26 @@ +import { ArticlePage } from '@/components/ArticlePage' +import { loadCollection, loadUi, requireString } from '@/lib/content' +import { entrySummary, formatPublicationDate } from '@/lib/site' + +export function generateStaticParams() { + return loadCollection('blogs').map((entry) => ({ slug: entry.slug })) +} + +export default async function BlogPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const entry = loadCollection('blogs').find((item) => item.slug === slug) + if (!entry) throw new Error(`Story not found: ${slug}`) + const heading = entry.body.match(/^\s*# ([^\n]+)\n/) + const title = heading ? heading[1].trim() : requireString(entry.title, 'title', `blogs/${slug}.md`) + const body = heading ? entry.body.slice(heading[0].length) : entry.body + return ( + + ) +} diff --git a/web/app/(site)/calendar/page.tsx b/web/app/(site)/calendar/page.tsx new file mode 100644 index 0000000..1393ddb --- /dev/null +++ b/web/app/(site)/calendar/page.tsx @@ -0,0 +1,22 @@ +import { PageMeta } from '@/components/PageMeta' +import { Shell } from '@/components/Shell' +import { CalendarApp } from '@/components/CalendarApp' +import { JoinSection, PageIntro } from '@/components/SiteSections' +import { loadCalendar } from '@/lib/calendar' +import { loadPage, loadUi } from '@/lib/content' + +export default function CalendarPage() { + const page = loadPage('calendar.md') + const ui = loadUi() + return <> + + + +
+ + +
+ +
+ +} diff --git a/web/app/(site)/committees/page.tsx b/web/app/(site)/committees/page.tsx new file mode 100644 index 0000000..bdb7886 --- /dev/null +++ b/web/app/(site)/committees/page.tsx @@ -0,0 +1,37 @@ +import { PageMeta } from '@/components/PageMeta' +import { Icon } from '@/components/Icon' +import { Shell } from '@/components/Shell' +import { Arrow, JoinSection, PageIntro } from '@/components/SiteSections' +import { Markdown } from '@/lib/markdown' +import { loadPage, loadUi } from '@/lib/content' +import { loadRecruitment } from '@/lib/site' + +export default function CommitteesPage() { + const page = loadPage('committees.md') + const recruitment = loadRecruitment() + const ui = loadUi() + return <> + + + +
+ {ui.recruitment.open_seats.replace('{seats}', String(recruitment.seats))} + {recruitment.tracks.map((track) => ( + {track} track + ))} +
+ {ui.shared.apply_committee} +
+
+
{page.body}
+ +
+ +
+ +} diff --git a/web/app/(site)/contact/page.tsx b/web/app/(site)/contact/page.tsx new file mode 100644 index 0000000..fbe098a --- /dev/null +++ b/web/app/(site)/contact/page.tsx @@ -0,0 +1,16 @@ +import { ArticlePage } from '@/components/ArticlePage' +import { loadContact, loadPage, loadUi, url } from '@/lib/content' +import { Arrow } from '@/components/SiteSections' + +export default function ContactPage() { + const page = loadPage('contact.md') + const ui = loadUi() + return ( + + + + ) +} diff --git a/web/app/(site)/events/(index)/page.tsx b/web/app/(site)/events/(index)/page.tsx new file mode 100644 index 0000000..e46b55f --- /dev/null +++ b/web/app/(site)/events/(index)/page.tsx @@ -0,0 +1,25 @@ +import { PageMeta } from '@/components/PageMeta' +import { Shell } from '@/components/Shell' +import { ActivityGrid } from '@/components/ActivityGrid' +import { CalendarSection, JoinSection, PageIntro } from '@/components/SiteSections' +import { loadPage, loadUi, markdownifyStrip } from '@/lib/content' +import { loadActivities } from '@/lib/site' + +export default function EventsPage() { + const page = loadPage('events.md') + const ui = loadUi() + return <> + + + +
+
+

{ui.sections.find_starting_point}.

+
+ +
+ + +
+ +} diff --git a/web/app/(site)/events/[slug]/page.tsx b/web/app/(site)/events/[slug]/page.tsx new file mode 100644 index 0000000..efd8222 --- /dev/null +++ b/web/app/(site)/events/[slug]/page.tsx @@ -0,0 +1,27 @@ +import { ArticlePage } from '@/components/ArticlePage' +import { Arrow } from '@/components/SiteSections' +import { loadCollection, loadUi, requireString, url } from '@/lib/content' +import { entrySummary, loadActivities } from '@/lib/site' + +export function generateStaticParams() { + return loadCollection('events').map((entry) => ({ slug: entry.slug })) +} + +export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const entry = loadCollection('events').find((item) => item.slug === slug) + if (!entry) throw new Error(`Activity not found: ${slug}`) + const title = requireString(entry.title, 'title', `events/${slug}.md`) + const activity = loadActivities().find((item) => item.title === title) + const ui = loadUi() + return ( + + {activity?.image &&
} +
+

{ui.sections.find_next_session}

+

{ui.sections.find_next_body}

+ {ui.shared.view_calendar} +
+
+ ) +} diff --git a/web/app/(home)/layout.tsx b/web/app/(site)/layout.tsx similarity index 65% rename from web/app/(home)/layout.tsx rename to web/app/(site)/layout.tsx index b7dbfa3..2e95620 100644 --- a/web/app/(home)/layout.tsx +++ b/web/app/(site)/layout.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react' import { SiteDocument } from '@/components/SiteDocument' +// Single root layout for every production route; the playground keeps its own +// root layout in the (playground) group. export default function Layout({ children }: Readonly<{ children: ReactNode }>) { return {children} } diff --git a/web/app/projects/page.tsx b/web/app/(site)/projects/page.tsx similarity index 57% rename from web/app/projects/page.tsx rename to web/app/(site)/projects/page.tsx index 52766fa..1404e87 100644 --- a/web/app/projects/page.tsx +++ b/web/app/(site)/projects/page.tsx @@ -6,11 +6,14 @@ import { loadPage, loadProjects, markdownifyStrip } from '@/lib/content' export default function ProjectsPage() { const page = loadPage('projects.md') + const headline = typeof page.headline === 'string' ? page.headline : String(page.title ?? 'Projects') return <> - + - -
{loadProjects().map((project) => )}
+ +
+ {loadProjects().map((project) => )} +
diff --git a/web/app/(site)/team/(index)/page.tsx b/web/app/(site)/team/(index)/page.tsx new file mode 100644 index 0000000..0f408b8 --- /dev/null +++ b/web/app/(site)/team/(index)/page.tsx @@ -0,0 +1,27 @@ +import { PageMeta } from '@/components/PageMeta' +import { Shell } from '@/components/Shell' +import { Arrow, JoinSection, MemberCards, PageIntro, Recruitment, Welcome } from '@/components/SiteSections' +import { loadPage, loadUi, markdownifyStrip } from '@/lib/content' +import { loadSiteData } from '@/lib/site' + +export default function TeamPage() { + const page = loadPage('team.md') + const ui = loadUi() + return <> + + + + {ui.shared.join_discord} + + +
+
+

{ui.shared.meet_team_leads}.

+
+ +
+ + +
+ +} diff --git a/web/app/(site)/team/[slug]/page.tsx b/web/app/(site)/team/[slug]/page.tsx new file mode 100644 index 0000000..be8152a --- /dev/null +++ b/web/app/(site)/team/[slug]/page.tsx @@ -0,0 +1,40 @@ +import { PageMeta } from '@/components/PageMeta' +import { Icon } from '@/components/Icon' +import { Shell } from '@/components/Shell' +import { Arrow, JoinSection, PageIntro, Recruitment } from '@/components/SiteSections' +import { Markdown } from '@/lib/markdown' +import { loadCollection, loadUi, requireString, url } from '@/lib/content' +import { entrySummary } from '@/lib/site' + +export function generateStaticParams() { + return loadCollection('team').map((entry) => ({ slug: entry.slug })) +} + +export default async function TeamMemberPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const entry = loadCollection('team').find((item) => item.slug === slug) + if (!entry) throw new Error(`Member not found: ${slug}`) + const title = requireString(entry.title, 'title', `team/${slug}.md`) + const role = typeof entry.jobtitle === 'string' ? entry.jobtitle : '' + const linkedin = typeof entry.linkedinurl === 'string' ? entry.linkedinurl : undefined + const image = typeof entry.image === 'string' ? entry.image : undefined + const ui = loadUi() + return <> + + + +
+ {ui.shared.community} +
+ {image ? {title} : null} + +
+
+ {entry.promoted !== false && } + +
+ +} diff --git a/web/app/about/layout.tsx b/web/app/about/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/about/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/blogs/(index)/layout.tsx b/web/app/blogs/(index)/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/blogs/(index)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/blogs/(index)/page.tsx b/web/app/blogs/(index)/page.tsx deleted file mode 100644 index ca478a5..0000000 --- a/web/app/blogs/(index)/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { PageMeta } from '@/components/PageMeta' -import { Shell } from '@/components/Shell' -import { Arrow, JoinSection, PageIntro } from '@/components/SiteSections' -import { firstParagraph, loadCollection, loadPage, loadUi, markdownifyStrip, url } from '@/lib/content' -import { formatPublicationDate } from '@/lib/site' - -export default function BlogsPage() { - const page = loadPage('blogs.md') - const blogs = loadCollection('blogs', 'weight') - const ui = loadUi() - return <> - -
-
-} diff --git a/web/app/blogs/[slug]/layout.tsx b/web/app/blogs/[slug]/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/blogs/[slug]/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/blogs/[slug]/page.tsx b/web/app/blogs/[slug]/page.tsx deleted file mode 100644 index ac5347f..0000000 --- a/web/app/blogs/[slug]/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ArticlePage } from '@/components/ArticlePage' -import { loadCollection, loadUi } from '@/lib/content' -import { formatPublicationDate } from '@/lib/site' - -export function generateStaticParams() { return loadCollection('blogs').map((entry) => ({ slug: entry.slug })) } - -export default async function BlogPage({ params }: { params: Promise<{ slug: string }> }) { - const { slug } = await params - const entry = loadCollection('blogs').find((item) => item.slug === slug) - if (!entry) throw new Error(`Story not found: ${slug}`) - const heading = entry.body.match(/^\s*# ([^\n]+)\n/) - const title = heading ? heading[1].trim() : String(entry.title) - const body = heading ? entry.body.slice(heading[0].length) : entry.body - return -} diff --git a/web/app/calendar/layout.tsx b/web/app/calendar/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/calendar/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/calendar/page.tsx b/web/app/calendar/page.tsx deleted file mode 100644 index 3152d60..0000000 --- a/web/app/calendar/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { PageMeta } from '@/components/PageMeta' -import { Shell } from '@/components/Shell' -import { CalendarApp } from '@/components/CalendarApp' -import { JoinSection, PageIntro } from '@/components/SiteSections' -import { loadCalendar } from '@/lib/calendar' -import { loadPage, loadUi } from '@/lib/content' - -export default function CalendarPage() { - const page = loadPage('calendar.md') - const ui = loadUi() - return <> - -
-
-} diff --git a/web/app/committees/layout.tsx b/web/app/committees/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/committees/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/committees/page.tsx b/web/app/committees/page.tsx deleted file mode 100644 index b97eafb..0000000 --- a/web/app/committees/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PageMeta } from '@/components/PageMeta' -import { Icon } from '@/components/Icon' -import { Shell } from '@/components/Shell' -import { Arrow, JoinSection, PageIntro } from '@/components/SiteSections' -import { Markdown } from '@/lib/markdown' -import { loadPage, loadUi } from '@/lib/content' -import { loadRecruitment } from '@/lib/site' - -export default function CommitteesPage() { - const page = loadPage('committees.md') - const recruitment = loadRecruitment() - const ui = loadUi() - return <> -
{recruitment.seats} open seats{recruitment.tracks.map((track) => {track} track )}
{ui.shared.apply_committee}
-
{page.body}
-
-} diff --git a/web/app/contact/layout.tsx b/web/app/contact/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/contact/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/contact/page.tsx b/web/app/contact/page.tsx deleted file mode 100644 index d7ac4b7..0000000 --- a/web/app/contact/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { ArticlePage } from '@/components/ArticlePage' -import { loadContact, loadPage, loadUi, url } from '@/lib/content' -import { Arrow } from '@/components/SiteSections' - -export default function ContactPage() { - const page = loadPage('contact.md') - const ui = loadUi() - return -} diff --git a/web/app/events/(index)/layout.tsx b/web/app/events/(index)/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/events/(index)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/events/(index)/page.tsx b/web/app/events/(index)/page.tsx deleted file mode 100644 index dde98b2..0000000 --- a/web/app/events/(index)/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { PageMeta } from '@/components/PageMeta' -import { Shell } from '@/components/Shell' -import { ActivityGrid } from '@/components/ActivityGrid' -import { CalendarSection, JoinSection, PageIntro } from '@/components/SiteSections' -import { loadPage, loadUi, markdownifyStrip } from '@/lib/content' -import { loadActivities } from '@/lib/site' - -export default function EventsPage() { - const page = loadPage('events.md') - const ui = loadUi() - return <> - -

{ui.sections.find_starting_point}.

- -
-} diff --git a/web/app/events/[slug]/layout.tsx b/web/app/events/[slug]/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/events/[slug]/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/events/[slug]/page.tsx b/web/app/events/[slug]/page.tsx deleted file mode 100644 index 780337c..0000000 --- a/web/app/events/[slug]/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ArticlePage } from '@/components/ArticlePage' -import { Arrow } from '@/components/SiteSections' -import { loadCollection, loadUi, url } from '@/lib/content' -import { loadActivities } from '@/lib/site' - -export function generateStaticParams() { return loadCollection('events').map((entry) => ({ slug: entry.slug })) } - -export default async function EventPage({ params }: { params: Promise<{ slug: string }> }) { - const { slug } = await params - const entry = loadCollection('events').find((item) => item.slug === slug) - if (!entry) throw new Error(`Activity not found: ${slug}`) - const title = String(entry.title) - const activity = loadActivities().find((item) => item.title === title) - const ui = loadUi() - return - {activity?.image &&
} -

{ui.sections.find_next_session}

{ui.sections.find_next_body}

{ui.shared.view_calendar}
-
-} diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx new file mode 100644 index 0000000..8b227c0 --- /dev/null +++ b/web/app/not-found.tsx @@ -0,0 +1,22 @@ +import { PageMeta } from '@/components/PageMeta' +import { Shell } from '@/components/Shell' +import { Arrow, PageIntro } from '@/components/SiteSections' +import { SiteDocument } from '@/components/SiteDocument' +import { loadUi } from '@/lib/content' +import { loadSiteData } from '@/lib/site' + +// With multiple root layouts the global 404 renders its own /. +// Branded fallback so a dead link never lands on the framework default page. +export default function NotFound() { + const ui = loadUi() + return ( + + + + + {ui.not_found.back_home} + + + + ) +} diff --git a/web/app/playground/playground.css b/web/app/playground/playground.css deleted file mode 100644 index d5f487e..0000000 --- a/web/app/playground/playground.css +++ /dev/null @@ -1 +0,0 @@ -@import '../../styles/claude.css'; diff --git a/web/app/projects/layout.tsx b/web/app/projects/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/projects/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 4f95370..fddd8fe 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -6,10 +6,12 @@ export const dynamic = 'force-static' const staticPages = ['/', '/about/', '/projects/', '/contact/', '/events/', '/blogs/', '/team/', '/committees/', '/calendar/'] export default function sitemap(): MetadataRoute.Sitemap { + // Member pages with promoted: false keep their URLs but are not listed on + // Community, so they stay out of the sitemap until they become reachable. const collectionPages = [ ...loadCollection('events').map((e) => `/events/${e.slug}/`), ...loadCollection('blogs').map((b) => `/blogs/${b.slug}/`), - ...loadCollection('team').map((m) => `/team/${m.slug}/`), + ...loadCollection('team').filter((m) => m.promoted !== false).map((m) => `/team/${m.slug}/`), ] return [...staticPages, ...collectionPages].map((path) => ({ url: absoluteUrl(path), diff --git a/web/app/team/(index)/layout.tsx b/web/app/team/(index)/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/team/(index)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/team/(index)/page.tsx b/web/app/team/(index)/page.tsx deleted file mode 100644 index 48723e0..0000000 --- a/web/app/team/(index)/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { PageMeta } from '@/components/PageMeta' -import { Shell } from '@/components/Shell' -import { Arrow, JoinSection, MemberCards, PageIntro, Recruitment, Welcome } from '@/components/SiteSections' -import { loadPage, loadUi, markdownifyStrip } from '@/lib/content' -import { loadSiteData } from '@/lib/site' - -export default function TeamPage() { - const page = loadPage('team.md') - const ui = loadUi() - return <> - {ui.shared.join_discord} -

{ui.shared.meet_team_leads}.

- -
-} diff --git a/web/app/team/[slug]/layout.tsx b/web/app/team/[slug]/layout.tsx deleted file mode 100644 index b7dbfa3..0000000 --- a/web/app/team/[slug]/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type { ReactNode } from 'react' -import { SiteDocument } from '@/components/SiteDocument' - -export default function Layout({ children }: Readonly<{ children: ReactNode }>) { - return {children} -} diff --git a/web/app/team/[slug]/page.tsx b/web/app/team/[slug]/page.tsx deleted file mode 100644 index 3dfb6bc..0000000 --- a/web/app/team/[slug]/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { PageMeta } from '@/components/PageMeta' -import { Icon } from '@/components/Icon' -import { Shell } from '@/components/Shell' -import { Arrow, JoinSection, PageIntro, Recruitment } from '@/components/SiteSections' -import { Markdown } from '@/lib/markdown' -import { loadCollection, loadUi, url } from '@/lib/content' - -export function generateStaticParams() { return loadCollection('team').map((entry) => ({ slug: entry.slug })) } - -export default async function TeamMemberPage({ params }: { params: Promise<{ slug: string }> }) { - const { slug } = await params - const entry = loadCollection('team').find((item) => item.slug === slug) - if (!entry) throw new Error(`Member not found: ${slug}`) - const title = String(entry.title) - const ui = loadUi() - return <> - -
{ui.shared.community}
{entry.image ? {title} : null}
- {entry.promoted !== false && } -
-} diff --git a/web/components/ActivityGrid.tsx b/web/components/ActivityGrid.tsx index 99e8857..968a80e 100644 --- a/web/components/ActivityGrid.tsx +++ b/web/components/ActivityGrid.tsx @@ -3,25 +3,33 @@ import { useState } from 'react' import { Arrow } from '@/components/Icon' import type { UiCopy } from '@/lib/content' - -interface Activity { title: string; description: string; href: string; image?: string } +import type { Activity } from '@/lib/site' export function ActivityGrid({ activities, ui, filterable = false }: { activities: Activity[]; ui: UiCopy; filterable?: boolean }) { const [filter, setFilter] = useState('All') const visible = activities.filter((item) => filter === 'All' || item.title === filter) return ( <> - {filterable &&
- {['All', ...activities.map((item) => item.title)].map((title) => )} + {filterable &&
+ {['All', ...activities.map((item) => item.title)].map((title) => ( + + ))}
} -
- {visible.map((item) => + {filterable && ( +

+ {ui.activities.results.replace('{count}', String(visible.length))} +

+ )} +
+ {visible.map((item) =>
-

{item.title}

{item.description}

- Discover {item.title.toLowerCase()}s + +
)} {!visible.length &&

{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() const [chosenMonth, setChosenMonth] = useState() const [selectedId, setSelectedId] = useState() + // The detail region announces only after an explicit user selection; automatic + // defaults on month change stay silent (the month label already announces). + const userPicked = useRef(false) + const datesRegion = useRef(null) + // Keyboard-focusable only while the dates grid can actually scroll. + const [datesScrollable, setDatesScrollable] = useState(false) function statusLabel(event: CalendarEvent) { return { archived: ui.calendar.status_completed, completed: ui.calendar.status_completed, scheduled: ui.calendar.status_scheduled, cancelled: ui.calendar.cancelled }[event.status] @@ -34,38 +40,68 @@ export function CalendarApp({ events, ui }: { events: CalendarEvent[]; ui: UiCop } }, []) - if (!today) return

{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() + monthEvents.forEach((event) => { + const dayEvents = grouped.get(event.date) ?? [] + dayEvents.push(event) + grouped.set(event.date, dayEvents) + }) + return grouped + }, [monthEvents]) + + if (!today || !monthKey || !layout) { + return

{ui.calendar.loading}

+ } + + const { label: monthName, firstDay, days } = layout const selected = selectedEvent(monthEvents, selectedId, today) - const eventsByDate = new Map() - monthEvents.forEach((event) => { - const dayEvents = eventsByDate.get(event.date) ?? [] - dayEvents.push(event) - eventsByDate.set(event.date, dayEvents) - }) const slots = Math.ceil((firstDay + days) / 7) * 7 function changeMonth(next?: string) { + userPicked.current = false setChosenMonth(next) setSelectedId(undefined) } + function chooseEvent(id: string) { + userPicked.current = true + setSelectedId(id) + } + return <>

{monthName}

- - + +
-
+
{Array.from({ length: slots }, (_, index) => { @@ -74,8 +110,8 @@ export function CalendarApp({ events, ui }: { events: CalendarEvent[]; ui: UiCop const date = `${monthKey}-${String(day).padStart(2, '0')}` const dayEvents = eventsByDate.get(date) ?? [] return
- - {dayEvents.map((event) => )}
@@ -85,7 +121,12 @@ export function CalendarApp({ events, ui }: { events: CalendarEvent[]; ui: UiCop

{ui.calendar.scroll_days}

- {selected &&
+ {selected &&
{selected.type}{statusLabel(selected)}

{selected.title}

{ui.calendar.date}
{ui.calendar.time}
{selected.time}
{ui.calendar.location}
{selected.location}
@@ -93,7 +134,7 @@ export function CalendarApp({ events, ui }: { events: CalendarEvent[]; ui: UiCop

{ui.calendar.events_this_month}

{monthEvents.length > 3 &&

{ui.calendar.scroll_more}

} - {monthEvents.length ?
    3 ? 0 : undefined} aria-label={`Events in ${monthName}`}>{monthEvents.map((event) =>
  • } +
+ {full && ( +
+ {['All', ...data.events.map((event) => event.title)].map((title) => ( + + ))} +
+ )} + {full && ( +

+ {data.ui.activities.results.replace('{count}', String(filteredEvents.length))} +

+ )} +
+ {(full ? filteredEvents : data.events).map((event) => ( + + +
+ + + +
+
+ ))} + {filteredEvents.length === 0 &&

{data.ui.activities.no_match}

} +
+ + ) +} + +function Welcome({ data }: { data: PlaygroundData }) { + return ( +
+ +

{data.copy.welcome_title}

{data.copy.welcome_copy}

+ {data.ui.shared.meet_club} +
+ ) +} + export function DesignPlayground({ data }: { data: PlaygroundData }) { const [view, setView] = useState('home') const [surface, setSurface] = useState('paper') @@ -82,53 +143,6 @@ export function DesignPlayground({ data }: { data: PlaygroundData }) { requestAnimationFrame(() => mainHeading.current?.focus({ preventScroll: true })) } - const filteredEvents = data.events.filter((event) => filter === 'All' || event.title === filter) - - function Activities({ full = false }: { full?: boolean }) { - return ( -
-
-
-

{data.ui.sections.find_starting_point}.

-
- {!full && } -
- {full && ( -
- {['All', ...data.events.map((event) => event.title)].map((title) => ( - - ))} -
- )} -
- {(full ? filteredEvents : data.events).map((event) => ( - - -
-

{event.title}

-

{event.description}

- Discover {event.title.toLowerCase()}s -
-
- ))} - {filteredEvents.length === 0 &&

{data.ui.activities.no_match}

} -
-
- ) - } - - function Welcome() { - return ( -
- -

{data.copy.welcome_title}

{data.copy.welcome_copy}

- {data.ui.shared.meet_club} -
- ) - } - return (
Skip to content @@ -171,8 +185,8 @@ export function DesignPlayground({ data }: { data: PlaygroundData }) {
- - + showView('explore')} /> + ) : view === 'explore' ? ( <> @@ -180,7 +194,7 @@ export function DesignPlayground({ data }: { data: PlaygroundData }) {

{data.ui.sections.from_what_if}

{data.exploreIntro}

- + showView('explore')} />

{data.ui.sections.make_time_to_build}

{data.ui.sections.make_time_body}

{data.ui.shared.open_calendar}
) : ( @@ -190,13 +204,13 @@ export function DesignPlayground({ data }: { data: PlaygroundData }) {

{data.communityIntro}

{data.ui.shared.join_discord} - +

{data.ui.shared.meet_team_leads}.

diff --git a/web/components/SiteDocument.tsx b/web/components/SiteDocument.tsx index 1d56025..b8a3852 100644 --- a/web/components/SiteDocument.tsx +++ b/web/components/SiteDocument.tsx @@ -3,10 +3,19 @@ import '@/styles/claude.css' import '@/styles/site.css' import { url } from '@/lib/content' +// Restore the saved surface before first paint: this parser-blocking inline +// script runs before the body renders, so a returning charcoal user never sees +// a light flash. Every navigation is a full page load (plain links), so +// this runs on each page. Kept in sync with SiteFrame's toggle. +const restoreSurface = `try{if(localStorage.getItem('cbc-surface')==='charcoal')document.documentElement.dataset.surface='charcoal'}catch(e){}` + export function SiteDocument({ children }: { children: ReactNode }) { return ( - - + + + +