This file provides guidance to AI agents when working with code in this repository.
When making changes to the codebase, please also update this file as needed to reflect any new patterns, tools, or workflows that agents should be aware of. Especially when refactors or architectural changes are made, please update the "Architecture" and "Tech Stack" sections to reflect the new structure and technologies used in the project.
Static website for Processing Community Day (PCD) 2026 — a global map of events. Built with Astro 5 (static output) + Vue 3 + Leaflet. No database or application backend. Event content is static; the event-list sidebar loads the public Discourse feed at runtime through a fixed proxy.
The Astro project root is pcd-website/. All build commands run from there.
All from the pcd-website/ directory:
npm install
npm run dev # localhost:4321
npm run build # production build → dist/
npm run preview # preview production buildThere are currently no lint scripts configured.
See TEST.md for the full test inventory and coverage notes.
node --test .github/scripts/event-issue-helpers.test.mjs
node --test .github/scripts/process-new-event-issue.test.mjs
node --test .github/scripts/process-edit-event-issue.test.mjs
node --test .github/scripts/plus-code.test.mjs
node --test .github/scripts/zines.test.mjs
node --test .github/scripts/zine-build.test.mjs
node --test .github/scripts/process-new-zine-issue.test.mjs
# Requires npm run build from pcd-website/ first:
node --test .github/scripts/data-json.test.mjs
node --test .github/scripts/og-images.test.mjsNeed to run the tests end-to-end? ./scripts/run-tests.sh executes the helper, event and zine intake, plus-code, and zine metadata suites; runs the zine fixture build; builds the Astro site via npm --prefix pcd-website run build; and then runs data-json.test.mjs in sequence. Run this script from the repo root after installing dependencies so you get the full battery of checks in one shot.
No install needed — open-location-code is already available at pcd-website/node_modules/.
- Tests live alongside the code they test in
.github/scripts/. - Use
node:test+node:assert(built into Node — no test framework needed). - Build-test assertions for shared icons should allow extra SVG attributes, attribute reordering, and either self-closing or paired path tags; scope checks to the relevant control.
- Mock
globalThis.fetchwithbeforeEach/afterEachfor any test that triggers a Nominatim call; always restore the original after each test. - When adding new functions to
.github/scripts/, extract pure/testable logic into a separate*.mjsmodule (as was done forplus-code.mjs) so it can be imported without triggering the main script's top-level side effects.
-
Organizer Kit pages use a wider shell at 80rem and above to shift the article left and place “On this page” in a 16rem right column. The single TOC stays sticky below the header with bounded scrolling; below that breakpoint it remains inline above the content. Pages without a TOC retain the article width cap.
-
Astro owns routing, layouts, metadata, and static content pages.
src/layouts/BaseLayout.astroprovides the document shell;MapLayout.astro,SiteLayout.astro, andDocsLayout.astroprovide the map, standard content, and Organizer Kit shells respectively. -
The Organizer Kit introduction lives in
src/content/organizer-kit/getting-started/introduction.md;/organize/redirects to its canonical/organize/getting-started/introduction/route. -
The Organizer Kit's
partners.mdsupplies the “Discover our Partners” overview introduction.PartnerCards.astrorenders published Organizer Kit entries withpartnerCardfrontmatter, sorted byorderthen title. Each partner detail page insrc/content/organizer-kit/partner-pages/owns its card'slogo(relative image path validated by Astro),summary(one paragraph leading with the participant or organizer benefit, followed by a short partner description); its title and route supply the card name and link. The same data supplies the overview's shared Markdown and the detail-page logo. Detail pages usehideFromNav: trueto stay published while being omitted from both shared navigation trees and prev/next links;draft: truestill excludes a page from build output and cards. Partner card styles live insrc/styles/prose.css. -
Partner detail routes pass their logo to
DocsLayoutthrough its optionalheadingLogoprop; the image stays inside the h1 with the page title as alt text. Its optionalbackLinkprop renders the sharedBackButtonabove the heading in place of the eyebrow; partner pages link back to/organize/partners/. -
Vue handles the interactive map UI as
client:only="vue"island components. Map-specific interactive features belong in Vue; static site and Organizer Kit pages belong in Astro and Markdown content collections. The map filter drawer inMapView.vuefilters clustered markers by date visibility (Future, Past, and Other/no date), format, and activity type; checkbox choices are ORed within a group and filter groups are ANDed together. Date categories are independent eye-icon toggle buttons witharia-pressed; all three may be hidden. All three start visible, “Show all” restores only date visibility, and clearing filters restores all categories and clears other criteria. Undated events belong to Other, while ongoing events remain Future through their final local calendar day. Whenever the last category is hidden,EyelidBlink.vuemounts a standalone CSS animation teleported over the full viewport: concave radial-gradient masks curve the top lid down at the sides and the bottom lid up at the sides; eyelids close for 420 ms, hold for 1500 ms, and reopen for 480 ms. The last hidden category is restored as reopening begins. The restored eye is already dilated as the screen reopens. After the full-screen blink completes, it holds dilation for 180 ms, then plays a 700 ms blink and squint while its pupil shrinks from 1.35× to normal size and its inward-thickened stroke recedes from a filled disk to the normal ring; this follow-up runs only in the visible filter drawer and is disabled for reduced motion. The animation blocks covered-screen interactions, allows Escape to finish early, finishes when the tab is hidden, cleans up timers/listeners, and uses an opacity fade for reduced motion. No canvas or p5.js dependency is used.
The map filter drawer slides in and out from the left using the shared panel transition, with reduced-motion support. At 720px and below, the shared panel-opening easing stops without overshoot for both the filter drawer and event detail panel; desktop retains its overshoot. The filter drawer and event detail panel are mutually exclusive: opening either closes the other, with focus moving into the newly opened panel. Opening the filter drawer also closes any open Leaflet popup. While open, it hides the filter and language controls. On desktop, a double left caret closes the filter drawer from the right side of its header; the event detail panel uses a double right caret on the left side of its sticky header, with Share on the right. These replace the former edge tabs. Both close controls use matching inline SVG carets spaced 8 SVG units apart in an 18×16 viewBox. At 640px and below, the filter drawer fills the width and hides its desktop close button. On all screen sizes, the compact “n of m” count floats in a rounded pill at the bottom right above the footer actions. The options scroller has bottom padding so its final options can scroll clear of the pill. On all screen sizes, the footer places secondary “Clear Filters” on the left and primary “Done” on the right; Done closes the drawer and returns focus to the filter button while preserving selections. Zero results replace the desktop/mobile count with a localized, bold purple “No matching events” live status. On mobile, “Filter events” sits on the left of the top row beside the right-aligned “Back to map” button with a right-pointing arrow; desktop omits this text button. The date visibility legend reads “Show/hide events” beside Show all on every screen size. At 720px and below, the event detail panel shows Share on the right and “Back to map” on the left instead of the double-caret close button, matching this purple, semibold, 44px-minimum-height style with a left-pointing arrow. The filter footer and header row stay outside the flexing options scroller, with safe-area footer padding. Map-page root overscroll is suppressed, while the options scroller contains scroll chaining and retains local rubberbanding. Date controls offer Show all and individual visibility toggles. Individual labels describe the next action: “Hide” for visible categories and “Show” for hidden categories, localized in every supported language.
/events/ is an Astro page with build-time event cards and a bundled browser script for search, confirmation filters, and accessible Upcoming/Past/Other events tabs. Cmd+K or Ctrl+K focuses the event search and selects its current text; a platform-specific shortcut indicator (⌘K or Ctrl+K) appears inside the input at the right edge and is hidden on mobile. Its English copy follows the static site pages. src/lib/event-list.mjs contains shared pure filtering and date-heading logic; src/styles/event-list.css owns its presentation. Dates use the visitor's local calendar day and refresh while the page stays open. Multi-day events remain upcoming through their end date and group under Today while ongoing; undated events appear exclusively in the “Other events” tab during normal browsing. A non-empty search keeps the selected tab as its primary result set, including that tab's empty state, then shows matches from other date categories in a labeled section below; confirmation filters still apply to every match. Date headings sit above card groups on a vertical timeline. Cards show shared TBD pills from base.css: undated events show “Date to be announced” above the title, or “Date and location to be announced” when both are missing, without a duplicate missing-venue row. Dated events missing a location show “Location to be announced” in the venue row using the date-and-location color; this pill is hidden for past events, including when the visitor’s local date advances while the page is open. Online events have a known location. Cards show date and time above the title; missing times use a light blue (#81b4d9) “Time to be announced” pill with white text and are omitted for past events. Upcoming sorts ascending by date; Past sorts descending by date so the most recent events appear first; Other events sorts by event name. The filter-16 icon opens a labeled native disclosure at the right of the tab bar; search sits below the tabs. Its dropdown applies time, location, and description criteria directly, with no master toggle. Date availability is controlled by the tabs, so it has no filter checkbox. All criteria start unchecked; clearing filters unchecks them. Selecting any criterion excludes placeholders and requires every selected field, and treats a location as a physical address or online event URL. This UI definition does not change /data.json eligibility.
src/components/SkeletonLoader.astro provides event-card and forum-thread placeholders, with reduced-motion support. Both loaders wait 150 ms before appearing, and completion cancels pending display. Forum placeholders share row, metadata, and avatar styles with real threads to preserve spacing. Event skeletons cover client initialization only; static events remain available without JavaScript and return on window load if initialization fails. Forum skeletons cover each runtime request, including retries, and clear on success, empty responses, and errors.
src/components/EventListSidebar.astro renders the submission link (tracked as Event List Submit Event Button Click through the shared analytics helper), a 3:2 rectangular CARTO map preview linking to the map, with matching centered crops for the tile and event dots to preserve geographic proportions, and recent PCD forum threads. Forum data is fetched by the browser (never at build time) from /api/pcd-forum. netlify.toml proxies that exact route to the public Discourse PCD tag JSON feed; astro.config.mjs supplies a matching Vite development proxy. Plain astro preview does not run this proxy; use npm run dev or Netlify to verify the live feed. No API key is required. Keep this a fixed destination, not an open proxy. src/lib/forum-topics.mjs validates the response and sorts by bumped_at (fallback last_posted_at) so pinned topics cannot override activity order. Thread metadata includes up to five overlapping participant avatars, replies (posts_count - 1, matching Discourse’s topic list), and relative activity timestamps refreshed every minute with exact dates in tooltips. Avatars resolve from the response’s users and posters, with initials as a fallback. Titles are inserted as text, with loading, empty, error, retry, and direct-forum-link states. The sidebar moves below the list on small screens.
EventForumThread.vue renders forum metadata with a slotted hero button when forum_thread_url exists. The Forum Thread button matches the info modal host button: transparent background, primary purple text and 1px border, including forum-only events. Hero actions sit directly in the panel without a surrounding box, with a plain localized heading (“Join this PCD” for upcoming events, “More about this PCD” for past events in English), below the date/venue info card and above “About the event”. When both event-page and forum URLs exist, the hero shows equal-width Event Page and Forum Thread buttons (stacked at 640px and below) with metadata below the forum button; forum-only events show the button beside its metadata, wrapping on narrow screens. Map popups show missing times as a compact blue time-tbd-pill with localized “Time TBD” text, hidden for past events. Missing times in the detail panel use the shared blue (#81b4d9) time-tbd-pill with white localized “Time to be announced” text, hidden for past events. The venue/address follows the date row. The hero and About headings share one typography rule. Info-card address links and disclaimer links share colors; OpenStreetMap links and the calendar action use dotted underlines. The localized “About the event” section follows with the description and icon-free activity tags (outside the expandable description), then the rounded minimap. The minimap’s top-right Get directions button requests fresh browser geolocation on click, then navigates in the same tab to OpenStreetMap directions with origin and event destination. Denied, unavailable, or timed-out location requests open directions with only the destination. The button disables repeated requests while locating, and closing/changing the event or unmounting ignores pending callbacks; keep the button outside the map’s aria-hidden region and above its interaction shield. Contact email appears as a mailto link in the disclaimer using i18n named slots; Foundation events retain a contact sentence without the independence disclaimer. The detail title uses dark grey and its eyebrow stays muted grey. Date and venue icon badges have no colored fill, with purple-300 borders and purple-900 text/icons; the calendar month strip uses purple-700 with white text. The info card uses 40px outlined icon boxes, a localized month/day date badge, stacked primary and secondary date/location text, and 16px row spacing. Known times and the missing-time pill sit to the right of the date and wrap on narrow screens. The purple calendar action sits underneath the date/time line with a left-aligned dropdown; it is hidden for undated events and after the end date (or start date for single-day events), using the visitor’s local calendar day. Past events instead show a shared yellow .past-event-pill labeled “Past event” to the right of the date in the event panel and map popup, using the time-TBD pill layout. A link-styled “Hide past events” button sits to the right of the pill in each info card. Both buttons turn off Past visibility while preserving other visible date categories and other filter selections; when Past was the only visible category, they trigger the same blink and restore Past as it reopens. They close the event panel/popup and move focus to the filter button. Visitors can restore past events from the date filter drawer or by clearing filters. Forum metadata includes participant avatars, replies, and relative activity. Its compact 28px-high metadata slot keeps loaded data and skeletons on one row and reserves space immediately (including for retries and failures), while avatar/text skeletons appear after 150 ms using the event-list pulse and reduced-motion behavior. It omits the thread title and loads participants, replies, and activity through /api/pcd-forum-topic/:id (fixed Discourse origin; matching Netlify and Vite proxies). forum-topics.mjs validates topic URLs and payloads. Requests time out after ten seconds and abort on unmount; failures preserve the visit link and offer retry. Plain Astro preview does not proxy these requests.
Event data lives in src/content/events/<event-id>/:
metadata.json— event fields (id, uid, name, location, dates, organizers, etc.)content.md— markdown body (frontmatter must includeid:anduid:)uid:values in frontmatter must always be quoted (uid: "abc1234") because unquoted hex strings like1e46977are parsed as scientific notation by YAML, destroying the value.
src/lib/nodes.ts loads all events at Astro build time using import.meta.glob() + getCollection('events'), validates plus codes with OpenLocationCode, decodes lat/lng, and returns a sorted Node[] array passed as props to <MapView>.
Zine cards live in src/content/zines/<slug>/ and the library grid at /organize/zines/zine-library/ is built dynamically from every */index.md, sorted by its required numeric order frontmatter. Every published zine has index.md, metadata.json, an optional root-level cover image, and a downloads/ directory. Metadata uses a non-empty generic downloads array: local records are { file, file_size, role? } (where file is a basename in downloads/), and manually maintained external records use { url, filename, file_size, role? }; roles are optionally reader-order or print-ready. Metadata also requires an event-style intake object with issue_number, submitted_by_github, submitted_date, and maintainer_notes; the zine detail page links the submitted date to the issue and the username to the submitter's GitHub profile. Root image imports are covers only; opaque downloads import from */downloads/ using ?url&no-inline, while image downloads are emitted from that same directory via Astro's image asset helper. Set optional created_by_url in metadata to an http(s) page for the credited creator(s); the detail page links the author name when it is present. Optional tags and languages are arrays of non-empty strings. Optional activity_type describes how the guide is used, while zine_format describes publication structure; do not combine them under a generic format field. Cover metadata uses { "src": "cover.png", "alt": "..." }. Zines must use index.md (not content.md) so Astro's glob loader makes the entry id equal to the folder slug. A zine without a cover renders a grey title fallback in the library and detail page.
The Zine Intake workflow produces final content directly in src/content/zines/<slug>/. Submitters attach files only through GitHub issue-form fields: exactly one reader-order PDF and one print-ready PDF, optional PNG/JPG/JPEG/WebP cover, and optional PDF/ZIP/image/TXT/MD/CSV/JSON supplements. The limits are ten files total, 25 MB per non-image, 10 MB per image, and 50 MB total. Attachment URLs must be GitHub uploads; both Markdown upload links/images and GitHub-generated HTML <img> embeds are accepted. When an embedded image URL has no filename, the workflow derives its extension from the validated file signature. The workflow follows only approved GitHub object-store redirects, validates signatures/UTF-8/JSON content, assigns title-derived cover alt text, and writes original filenames (with lowercase extensions) into downloads/. Cover and download filenames are separate namespaces, while download filenames must be case-insensitively unique. It builds the Astro site before force-updating the automation-owned automation/new-zine-<issue-number> review branch. An invalid later edit preserves the last valid PR commit, marks the PR needs changes, and a subsequent valid edit restores needs review; maintainer notes are retained in the PR body. Review PDFs for selectable text, logical reading order, document title and language, tagged headings where possible, alt text, and at least one screen-reader-friendly reading-order version.
The global Markdown pipeline runs rehype-table-wrapper and rehype-heading-anchors, which respectively wrap rendered tables in .table-wrapper and add permalink anchors to h2–h6. Their presentation styles live in the shared prose.css layer, scoped to both .prose and .docs-prose, because both plugins apply to all Markdown collections. For a styled caption immediately after a Markdown image, use <span class="prose-figure-caption">…</span> on the next line; it supports normal Markdown links for attribution. Embed videos hosted on Google Drive with a raw <iframe class="prose-video" src="https://drive.google.com/file/d/<id>/preview" title="…"> (styled in prose.css with a 1000:730 aspect ratio); the Drive file must be shared as “Anyone with the link”.
If a plus_code is invalid or too short, the build fails with a clear error — this is intentional.
"Confirmed" events in data.json: An event is included in the /data.json feed if it is present in loadNodes() and has no placeholder: true flag. There are currently no other event states (draft, hidden, etc.). If new states are added in future, the filter in src/pages/data.json.ts must be updated explicitly.
-
Favicons:
public/favicon.svgis the source artwork and primary favicon inBaseLayout.astro. The preceding ICO link provides a fallback using the same artwork at 16, 32, and 48 pixels. Both URLs use a?v=query derived from the SVG's SHA-256 content hash, so artwork changes automatically bust browser caches on the next build while unchanged artwork keeps stable URLs. After changing the SVG, runnode scripts/generate-favicon.mjsfrompcd-website/to regeneratepublic/favicon.icousing the existing Sharp dependency. -
Organizer Kit OG images:
src/pages/og/[...path].png.tsprerenders 1200×630 PNGs for published Organizer Kit pages and all zines.src/lib/og-image.mjsuses Satori for layout and Sharp for rasterization, with local@fontsource/space-groteskWOFF fonts (Latin and Latin extended),src/images/og-background.png, and the existing black PCD logo. No runtime adapter or build-time network requests are needed. Images live at/og/organizer-kit/<entry-id>.pngand/og/zines/<id>.png;ogImagePath()keeps metadata URLs base-aware./organize/shares the introduction image. The library shows up to the first five available covers (OG_GALLERY_COVERS, inloadZines()order) as two straight, unrotated columns of 232×330 rounded cards running off the top and bottom of the canvas. The left column sits half a card lower than the right; the first cover is the middle left card, the second and third fill the right column fully in view, and the fourth and fifth peek in above and below the left column (GALLERY_SLOTS). Library cards have rounded corners, a hairline ring, and a soft outer shadow but no inner shadow; individual zines show one 448px-wide rounded cover (24px radius, no shadow) inset 32px from the top, right, and bottom edges, cropped to fit, and missing covers use the title layout.roundedPhoto()in Sharp resizes each cover and bakes the corner rounding into the pixels so corners stay antialiased.Zine.cover.sourcePathis a build-only absolute source path used by the renderer. Cards use a bottom-anchored title block. Regular Organizer Kit pages passbadge: 'Organizer Kit', rendered as a large pill at the top right (opposite the logo), andeyebrow, plain purple text above the title: the page'ssection, the Partners overview title (“Discover our Partners”) for partner pages, or nothing. Partner detail pages passtitleImage(the card's logo, resolved from itslogofrontmatter path because Astro's SVG metadata has no source path) and show it, scaled to fit 640×150, in place of the title text; its bytes are part of the cache key. Zines and the zine library have no pill or eyebrow (the library’s covers fill the top right). Title typography: Space Grotesk title (the pill is primary purple#5503a4in semibold; the title is dark grey#292929in the lighter Regular 400 weight,TITLE_WEIGHT, so it reads as distinct from the bold logo; zine cards also show a 36px “by ” byline under the title (muted “by”, semibold purple name), fromcreated_by, passed as the renderer’sauthoroption and included in the cache key, and each zine page’s meta description is “By .”), with the logo top left and no footer URL. The logo width scales with the final title font size ( LOGO_PER_FONT, capped at 320px and the title column width) and is reduced as needed to keep 32px clear of the measured title block, so short titles get a large logo and long or wrapped titles a smaller one; the renderer lays out twice (once to fit the title, once with the final logo size). Titles shrink until the eyebrow, title, and byline fitMAX_BLOCK_HEIGHT. The route callsrenderOgImageCached(), which stores finished PNGs innode_modules/.cache/pcd-og/<template-hash>/<content-hash>.png(override withPCD_OG_CACHE_DIR). The content hash covers title, eyebrow, badge, gallery flag, and cover file bytes; the template hash coversog-image.mjs, the background and logo artwork, font files, and the locked satori/sharp versions, so editing the design invalidates everything and stale template directories are pruned. Cache read/write failures fall back to rendering. On Netlify the cache only helps if the build cache restoresnode_modules/.cache; an empty cache just renders everything. Keep homepage/event OG artwork on/og-image.png. -
PCD logo assets: Keep the 2026 logo variants in
src/images/PCD_2026_logo/. The shared header imports its line and mini white SVGs from this folder; do not duplicate them directly insrc/images/. -
Info modal banner: Homepage and canonical event routes pass the imported
src/images/og-background-withPhotos.pngURL to the map island;InfoModal.vueimports the same fallback. The banner uses a 1200:630 photo background with the black PCD 2026 SVG at top left andprocessing_foundation_logo.svgat bottom left, positioned proportionally to match/og-image.pngwithout its button graphic. The banner close control is a white circle with a purple cross, revealed on banner hover or keyboard focus; the body buttons and Escape also dismiss the modal. SVG files used as image URLs must include the SVG XML namespace so browsers can render them as standalone images. Homepage/event social previews still use the base-aware/og-image.pngURL frompublic/og-image.png. Other imported and content images stay insrc/for asset processing;public/holds assets requiring stable, unprocessed URLs. -
Leaflet CSS is loaded via
<link>tags inMapLayout.astro, NOT via JS imports — avoids SSR issues since MapView isclient:only="vue". -
Mobile Organizer Kit navigation lives in the shared header below 641px: “Organize” becomes a native “Organizer Kit” disclosure populated by
getKitNav(), with nested section disclosures and 48px touch targets. The separate docs navigation is hidden at that breakpoint; tablet and desktop navigation remain in the docs shell. Keep both navigation trees sourced fromgetKitNav(). -
Docs page share actions render
ShareMenu.vuewithclient:loadso the control is present in the initial HTML and hydrates immediately. Keepvue-i18nin Vite'sssr.noExternallist so this server render can resolve its compile-time feature flags. -
Browser storage must be accessed through
src/lib/safeStorage.mjs; directlocalStorageproperty access can throw under iOS privacy restrictions and abort Vue island hydration. -
Transient feedback uses
Snackbar.vue. Pass a translated message and close label, and mount each new notification with a uniquekeyso its dismiss timeout and circular timer restart. Snackbar close controls and modal close controls share the global.modal-close-buttonstyle frombase.css. -
open-location-codeexports{ OpenLocationCode }as a named export — usenew OpenLocationCode()(not static methods). -
leaflet.markerclustercauses a circular dependency warning, suppressed viarollupOptions.onwarninastro.config.mjs. -
Initial map view: Open at the default world view (center
[20, 10], zoom3) without requesting the visitor's location. Event deep links still focus their event. The map-pin “Find PCDs near me” button requests browser geolocation only on click and centers the map at regional zoom 7. It disables repeated requests while locating and reports denied/unavailable locations through a translated Snackbar. On mobile (600px and below), this button is centered 24px above the bottom safe area, with the info button at bottom right. -
Past event markers: Individual map dots use light grey (
#b0b0b0) after the event's final day in the visitor's local time, usingisPastEvent()fromsrc/lib/format.ts. Clusters containing only past events use the same grey through--color-event-past; mixed clusters retain the confirmed purple. Cluster colors are recomputed from their child markers as filters change. All individual markers are 26px circular dots with a drop shadow. Confirmed events with a date and known physical/online location show a white calendar icon; events with date and/or location TBD (or placeholders) show a white question mark unless they are past events, which remain plain when their details are incomplete. This status icon also applies to online events. Marker symbols importsrc/icons/calendar.svgandquestion-mark.svgas raw SVG; these use the supplied Octicon paths, with the question icon’s outer circle removed. Shadow and active-scale rules target only the marker’s outer SVG so nested symbol SVGs are not scaled or shadowed twice. Upcoming dots use purple (--color-event-confirmed,#5503a4), and date-only TBD dots use purple (#7a3eb6). The shared--color-event-undatedtoken also colors the rounded “Date TBD” pill in the map popup and event detail panel. If both date and location are TBD, the marker and combined popup pill use light purple (--color-event-date-location-tbd,#9b6ac7); the popup pill reads “Date and location TBD”, and the popup omits the duplicate location row. The detail panel instead shows separate “Date to be announced” and “Location to be announced” pills beside their respective icons, using the undated and date-and-location colors respectively. Popup TBD pills sit in the date row below the title/byline with extra vertical spacing. TBD pill text is white for contrast against these colors. Online events retain the date-only undated state because their location is online. Marker colors and active outlines target.marker-shape, leaving the white.marker-symboluntouched. Future, ongoing, and undated event dots stack above past event dots. Within each group, individual dots stack by the number of known date, location, and start-time fields, with equal counts prioritizing date, then location, then time. From top to bottom: all three; date + location; date + time; location + time; date only; location only; time only; none. Online events count as having a location. This applies to past events and placeholders too. A 32-point non-past bonus exceeds the maximum information score of 31; the combined score sets LeafletzIndexOffsetin 1000-point increments so overlapping dots retain this priority independent of insertion order. -
Deep linking:
?event=<id-or-uid>query param auto-opens the event detail panel. Both the slugidand the shortuidare accepted. -
Event UIDs: Each event has a stable 7-char hex
uidstored in bothmetadata.jsonandcontent.mdfrontmatter. UIDs never change after creation. Three static URL formats are generated per event:/event/<slug>(redirects to canonical),/event/<slug>-<uid>(canonical, has OG tags, redirects into SPA), and/event/<uid>(short form, redirects to canonical). The canonical URL is what the share button copies.
Every event shows a “Processing Community Day” eyebrow above its title in both NodePanel.vue and Leaflet popups (src/lib/popup.ts), using the shared panel.event_eyebrow translation key. Past-event popups and the event detail panel place a yellow “Past event” pill and the “Hide past events” link beside the date inside the info card, wrapping on narrow screens. They omit physical/online addresses, descriptions, activity tags, and the separator above the details button; venue names and the details button remain available. This compact presentation applies only to map popups, not the event detail panel. Visible popup activity tags fit into at most two rendered rows, including a “+N” overflow indicator. fitPopupActivities() measures wrapping on popup open, after fonts load, and when the tag container width changes; it preserves tag order and counts hidden tags. The overflow indicator exposes the full activity list through native title hover text and an accessible label, without a custom tooltip or popover. Its resize observer is disconnected when the popup closes or the map unmounts.
Desktop double-caret close buttons sit 8px from their panel edge: the filter header uses 8px right padding and the event detail header uses 8px left padding. The opposite padding and mobile spacing remain unchanged.
The map filter header (.filter-panel-header) and event detail header (.panel-back-row) use a 1px bottom border with no drop shadow. Both use symmetric padding, vertically centered contents, and no flex shrinking; they can grow to fit wrapped text. Above 720px, both have a 56px minimum height and 4px vertical padding, with 36px double-caret buttons and 1rem icons. At 720px and below, the headers retain their 64px minimum height (--header-height) and original padding and control sizes. The “Filter events” heading uses a compact 1rem font size and 1.3 line height.
| File | Role |
|---|---|
src/components/EyelidBlink.vue |
Standalone CSS eyelid animation, reopen/complete events, reduced-motion fade, and lifecycle cleanup |
src/components/MapView.vue |
Leaflet map, marker clustering, filter drawer, keyboard shortcuts |
src/components/NodePanel.vue |
Slide-in event detail panel with minimap, calendar links, share button |
src/components/LanguageSwitcher.vue |
Language selector dropdown in the top bar |
src/components/BackButton.astro |
Reusable button-style link for navigating from a detail page back to its parent listing |
src/components/DocsPageActions.astro |
Reusable share dropdown (Markdown, permalink, QR code) and GitHub edit action used by Organizer Kit and zine detail pages |
src/components/ShareMenu.vue |
Shared Markdown, permalink, and QR-code menu used by docs actions and the event detail panel |
src/components/Snackbar.vue |
Reusable auto-dismissing feedback snackbar with an accessible close control and circular countdown |
src/directives/touchActivate.ts |
Direct-touch activation for buttons affected by iOS WebKit's unreliable synthesized clicks |
src/components/ZineDownloads.astro |
Renders zine download rows with a button, filename, and human-readable file size |
src/components/Header.astro |
Shared fixed site header and primary navigation |
src/components/Footer.astro |
Shared site footer, policy links, community links, and sponsors |
src/layouts/BaseLayout.astro |
Shared HTML document shell and metadata |
src/layouts/MapLayout.astro |
Map-page shell and Leaflet stylesheet links |
src/layouts/SiteLayout.astro |
Standard static content-page shell |
src/layouts/DocsLayout.astro |
Organizer Kit shell with sidebar, page TOC, and footer |
src/lib/analytics.ts |
trackEvent() Fathom helper + AnalyticsEvent type + event-name constants |
src/lib/carto.ts |
Adds the optional local-development CARTO API key to basemap tile URLs |
src/lib/safeStorage.mjs |
Guards localStorage reads and writes so browser privacy settings cannot abort hydration |
src/lib/nodes.ts |
Node interface + loadNodes() |
src/lib/format.ts |
formatDate(), formatDateRange(), calendarLinks(), etc. |
src/lib/popup.ts |
Leaflet popup HTML generation (makePopupContent()) |
src/styles/base.css |
Shared design tokens, reset, typography, focus, and skip-link styles |
src/styles/map.css |
Map layout, controls, popup styling, and Leaflet overrides |
src/styles/prose.css |
Standard static content-page presentation styles |
src/styles/docs/*.css |
Organizer Kit's modular Just-the-Docs-derived tokens, layout, navigation, and Markdown presentation styles |
src/lib/rehype-table-wrapper.mjs |
Markdown rehype plugin that wraps rendered tables for horizontal scrolling |
src/pages/data.json.ts |
Static JSON feed of confirmed events, served at /data.json |
src/pages/activity-guide/[id].astro |
Standalone zine pages |
src/lib/zines.ts |
Build-time zine loader and topic-slot mapping |
src/lib/zine-metadata.js |
Zine schema and pure metadata/asset validation |
src/content.config.ts |
Astro content collection Zod schemas for events, legal pages, Organizer Kit, and zines |
src/config.ts |
Global static constants (contact email, etc.) |
src/i18n/index.ts |
Creates the vue-i18n instance and exports syncLocale() |
src/i18n/localeState.ts |
Reactive currentLocale ref, browser detection, localStorage persistence |
src/i18n/vuePlugin.ts |
Astro appEntrypoint — installs vue-i18n on every Vue island |
src/i18n/locales/en.json |
Source-of-truth translation file (all keys must exist here) |
src/i18n/locales/*.json |
Per-language translations (es, de, fr, pt, zh-TW, zh-CN, ja, ko) |
The site uses vue-i18n@11 with 9 supported locales: en, es, de, fr, pt, zh-TW, zh-CN, ja, ko.
vue-i18nis installed globally viaastro.config.mjs→vue({ appEntrypoint: '/src/i18n/vuePlugin' }).- Locale detection order: localStorage (
pcd-locale) →navigator.language→'en'. - The active locale is a reactive singleton (
currentLocaleref inlocaleState.ts) shared across all components.
- Always add the key to
en.jsonfirst. It is the source of truth and the fallback for all other locales. - Add the same key to every other locale file in
src/i18n/locales/. Missing keys fall back to English silently. - In Vue components, use
const { t, locale } = useI18n()and replace hardcoded text witht('key'). - In non-component TS files (e.g.
popup.ts), usei18n.global.t('key')imported fromsrc/i18n/index.ts. - Pass
locale(orlocale.valueas a string) toformatDateRange(),formatDate(), etc. for locale-aware date formatting.
Event data coming from content files — event_name, details_text, city, country, organization_name, organizer names, URLs — must never be wrapped in t(). Only static UI strings get translated.
Non-English locales use "Events" (not "Nodes") in list/dialog labels, since "Nodes" is a technical term that doesn't translate naturally.
Use src/config.ts for static, non-secret values that are referenced across multiple files or are likely to change. Import from it rather than hardcoding inline.
Store here:
- Contact emails (e.g.
PCD_EMAIL) - Stable URLs referenced in UI (e.g. a feedback form link)
- Project-wide constants (e.g. site name, org name)
Do not store here:
- Environment-specific or secret values — use
.envwithimport.meta.envfor those - Anything already defined in
astro.config.mjs(e.g. base path) - Component-local constants that aren't shared
For local map development, pcd-website/.env may define PUBLIC_CARTO_API_KEY. During npm run dev, all CARTO raster tile URLs append it as the key query parameter; production builds do not embed it.
-
Both map panels open with the shared
--transition-panel-open(350ms with subtle overshoot), retain--transition-panelfor closing, and disable transitions for reduced motion. Caret nudges wait another 350ms after the opening transition. Absolutely positioned background pseudo-elements extend 32px beyond each panel's outer viewport edge with a 1px overlap to cover overshoot without changing layout; keep panel overflow visible on mobile too, with content scrolling contained by the inner scroller. -
Map filter and event detail header close carets use
--color-primary(strong purple), including on hover, with a 0.75-unit SVG stroke to thicken the filled chevrons. The sharedclose-direction-hintanimation inmap.cssnudges the caret twice after opening (left for filters, right for event details), starting after the panel entrance and respecting reduced motion. The filter button sets--close-nudge-x: -4pxto reverse the horizontal translation; toggle the hint class with its open state to replay on reopening. Each hint scales up, nudges twice while enlarged, then scales down. Clicking a header close button (including keyboard activation) permanently dismisses that panel’s hint viasafeStorage, using separatepcd-filter-close-learnedandpcd-details-close-learnedkeys; other closing methods do not dismiss the hint. Reactive state also suppresses it for the current page when storage is unavailable. -
Organizer Kit
.nav-listhighlights use dedicated tokens indocs/tokens.css: light purple for the active page, a paler hover background, and a stronger active-page hover background. Expandable group headings also receive the hover highlight. Nested child links use the same active and hover text colors as top-level links; their muted default color applies only when neither active nor hovered. Active items retain their normal font weight; selection must not make regular-weight links bold. -
The shared purple palette in
base.cssis#5503a4,#7a3eb6,#9b6ac7,#ba95d8, and#d8c2e9(--color-purple-900through--color-purple-100). Use semantic tokens for primary actions, links, focus, and event states; lighter palette tokens supply accents and tinted surfaces. Time pills retain their requested blue, past events remain grey, and warning colors retain their semantic meaning. -
Event detail hero actions render directly in the panel without the former surrounding box or title bar; the localized hero heading remains plain text. The detail title uses dark grey while the eyebrow remains muted grey.
-
The detail byline sits below the hero actions and above About, as separate localized “Hosted by” then “Organized by” sections with names below each heading. Omit empty sections; retain inline Markdown links and host expansion. Its grid uses a zero-minimum column and shrinkable sections so collapsed hosts reserve space for the ellipsis and More button within the panel. Organization names, expanded hosts, and host lists below the expansion threshold wrap, including unbroken names and links.
-
Do not use diagonal arrows for internal links; use a right-pointing arrow when a directional indicator is needed.
-
The site is light-mode only — there is no dark mode, no
[data-theme]toggling, and no theme-related CSS. Do not reintroduce it without an explicit decision to do so.
Must follow standard accessibility best practices (semantic HTML, ARIA attributes, keyboard navigation, focus management) for all interactive components (map, panels, buttons, etc.). WCAG 2.1 AA compliance is the goal.
New events are submitted via GitHub Issues using .github/ISSUE_TEMPLATE/01-new-event.yml. The workflow .github/workflows/new-event-intake.yml (process-new-event job) runs .github/scripts/process-new-event-issue.mjs to validate the issue and, if valid, opens a PR with generated metadata.json + content.md files. A stable uid is generated at intake and written into both files.
If the generated event ID already exists, the script skips all other field errors and posts a duplicate-event comment (buildDuplicateEventComment()) linking to the existing canonical event page and telling the submitter to use its “Edit this event data” button. It does not ask them to edit the issue.
When an edit changes the address but retains the same resolved Plus Code, both the issue confirmation and PR body show a warning linking to the Plus Code so organizers can verify the map location. Comparison trims addresses and normalizes Plus Code case and whitespace. Edit the shared copy in LOCATION_WARNING_TEMPLATE in .github/workflows/new-event-intake.yml; {plus_code_url} and {issue_reference} are substituted by the edit script. Direct script runs must supply this environment variable when an address-change warning is needed.
PR confirmation comments use separate initial-submission and event-update messages, selected by the validated edit job output. Confirmations mention the issue author, remind them to keep their community posted on their forum thread (linked when a URL is available), and link to the Organizer Kit; PR bodies omit these reminders. An address-change Plus Code warning appears at the top of the issue confirmation. Only event updates include the canonical event-page update notice; initial submission confirmations explain that the event will be added to the map after merge.
When a valid edit produces no PR because the generated files already match the site, the create-pr job upserts the shared status comment with the canonical event link and a short note about the form workaround. This feedback runs only after successful PR creation returns operation none without a PR number; failures and existing PRs must not be described as unchanged submissions.
Organizers can edit existing events via .github/ISSUE_TEMPLATE/04-edit-event.yml. The same workflow (process-edit-event job) runs .github/scripts/process-edit-event-issue.mjs. The edit script: reads the existing event by event_id, preserves the immutable uid and intake block, preserves event_activities if all checkboxes are unchecked (GitHub issue forms cannot prefill checkboxes), and preserves content.md if full_description is blank.
New zines use .github/ISSUE_TEMPLATE/05-new-zine.yml and .github/workflows/new-zine-intake.yml. The workflow runs .github/scripts/process-new-zine-issue.mjs when an issue labelled new zine is opened, edited, reopened, or labelled. It validates and downloads attachments, generates final published files with an event-style intake provenance block, builds the site, and force-updates the stable automation-owned automation/new-zine-<issue-number> review PR. It upserts one marked status comment; invalid edits leave an existing valid PR unchanged. Zines have no edit issue workflow.
Event intake normalizes start/end times with normalizeTime() before validation, chronological comparisons, and metadata/PR generation. Accept one- or two-digit hours with two-digit minutes in 24-hour input, or 1–12 hours with optional minutes and case-insensitive AM/PM (optional spaces and periods). Store only zero-padded 24-hour HH:MM; blank optional fields remain empty. Reject invalid ranges and ambiguous bare hours without AM/PM. Single-day end-after-start checks use isEndTimeAfterStart(), which treats an end time of 00:00 (e.g. “12 AM”) as the end of the day; it is still stored as 00:00.
Pure functions shared by both intake scripts live in .github/scripts/event-issue-helpers.mjs. This includes parseIssueSections, validation helpers, slugify, parseActivities, parseOrganizers, buildValidationComment, and generateUniqueUid.
Intake scripts guard against running on the wrong template:
process-new-event-issue.mjsskips if the body contains### Event ID(unique to the edit template)process-edit-event-issue.mjsskips if the body does NOT contain### Event IDprocess-new-zine-issue.mjsskips unless the body contains### Reader-order PDF(which also matches the former### Reader-order PDF URLheading)
Netlify, configured via netlify.toml. The site deploys to https://day.processing.org/ on push to main.
The browser-facing CARTO tile key is supplied at build time through
PUBLIC_CARTO_API_KEY. Configure it as a Netlify environment variable with
Builds scope; for local development it may be placed in pcd-website/.env.
Never commit the key. Without it, the site falls back to unkeyed tile URLs for
local development and may display CARTO's API-key watermark.