SB Sommar – Project Documentation

SB Sommar – Architecture: Pages and Content

Unified site navigation, the upcoming-camps section, the hero redesign, location accordions, the iCal calendar export, analytics, image dimension attributes, static asset cache headers, the feedback button, and the registration banner.

Part of the architecture index. Section IDs (03-§N.M) are stable and cited from code; they do not encode the file path.


12. Unified Navigation

All pages share a single navigation component generated by pageNav() in source/build/layout.js. There is no secondary navigation anywhere on any page.

12.1 Navigation structure

The pageNav(activeHref, navSections) function renders a <nav class="page-nav"> element with two tiers:

  1. Page links – links to the five main pages. Each link carries two labels: a short one (label) shown on desktop and a longer one (menuLabel) shown in the mobile hamburger menu. Desktop labels are displayed uppercase via CSS. The current page is marked with class active.

    Page Desktop label Hamburger label
    index.html Hem Hem
    schema.html Schema Lägrets schema
    idag.html Idag Dagens aktiviteter
    lagg-till.html Lägg till Lägg till aktivitet
    arkiv.html Arkiv Lägerarkiv

    Each <a> contains a <span class="nav-label-short"> (desktop) and a <span class="nav-label-long"> (hamburger). CSS shows one and hides the other based on viewport width.

  2. Section links – anchor links to the index page sections, derived from navSections (array of { id, navLabel }). On non-index pages these point to index.html#id.

A hamburger <button class="nav-toggle"> is rendered for mobile use. It is hidden on desktop via CSS (@media (min-width: 768px)) and toggles a class is-open on the <div class="nav-menu"> container via nav.js.

12.2 Data flow

Section link data originates from source/content/sections.yaml. build.js reads and resolves this file early, before rendering any page, and passes the resulting navSections array to every render function as a trailing optional parameter:

renderSchedulePage(camp, events, footerHtml, navSections = [])
renderAddPage(camp, locations, apiUrl, footerHtml, navSections = [])
renderEditPage(camp, locations, apiUrl, footerHtml, navSections = [])
renderIdagPage(camp, events, footerHtml, navSections = [])
renderIndexPage({ heroSrc, heroAlt, sections }, footerHtml, navSections = [])
renderArkivPage(allCamps, footerHtml, navSections = [])

Defaulting to [] keeps all existing tests backward-compatible.

12.3 Short nav labels

Each section in sections.yaml must have a nav: field with a concise label (one or two words). This label appears in the navigation on all pages.

12.4 Client-side script

source/assets/js/client/nav.js handles the hamburger toggle:

This script is included at the bottom of <body> on every page that has a nav. render-today.js (display mode) has no navigation and does not include nav.js.

12.5 Section-nav removal

The <nav class="section-nav"> previously rendered on the index page below the hero image is removed. All section navigation is now in the shared header nav. The .section-nav CSS rule is also removed.

12.6 Sticky positioning

.page-nav uses position: sticky; top: 0 with margin-top: calc(-1 * var(--space-xs)) and padding-top: var(--space-xs). The negative margin pulls the element up into body’s padding, while the padding restores the content position. The result is that nav content sits at the same vertical offset in both normal flow and stuck mode — no visible jump. The background pseudo-element covers the full area.

html has scroll-padding-top set to the approximate nav height so that anchor targets are not obscured by the sticky bar.


14. Upcoming Camps Section on Homepage

14.1 Overview

The homepage includes a “Kommande läger” section that lists camps the visitor should know about: upcoming camps, currently running camps, and camps that already took place this year. Past camps are visually marked so visitors can see what happened and what is still ahead.

14.2 Data source

The section uses camps.yaml — the same registry used by the archive page. No per-camp event files are loaded.

14.3 Build-time rendering

render-index.js exports a new function renderUpcomingCampsHtml(camps) that:

  1. Filters camps: include if archived === false OR start_date year matches the current year. The current year for filtering is determined at build time. Client-side JS re-evaluates the year check is not needed — the build runs frequently enough and the year boundary is a rare edge case.
  2. Sorts ascending by start_date.
  3. Renders an <ul class="upcoming-camps"> list. Each <li> carries a data-end="{end_date}" attribute.
  4. Each item shows: camp name (plain text), location, and date range. An information paragraph is included when non-empty.

14.4 Section integration

The section is integrated into the index page via sections.yaml as a special section type. A new type: upcoming-camps property signals build.js to call renderUpcomingCampsHtml(camps) instead of loading a markdown file.

