This file provides guidance to coding agents when working with code in this repository.
pnpm/turborepo monorepo for CodeDay's web applications and shared packages.
apps/www— main CodeDay website (Next.js 16, Pages Router)apps/topo-gallery— Storybook gallery forpackages/topo(one story per component), verified with Playwright against the built static Storybookpackages/topo— shared design system: Chakra UI v3 components (Atom/Molecule/Organism), theming, region detectionpackages/i18n— shared internationalization (Paraglide JS messages + runtime)packages/utils— shared utilities (GraphQL fetch, debug, etc.)packages/topocons— icon library (icons are generated from thepackages/topocons/svggit submodule, not hand-authored)packages/tsconfig— shared TypeScript configuration
Run from repo root (turborepo fans out to workspaces):
pnpm install
pnpm run build # turbo build (all apps/packages, respects dependency graph)
pnpm run dev # turbo dev (persistent, runs codegen first)
pnpm run lint # oxlint . (type-aware; see .oxlintrc.json)
pnpm run lint:fix # oxlint --fix --fix-suggestions .
pnpm run format:fix # oxfmt . (formatter, also sorts imports)
pnpm run codegen # turbo codegen — regenerates GraphQL types from gql`...` tags in .ts/.tsx filesPer-app, run from the app directory (or via turbo run <script> --filter=<pkg>):
pnpm --filter @codeday/www test # Playwright against a prod build; see apps/www/playwright.config.ts
pnpm --filter @codeday/topo-gallery test # builds the static Storybook, then Playwright against it
pnpm --filter @codeday/topo-gallery dev # Storybook dev server on :3100Linting/formatting: this repo uses oxlint and oxfmt, not eslint/prettier. Always use the lint/format scripts above, not eslint/prettier directly. oxlint is type-aware (typeAware/typeCheck in .oxlintrc.json), so it needs a build (^build) to have run first — this is wired into turbo.json's //#lint task dependency. oxfmt also sorts imports — see "Import order" below; don't hand-order them.
-
The API is a single GraphQL endpoint at
https://graph.codeday.org/(seepackages/topo/src/utils.ts—apiFetch,useApi). -
Queries/fragments are
gqltemplate-literal tags co-located in the.tsxfile that uses them. Use the generatedgraphql()tag from@/gql(path-aliased toapps/www/src/gql/, seeapps/www/tsconfig.json):import { graphql } from "@/gql"; import { FragmentType, useFragment } from "@/gql/fragment-masking"; export const StatsFragment = graphql(` fragment IndexStatsComponent on Query { impact { studentCount } } `); function Stats({ data }: { data: FragmentType<typeof StatsFragment> }) { const { impact } = useFragment(StatsFragment, data); ... }
-
graphql-codegen's client preset (apps/www/codegen.yml) scanssrc/**/*.{ts,tsx}forgraphql(...)tags against the live schema and emits typed helpers intosrc/gql/(gql.ts,graphql.ts,fragment-masking.ts,index.ts— gitignored, generated). Runpnpm codegen(orpnpm dev/build, which depend on it) after adding/changing agraphql(...)tag. -
Fragment naming drives composition: name a component-level fragment
<Page><Component>Component(e.g.IndexStatsComponent,IndexLogoWallComponent,PageComponent). A page's root query then just spreads...IndexStatsComponentetc. — codegen resolves the fragment wherever it's declared in the project, so the page doesn't need to import the child fragment consts to compose them (seeapps/www/src/pages/index.tsx). UseuseFragment/FragmentType(fragment masking) so a component only has type access to the fields its own fragment selected, not the whole page query. -
Use
ResultOf<typeof SomeQuery>(from@graphql-typed-document-node/core) to type a query's result shape, e.g. for a page'sprops.query. -
Not all data comes from a traditional API/database. A large portion of the schema is content pulled live from CodeDay's Contentful CMS, exposed through the
cms { ... }namespace onQuery(see e.g. thePageComponentfragment inapps/www/src/components/Page/index.tsx). Fields undercms(e.g.strings,asset,programs,events,testimonials,newsCoverages,pressPhotos,globalSponsors,faqs,forms,regions,tickets,projects,mentors,publications) are Contentful content types/entries, typically queried with awherefilter and returning{ items { ... } }. Copy strings in particular are modeled as CMS entries keyed by a dottedkey(e.g.strings(where: { key: "common.mission" })) rather than hardcoded inpackages/i18n— when hunting for where a piece of site copy or an image/asset comes from, check the relevant component'sgraphql(...)fragment for acmsblock before assuming it's a local constant.
-
Locale (UI language) —
packages/i18n, powered by Paraglide JS. Message files:packages/i18n/messages/{locale}.json, keys prefixed by package (topo_*,www_*). Use viaimport * as m from "@codeday/i18n/messages"→m.some_key({ args }).- Every URL includes a locale prefix (
/en/about). Next.js's Pages Router requires adefaultLocale, so this repo uses"_default"as a sentinel non-locale — bare paths resolve to_default, whichapps/www/src/proxy.ts(this Next version's replacement formiddleware.ts) detects and redirects to a real locale based on theNEXT_LOCALEcookie orAccept-Language. - Get locale:
getLocale()(client,@codeday/i18n/runtime) orgetLocaleFromContext(ctx)(server,@codeday/i18n/next-pages). Form.*()calls insidegetStaticProps/getServerSideProps, wrap the function withwithLocaleStaticProps/withLocaleServerSideProps. - Adding a language: add the locale code to
packages/i18n/project.inlang/settings.json, addpackages/i18n/messages/{locale}.json, then add the locale to each app'snext.config.ts(i18n.locales) andAVAILABLE_LOCALESin its proxy/middleware. - This is a separate mechanism from Contentful content localization: some
cms { ... }fields (see "GraphQL data fetching" above) take their ownlocaleargument resolved server-side by Contentful (e.g.cms.localizationConfigs(where: { id: $region }, locale: $locale)inapps/www/src/pages/contact.tsx,cms.legal.terms(locale: "en-US")inapps/www/src/pages/legal/[policy].tsx). When a page needs localized CMS content, thread the Paraglide locale through as a GraphQL variable rather than assumingm.*()covers it — mostcmsqueries in this repo don't passlocaleat all and just return the default (English) entry.
- Every URL includes a locale prefix (
-
Region (domain-specific data — phone numbers, emails, legal) — resolved from the TLD of the visitor's hostname, independent of locale (e.g.
codeday.fr→ regioneu, UI can still be English). Provided by@codeday/topo/Region.@codeday/topo/Region/configis dependency-free (no React) so it can run in the Edge runtime (proxy/middleware);@codeday/topo/RegionaddsRegionProvider/useRegion()/getRegionFromContext()for React/server use.- New TLD → add an entry to
TLD_REGION_MAPinpackages/topo/src/Region/config.ts; nothing else needs to change. Full-domain overrides can be passed at call sites via anoverridesmap (checked before TLD-map lookup).
Chakra UI v3 based, organized by atomic-design tier: Atom (context-free primitives — Box, Button, Text, Input, ...), Molecule (small reusable combinations of a couple of Atoms — Band, Wash, Section, ActionLink, MarqueeRow, Markdown, VideoPlayer, CognitoForm, ContentfulRichText, ...), Organism (complete, distinct page sections, typically used once per page — Header, Footer, Announcement, MailingListSubscribe, HistoryRail, ImpactTicker, CreditLists, FormatCards, StatementBlock, RowList, PullQuote, PortraitWall, StatTrio). Theme/data context lives in packages/topo/src/Theme and utils.ts (ThemeDataProvider, useThemeData/useTheme, usePageData).
Everything used to get dumped straight into Atom regardless of shape — by mid-2026 it held full page sections (a 623-line nav header, an animated stats ticker, a credits/press-logos block, etc.) alongside genuine primitives, which these got audited and moved out of. When adding a new component, don't default to Atom: a single-purpose primitive with no composition of sibling components belongs in Atom; a small reusable combination of a couple of Atoms (a layout wrapper, an icon+link combo) belongs in Molecule; a complete, content-specific section that would only ever appear once per page belongs in Organism. If a component's file has its own local sub-components and is only ever imported by one page, it's Organism-shaped even if it happens to live under 200 lines.
Adding/changing an Atom component touches up to three places:
packages/topo/src/Atom/<Name>/index.tsx— the component itself.packages/topo/src/Theme/vars/recipes/<name>.ts— its optional Chakra recipe (variants/sizes/slots), registered inrecipes/index.ts.apps/topo-gallery/src/stories/<Name>.stories.tsx— a Storybook story exercising its variants/sizes/states; this is what the gallery's Playwright test renders and screenshots.apps/topo-gallery/.storybook/preview.tsxwires up only the Chakra provider, color-mode, legacyThemeDataProvider, and brand fonts — deliberately not the full appThemeProvider(no Cognito Forms script tag, no cookie-consent banner) — a story that needsusePageData()/ThemeDataProvidercontext should supply its own decorator, not assume every app-level provider is present.- For localized copy required by a component's props, type it as
Message(from@codeday/topo/utils, aliased from Paraglide'sLocalizedString) rather thanstring— this makes passing a raw string literal a type error, enforcing "no copy outside the message catalogue" at compile time.
All sizes and colors must come from theme tokens, not hardcoded literals. Never write a raw "16px", "1.5rem", "50%", etc. into a style prop or css={{...}} block — use the matching Chakra token instead (mb={4} or mb="4" for the space scale, borderRadius="md" for radii, fontSize="lg" for font sizes, "1/2"/"full" for proportional widths, and so on). Bare numeric Chakra props like mb={4} already index the space token scale and are correct as-is — the rule targets string literal CSS lengths that bypass tokens entirely. Inside a css={{...}} block or a CSS string (clamp(...), linear-gradient(...), boxShadow), reference a token with {category.token} interpolation, e.g. fontSize="clamp({fontSizes.4xl}, 5.4vw, {fontSizes.6xl})" (see packages/topo/src/Theme/vars/recipes/button.ts for existing examples of this syntax). This repo's actual token scale is Chakra v3's defaults (space/sizes/radii/fontSizes/borders/letterSpacings/lineHeights/durations/blurs/aspectRatios/zIndex — see node_modules/@chakra-ui/react/dist/*/theme/tokens/* for ground truth, don't rely on memory) plus this repo's own sizes.container.{sm,md,lg,xl} (640/768/1024/1280px, packages/topo/src/Theme/vars/index.ts) and colors/fonts/gradients. If a design genuinely needs a size with no close existing token (e.g. a large decorative border-radius well past 4xl=32px) add a new token to the theme (or a semantic one, following the pattern in Theme/vars/index.ts) only with permission from the user. Generated code is exempt: don't hand-edit packages/topocons/src/Icon/* (SVG icon sizing like width="1em" is the standard icon-scaling technique, not a hardcoded size) or the token-definition files themselves (Theme/vars/colors.ts, darkColors.ts, gradients.ts, fonts.ts, cornerShape.ts, grain.tsx, utils.ts's defaultSpace/defaultRadii/defaultFontSizes tables).
Avoid transparent colors. They do not work well with dark vs light mode.
Never reference a colour token via a raw var(--chakra-colors-*) CSS custom property (e.g. embedded in a linear-gradient(...) string inside a css={{...}} block). @chakra-ui/react ships its own flat, non-mode-aware stock colour scale under the exact same --chakra-colors-<hue>-<stop> custom property name for every hue this repo also defines (gray/red/orange/yellow/green/teal/blue/purple/pink/cyan) — that stock value wins over this repo's semantic override at that property, in both light and dark mode (verified empirically: computed-style checks on gray-700, gray-50, red-600, yellow-200, and yellow-500 all returned Chakra's stock hex, never this repo's, regardless of .dark). This directly caused a shipped bug (Atom/Highlight's dark-mode band silently using Chakra's stock yellow instead of this repo's own). Use a Chakra token prop/recipe value instead (bg: "gray.50", which goes through Chakra's own build-time token resolution and correctly prefers this repo's override), or useColorModeValue reading colors.ts/darkColors.ts directly when a raw hex is unavoidable (composing a linear-gradient(...) string, for instance — see Atom/Highlight, Organism/CreditLists). Enforced by the codeday-colors/no-raw-chakra-color-var oxlint rule (.oxlint/).
Testing dark mode: this app has no manual light/dark toggle — Theme/Provider.tsx configures next-themes with storage="none", defaultTheme="system", followSystem; colour mode is derived entirely from the OS prefers-color-scheme media query. Toggling the .dark class on <html> by hand (or setting localStorage) updates CSS-based styling but does not update React state, so anything driven by useColorMode/useColorModeValue won't respond to it — a real test (Playwright or otherwise) needs to emulate the media query itself (e.g. Playwright's colorScheme: "dark" context/page option), not fake the class.
When editing Topo, refer to the Chakra documentation:
- Complete documentation: The complete Chakra UI v3 documentation including all components, styling and theming
- Components: Documentation for all components in Chakra UI v3.
- Charts: Documentation for the charts in Chakra UI v3.
- Styling: Documentation for the styling system in Chakra UI v3.
- Theming: Documentation for theming Chakra UI v3.
A component should either (a) be genuinely reusable — used in 2+ places, or built for a design-system tier (Atom/Molecule/Organism) where reuse is the point — or (b) contain real logic (data transforms, GraphQL fragments with fields it actually uses, state/effects, conditional structure). If a component is neither, don't create it — write the markup directly at the call site instead.
Don't wrap a single child call in its own component just to give it a name. A component whose entire body is one call to another component, fed only static values or m.*() message-catalog strings, and which is only ever imported in one place, adds a file and an indirection without adding reuse or logic. Inline it at the call site instead. Before extracting a new one-off "section" component in apps/www/src/components/** (e.g. for a page section), ask whether it's more than a single-use pass-through — if not, write the JSX directly in the page.
A section component renders the thing, not the section. The page owns the section lead — the StatementBlock, or Heading as="h1"/"h2" plus its intro Text — and renders it directly in the page file (apps/www/src/pages/**), then renders the component beside it. The component renders only what sits below the lead: the grid, the cards, the rows, the ticker. This holds whether the heading would arrive as a prop or be hardcoded from m.*() inside the component — both are the same bundling. Heading level is the test: h1/h2 title a page or a section and belong in the page; h3 and below title an item inside the thing and belong in the component. Nothing under apps/www/src/components/** renders <StatementBlock>, <Heading as="h1"|"h2">, or <Box as="h1"|"h2"> — the codeday/no-section-lead-in-component oxlint rule (.oxlint/) enforces this and runs on every edit; components/Page/** is the sole exception (the document's own hidden h1). The section's outer wrapper (<Section>, <Box as="section">) is page layout too, so the page renders it.
Corollary: don't bundle unrelated concerns into one component's props or body. A component describes one thing — not "the thing, plus a heading above it, plus a call-to-action below it" assembled for a single caller. If a caller needs a footer, aside, or CTA next to the thing, the caller composes it in the page (reusing ActionLink, StatementBlock, Button, etc.) rather than the component growing a lead/aggregate/variant-style knob to render or suppress it. lead: { heading, body } (formerly on ImpactTicker/RowList/FormatCards) and the variant="mixed" aggregate footer (formerly inside Index/Impact) were both this anti-pattern and have been removed; don't reintroduce either shape. The reverse still applies: a prop pattern deliberately shared across sibling Atoms for visual consistency is intentional, tested reuse — don't split it into ad hoc per-caller markup.
If removing the lead leaves a component with no logic, inline it. Once a section component stops rendering its heading, ask again whether what's left earns a file: static markup fed only by m.*() strings and used once belongs directly in the page (see "Don't wrap a single child call"). A component earns its file by having real logic (data transforms, GraphQL fragments, state/effects, a loop or conditional over data) or genuine reuse. When auditing: grep -r the component name for usage count, and read for logic beyond forwarding props.
Pages router under src/pages. Notable dynamic routes:
e/[calendarId]/[eventId]— event pagesf/[slug]— generic form pages (Cognito forms)help/[program]/[audience],help/article/[article]— help centerlegal/[policy],email/[slug],m/[...slug],doi/[...doi](anddoi/crossref/[...doi]) — DOI resolutionvolunteer/[region],volunteer/labs,volunteer/share
src/proxy.ts runs on every request except /_next, API routes, and files with an extension — it only handles locale-prefix redirection.
Icon source SVGs live in the packages/topocons/svg git submodule (git@github.com:codeday/TopoconsSvg.git), not in this repo directly — run git submodule update --init if icons are missing. Icons are generated (svgo optimize → create-chakra-icons → post-processing replacements) via the package's pregenerate-icons/generate-icons/postgenerate-icons scripts; don't hand-edit generated files under src/Icon.
- Import order is enforced by
oxfmt(runpnpm format:fixrather than hand-ordering) — three blank-line-separated groups: external packages (alphabetized by package name), then@/-aliased imports, then relative (./,../) imports. - Use comments very rarely. Comments should only be used where information cannot be easily inferred from the code.
- Avoid
any/untyped props on new code — the pre-migration components still using[key: string]: any-style props are legacy, not the pattern to copy.
apps/www:pnpm --filter @codeday/www testruns Playwright (apps/www/tests/*.spec.ts) against a production build — the config does not start the server for you (a full CMS-backed SSG build takes minutes); build and start it yourself first (pnpm --filter @codeday/www build && pnpm --filter @codeday/www start -- -p 4400, matchingapps/www/playwright.config.ts'sbaseURL).apps/topo-gallery:pnpm --filter @codeday/topo-gallery testbuilds the static Storybook (storybook build -o dist) and runs Playwright (apps/topo-gallery/tests/*.spec.ts) against it — this is the mechanism that catches visual/rendering regressions inpackages/topocomponents.packages/topo:pnpm --filter @codeday/topo testruns Vitest (src/Theme/vars/*.test.ts— color/grain/gradient math). Not wired into the rootpnpm runscripts orturbo.json, so run it directly with--filterrather than expectingpnpm testat the repo root to reach it.