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.
All pages share a single navigation component generated by pageNav() in
source/build/layout.js. There is no secondary navigation anywhere on any page.
The pageNav(activeHref, navSections) function renders a <nav class="page-nav">
element with two tiers:
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.
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.
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.
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.
source/assets/js/client/nav.js handles the hamburger toggle:
aria-expanded on the button for accessibility.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.
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.
.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.
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.
The section uses camps.yaml — the same registry used by the archive page.
No per-camp event files are loaded.
render-index.js exports a new function renderUpcomingCampsHtml(camps) that:
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.start_date.<ul class="upcoming-camps"> list. Each <li> carries a
data-end="{end_date}" attribute.information paragraph is included when non-empty.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.
A small inline <script> at the end of the section (or a dedicated JS file)
runs on page load:
.camp-item[data-end] elements.new Date().toLocaleDateString('sv-SE',
{ timeZone: 'Europe/Stockholm' }) → YYYY-MM-DD.data-end < today, adds class .camp-past to the element..camp-past applies a green checkmark and strikethrough via CSS.No external dependencies. ~15 lines of JS.
New classes in style.css:
.upcoming-camps — list reset, spacing.camp-item — individual camp row; horizontal flex layout with icon, name, and meta on a single line; no border separators.camp-check — the checkbox/checkmark indicator.camp-past — green checkmark + text-decoration: line-through.camp-body — flex row container; display: flex; align-items: baseline places name and meta on one line.camp-name — camp name (plain text, terracotta); inline within .camp-body.camp-meta — location and date range; inline within .camp-body.camp-info — information textAll values use CSS custom properties from 07-DESIGN.md §7.
| 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 |
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.
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.
A small inline <script> at the end of the hero section:
data-target from the countdown element.No external dependencies.
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".
New/modified classes in style.css:
.hero — CSS Grid, two-column layout on desktop, single column on mobile.hero-title — terracotta H1 above the image.hero-img — rounded corners via --radius-lg.hero-sidebar — flexbox column, centered items.hero-social — social icon links.hero-countdown — countdown widget with #FAF7EF background.hero-countdown-number — large number display.hero-countdown-label — “Dagar kvar” textAll values use CSS custom properties.
| 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 |
The Lokaler section displays each location from source/data/local.yaml as an
individual accordion, rendered at build time into index.html.
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.
## Lokaler heading and the introductory paragraph in locations.md render
as normal HTML (heading + paragraph), not wrapped in any accordion.collapsible: true flag is removed from the lokaler entry in sections.yaml.<summary> = location nameinformation text (inline markdown converted) + images.image_path is a string, one <img>; if an array, one <img> per
entry. Empty strings are skipped.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.
| 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 |
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.
The renderer receives the same camp, events, and SITE_URL already
loaded by build.js — no additional file reads are needed.
| File | Content |
|---|---|
public/schema.ics |
Full-camp feed — one VEVENT per event |
public/schema/{event-id}/event.ics |
Single-event file |
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.
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.
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.
The per-event detail page adds a calendar download link as a third line after the existing Plats/Ansvarig line, styled consistently.
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.
| File | Role |
|---|---|
source/build/render-ical.js |
Renders .ics files at build time |
source/build/render-kalender.js |
Renders the calendar tips page |
| 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 |
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.
The GoatCounter count.js script is included via a <script> tag just
before </body> on every page. Two inclusion points:
layoutTop/layoutBottom or
equivalent) includes the script once, so all pages with the site header
and footer inherit it./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.
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.
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).
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).
Every <img> must carry width and height attributes so the browser
can reserve layout space before the image loads, preventing Cumulative
Layout Shift (CLS).
Two categories:
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.
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).
source/build/image-dimensions.js exports a getImageDimensions(filePath)
helper. It is called:
image() renderer of createMarked() and inlineHtml() inside
render-index.js, for markdown content images.renderLocationAccordions() for facility images from local.yaml.The hero image dimensions are also read at build time (not hardcoded).
| 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 |
Returning visitors re-download all static assets because no Cache-Control
headers are set. Adding cache headers via .htaccess reduces repeat-visit
load times.
A static .htaccess file in source/static/ is copied to public/ during
the build. It sets Apache Cache-Control headers:
max-age=31536000)max-age=604800)no-cache)build.js copies source/static/.htaccess to public/.htaccess using an
explicit fs.copyFileSync() call after the existing asset copy step.
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.
| File | Role |
|---|---|
source/static/.htaccess |
Cache rules for Apache |
source/build/build.js |
Copies .htaccess to public/ |
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.
A new script source/assets/js/client/feedback.js manages:
issueUrl): display a warning in
.form-error-msg style that the feedback was not saved because
this is a test site.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".
source/api/feedback.js)source/api/validate.js).website field is non-empty, return success without
creating an issue.feedback instance of
express-rate-limit defined in app.js with { limit: 5, windowMs:
3_600_000 }. See platform-and-security.md §31 for the full mechanism.githubRequest() (exported from
source/api/github.js).POST /feedback registered in app.js.BUILD_ENV is neither production nor qa, the
endpoint logs the request and returns { success: true, issueUrl: '' }
without creating a GitHub Issue. This means only local development
is dry-run; QA creates real GitHub Issues.api/src/Feedback.php)SBSommar\RateLimit::isLimited($ip, 'feedback',
5, 3600). See platform-and-security.md §31 for the shared helper.GitHub::createIssue() (new public method on the existing class).POST /api/feedback registered in api/index.php.[Feedback] {category}: {title}feedback:bug, feedback:suggestion, feedback:question.| 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 |
Two coordinated UI elements make registration status and the entry point visible on the homepage:
#anmalan section.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.
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.
source/build/render-index.js:
renderIndexPage(...) accepts a new parameter registrationCamps: an
array of { id, name, registrationOpens, registrationCloses,
lastRegistrationLabel } objects. The array is already sorted ascending
by start_date by the caller.<img> (before the existing hero-actions
block), a <div class="hero-registration-banners"> wraps one
<a class="hero-registration-banner" hidden data-opens="..."
data-closes="..." href="#anmalan" data-goatcounter-click="click-register-banner-<id>">
per camp. Each banner contains a title span and a meta span with the
last-registration date.anmalan section is post-processed (same pattern as
wrapTestimonialCards): the renderer wraps the section body in a flex
container and prepends a .registration-cta element holding a
.btn-primary with href to the external registration URL,
target="_blank", rel="noopener noreferrer", and
data-goatcounter-click="click-register-section". The markdown source
no longer contains the inline bold link.render-index.js so a future move to a YAML-driven URL is a small
refactor, not a schema change.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.
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.
source/assets/cs/style.css gains:
.hero-registration-banners — vertical flex column, gap:
var(--space-sm), centred within the hero container, hidden from
layout when all children are [hidden] (achieved by letting each
banner own its own vertical spacing)..hero-registration-banner — full-width card with cream background
(--color-cream-light), terracotta left-border accent, padding:
var(--space-sm) var(--space-md), border-radius: var(--radius-md)..hero-registration-banner-title — bold, terracotta text, slightly
larger than body..hero-registration-banner-meta — smaller, charcoal text on a new
line..registration-cta — wrapper: desktop floats the CTA right of the
text flow (float: right; margin: 0 0 var(--space-sm) var(--space-md)),
mobile (< 720 px) sets float: none, full width, centred..registration-cta-btn — a modifier on .btn-primary that only
widens the button on mobile.All values come from existing tokens in 07-DESIGN.md §7.
<a> elements so they are reachable via the tab sequence
and clickable as a single target..btn-primary’s :focus-visible rule from
07-§9.2.| 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 |