14.5 Client-side past-camp marking

A small inline <script> at the end of the section (or a dedicated JS file) runs on page load:

  1. Selects all .camp-item[data-end] elements.
  2. Computes “today” in Stockholm time: new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Stockholm' })YYYY-MM-DD.
  3. If data-end < today, adds class .camp-past to the element.
  4. .camp-past applies a green checkmark and strikethrough via CSS.

No external dependencies. ~15 lines of JS.

14.6 CSS

New classes in style.css:

All values use CSS custom properties from 07-DESIGN.md §7.

14.7 Files changed

File Change
source/build/render-index.js Add renderUpcomingCampsHtml() function
source/build/build.js Pass camps to index rendering; handle type: upcoming-camps
source/content/sections.yaml Add upcoming-camps section entry
source/assets/cs/style.css Add .upcoming-camps, .camp-item, .camp-past styles

15. Hero Section Redesign

15.1 Overview

The homepage hero is a two-column layout: a large image with rounded corners on the left (~2/3) and a sidebar on the right (~1/3) with social links and a camp countdown.

15.2 Build-time rendering

render-index.js renders the hero section. The function renderIndexPage receives heroSrc, heroAlt, and new parameters for social links and the countdown target date.

build.js computes the countdown target by finding the nearest future camp from camps.yaml (comparing start_date against today). This date is embedded as a data-target attribute on the countdown element.

Social link URLs (Discord and Facebook) are passed from build.js based on configuration.

15.3 Client-side countdown

A small inline <script> at the end of the hero section:

  1. Reads data-target from the countdown element.
  2. Computes the difference in days between today (Stockholm time) and the target date.
  3. Writes the number into the element.
  4. If the target is in the past or missing, hides the countdown.

No external dependencies.

15.4 Social icons

Discord and Facebook SVG/WebP icons are stored in source/content/images/. They are rendered as <a> elements wrapping <img> tags with appropriate alt text and target="_blank" rel="noopener noreferrer".

15.5 CSS

New/modified classes in style.css:

All values use CSS custom properties.

15.6 Files changed

File Change
source/build/render-index.js Redesign hero HTML, accept social/countdown params
source/build/build.js Compute countdown target date, pass social links
source/assets/cs/style.css Redesign .hero layout, add new hero classes
source/content/images/ Add Discord and Facebook icon images
docs/07-DESIGN.md Add --radius-lg token

16. Location Accordions on Index Page

The Lokaler section displays each location from source/data/local.yaml as an individual accordion, rendered at build time into index.html.

16.1 Data flow

build.js already loads local.yaml. The full location objects (not just names) are passed to the index rendering pipeline. A new function renderLocationAccordions(locations) in render-index.js generates one <details class="accordion"> per location entry.

16.2 Rendering rules

16.3 Injection mechanism

build.js identifies the lokaler section by its id: lokaler in sections.yaml and appends the location accordion HTML after the section’s markdown HTML. This mirrors the pattern used for camp listings in section id: start.

16.4 Files changed

File Change
source/content/sections.yaml Remove collapsible: true from lokaler
source/build/render-index.js Add renderLocationAccordions() function
source/build/build.js Pass full location data; inject into lokaler section

22. iCal Calendar Export

At build time, source/build/render-ical.js produces iCalendar (.ics) files that allow participants to subscribe to or import the camp schedule into their phone or desktop calendar app.

22.1 Data source

The renderer receives the same camp, events, and SITE_URL already loaded by build.js — no additional file reads are needed.

22.2 Output files

File Content
public/schema.ics Full-camp feed — one VEVENT per event
public/schema/{event-id}/event.ics Single-event file

22.3 iCalendar structure

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SB Sommar//Schema//SV
X-WR-CALNAME:Schema – {camp name}
METHOD:PUBLISH
BEGIN:VEVENT
DTSTART:20260630T163000
DTEND:20260630T180000
DTSTAMP:20260228T120000Z
SUMMARY:{title}
LOCATION:{location}
DESCRIPTION:Ansvarig: {responsible}\n{description}
URL:{SITE_URL}/schema/{event-id}/
UID:{event-id}@{hostname}
END:VEVENT
...
END:VCALENDAR

Times use floating local format (YYYYMMDDTHHMMSS, no Z, no TZID) consistent with the no-timezone policy (05-§4.5).

DTSTAMP is a UTC timestamp set to the build time. RFC 5545 §3.6.1 requires it in every VEVENT.

When end is null, DTEND is omitted.

22.4 iCal text escaping

iCalendar content lines escape commas, semicolons, and backslashes with a backslash prefix. Newlines in DESCRIPTION are encoded as literal \n. The renderer provides an escapeIcal() helper for this.

22.5 Schedule page integration

The schedule page header displays two icons beside the title: an RSS icon and a calendar icon. The calendar icon is an inline SVG (38 px, matching the RSS icon) that links to kalender.html.

Each event row on the schedule page includes a small “iCal” text link at the end of the row. The link downloads the per-event .ics file directly (using the download attribute).

A text link to kalender.html also appears near the intro text so users can discover subscription instructions.

22.6 Event detail page

The per-event detail page adds a calendar download link as a third line after the existing Plats/Ansvarig line, styled consistently.

22.7 Calendar tips page

source/build/render-kalender.js produces public/kalender.html — a static page with step-by-step instructions in Swedish for subscribing to the camp calendar on iOS, Android, Gmail, and Outlook. Uses the shared pageNav() and pageFooter() layout.

The page uses card-based layout (white background, card shadow, sage left border) with each platform section as a visually separate card. The webcal URL is displayed in a copy-friendly dark code block.

22.8 Files

File Role
source/build/render-ical.js Renders .ics files at build time
source/build/render-kalender.js Renders the calendar tips page

22.9 Files changed

File Change
source/build/build.js Call renderIcalFeed(), renderEventIcal(), renderKalenderPage(); write output files
source/build/render-event.js Add iCal download link to event detail page
source/build/render.js Add webcal link alongside RSS link in schedule header
.github/workflows/event-data-deploy.yml Add schema.ics to the artefact and FTP deploy

23. Site Analytics

23.1 Overview

GoatCounter (hosted, free tier) provides privacy-friendly analytics with no cookies and no personal data collection. The analytics script (~3.5 KB) loads asynchronously on every page.

23.2 Script inclusion

The GoatCounter count.js script is included via a <script> tag just before </body> on every page. Two inclusion points:

  1. Shared layout pages — the layout helper (layoutTop/layoutBottom or equivalent) includes the script once, so all pages with the site header and footer inherit it.
  2. Display view (/live.html) — does not use the shared layout, so its renderer includes the script separately.

The script tag uses the data-goatcounter attribute pointing to the GoatCounter endpoint. The site code comes from the GOATCOUNTER_SITE_CODE environment variable, injected at build time. When the variable is absent (local dev, CI), no script tag is rendered.

23.3 Custom events

Behavioural tracking uses GoatCounter’s data-goatcounter-click HTML attribute on interactive elements. No additional JS libraries are needed.

For events that cannot be tracked via HTML attributes alone (form submission success, form abandonment, scroll depth), minimal inline JavaScript calls window.goatcounter.count() directly. This keeps the JS footprint to the GoatCounter script itself plus a few lines of glue code.

23.4 QR code referrer tracking

Physical QR codes include a ?ref=<id> query parameter. GoatCounter records the full URL including query string, making each QR code identifiable as a distinct referrer source.

The list of QR code identifiers lives in source/data/qr-codes.yaml and is maintained manually. The build reads this file only when generating the display view’s QR code (to embed the correct ?ref= parameter).

23.5 Data file

source/data/qr-codes.yaml schema:

codes:
  - id: qr-display-01
    description: Dagens schema — skylt vid matsalen
  - id: qr-affisch-01
    description: Affisch i receptionen

Fields: id (string, unique, used in ?ref= parameter), description (string, human-readable location/purpose).


24. Image Dimension Attributes

24.1 Why dimensions matter

Every <img> must carry width and height attributes so the browser can reserve layout space before the image loads, preventing Cumulative Layout Shift (CLS).

24.2 Dimension strategy

Two categories:

  1. Fixed-size images — social icons, testimonial avatars, the RSS icon, and the archive Facebook logo — have known display sizes determined by CSS. Their width and height are hardcoded in the render templates.

  2. Content images — markdown content images (content-img) and location/facility images — vary by source file. The build reads each image’s natural pixel dimensions at build time using the image-size npm package, which parses only the image header (no full decode).

24.3 Build-time dimension resolution

source/build/image-dimensions.js exports a getImageDimensions(filePath) helper. It is called:

The hero image dimensions are also read at build time (not hardcoded).

24.4 Image dimension files

File Role
source/build/image-dimensions.js getImageDimensions() — thin wrapper around image-size
source/build/render-index.js Passes dimensions to <img> tags for content, hero, social, testimonial images
source/build/render.js RSS icon dimensions
source/build/render-arkiv.js Facebook logo dimensions

25. Static Asset Cache Headers

25.1 Why cache headers

Returning visitors re-download all static assets because no Cache-Control headers are set. Adding cache headers via .htaccess reduces repeat-visit load times.

25.2 Cache rule tiers

A static .htaccess file in source/static/ is copied to public/ during the build. It sets Apache Cache-Control headers:

25.3 Build copy step

build.js copies source/static/.htaccess to public/.htaccess using an explicit fs.copyFileSync() call after the existing asset copy step.

25.4 Separation from API .htaccess

The static site .htaccess (public/.htaccess) is distinct from the PHP API routing file (api/.htaccess). They serve different purposes and are deployed to different directories.

25.5 Cache header files

File Role
source/static/.htaccess Cache rules for Apache
source/build/build.js Copies .htaccess to public/

26. Feedback Button (GitHub Issues)

26.1 Overview

A feedback button in the navigation bar opens a modal form. Submissions create GitHub Issues via the Issues API. Both Node.js and PHP backends are supported.

26.2 Client-side

A new script source/assets/js/client/feedback.js manages:

26.3 Navigation integration

The feedback button is rendered by pageNav() in source/build/layout.js, positioned between the hamburger toggle and the scroll-to-top button. It is an inline SVG speech-bubble icon with aria-label="Ge feedback".

26.4 Node.js API (source/api/feedback.js)

26.5 PHP API (api/src/Feedback.php)

26.6 GitHub Issue format

26.7 Files

File Role
source/assets/js/client/feedback.js Client modal + form + submit
source/api/feedback.js Node.js validation + GitHub Issue creation
api/src/Feedback.php PHP validation + GitHub Issue creation
source/build/layout.js Feedback button in nav
source/assets/css/main.css Feedback button + modal styles
app.js POST /feedback route
api/index.php POST /api/feedback route

32. Registration Banner and CTA Button

32.1 Overview

Two coordinated UI elements make registration status and the entry point visible on the homepage:

  1. Hero banners — one per non-archived camp, rendered directly below the hero image, each announcing that registration is open for that specific camp and linking to the #anmalan section.
  2. CTA button in the anmalan section — a prominent .btn-primary “Anmäl er här” call-to-action injected by the renderer, replacing the inline bold markdown link in source/content/registration.md.

Both elements share the “render always, hide via client-side JS when the relevant date window is inactive” pattern already used for hero action buttons (§15, §71) and hero countdown (§15.3). Nothing is ever server-rendered based on the current date, so the static HTML remains cache-friendly.

32.2 Data source

source/data/camps.yaml carries two new date fields per camp — registration_opens and registration_closes, inclusive — described in 05-DATA_CONTRACT.md §1.7. source/scripts/validate-camps.js rejects non-archived camps with missing, malformed, or out-of-order values, and treats the fields as optional for archived camps.

32.3 Build-time rendering

source/build/render-index.js:

source/build/build.js computes registrationCamps by filtering camps.yaml to entries where archived !== true, reading the two new fields, and sorting ascending by start_date. Entries missing the fields (already validated away in non-archived camps) are dropped defensively.

32.4 Client-side visibility

The inline <script> at the end of index.html that currently toggles .hero-actions based on data-opens/data-closes is generalised to also toggle every .hero-registration-banner[data-opens]. The script uses the same Europe/Stockholm-anchored “today” string comparison as §71.5 — no timezone library, no Date arithmetic.

Outside the registration window, each banner keeps its hidden attribute and the container .hero-registration-banners collapses to zero visible height because the banners stack vertically with no surrounding padding of their own.

32.5 Styling

source/assets/cs/style.css gains:

All values come from existing tokens in 07-DESIGN.md §7.

32.6 Accessibility

32.7 Files changed

File Change
source/data/camps.yaml Add registration_opens + registration_closes to non-archived camps
source/scripts/validate-camps.js Validate new fields (presence, ISO format, ordering, before start_date)
source/build/build.js Compute registrationCamps array, pass to renderIndexPage
source/build/render-index.js Render banner block; inject .registration-cta into anmalan section
source/content/registration.md Remove the inline **[Anmäl er här](...)** line
source/assets/cs/style.css New classes for banner and CTA wrapper